From e1b77b7cb0abdd1233ff766c659121e358d7e040 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 15 Mar 2023 06:36:14 +0800 Subject: [PATCH 1/7] chore: Add the remove trailing spaces lint rule --- client/.eslintrc.json | 1 + server/.eslintrc.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 932d693..5e87096 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -15,6 +15,7 @@ "semi": ["error", "never"], "no-unused-vars": "error", "no-undef": "error", + "no-trailing-spaces": "error", "no-console": ["error", { "allow": ["error"] }] } } diff --git a/server/.eslintrc.js b/server/.eslintrc.js index 052b9f2..0a18f58 100644 --- a/server/.eslintrc.js +++ b/server/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { 'quotes': ['error', 'single'], 'semi': ['error', 'never'], 'no-unused-vars': 'error', - 'no-undef': 'error' + 'no-undef': 'error', + 'no-trailing-spaces': 'error' } } From 36309fbd736c957b2201f87c9bab0d8635569976 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 15 Mar 2023 07:06:01 +0800 Subject: [PATCH 2/7] wip: Watch the login authentication state. --- client/src/common/auth/protectedpage/index.js | 39 ++++++++++ client/src/common/auth/withauth/index.js | 71 +++++++++++++++++++ .../src/common/layout/loadingcover/index.js | 19 +++++ client/src/components/dashboard/index.js | 14 ++++ client/src/components/login/index.js | 20 ++++-- client/src/lib/store/users/userSlice.js | 70 +++++++++++++++--- client/src/lib/store/users/userThunk.js | 14 ++++ client/src/lib/utils/firebase/config.js | 8 ++- client/src/pages/dashboard/index.js | 23 ++++++ client/src/pages/login/index.js | 36 +++++++++- 10 files changed, 293 insertions(+), 21 deletions(-) create mode 100644 client/src/common/auth/protectedpage/index.js create mode 100644 client/src/common/auth/withauth/index.js create mode 100644 client/src/common/layout/loadingcover/index.js create mode 100644 client/src/components/dashboard/index.js create mode 100644 client/src/lib/store/users/userThunk.js create mode 100644 client/src/pages/dashboard/index.js diff --git a/client/src/common/auth/protectedpage/index.js b/client/src/common/auth/protectedpage/index.js new file mode 100644 index 0000000..1a6a279 --- /dev/null +++ b/client/src/common/auth/protectedpage/index.js @@ -0,0 +1,39 @@ +import { useEffect } from 'react' +import { useSelector } from 'react-redux' +import { useRouter } from 'next/router' +import LoadingCover from '@/common/layout/loadingcover' +import WithAuth from '@/common/auth/withauth' + +/** + * Displays a loading cover page while waiting for the remote authentication info to settle in. + * Displays and returns the contents (children) of a tree if there is a signed-in user from the remote auth. + * Redirect to the /login page if there is no user info after the remote auth settles in. + * @param {Component} children + * @returns {Component} + */ +function ProtectedPage ({ children }) { + const router = useRouter() + const { authLoading, authUser } = useSelector(state => state.user) + + useEffect(() => { + if (!authLoading && !authUser) { + router.push('/login') + } + }, [authUser, authLoading, router]) + + const ItemComponent = () => { + if (authLoading) { + return + } else if (authUser) { + return
+ {children} +
+ } else { + return + } + } + + return () +} + +export default WithAuth(ProtectedPage) diff --git a/client/src/common/auth/withauth/index.js b/client/src/common/auth/withauth/index.js new file mode 100644 index 0000000..a8f1992 --- /dev/null +++ b/client/src/common/auth/withauth/index.js @@ -0,0 +1,71 @@ +import { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { authErrorReceived, authReceived } from '@/store/users/userSlice' +import { authSignOut } from '@/lib/store/users/userThunk' +import { auth, onAuthStateChanged } from '@/lib/utils/firebase/config' + +/** + * HOC that listens for the Firebase user auth info using the onAuthStateChanged() method. + * Returns a Component with auth props for convenience. + * Sets the global Firebase user auth details in the user { authUser, authLoading, authError, authStatus } redux store. + * @param {*} Component + * @returns + */ +function WithAuth (Component) { + function AuthListener (props) { + const { authUser, authLoading, authError } = useSelector(state => state.user) + const dispatch = useDispatch() + + useEffect(() => { + const handleFirebaseUser = async (firebaseUser) => { + if (firebaseUser) { + // Check if user is emailVerified + if (!firebaseUser?.emailVerified ?? false) { + dispatch(authSignOut('Email not verified. Please verify your email first.')) + return + } + + try { + // Retrieve the custom claims information + const { claims } = await firebaseUser.getIdTokenResult() + + if (claims.account_level) { + // Get the firebase auth items of interest + dispatch(authReceived({ + uid: firebaseUser.uid, + email: firebaseUser.email, + name: firebaseUser.displayName, + accessToken: firebaseUser.accessToken, + emailVerified: firebaseUser.emailVerified, + account_level: claims.account_level + })) + } else { + // User did not sign-up using the custom process + dispatch(authSignOut('Missing custom claims')) + } + } catch (err) { + dispatch(authErrorReceived(err?.response?.data ?? err.message)) + dispatch(authReceived(null)) + } + } else { + dispatch(authReceived(null)) + } + } + + const unsubscribe = onAuthStateChanged(auth, handleFirebaseUser) + return () => unsubscribe() + }, [dispatch]) + + return ( + ) + } + + return AuthListener +} + +export default WithAuth diff --git a/client/src/common/layout/loadingcover/index.js b/client/src/common/layout/loadingcover/index.js new file mode 100644 index 0000000..edb1cd8 --- /dev/null +++ b/client/src/common/layout/loadingcover/index.js @@ -0,0 +1,19 @@ +import Box from '@mui/material/Box' +import Page from '@/common/layout/page' + +function LoadingCover () { + return ( + + +

Loading...

+
+
+ ) +} + +export default LoadingCover diff --git a/client/src/components/dashboard/index.js b/client/src/components/dashboard/index.js new file mode 100644 index 0000000..1bb2823 --- /dev/null +++ b/client/src/components/dashboard/index.js @@ -0,0 +1,14 @@ +import Page from '@/common/layout/page' + +function DashboardComponent ({ logout }) { + return ( + +

Dashboard

+ +
+ ) +} + +export default DashboardComponent diff --git a/client/src/components/login/index.js b/client/src/components/login/index.js index e1c0b5e..86f0fd7 100644 --- a/client/src/components/login/index.js +++ b/client/src/components/login/index.js @@ -11,12 +11,14 @@ import CheckIcon from '@mui/icons-material/Check' import Paper from '@mui/material/Paper' import SimpleSnackbar from '@/common/snackbars/simpleSnackbar' import TransparentTextfield from '@/common/ui/transparentfield' +import LoadingButton from '@/common/ui/loadingbutton' import { useTheme } from '@emotion/react' -function LoginComponent ({state, eventsHandler}) { +function LoginComponent ({ state, eventsHandler, resetError }) { const theme = useTheme() - const { username, password, errorMessage, joke } = state + const { username, password, errorMessage, joke, loading } = state const { usernameHandler, passwordHandler, loginHandler } = eventsHandler + return ( : - + } {errorMessage && - + } diff --git a/client/src/lib/store/users/userSlice.js b/client/src/lib/store/users/userSlice.js index 117373d..b90760f 100644 --- a/client/src/lib/store/users/userSlice.js +++ b/client/src/lib/store/users/userSlice.js @@ -4,6 +4,7 @@ import { } from '@reduxjs/toolkit' import { ADAPTER_STATES, USER_STATES } from '@/store/constants' +import { authSignOut } from './userThunk' const userAdapter = createEntityAdapter({ selectId: (app) => app.id, @@ -12,28 +13,77 @@ const userAdapter = createEntityAdapter({ const userSlice = createSlice({ name: 'user', initialState: userAdapter.getInitialState({ + /** Descriptive Auth status info. One of USER_STATES */ + authStatus: USER_STATES.LOADING, + /** Firebase Auth is waiting to settle down from 1st page load or during signOut */ + authLoading: true, + /** Firebase Auth errors */ + authError: '', + /** Firebase Auth user account */ + authUser: null, + /** User Profile document */ + profile: null, status: ADAPTER_STATES.IDLE, - authState: USER_STATES.LOADING, message: '', - error: '', - profile: null + error: '' }), reducers: { - profileReceived (state, action) { - state.profile = action.payload - state.authState = (action.payload !== null) + authReceived (state, action) { + state.authUser = action.payload + state.authLoading = false + + state.authStatus = (action.payload !== null) ? USER_STATES.SIGNED_IN : USER_STATES.SIGNED_OUT + + if (action.payload !== null) { + state.authError = '' + } + }, + authStatusReceived (state, action) { + state.authStatus = action.payload + state.authLoading = (state.authStatus === USER_STATES.LOADING) }, - authStateReceived (state, action) { - state.authState = action.payload + authErrorReceived (state, action) { + state.authError = action.payload }, + authLoadingReceived (state, action) { + state.authLoading = action.payload + + if (action.payload) { + state.authStatus == USER_STATES.LOADING + } + }, + profileReceived (state, action) { + state.profile = action.payload + } + }, + extraReducers: (builder) => { + // Sign-out success + builder.addCase(authSignOut.fulfilled, (state, { payload }) => { + state.authStatus = USER_STATES.SIGNED_OUT + state.authLoading = false + state.authError = payload + }) + + // Sign-out failure + builder.addCase(authSignOut.rejected, (state, action) => { + const { message } = action.error + state.authStatus = USER_STATES.SIGNED_IN + state.authLoading = false + state.authError = `${message}. ${action.payload}` + }) } }) export const { - authStateReceived, - profileReceived + authErrorReceived, + authInitReceived, + authLoadingReceived, + authReceived, + authStatusReceived, + profileReceived, + userLoading } = userSlice.actions export default userSlice.reducer diff --git a/client/src/lib/store/users/userThunk.js b/client/src/lib/store/users/userThunk.js new file mode 100644 index 0000000..14991fc --- /dev/null +++ b/client/src/lib/store/users/userThunk.js @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit' +import { authLoadingReceived } from '@/store/users/userSlice' +import { auth, signOut } from '@/lib/utils/firebase/config' + +export const authSignOut = createAsyncThunk('auth/signout', async(errorMessage = '', thunkAPI) => { + thunkAPI.dispatch(authLoadingReceived(true)) + + try { + await signOut(auth) + return errorMessage + } catch (err) { + return thunkAPI.rejectWithValue(err?.response?.data ?? err.message) + } +}) diff --git a/client/src/lib/utils/firebase/config.js b/client/src/lib/utils/firebase/config.js index 784d5c1..ef56973 100644 --- a/client/src/lib/utils/firebase/config.js +++ b/client/src/lib/utils/firebase/config.js @@ -5,7 +5,13 @@ import { initializeApp, getApps } from 'firebase/app' // https://firebase.google.com/docs/web/setup#available-libraries import { getFirestore } from 'firebase/firestore' import { getStorage, getDownloadURL, ref } from 'firebase/storage' -import { getAuth, signOut, signInWithEmailAndPassword, onAuthStateChanged, createUserWithEmailAndPassword } from 'firebase/auth' +import { + getAuth, + signOut, + signInWithEmailAndPassword, + onAuthStateChanged, + createUserWithEmailAndPassword +} from 'firebase/auth' // Your web app's Firebase configuration // For Firebase JS SDK v7.20.0 and later, measurementId is optional diff --git a/client/src/pages/dashboard/index.js b/client/src/pages/dashboard/index.js new file mode 100644 index 0000000..d8b13be --- /dev/null +++ b/client/src/pages/dashboard/index.js @@ -0,0 +1,23 @@ +import { useDispatch } from 'react-redux' +import { authSignOut } from '@/store/users/userThunk' + +import ProtectedPage from '@/common/auth/protectedpage' +import DashboardComponent from '@/components/dashboard' + +function Dashboard () { + const dispatch = useDispatch() + + const logout = () => { + dispatch(authSignOut()) + } + + return ( + + + + ) +} + +export default Dashboard diff --git a/client/src/pages/login/index.js b/client/src/pages/login/index.js index a01b390..ef83e07 100644 --- a/client/src/pages/login/index.js +++ b/client/src/pages/login/index.js @@ -1,8 +1,11 @@ import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' + import LoginComponent from '@/components/login' import { getRandomJoke } from '@/lib/services/random' import { Validate } from '@/lib/utils/textValidation' import AuthUtil from '@/lib/utils/firebase/authUtil' +import WithAuth from '@/common/auth/withauth' const defaultState = { username:{ @@ -19,11 +22,13 @@ const defaultState = { }, errorMessage:undefined, joke:undefined, + loading:false } -function Login () { +function Login (props) { const [state, setState] = useState(defaultState) const { username, password } = state + const router = useRouter() class eventsHandler { static usernameHandler = (e) => { @@ -56,11 +61,15 @@ function Login () { static loginHandler = () => { (async()=>{ + setState({ ...state, loading: true, errorMessage: undefined }) + const response = await AuthUtil.signIn(username.value, password.value) const errorMessage = response.errorMessage + setState(prev=>({ ...prev, - errorMessage + errorMessage, + loading: (errorMessage === undefined) })) })() } @@ -76,12 +85,33 @@ function Login () { })() },[]) + useEffect(() => { + if (!props.authLoading) { + setState(prev => ({ + ...prev, + loading: false, + errorMessage: (props.authError !== '') + ? props.authError + : prev.errorMessage + })) + + if (props.authUser) { + router.push('/dashboard') + } + } + }, [props.authError, props.authLoading, props.authUser, router]) + + const resetError = () => { + setState({ ...state, errorMessage: undefined }) + } + return ( ) } -export default Login +export default WithAuth(Login) From 09774e4384f382cd213653f6d8f39c819b33f7c4 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 15 Mar 2023 07:32:34 +0800 Subject: [PATCH 3/7] chore: Style the loading cover --- .../src/common/layout/loadingcover/index.js | 29 +++++++++++++++++-- client/src/common/ui/transparentbox/index.js | 22 ++++++++++++++ client/src/components/dashboard/index.js | 5 ++-- 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 client/src/common/ui/transparentbox/index.js diff --git a/client/src/common/layout/loadingcover/index.js b/client/src/common/layout/loadingcover/index.js index edb1cd8..136fc96 100644 --- a/client/src/common/layout/loadingcover/index.js +++ b/client/src/common/layout/loadingcover/index.js @@ -1,16 +1,39 @@ import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' +import CircularProgress from '@mui/material/CircularProgress' + import Page from '@/common/layout/page' +import TransparentBox from '@/common/ui/transparentbox' +import { useSyncLocalStorage } from '@/lib/hooks/useSync' function LoadingCover () { + const [activeTheme] = useSyncLocalStorage('activeTheme') + return ( -

Loading...

+ + + + Loading... + + + + + + +
) diff --git a/client/src/common/ui/transparentbox/index.js b/client/src/common/ui/transparentbox/index.js new file mode 100644 index 0000000..35abd07 --- /dev/null +++ b/client/src/common/ui/transparentbox/index.js @@ -0,0 +1,22 @@ +import Box from '@mui/material/Box' + +function TransparentBox ({ children }) { + return ( + + {children} + + ) +} + +export default TransparentBox diff --git a/client/src/components/dashboard/index.js b/client/src/components/dashboard/index.js index 1bb2823..b0c5dc3 100644 --- a/client/src/components/dashboard/index.js +++ b/client/src/components/dashboard/index.js @@ -1,12 +1,13 @@ +import Button from '@mui/material/Button' import Page from '@/common/layout/page' function DashboardComponent ({ logout }) { return (

Dashboard

- +
) } From 8d8b94deb7ef501bf53520d0bda3f10835318793 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 15 Mar 2023 07:34:22 +0800 Subject: [PATCH 4/7] chore: Remove trailing spaces --- client/src/common/layout/header/index.js | 12 ++++---- .../src/common/layout/loadingcover/index.js | 22 +++++++------- client/src/common/layout/page/index.js | 6 ++-- client/src/components/dashboard/index.js | 30 +++++++++---------- client/src/components/userProfile/index.js | 14 ++++----- client/src/lib/utils/firebase/authUtil.js | 2 +- client/src/lib/utils/textValidation.js | 10 +++---- client/src/pages/index.js | 2 +- 8 files changed, 49 insertions(+), 49 deletions(-) diff --git a/client/src/common/layout/header/index.js b/client/src/common/layout/header/index.js index 35fbf5a..4af3a1a 100644 --- a/client/src/common/layout/header/index.js +++ b/client/src/common/layout/header/index.js @@ -152,7 +152,7 @@ function Header() { ))} - {isLoggedIn + {isLoggedIn ? @@ -190,7 +190,7 @@ function Header() { }}> -
- ) -} - -export default DashboardComponent +import Button from '@mui/material/Button' +import Page from '@/common/layout/page' + +function DashboardComponent ({ logout }) { + return ( + +

Dashboard

+ +
+ ) +} + +export default DashboardComponent diff --git a/client/src/components/userProfile/index.js b/client/src/components/userProfile/index.js index 805f066..3b5e218 100644 --- a/client/src/components/userProfile/index.js +++ b/client/src/components/userProfile/index.js @@ -25,7 +25,7 @@ export const UserProfileComponent = ({state, eventsHandler}) => { backdropFilter:'contrast(120%)' }}> my Profile - { height:'100%' }} onClick={profilePictureHandler} - /> + /> @@ -60,7 +60,7 @@ export const UserProfileComponent = ({state, eventsHandler}) => { /> Email Address - { /> Contact No - { /> {state.profileChanged ? - ) } diff --git a/client/src/pages/account/index.js b/client/src/pages/account/index.js index bb2d9d9..6f5391a 100644 --- a/client/src/pages/account/index.js +++ b/client/src/pages/account/index.js @@ -8,6 +8,7 @@ import { usePromise, RequestStatus } from '@/lib/hooks/usePromise' import AccountComponent from '@/components/account' import messages from './messages' +import WithAuth from '@/common/auth/withauth' const defaultState = { loading: true, @@ -89,4 +90,4 @@ function Account () { ) } -export default Account +export default WithAuth(Account) diff --git a/client/src/pages/dashboard/index.js b/client/src/pages/dashboard/index.js index d8b13be..a6ff372 100644 --- a/client/src/pages/dashboard/index.js +++ b/client/src/pages/dashboard/index.js @@ -1,21 +1,10 @@ -import { useDispatch } from 'react-redux' -import { authSignOut } from '@/store/users/userThunk' - import ProtectedPage from '@/common/auth/protectedpage' import DashboardComponent from '@/components/dashboard' function Dashboard () { - const dispatch = useDispatch() - - const logout = () => { - dispatch(authSignOut()) - } - return ( - + ) } diff --git a/client/src/pages/index.js b/client/src/pages/index.js index f6536b5..4354bc7 100644 --- a/client/src/pages/index.js +++ b/client/src/pages/index.js @@ -1,4 +1,5 @@ import HomeComponent from '@/components/home' +import WithAuth from '@/common/auth/withauth' import { useSyncLocalStorage } from '@/lib/hooks/useSync' import { getRandomJoke } from '@/lib/services/random' import { useEffect, useState } from 'react' @@ -8,7 +9,7 @@ const defaultState = { activeTheme:'' } -export default function Index() { +function Index() { const [state, setState] = useState(defaultState) const [activeTheme] = useSyncLocalStorage('activeTheme') @@ -35,3 +36,5 @@ export default function Index() { /> ) } + +export default WithAuth(Index) \ No newline at end of file diff --git a/client/src/pages/recoverPassword/index.js b/client/src/pages/recoverPassword/index.js index cf17675..88f00b8 100644 --- a/client/src/pages/recoverPassword/index.js +++ b/client/src/pages/recoverPassword/index.js @@ -5,6 +5,7 @@ import { Validate } from '@/lib/utils/textValidation' import { sendPasswordResetEmail } from '@/lib/services/account' import { usePromise, RequestStatus } from '@/lib/hooks/usePromise' +import WithAuth from '@/common/auth/withauth' const defaultState = { username:{ @@ -69,4 +70,4 @@ const RecoverPassword = () => { ) } -export default RecoverPassword \ No newline at end of file +export default WithAuth(RecoverPassword) \ No newline at end of file diff --git a/client/src/pages/register/index.js b/client/src/pages/register/index.js index 6c9e9fd..1a624e8 100644 --- a/client/src/pages/register/index.js +++ b/client/src/pages/register/index.js @@ -5,6 +5,7 @@ import { Validate } from '@/lib/utils/textValidation' import AuthUtil from '@/lib/utils/firebase/authUtil' import { sendEmailVerification } from '@/lib/services/account' import PromiseWrapper from '@/lib/utils/promiseWrapper' +import WithAuth from '@/common/auth/withauth' const defaultState = { joke:undefined, @@ -128,4 +129,4 @@ const Register = () => { ) } -export default Register \ No newline at end of file +export default WithAuth(Register) \ No newline at end of file diff --git a/client/src/pages/userProfile/index.js b/client/src/pages/userProfile/index.js index 371a547..f10334d 100644 --- a/client/src/pages/userProfile/index.js +++ b/client/src/pages/userProfile/index.js @@ -1,4 +1,5 @@ import { UserProfileComponent } from '@/components/userProfile' +import WithAuth from '@/common/auth/withauth' import { useEffect, useRef, useState } from 'react' const defaultState = { @@ -54,4 +55,4 @@ const UserProfile = () => { ) } -export default UserProfile \ No newline at end of file +export default WithAuth(UserProfile) \ No newline at end of file From eb91ce27306cb03798d3d7b490c5c44d04e4f014 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 15 Mar 2023 11:39:20 +0800 Subject: [PATCH 7/7] feat: Use context provider to ensure 1 onauthstatechange listener exist for the whole app --- client/src/common/auth/protectedpage/index.js | 52 ++++++------ client/src/common/auth/withauth/index.js | 71 ---------------- client/src/common/layout/header/index.js | 11 ++- .../src/common/layout/loadingcover/index.js | 8 +- client/src/lib/hooks/useAuth.js | 81 +++++++++++++++++++ client/src/lib/store/users/userThunk.js | 1 + client/src/pages/_app.js | 5 +- client/src/pages/account/index.js | 3 +- client/src/pages/dashboard/index.js | 8 +- client/src/pages/index.js | 3 +- client/src/pages/login/index.js | 18 ++--- client/src/pages/recoverPassword/index.js | 3 +- client/src/pages/register/index.js | 3 +- client/src/pages/userProfile/index.js | 4 +- 14 files changed, 143 insertions(+), 128 deletions(-) delete mode 100644 client/src/common/auth/withauth/index.js create mode 100644 client/src/lib/hooks/useAuth.js diff --git a/client/src/common/auth/protectedpage/index.js b/client/src/common/auth/protectedpage/index.js index 1a6a279..9864ba0 100644 --- a/client/src/common/auth/protectedpage/index.js +++ b/client/src/common/auth/protectedpage/index.js @@ -1,39 +1,43 @@ import { useEffect } from 'react' -import { useSelector } from 'react-redux' import { useRouter } from 'next/router' +import { useAuth } from '@/lib/hooks/useAuth' + import LoadingCover from '@/common/layout/loadingcover' -import WithAuth from '@/common/auth/withauth' /** + * HOC that listens for the Firebase user auth info from the global "user" redux states set by the useAuth() hook. * Displays a loading cover page while waiting for the remote authentication info to settle in. - * Displays and returns the contents (children) of a tree if there is a signed-in user from the remote auth. + * Displays and returns the Component parameter if there is user data from the remote auth. * Redirect to the /login page if there is no user info after the remote auth settles in. - * @param {Component} children - * @returns {Component} + * @param {Component} Component + * @returns {Component} (Component parameter or a Loading placeholder component) + * Usage: export default ProtectedPage(MyComponent) */ -function ProtectedPage ({ children }) { - const router = useRouter() - const { authLoading, authUser } = useSelector(state => state.user) +function ProtectedPage (Component) { + function AuthListener (props) { + const router = useRouter() + const { authLoading, authError, authUser } = useAuth() - useEffect(() => { - if (!authLoading && !authUser) { - router.push('/login') - } - }, [authUser, authLoading, router]) + useEffect(() => { + if (!authLoading && !authUser) { + router.push('/login') + } + }, [authUser, authLoading, router]) - const ItemComponent = () => { - if (authLoading) { - return - } else if (authUser) { - return
- {children} -
- } else { - return + const ItemComponent = () => { + if (authLoading) { + return + } else if (authUser) { + return + } else { + return + } } + + return () } - return () + return AuthListener } -export default WithAuth(ProtectedPage) +export default ProtectedPage diff --git a/client/src/common/auth/withauth/index.js b/client/src/common/auth/withauth/index.js deleted file mode 100644 index a8f1992..0000000 --- a/client/src/common/auth/withauth/index.js +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { authErrorReceived, authReceived } from '@/store/users/userSlice' -import { authSignOut } from '@/lib/store/users/userThunk' -import { auth, onAuthStateChanged } from '@/lib/utils/firebase/config' - -/** - * HOC that listens for the Firebase user auth info using the onAuthStateChanged() method. - * Returns a Component with auth props for convenience. - * Sets the global Firebase user auth details in the user { authUser, authLoading, authError, authStatus } redux store. - * @param {*} Component - * @returns - */ -function WithAuth (Component) { - function AuthListener (props) { - const { authUser, authLoading, authError } = useSelector(state => state.user) - const dispatch = useDispatch() - - useEffect(() => { - const handleFirebaseUser = async (firebaseUser) => { - if (firebaseUser) { - // Check if user is emailVerified - if (!firebaseUser?.emailVerified ?? false) { - dispatch(authSignOut('Email not verified. Please verify your email first.')) - return - } - - try { - // Retrieve the custom claims information - const { claims } = await firebaseUser.getIdTokenResult() - - if (claims.account_level) { - // Get the firebase auth items of interest - dispatch(authReceived({ - uid: firebaseUser.uid, - email: firebaseUser.email, - name: firebaseUser.displayName, - accessToken: firebaseUser.accessToken, - emailVerified: firebaseUser.emailVerified, - account_level: claims.account_level - })) - } else { - // User did not sign-up using the custom process - dispatch(authSignOut('Missing custom claims')) - } - } catch (err) { - dispatch(authErrorReceived(err?.response?.data ?? err.message)) - dispatch(authReceived(null)) - } - } else { - dispatch(authReceived(null)) - } - } - - const unsubscribe = onAuthStateChanged(auth, handleFirebaseUser) - return () => unsubscribe() - }, [dispatch]) - - return ( - ) - } - - return AuthListener -} - -export default WithAuth diff --git a/client/src/common/layout/header/index.js b/client/src/common/layout/header/index.js index dfd039a..6736cc9 100644 --- a/client/src/common/layout/header/index.js +++ b/client/src/common/layout/header/index.js @@ -1,7 +1,6 @@ // REACT import { useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { authSignOut } from '@/lib/store/users/userThunk' +import { useDispatch } from 'react-redux' // NEXT import Link from 'next/link' @@ -28,10 +27,10 @@ import HowToRegIcon from '@mui/icons-material/HowToReg' // LIB import { Avalon } from '@/lib/mui/theme' import { useSyncLocalStorage } from '@/lib/hooks/useSync' +import { useAuth } from '@/lib/hooks/useAuth' // VARIABLES const pages = ['about'] -// const settings = ['Profile', 'Account', 'Dashboard', 'Logout'] const settings = [ { name: 'Profile', @@ -56,7 +55,7 @@ function Header() { const [anchorElNav, setAnchorElNav] = useState(null) const [anchorElUser, setAnchorElUser] = useState(null) const [activeTheme, setActiveTheme] = useSyncLocalStorage('activeTheme') - const authUser = useSelector(state => state.user.authUser) + const { authUser, authSignOut } = useAuth() const dispatch = useDispatch() const router = useRouter() @@ -203,9 +202,9 @@ function Header() { onClose={handleCloseUserMenu} > {settings.map((setting, id) => { - return (setting === 'Logout') + return (setting.name === 'Logout') ? dispatch(authSignOut())}> - {setting} + {setting.name} : handleClickNavMenu(setting.route)}> {setting.name} diff --git a/client/src/common/layout/loadingcover/index.js b/client/src/common/layout/loadingcover/index.js index ef2196e..7ed2ce0 100644 --- a/client/src/common/layout/loadingcover/index.js +++ b/client/src/common/layout/loadingcover/index.js @@ -6,7 +6,7 @@ import Page from '@/common/layout/page' import TransparentBox from '@/common/ui/transparentbox' import { useSyncLocalStorage } from '@/lib/hooks/useSync' -function LoadingCover () { +function LoadingCover ({ authError }) { const [activeTheme] = useSyncLocalStorage('activeTheme') return ( @@ -33,6 +33,12 @@ function LoadingCover () { color={(activeTheme === 'light') ? 'dark' : 'primary'} /> + + {authError && + + {authError} + + }
diff --git a/client/src/lib/hooks/useAuth.js b/client/src/lib/hooks/useAuth.js new file mode 100644 index 0000000..12dcf5a --- /dev/null +++ b/client/src/lib/hooks/useAuth.js @@ -0,0 +1,81 @@ +import { useEffect, createContext, useContext } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { authErrorReceived, authReceived } from '@/store/users/userSlice' +import { authSignOut } from '@/lib/store/users/userThunk' +import { auth, onAuthStateChanged } from '@/lib/utils/firebase/config' + +/** + * Context provider that listens for the Firebase user auth info using the Firebase onAuthStateChanged() method. + * Sets the global Firebase user auth details in the user { authUser, authLoading, authError, authStatus } redux store. + * Also returns the user { authUser, authLoading, authError, authStatus } redux data for convenience. + * @returns {Object} authUser - partial, relevant signed-in Firebase user data + * @returns {Bool} authLoading - Firebase auth status is being fetched from Firebase (from intial page load or during sign-out) + * @returns {String} authError - Firebase authentication error + * @returns {String} authStatus - Descriptive Auth status info. One of USER_STATES + * Usage: const { authUser, authLoading, authError, authStatus } = useAuth() + */ + +const AuthContext = createContext() + +export function AuthProvider ({ children }) { + const authUser = useFirebaseAuth() + return {children} +} + +export const useAuth = () => { + return useContext(AuthContext) +} + +function useFirebaseAuth () { + const { authUser, authLoading, authStatus, authError } = useSelector(state => state.user) + const dispatch = useDispatch() + + useEffect(() => { + const handleFirebaseUser = async (firebaseUser) => { + if (firebaseUser) { + // Check if user is emailVerified + if (!firebaseUser?.emailVerified ?? false) { + dispatch(authSignOut('Email not verified. Please verify your email first.')) + return + } + + try { + // Retrieve the custom claims information + const { claims } = await firebaseUser.getIdTokenResult() + + if (claims.account_level) { + // Get the firebase auth items of interest + dispatch(authReceived({ + uid: firebaseUser.uid, + email: firebaseUser.email, + name: firebaseUser.displayName, + accessToken: firebaseUser.accessToken, + emailVerified: firebaseUser.emailVerified, + account_level: claims.account_level + })) + } else { + // User did not sign-up using the custom process + dispatch(authSignOut('Missing custom claims')) + } + } catch (err) { + dispatch(authErrorReceived(err?.response?.data ?? err.message)) + dispatch(authReceived(null)) + } + } else { + // No user is signed-in + dispatch(authReceived(null)) + } + } + + const unsubscribe = onAuthStateChanged(auth, handleFirebaseUser) + return () => unsubscribe() + }, [dispatch]) + + return { + authUser, + authLoading, + authStatus, + authError, + authSignOut + } +} diff --git a/client/src/lib/store/users/userThunk.js b/client/src/lib/store/users/userThunk.js index 14991fc..270d0ca 100644 --- a/client/src/lib/store/users/userThunk.js +++ b/client/src/lib/store/users/userThunk.js @@ -12,3 +12,4 @@ export const authSignOut = createAsyncThunk('auth/signout', async(errorMessage = return thunkAPI.rejectWithValue(err?.response?.data ?? err.message) } }) + diff --git a/client/src/pages/_app.js b/client/src/pages/_app.js index 4b14070..46ef04b 100644 --- a/client/src/pages/_app.js +++ b/client/src/pages/_app.js @@ -7,6 +7,7 @@ import '@/styles/globals.css' // Redux import { Provider } from 'react-redux' import { store } from '@/store/store' +import { AuthProvider } from '@/lib/hooks/useAuth' // MUI import createEmotionCache from '@/lib/mui/createEmotionCache' @@ -31,7 +32,9 @@ export default function MyApp(props) { {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} - + + + diff --git a/client/src/pages/account/index.js b/client/src/pages/account/index.js index 6f5391a..bb2d9d9 100644 --- a/client/src/pages/account/index.js +++ b/client/src/pages/account/index.js @@ -8,7 +8,6 @@ import { usePromise, RequestStatus } from '@/lib/hooks/usePromise' import AccountComponent from '@/components/account' import messages from './messages' -import WithAuth from '@/common/auth/withauth' const defaultState = { loading: true, @@ -90,4 +89,4 @@ function Account () { ) } -export default WithAuth(Account) +export default Account diff --git a/client/src/pages/dashboard/index.js b/client/src/pages/dashboard/index.js index a6ff372..eb7eb53 100644 --- a/client/src/pages/dashboard/index.js +++ b/client/src/pages/dashboard/index.js @@ -2,11 +2,7 @@ import ProtectedPage from '@/common/auth/protectedpage' import DashboardComponent from '@/components/dashboard' function Dashboard () { - return ( - - - - ) + return () } -export default Dashboard +export default ProtectedPage(Dashboard) diff --git a/client/src/pages/index.js b/client/src/pages/index.js index 4354bc7..0f08532 100644 --- a/client/src/pages/index.js +++ b/client/src/pages/index.js @@ -1,5 +1,4 @@ import HomeComponent from '@/components/home' -import WithAuth from '@/common/auth/withauth' import { useSyncLocalStorage } from '@/lib/hooks/useSync' import { getRandomJoke } from '@/lib/services/random' import { useEffect, useState } from 'react' @@ -37,4 +36,4 @@ function Index() { ) } -export default WithAuth(Index) \ No newline at end of file +export default Index \ No newline at end of file diff --git a/client/src/pages/login/index.js b/client/src/pages/login/index.js index ef83e07..42a4093 100644 --- a/client/src/pages/login/index.js +++ b/client/src/pages/login/index.js @@ -5,7 +5,7 @@ import LoginComponent from '@/components/login' import { getRandomJoke } from '@/lib/services/random' import { Validate } from '@/lib/utils/textValidation' import AuthUtil from '@/lib/utils/firebase/authUtil' -import WithAuth from '@/common/auth/withauth' +import { useAuth } from '@/lib/hooks/useAuth' const defaultState = { username:{ @@ -25,10 +25,11 @@ const defaultState = { loading:false } -function Login (props) { +function Login () { const [state, setState] = useState(defaultState) const { username, password } = state const router = useRouter() + const { authLoading, authUser, authError } = useAuth() class eventsHandler { static usernameHandler = (e) => { @@ -62,7 +63,6 @@ function Login (props) { static loginHandler = () => { (async()=>{ setState({ ...state, loading: true, errorMessage: undefined }) - const response = await AuthUtil.signIn(username.value, password.value) const errorMessage = response.errorMessage @@ -86,20 +86,20 @@ function Login (props) { },[]) useEffect(() => { - if (!props.authLoading) { + if (!authLoading) { setState(prev => ({ ...prev, loading: false, - errorMessage: (props.authError !== '') - ? props.authError + errorMessage: (authError !== '') + ? authError : prev.errorMessage })) - if (props.authUser) { + if (authUser) { router.push('/dashboard') } } - }, [props.authError, props.authLoading, props.authUser, router]) + }, [authError, authLoading, authUser, router]) const resetError = () => { setState({ ...state, errorMessage: undefined }) @@ -114,4 +114,4 @@ function Login (props) { ) } -export default WithAuth(Login) +export default Login diff --git a/client/src/pages/recoverPassword/index.js b/client/src/pages/recoverPassword/index.js index 88f00b8..cf17675 100644 --- a/client/src/pages/recoverPassword/index.js +++ b/client/src/pages/recoverPassword/index.js @@ -5,7 +5,6 @@ import { Validate } from '@/lib/utils/textValidation' import { sendPasswordResetEmail } from '@/lib/services/account' import { usePromise, RequestStatus } from '@/lib/hooks/usePromise' -import WithAuth from '@/common/auth/withauth' const defaultState = { username:{ @@ -70,4 +69,4 @@ const RecoverPassword = () => { ) } -export default WithAuth(RecoverPassword) \ No newline at end of file +export default RecoverPassword \ No newline at end of file diff --git a/client/src/pages/register/index.js b/client/src/pages/register/index.js index 1a624e8..6c9e9fd 100644 --- a/client/src/pages/register/index.js +++ b/client/src/pages/register/index.js @@ -5,7 +5,6 @@ import { Validate } from '@/lib/utils/textValidation' import AuthUtil from '@/lib/utils/firebase/authUtil' import { sendEmailVerification } from '@/lib/services/account' import PromiseWrapper from '@/lib/utils/promiseWrapper' -import WithAuth from '@/common/auth/withauth' const defaultState = { joke:undefined, @@ -129,4 +128,4 @@ const Register = () => { ) } -export default WithAuth(Register) \ No newline at end of file +export default Register \ No newline at end of file diff --git a/client/src/pages/userProfile/index.js b/client/src/pages/userProfile/index.js index f10334d..b05cd10 100644 --- a/client/src/pages/userProfile/index.js +++ b/client/src/pages/userProfile/index.js @@ -1,6 +1,6 @@ import { UserProfileComponent } from '@/components/userProfile' -import WithAuth from '@/common/auth/withauth' import { useEffect, useRef, useState } from 'react' +import ProtectedPage from '@/common/auth/protectedpage' const defaultState = { user:{ @@ -55,4 +55,4 @@ const UserProfile = () => { ) } -export default WithAuth(UserProfile) \ No newline at end of file +export default ProtectedPage(UserProfile) \ No newline at end of file