diff --git a/packages/server/src/system/models/SystemUser.ts b/packages/server/src/system/models/SystemUser.ts index 627caaeb6..ce17186df 100644 --- a/packages/server/src/system/models/SystemUser.ts +++ b/packages/server/src/system/models/SystemUser.ts @@ -35,7 +35,7 @@ export default class SystemUser extends SystemModel { * Virtual attributes. */ static get virtualAttributes() { - return ['fullName', 'isDeleted', 'isInviteAccepted']; + return ['fullName', 'isDeleted', 'isInviteAccepted', 'isVerified']; } /** diff --git a/packages/webapp/src/components/App.tsx b/packages/webapp/src/components/App.tsx index 57effb72a..856f83686 100644 --- a/packages/webapp/src/components/App.tsx +++ b/packages/webapp/src/components/App.tsx @@ -9,7 +9,7 @@ import 'moment/locale/ar-ly'; import 'moment/locale/es-us'; import AppIntlLoader from './AppIntlLoader'; -import PrivateRoute from '@/components/Guards/PrivateRoute'; +import { EnsureAuthenticated } from '@/components/Guards/EnsureAuthenticated'; import GlobalErrors from '@/containers/GlobalErrors/GlobalErrors'; import DashboardPrivatePages from '@/components/Dashboard/PrivatePages'; import { Authentication } from '@/containers/Authentication/Authentication'; @@ -20,6 +20,9 @@ import { queryConfig } from '../hooks/query/base'; import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified'; import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated'; +const EmailConfirmation = LazyLoader({ + loader: () => import('@/containers/Authentication/EmailConfirmation'), +}); const RegisterVerify = LazyLoader({ loader: () => import('@/containers/Authentication/RegisterVerify'), }); @@ -33,24 +36,28 @@ function AppInsider({ history }) { + + + + + + + + + + - - - - - - - + - + diff --git a/packages/webapp/src/components/Dashboard/DashboardBoot.tsx b/packages/webapp/src/components/Dashboard/DashboardBoot.tsx index bae18273e..0d105c112 100644 --- a/packages/webapp/src/components/Dashboard/DashboardBoot.tsx +++ b/packages/webapp/src/components/Dashboard/DashboardBoot.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useEffect } from 'react'; import { useAuthenticatedAccount, useCurrentOrganization, @@ -116,6 +116,14 @@ export function useApplicationBoot() { isBooted.current = true; }, ); + // Reset the loading states once the hook unmount. + useEffect( + () => () => { + isAuthUserLoading && !isBooted.current && stopLoading(); + isOrgLoading && !isBooted.current && stopLoading(); + }, + [isAuthUserLoading, isOrgLoading, stopLoading], + ); return { isLoading: isOrgLoading || isAuthUserLoading, diff --git a/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx b/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx index e849a572c..97539a32d 100644 --- a/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx +++ b/packages/webapp/src/components/Guards/EnsureAuthNotAuthenticated.tsx @@ -3,12 +3,20 @@ import React from 'react'; import { Redirect } from 'react-router-dom'; import { useIsAuthenticated } from '@/hooks/state'; -interface PrivateRouteProps { +interface EnsureAuthNotAuthenticatedProps { children: React.ReactNode; + redirectTo?: string; } -export function EnsureAuthNotAuthenticated({ children }: PrivateRouteProps) { +export function EnsureAuthNotAuthenticated({ + children, + redirectTo = '/', +}: EnsureAuthNotAuthenticatedProps) { const isAuthenticated = useIsAuthenticated(); - return !isAuthenticated ? children : ; + return !isAuthenticated ? ( + <>{children} + ) : ( + + ); } diff --git a/packages/webapp/src/components/Guards/PrivateRoute.tsx b/packages/webapp/src/components/Guards/EnsureAuthenticated.tsx similarity index 52% rename from packages/webapp/src/components/Guards/PrivateRoute.tsx rename to packages/webapp/src/components/Guards/EnsureAuthenticated.tsx index a4ef8d3b7..0a223a9ae 100644 --- a/packages/webapp/src/components/Guards/PrivateRoute.tsx +++ b/packages/webapp/src/components/Guards/EnsureAuthenticated.tsx @@ -1,19 +1,22 @@ // @ts-nocheck import React from 'react'; -import BodyClassName from 'react-body-classname'; import { Redirect } from 'react-router-dom'; import { useIsAuthenticated } from '@/hooks/state'; -interface PrivateRouteProps { +interface EnsureAuthenticatedProps { children: React.ReactNode; + redirectTo?: string; } -export default function PrivateRoute({ children }: PrivateRouteProps) { +export function EnsureAuthenticated({ + children, + redirectTo = '/auth/login', +}: EnsureAuthenticatedProps) { const isAuthenticated = useIsAuthenticated(); return isAuthenticated ? ( - children + <>{children} ) : ( - + ); } diff --git a/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx index 6fb57c4a5..f24d93533 100644 --- a/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx +++ b/packages/webapp/src/components/Guards/EnsureUserEmailVerified.tsx @@ -4,6 +4,7 @@ import { useAuthUserVerified } from '@/hooks/state'; interface EnsureUserEmailVerifiedProps { children: React.ReactNode; + redirectTo?: string; } /** @@ -12,11 +13,12 @@ interface EnsureUserEmailVerifiedProps { */ export function EnsureUserEmailVerified({ children, + redirectTo = '/auth/register/verify', }: EnsureUserEmailVerifiedProps) { const isAuthVerified = useAuthUserVerified(); if (!isAuthVerified) { - return ; + return ; } return <>{children}; } diff --git a/packages/webapp/src/containers/Authentication/Authentication.tsx b/packages/webapp/src/containers/Authentication/Authentication.tsx index 34a99f104..408dc78f0 100644 --- a/packages/webapp/src/containers/Authentication/Authentication.tsx +++ b/packages/webapp/src/containers/Authentication/Authentication.tsx @@ -1,5 +1,4 @@ // @ts-nocheck -import React from 'react'; import { Route, Switch, useLocation } from 'react-router-dom'; import BodyClassName from 'react-body-classname'; import styled from 'styled-components'; diff --git a/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx b/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx index c9aeb985e..e7fe66d5e 100644 --- a/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx +++ b/packages/webapp/src/containers/Authentication/EmailConfirmation.tsx @@ -1,27 +1,46 @@ // @ts-nocheck -import { useEffect } from 'react'; -import { useHistory, useParams } from 'react-router-dom'; +import { useEffect, useMemo } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; import { useAuthSignUpVerify } from '@/hooks/query'; +import { AppToaster } from '@/components'; +import { Intent } from '@blueprintjs/core'; + +function useQuery() { + const { search } = useLocation(); + return useMemo(() => new URLSearchParams(search), [search]); +} export default function EmailConfirmation() { const { mutateAsync: authSignupVerify } = useAuthSignUpVerify(); - const params = useParams(); const history = useHistory(); + const query = useQuery(); - const token = params.token; - const email = params.email; + const token = query.get('token'); + const email = query.get('email'); useEffect(() => { if (!token || !email) { - history.push('register/email_confirmation'); + history.push('/auth/login'); } }, [history, token, email]); useEffect(() => { - authSignupVerify(token, email) - .then(() => {}) - .catch((error) => {}); - }, [token, email, authSignupVerify]); + authSignupVerify({ token, email }) + .then(() => { + AppToaster.show({ + message: 'Your email has been verified, Congrats!', + intent: Intent.SUCCESS, + }); + history.push('/'); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong', + intent: Intent.DANGER, + }); + history.push('/'); + }); + }, [token, email, authSignupVerify, history]); return null; } diff --git a/packages/webapp/src/containers/Authentication/RegisterVerify.tsx b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx index 2147a8f2e..a707bfdb0 100644 --- a/packages/webapp/src/containers/Authentication/RegisterVerify.tsx +++ b/packages/webapp/src/containers/Authentication/RegisterVerify.tsx @@ -7,10 +7,8 @@ import { AppToaster, Stack } from '@/components'; import { useAuthActions } from '@/hooks/state'; import { useAuthSignUpVerifyResendMail } from '@/hooks/query'; import { AuthContainer } from './AuthContainer'; -import { useHistory } from 'react-router-dom'; export default function RegisterVerify() { - const history = useHistory(); const { setLogout } = useAuthActions(); const { mutateAsync: resendSignUpVerifyMail, isLoading } = useAuthSignUpVerifyResendMail(); @@ -30,8 +28,6 @@ export default function RegisterVerify() { }); }); }; - - // Handle logout link click. const handleSignOutBtnClick = () => { setLogout(); }; @@ -60,11 +56,11 @@ export default function RegisterVerify() { diff --git a/packages/webapp/src/hooks/query/authentication.tsx b/packages/webapp/src/hooks/query/authentication.tsx index d43d244c1..7f48d78b2 100644 --- a/packages/webapp/src/hooks/query/authentication.tsx +++ b/packages/webapp/src/hooks/query/authentication.tsx @@ -78,7 +78,7 @@ export const useAuthResetPassword = (props) => { */ export const useAuthMetadata = (props) => { return useRequestQuery( - [t.AUTH_METADATA_PAGE,], + [t.AUTH_METADATA_PAGE], { method: 'get', url: `auth/meta`, @@ -88,12 +88,11 @@ export const useAuthMetadata = (props) => { defaultData: {}, ...props, }, - ); -} - + ); +}; /** - * + * */ export const useAuthSignUpVerifyResendMail = (props) => { const apiRequest = useApiRequest(); @@ -104,16 +103,20 @@ export const useAuthSignUpVerifyResendMail = (props) => { ); }; - +interface AuthSignUpVerifyValues { + token: string; + email: string; +} /** - * + * */ export const useAuthSignUpVerify = (props) => { const apiRequest = useApiRequest(); return useMutation( - (token: string, email: string) => apiRequest.post('auth/register/verify'), + (values: AuthSignUpVerifyValues) => + apiRequest.post('auth/register/verify', values), props, ); -}; \ No newline at end of file +}; diff --git a/packages/webapp/src/hooks/query/users.tsx b/packages/webapp/src/hooks/query/users.tsx index 73bcf0691..646308353 100644 --- a/packages/webapp/src/hooks/query/users.tsx +++ b/packages/webapp/src/hooks/query/users.tsx @@ -5,6 +5,7 @@ import { useQueryTenant, useRequestQuery } from '../useQueryRequest'; import useApiRequest from '../useRequest'; import { useSetFeatureDashboardMeta } from '../state/feature'; import t from './types'; +import { useSetAuthEmailConfirmed } from '../state'; // Common invalidate queries. const commonInvalidateQueries = (queryClient) => { @@ -130,6 +131,8 @@ export function useUser(id, props) { } export function useAuthenticatedAccount(props) { + const setEmailConfirmed = useSetAuthEmailConfirmed(); + return useRequestQuery( ['AuthenticatedAccount'], { @@ -139,6 +142,9 @@ export function useAuthenticatedAccount(props) { { select: (response) => response.data.data, defaultData: {}, + onSuccess: (data) => { + setEmailConfirmed(data.is_verified); + }, ...props, }, ); @@ -166,4 +172,3 @@ export const useDashboardMeta = (props) => { }, [state.isSuccess, state.data, setFeatureDashboardMeta]); return state; }; - diff --git a/packages/webapp/src/hooks/state/authentication.tsx b/packages/webapp/src/hooks/state/authentication.tsx index b520e1ac1..73054c663 100644 --- a/packages/webapp/src/hooks/state/authentication.tsx +++ b/packages/webapp/src/hooks/state/authentication.tsx @@ -2,7 +2,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { useCallback } from 'react'; import { isAuthenticated } from '@/store/authentication/authentication.reducer'; -import { setLogin } from '@/store/authentication/authentication.actions'; +import { + setEmailConfirmed, + setLogin, +} from '@/store/authentication/authentication.actions'; import { useQueryClient } from 'react-query'; import { removeCookie } from '@/utils'; @@ -66,8 +69,20 @@ export const useAuthOrganizationId = () => { }; /** - * + * Retrieves the user's email verification status. */ export const useAuthUserVerified = () => { - return useSelector(() => false); + return useSelector((state) => state.authentication.verified); +}; + +/** + * Sets the user's email verification status. + */ +export const useSetAuthEmailConfirmed = () => { + const dispatch = useDispatch(); + + return useCallback( + (verified?: boolean = true) => dispatch(setEmailConfirmed(verified)), + [dispatch], + ); }; diff --git a/packages/webapp/src/store/authentication/authentication.actions.tsx b/packages/webapp/src/store/authentication/authentication.actions.tsx index 4d2d5f048..daefd8f48 100644 --- a/packages/webapp/src/store/authentication/authentication.actions.tsx +++ b/packages/webapp/src/store/authentication/authentication.actions.tsx @@ -3,4 +3,8 @@ import t from '@/store/types'; export const setLogin = () => ({ type: t.LOGIN_SUCCESS }); export const setLogout = () => ({ type: t.LOGOUT }); -export const setStoreReset = () => ({ type: t.RESET }); \ No newline at end of file +export const setStoreReset = () => ({ type: t.RESET }); +export const setEmailConfirmed = (verified?: boolean) => ({ + type: t.SET_EMAIL_VERIFIED, + action: { verified }, +}); diff --git a/packages/webapp/src/store/authentication/authentication.reducer.tsx b/packages/webapp/src/store/authentication/authentication.reducer.tsx index 9972bf2a8..ce56b81ec 100644 --- a/packages/webapp/src/store/authentication/authentication.reducer.tsx +++ b/packages/webapp/src/store/authentication/authentication.reducer.tsx @@ -1,8 +1,9 @@ // @ts-nocheck -import { createReducer } from '@reduxjs/toolkit'; +import { PayloadAction, createReducer } from '@reduxjs/toolkit'; import { persistReducer } from 'redux-persist'; import purgeStoredState from 'redux-persist/es/purgeStoredState'; import storage from 'redux-persist/lib/storage'; +import { isUndefined } from 'lodash'; import { getCookie } from '@/utils'; import t from '@/store/types'; @@ -13,6 +14,7 @@ const initialState = { tenantId: getCookie('tenant_id'), userId: getCookie('authenticated_user_id'), locale: getCookie('locale'), + verified: true, // Let's be optimistic and assume the user's email is confirmed. errors: [], }; @@ -32,6 +34,15 @@ const reducerInstance = createReducer(initialState, { state.errors = []; }, + [t.SET_EMAIL_VERIFIED]: ( + state, + payload: PayloadAction<{ verified?: boolean }>, + ) => { + state.verified = !isUndefined(payload.action.verified) + ? payload.action.verified + : true; + }, + [t.RESET]: (state) => { purgeStoredState(CONFIG); }, diff --git a/packages/webapp/src/store/authentication/authentication.types.tsx b/packages/webapp/src/store/authentication/authentication.types.tsx index c5a5b3c3f..f64af4652 100644 --- a/packages/webapp/src/store/authentication/authentication.types.tsx +++ b/packages/webapp/src/store/authentication/authentication.types.tsx @@ -7,4 +7,5 @@ export default { LOGOUT: 'LOGOUT', LOGIN_CLEAR_ERRORS: 'LOGIN_CLEAR_ERRORS', RESET: 'RESET', + SET_EMAIL_VERIFIED: 'SET_EMAIL_VERIFIED' }; \ No newline at end of file