Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: email verification #925

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"@testing-library/jest-dom": "^5.1.1",
"@testing-library/react": "^11.2.3",
"@testing-library/user-event": "^12.6.3",
"@types/auth0-js": "^9.14.6",
"@types/classnames": "^2.3.1",
"@types/jest": "^26.0.23",
"@types/lodash": "^4.14.170",
Expand Down
File renamed without changes.
9 changes: 8 additions & 1 deletion src/Me/Me.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import { desktop } from './styles/shared/devices';
import { isSsr } from '../helpers/ssr';
import { useUser } from '../context/userContext/UserContext';
import { useAuth } from '../context/authContext/AuthContext';
import { useRoutes } from '../hooks/useRoutes';

const Me = (props: any) => {
const { children, title } = props;
const { pathname } = useRouter();
const { pathname, push } = useRouter();
const routes = useRoutes();
const { currentUser, isLoading } = useUser();
const auth = useAuth();

Expand All @@ -34,6 +36,11 @@ const Me = (props: any) => {
return null;
}

if (!currentUser.email_verified) {
push(routes.root.get());
return <p>Email not verified, redirecting...</p>;
}

return (
<Container>
<>
Expand Down
14 changes: 12 additions & 2 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import partition from 'lodash/partition';
import * as Sentry from '@sentry/browser';
import { Application, Mentor, User } from '../types/models';
import { setVisitor } from '../utils/tawk';
import Auth from '../utils/auth';

type RequestMethod = 'POST' | 'GET' | 'PUT' | 'DELETE';
type ErrorResponse = {
Expand All @@ -32,9 +33,9 @@ let currentUser: User | undefined;

export default class ApiService {
mentorsPromise: Promise<Mentor[]> | null = null
auth: any
auth: Auth;

constructor(auth: any) {
constructor(auth: Auth) {
this.auth = auth
}

Expand Down Expand Up @@ -114,6 +115,11 @@ export default class ApiService {

clearCurrentUser = () => {
currentUser = undefined;
ApiService.clearCurrentUserFromStorage();
}

// because we need to call it from authContext which doesn't have access to ApiService
static clearCurrentUserFromStorage = () => {
Comment on lines +118 to +122
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this static method still necessary after adding forgetUser to the auth util?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. forgetUser calls this function.
The problem is that auth/utils not always has a reference to API so if it has reference it calls to clearCurrentUser but if not (it means that apiService is not initiated), it calls clearCurrentUserFromStorage directly.

We definitely should improve the design / structure of the auth / user area. We 2 providers and 2 services.
I did some research about auth0 react wrapper, it can help us clear our code but I didn't want to include this refactor in this PR to avoid big changes at once.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Thanks.

localStorage.removeItem(USER_LOCAL_KEY);
}

Expand Down Expand Up @@ -386,4 +392,8 @@ export default class ApiService {

return false;
}

resendVerificationEmail = async () => {
return this.makeApiCall(`${paths.USERS}/verify`, null, 'POST');
}
}
6 changes: 3 additions & 3 deletions src/components/Header/Header.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useContext } from 'react';
import React, { useState } from 'react';
import styled from 'styled-components';
import OffCanvas from 'react-aria-offcanvas';
import Modal from '../Modal/Modal';
Expand All @@ -8,7 +8,7 @@ import Logo from '../Logo';
import Title from '../SiteTitle';
import Navigation from '../Navigation/Navigation';
import MobileNavigation from '../MobileNavigation/MobileNavigation';
import { AuthContext } from '../../context/authContext/AuthContext';
import { useAuth } from '../../context/authContext/AuthContext';
import { useDeviceType } from '../../hooks/useDeviceType';

function Header() {
Expand All @@ -19,7 +19,7 @@ function Header() {
});
const [isOpen, setIsOpen] = useState(false);
const { isDesktop } = useDeviceType();
const auth = useContext(AuthContext);
const auth = useAuth();
const authenticated = auth.isAuthenticated();

const handleModal = ({ title, content, onClose }) => {
Expand Down
14 changes: 9 additions & 5 deletions src/components/MemberArea/MemberArea.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function MemberArea({ onOpenModal }) {
const [isMemberMenuOpen, setIsMemberMenuOpen] = useState(false);
const { currentUser, isMentor, isAdmin, isAuthenticated, logout } = useUser();
const api = useApi();
const user = useUser();
const auth = useAuth();
const openBecomeMentor = useCallback(
() => onOpenModal('Edit Your Profile', <EditProfile api={api} />),
Expand Down Expand Up @@ -51,7 +52,8 @@ function MemberArea({ onOpenModal }) {
<UserAvatar
data-testid="user-avatar"
onClick={() =>
currentUser && setIsMemberMenuOpen(!isMemberMenuOpen)
(currentUser || user.isNotYetVerified) &&
setIsMemberMenuOpen(!isMemberMenuOpen)
}
>
{currentUser ? (
Expand All @@ -70,10 +72,12 @@ function MemberArea({ onOpenModal }) {
Open pending applications
</MemberMenuItem>
)}
<Link href="/me">
<MemberMenuItem>Manage Account</MemberMenuItem>
</Link>
{!isMentor && (
{!user.isNotYetVerified && (
<Link href="/me">
<MemberMenuItem>Manage Account</MemberMenuItem>
</Link>
)}
{!isMentor && !user.isNotYetVerified && (
<MemberMenuItem onClick={openBecomeMentor}>
Become a mentor
</MemberMenuItem>
Expand Down
4 changes: 2 additions & 2 deletions src/components/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import classNames from 'classnames';

export default class Modal extends Component {
state = {
isActive: false,
isActive: this.props.isActive ?? false,
};

handleOpen = (children) => {
this.setState({
isActive: true,
isActive: !!children,
children,
});
};
Expand Down
27 changes: 26 additions & 1 deletion src/components/layouts/App/App.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components/macro';
import { toast, ToastContainer } from 'react-toastify';

Expand All @@ -11,6 +11,8 @@ import { ActionsHandler } from './ActionsHandler';
import { desktop, mobile } from '../../../Me/styles/shared/devices';
import { Sidebar } from '../../Sidebar/Sidebar';
import { useMentors } from '../../../context/mentorsContext/MentorsContext';
import { useUser } from '../../../context/userContext/UserContext';
import { VerificationModal } from './VerificationModal';

const App = (props) => {
const { children } = props;
Expand All @@ -22,6 +24,23 @@ const App = (props) => {
onClose: null,
});
const { mentors } = useMentors();
const { emailVerifiedInfo } = useUser();
const closeModal = useCallback(() => setModal({}), []);

const showVerifyEmailModal = useCallback(() => {
setModal({
title: 'Verify your email',
content: (
<VerificationModal
onSuccess={() => {
toast.success('We just sent you the verification email');
closeModal();
}}
/>
),
onClose: closeModal,
});
}, [closeModal]);

useEffect(() => {
if (process.env.REACT_APP_MAINTENANCE_MESSAGE) {
Expand All @@ -38,6 +57,12 @@ const App = (props) => {
}
}, []);

useEffect(() => {
if (emailVerifiedInfo?.isVerified === false) {
showVerifyEmailModal(emailVerifiedInfo.email);
}
}, [emailVerifiedInfo, showVerifyEmailModal]);

useEffect(
() => setWindowTitle({ tag, country, name, language }),
[tag, country, name, language]
Expand Down
65 changes: 65 additions & 0 deletions src/components/layouts/App/VerificationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useState } from 'react';
import styled from 'styled-components/macro';
import { useApi } from '../../../context/apiContext/ApiContext';
import { useUser } from '../../../context/userContext/UserContext';
import Button from '../../../Me/components/Button';
import { maskEmail } from '../../../utils/maskSansitiveString';

type VerificationModalProps = {
onSuccess: () => void;
email: string;
};

const ModalText = styled.p`
text-align: center;
font-size: 16px;
line-height: 1.5;
`;

export const VerificationModal = ({ onSuccess }: VerificationModalProps) => {
const [loading, setLoading] = useState(false);
const { emailVerifiedInfo } = useUser();
const api = useApi();

if (emailVerifiedInfo.isVerified === true) {
// eslint-disable-next-line no-console
console.warn('email is verified');
return;
}

const send = async () => {
setLoading(true);
try {
const result = await api.resendVerificationEmail();
if (result.success) {
onSuccess();
}
} catch {}
setLoading(false);
};

return (
<>
<ModalText>
Psst, we believe that you are who you say you are.
<br />
Just to make sure, we need you to verify your email.
<h3>Recognize {maskEmail(emailVerifiedInfo.email)}?</h3>
{emailVerifiedInfo.isRegisteredRecently ? (
<>
This is the address we sent a verification email to.
<br />
Can't find it? Hit the button
</>
) : (
<>Hit the button to send a verification email right to your inbox</>
)}
<p>
<Button isLoading={loading} onClick={send}>
Resend verification email
</Button>
</p>
</ModalText>
</>
);
};
11 changes: 7 additions & 4 deletions src/context/apiContext/ApiContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import ApiService from '../../api';
export const ApiContext = createContext<ApiService>(null);

export const ApiProvider: FC = (props: any) => {
const { children } = props
const auth = useContext(AuthContext)
const api = useMemo(() => new ApiService(auth), [auth]) ;
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>
const { children } = props;
const auth = useContext(AuthContext);
const api = useMemo(() => new ApiService(auth), [auth]);
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
};

export function useApi(): ApiService {
const api = useContext(ApiContext);
if (!api) {
throw new Error(`"useApi" has to be called inside ApiProvider`);
}
return api;
}
59 changes: 53 additions & 6 deletions src/context/authContext/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createContext, useContext, FC, useEffect, useState } from 'react';
import { NextRouter, useRouter } from 'next/router';
import { createContext, useContext, FC, useEffect, useState, useCallback, useRef } from 'react';
import { toast } from 'react-toastify';
import { isSsr } from '../../helpers/ssr';
import Auth from '../../utils/auth';

Expand All @@ -7,13 +9,46 @@ const auth = new Auth();

export const AuthProvider: FC = (props: any) => {
const { children } = props;
const [isLoading, setIsLoading] = useState(!isSsr() /* ssr doesn't need loader */);
const router = useRouter();
const [isLoading, setIsLoading] = useState(
!isSsr() /* ssr doesn't need loader */
);

const handleVerificationRedirect = useCallback(() => {
const {is, success, message} = justVerifiedEmail(router);
if (is) {
const text = `Verification result: ${message}`;
if (success) {
toast.success(text);
} else {
toast.error(text);
}
router.push('/');
}
return is;
}, [router])

useEffect(() => {
auth.renewSession().finally(() => {
setIsLoading(false);
});
}, []);
const isJustVerified = handleVerificationRedirect();
if (isJustVerified) {
auth.forgetUser();
return;
}
auth
.renewSession()
.catch((e) => {
toast.error(
<>
<div>Something went wrong, please login again</div>
<div>If the problem persists, please contact us.</div>
<div>Error: {typeof e === 'string' ? e : JSON.stringify(e)}</div>
</>
);
})
.finally(() => {
setIsLoading(false);
});
}, [handleVerificationRedirect]);

if (isLoading) {
return <></>;
Expand All @@ -24,5 +59,17 @@ export const AuthProvider: FC = (props: any) => {

export function useAuth(): Auth {
const auth = useContext(AuthContext);
if (!auth) {
throw new Error(`"useAuth" has to be called inside AuthProvider`);
}
return auth;
}

function justVerifiedEmail(router: NextRouter) {
const { query: {email, success, message} } = router;
return {
is: !!email && !!message && !!success,
success: success === 'true',
message
}
}
Loading