Merge pull request #426 from bigcapitalhq/big-163-user-email-verification-after-signing-up

feat: User email verification after signing-up.
This commit is contained in:
Ahmed Bouhuolia
2024-05-06 17:46:26 +02:00
committed by GitHub
38 changed files with 1193 additions and 54 deletions

View File

@@ -9,13 +9,24 @@ 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';
import LazyLoader from '@/components/LazyLoader';
import { SplashScreen, DashboardThemeProvider } from '../components';
import { queryConfig } from '../hooks/query/base';
import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified';
import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated';
import { EnsureUserEmailNotVerified } from './Guards/EnsureUserEmailNotVerified';
const EmailConfirmation = LazyLoader({
loader: () => import('@/containers/Authentication/EmailConfirmation'),
});
const RegisterVerify = LazyLoader({
loader: () => import('@/containers/Authentication/RegisterVerify'),
});
/**
* App inner.
@@ -26,9 +37,30 @@ function AppInsider({ history }) {
<DashboardThemeProvider>
<Router history={history}>
<Switch>
<Route path={'/auth'} component={Authentication} />
<Route path={'/auth/register/verify'}>
<EnsureAuthenticated>
<EnsureUserEmailNotVerified>
<RegisterVerify />
</EnsureUserEmailNotVerified>
</EnsureAuthenticated>
</Route>
<Route path={'/auth/email_confirmation'}>
<EmailConfirmation />
</Route>
<Route path={'/auth'}>
<EnsureAuthNotAuthenticated>
<Authentication />
</EnsureAuthNotAuthenticated>
</Route>
<Route path={'/'}>
<PrivateRoute component={DashboardPrivatePages} />
<EnsureAuthenticated>
<EnsureUserEmailVerified>
<DashboardPrivatePages />
</EnsureUserEmailVerified>
</EnsureAuthenticated>
</Route>
</Switch>
</Router>

View File

@@ -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,

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useIsAuthenticated } from '@/hooks/state';
interface EnsureAuthNotAuthenticatedProps {
children: React.ReactNode;
redirectTo?: string;
}
export function EnsureAuthNotAuthenticated({
children,
redirectTo = '/',
}: EnsureAuthNotAuthenticatedProps) {
const isAuthenticated = useIsAuthenticated();
return !isAuthenticated ? (
<>{children}</>
) : (
<Redirect to={{ pathname: redirectTo }} />
);
}

View File

@@ -0,0 +1,22 @@
// @ts-nocheck
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useIsAuthenticated } from '@/hooks/state';
interface EnsureAuthenticatedProps {
children: React.ReactNode;
redirectTo?: string;
}
export function EnsureAuthenticated({
children,
redirectTo = '/auth/login',
}: EnsureAuthenticatedProps) {
const isAuthenticated = useIsAuthenticated();
return isAuthenticated ? (
<>{children}</>
) : (
<Redirect to={{ pathname: redirectTo }} />
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useAuthUserVerified } from '@/hooks/state';
interface EnsureUserEmailNotVerifiedProps {
children: React.ReactNode;
redirectTo?: string;
}
/**
* Higher Order Component to ensure that the user's email is not verified.
* If is verified, redirects to the inner setup page.
*/
export function EnsureUserEmailNotVerified({
children,
redirectTo = '/',
}: EnsureUserEmailNotVerifiedProps) {
const isAuthVerified = useAuthUserVerified();
if (isAuthVerified) {
return <Redirect to={{ pathname: redirectTo }} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { useAuthUserVerified } from '@/hooks/state';
interface EnsureUserEmailVerifiedProps {
children: React.ReactNode;
redirectTo?: string;
}
/**
* Higher Order Component to ensure that the user's email is verified.
* If not verified, redirects to the email verification page.
*/
export function EnsureUserEmailVerified({
children,
redirectTo = '/auth/register/verify',
}: EnsureUserEmailVerifiedProps) {
const isAuthVerified = useAuthUserVerified();
if (!isAuthVerified) {
return <Redirect to={{ pathname: redirectTo }} />;
}
return <>{children}</>;
}

View File

@@ -1,19 +0,0 @@
// @ts-nocheck
import React from 'react';
import BodyClassName from 'react-body-classname';
import { Redirect } from 'react-router-dom';
import { useIsAuthenticated } from '@/hooks/state';
export default function PrivateRoute({ component: Component, ...rest }) {
const isAuthenticated = useIsAuthenticated();
return (
<BodyClassName className={''}>
{isAuthenticated ? (
<Component />
) : (
<Redirect to={{ pathname: '/auth/login' }} />
)}
</BodyClassName>
);
}

View File

@@ -0,0 +1,34 @@
// @ts-nocheck
import styled from 'styled-components';
import { Icon, FormattedMessage as T } from '@/components';
interface AuthContainerProps {
children: React.ReactNode;
}
export function AuthContainer({ children }: AuthContainerProps) {
return (
<AuthPage>
<AuthInsider>
<AuthLogo>
<Icon icon="bigcapital" height={37} width={214} />
</AuthLogo>
{children}
</AuthInsider>
</AuthPage>
);
}
const AuthPage = styled.div``;
const AuthInsider = styled.div`
width: 384px;
margin: 0 auto;
margin-bottom: 40px;
padding-top: 80px;
`;
const AuthLogo = styled.div`
text-align: center;
margin-bottom: 40px;
`;

View File

@@ -1,24 +1,16 @@
// @ts-nocheck
import React from 'react';
import { Redirect, Route, Switch, useLocation } from 'react-router-dom';
import { Route, Switch, useLocation } from 'react-router-dom';
import BodyClassName from 'react-body-classname';
import styled from 'styled-components';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import authenticationRoutes from '@/routes/authentication';
import { Icon, FormattedMessage as T } from '@/components';
import { useIsAuthenticated } from '@/hooks/state';
import { AuthMetaBootProvider } from './AuthMetaBoot';
import '@/style/pages/Authentication/Auth.scss';
export function Authentication() {
const to = { pathname: '/' };
const isAuthenticated = useIsAuthenticated();
if (isAuthenticated) {
return <Redirect to={to} />;
}
return (
<BodyClassName className={'authentication'}>
<AuthPage>

View File

@@ -0,0 +1,46 @@
// @ts-nocheck
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 history = useHistory();
const query = useQuery();
const token = query.get('token');
const email = query.get('email');
useEffect(() => {
if (!token || !email) {
history.push('/auth/login');
}
}, [history, token, email]);
useEffect(() => {
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;
}

View File

@@ -0,0 +1,18 @@
.root {
text-align: center;
}
.title{
font-size: 18px;
font-weight: 600;
margin-bottom: 0.5rem;
color: #252A31;
}
.description{
margin-bottom: 1rem;
font-size: 15px;
line-height: 1.45;
color: #404854;
}

View File

@@ -0,0 +1,70 @@
// @ts-nocheck
import { Button, Intent } from '@blueprintjs/core';
import AuthInsider from './AuthInsider';
import { AuthInsiderCard } from './_components';
import styles from './RegisterVerify.module.scss';
import { AppToaster, Stack } from '@/components';
import { useAuthActions } from '@/hooks/state';
import { useAuthSignUpVerifyResendMail } from '@/hooks/query';
import { AuthContainer } from './AuthContainer';
export default function RegisterVerify() {
const { setLogout } = useAuthActions();
const { mutateAsync: resendSignUpVerifyMail, isLoading } =
useAuthSignUpVerifyResendMail();
const handleResendMailBtnClick = () => {
resendSignUpVerifyMail()
.then(() => {
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The verification mail has sent successfully.',
});
})
.catch(() => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
const handleSignOutBtnClick = () => {
setLogout();
};
return (
<AuthContainer>
<AuthInsider>
<AuthInsiderCard className={styles.root}>
<h2 className={styles.title}>Please verify your email</h2>
<p className={styles.description}>
We sent an email to <strong>asdahmed@gmail.com</strong> Click the
link inside to get started.
</p>
<Stack spacing={4}>
<Button
large
fill
loading={isLoading}
intent={Intent.NONE}
onClick={handleResendMailBtnClick}
>
Resend email
</Button>
<Button
large
fill
minimal
intent={Intent.DANGER}
onClick={handleSignOutBtnClick}
>
Not my email
</Button>
</Stack>
</AuthInsiderCard>
</AuthInsider>
</AuthContainer>
);
}

View File

@@ -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,5 +88,35 @@ export const useAuthMetadata = (props) => {
defaultData: {},
...props,
},
);
);
};
/**
*
*/
export const useAuthSignUpVerifyResendMail = (props) => {
const apiRequest = useApiRequest();
return useMutation(
() => apiRequest.post('auth/register/verify/resend'),
props,
);
};
interface AuthSignUpVerifyValues {
token: string;
email: string;
}
/**
*
*/
export const useAuthSignUpVerify = (props) => {
const apiRequest = useApiRequest();
return useMutation(
(values: AuthSignUpVerifyValues) =>
apiRequest.post('auth/register/verify', values),
props,
);
};

View File

@@ -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;
};

View File

@@ -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';
@@ -64,3 +67,22 @@ export const useAuthUser = () => {
export const useAuthOrganizationId = () => {
return useSelector((state) => state.authentication.organizationId);
};
/**
* Retrieves the user's email verification status.
*/
export const useAuthUserVerified = () => {
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],
);
};

View File

@@ -28,10 +28,16 @@ export default [
loader: () => import('@/containers/Authentication/InviteAccept'),
}),
},
{
path: `${BASE_URL}/register/email_confirmation`,
component: LazyLoader({
loader: () => import('@/containers/Authentication/EmailConfirmation'),
}),
},
{
path: `${BASE_URL}/register`,
component: LazyLoader({
loader: () => import('@/containers/Authentication/Register'),
}),
}
},
];

View File

@@ -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 });
export const setStoreReset = () => ({ type: t.RESET });
export const setEmailConfirmed = (verified?: boolean) => ({
type: t.SET_EMAIL_VERIFIED,
action: { verified },
});

View File

@@ -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);
},

View File

@@ -7,4 +7,5 @@ export default {
LOGOUT: 'LOGOUT',
LOGIN_CLEAR_ERRORS: 'LOGIN_CLEAR_ERRORS',
RESET: 'RESET',
SET_EMAIL_VERIFIED: 'SET_EMAIL_VERIFIED'
};