diff --git a/package.json b/package.json index 0f6caa3d..996d275d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/index.js b/pages/index.tsx similarity index 100% rename from pages/index.js rename to pages/index.tsx diff --git a/src/Me/Me.tsx b/src/Me/Me.tsx index b8da5c47..46571f83 100644 --- a/src/Me/Me.tsx +++ b/src/Me/Me.tsx @@ -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(); @@ -34,6 +36,11 @@ const Me = (props: any) => { return null; } + if (!currentUser.email_verified) { + push(routes.root.get()); + return

Email not verified, redirecting...

; + } + return ( <> diff --git a/src/api/index.ts b/src/api/index.ts index 43737ac3..e8e9dc50 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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 = { @@ -32,9 +33,9 @@ let currentUser: User | undefined; export default class ApiService { mentorsPromise: Promise | null = null - auth: any + auth: Auth; - constructor(auth: any) { + constructor(auth: Auth) { this.auth = auth } @@ -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 = () => { localStorage.removeItem(USER_LOCAL_KEY); } @@ -386,4 +392,8 @@ export default class ApiService { return false; } + + resendVerificationEmail = async () => { + return this.makeApiCall(`${paths.USERS}/verify`, null, 'POST'); + } } diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js index 1ce3fc24..3095a9a1 100644 --- a/src/components/Header/Header.js +++ b/src/components/Header/Header.js @@ -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'; @@ -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() { @@ -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 }) => { diff --git a/src/components/MemberArea/MemberArea.js b/src/components/MemberArea/MemberArea.js index d3edb766..91155ee2 100644 --- a/src/components/MemberArea/MemberArea.js +++ b/src/components/MemberArea/MemberArea.js @@ -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', ), @@ -51,7 +52,8 @@ function MemberArea({ onOpenModal }) { - currentUser && setIsMemberMenuOpen(!isMemberMenuOpen) + (currentUser || user.isNotYetVerified) && + setIsMemberMenuOpen(!isMemberMenuOpen) } > {currentUser ? ( @@ -70,10 +72,12 @@ function MemberArea({ onOpenModal }) { Open pending applications )} - - Manage Account - - {!isMentor && ( + {!user.isNotYetVerified && ( + + Manage Account + + )} + {!isMentor && !user.isNotYetVerified && ( Become a mentor diff --git a/src/components/Modal/Modal.js b/src/components/Modal/Modal.js index 918c095a..976436e8 100644 --- a/src/components/Modal/Modal.js +++ b/src/components/Modal/Modal.js @@ -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, }); }; diff --git a/src/components/layouts/App/App.js b/src/components/layouts/App/App.js index bcaa4744..32c62dbd 100644 --- a/src/components/layouts/App/App.js +++ b/src/components/layouts/App/App.js @@ -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'; @@ -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; @@ -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: ( + { + toast.success('We just sent you the verification email'); + closeModal(); + }} + /> + ), + onClose: closeModal, + }); + }, [closeModal]); useEffect(() => { if (process.env.REACT_APP_MAINTENANCE_MESSAGE) { @@ -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] diff --git a/src/components/layouts/App/VerificationModal.tsx b/src/components/layouts/App/VerificationModal.tsx new file mode 100644 index 00000000..7a480202 --- /dev/null +++ b/src/components/layouts/App/VerificationModal.tsx @@ -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 ( + <> + + Psst, we believe that you are who you say you are. +
+ Just to make sure, we need you to verify your email. +

Recognize {maskEmail(emailVerifiedInfo.email)}?

+ {emailVerifiedInfo.isRegisteredRecently ? ( + <> + This is the address we sent a verification email to. +
+ Can't find it? Hit the button + + ) : ( + <>Hit the button to send a verification email right to your inbox + )} +

+ +

+
+ + ); +}; diff --git a/src/context/apiContext/ApiContext.tsx b/src/context/apiContext/ApiContext.tsx index fe26657c..a99f4731 100644 --- a/src/context/apiContext/ApiContext.tsx +++ b/src/context/apiContext/ApiContext.tsx @@ -5,13 +5,16 @@ import ApiService from '../../api'; export const ApiContext = createContext(null); export const ApiProvider: FC = (props: any) => { - const { children } = props - const auth = useContext(AuthContext) - const api = useMemo(() => new ApiService(auth), [auth]) ; - return {children} + const { children } = props; + const auth = useContext(AuthContext); + const api = useMemo(() => new ApiService(auth), [auth]); + return {children}; }; export function useApi(): ApiService { const api = useContext(ApiContext); + if (!api) { + throw new Error(`"useApi" has to be called inside ApiProvider`); + } return api; } diff --git a/src/context/authContext/AuthContext.tsx b/src/context/authContext/AuthContext.tsx index e09785ec..32f52bca 100644 --- a/src/context/authContext/AuthContext.tsx +++ b/src/context/authContext/AuthContext.tsx @@ -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'; @@ -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( + <> +
Something went wrong, please login again
+
If the problem persists, please contact us.
+
Error: {typeof e === 'string' ? e : JSON.stringify(e)}
+ + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, [handleVerificationRedirect]); if (isLoading) { return <>; @@ -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 + } +} \ No newline at end of file diff --git a/src/context/userContext/UserContext.tsx b/src/context/userContext/UserContext.tsx index 8ced6e2e..0dfd23d4 100644 --- a/src/context/userContext/UserContext.tsx +++ b/src/context/userContext/UserContext.tsx @@ -2,12 +2,23 @@ import React, { FC, useContext, useEffect, useState } from 'react'; import { User } from '../../types/models'; import { useAuth } from '../authContext/AuthContext'; import { useApi } from '../apiContext/ApiContext'; +import { daysAgo } from '../../helpers/time'; + +type EmailNotVerifiedInfo = { + isVerified: true; +} | { + email: string; + isVerified: false; + isRegisteredRecently: boolean; +} type UserProviderContext = { isAdmin: boolean; isMentor: boolean; isLoading: boolean; currentUser?: User; + emailVerifiedInfo?: EmailNotVerifiedInfo; + isNotYetVerified: boolean; isAuthenticated: boolean; updateCurrentUser(user: User): void; logout(): void; @@ -20,21 +31,37 @@ const UserContext = React.createContext( export const UserProvider: FC = ({ children }) => { const [isLoading, setIsloading] = useState(true); const [currentUser, updateCurrentUser] = useState(); + const [emailVerifiedInfo, setemailVerifiedInfo] = useState(); const auth = useAuth(); const api = useApi(); const isAuthenticated = auth.isAuthenticated(); const isMentor = !!currentUser?.roles?.includes('Mentor'); const isAdmin = !!currentUser?.roles?.includes('Admin'); + const isNotYetVerified = emailVerifiedInfo?.isVerified === false; const logout = () => { auth.doLogout(api); }; useEffect(() => { - api.getCurrentUser().then((user) => { - updateCurrentUser(user); + async function getCurrentUser() { + const user = await api.getCurrentUser(); setIsloading(false); - }); + if (!user) { + return; + } + + setemailVerifiedInfo({ + isVerified: user.email_verified, + isRegisteredRecently: daysAgo(user.createdAt) <= 5, + email: user.email, + }); + + if (user.email_verified) { + updateCurrentUser(user); + } + } + getCurrentUser(); }, [api]); return ( @@ -44,6 +71,8 @@ export const UserProvider: FC = ({ children }) => { isMentor, isLoading, currentUser, + emailVerifiedInfo, + isNotYetVerified, isAuthenticated, logout, updateCurrentUser, diff --git a/src/hooks/useRoutes.ts b/src/hooks/useRoutes.ts index 7d2e9834..dd980aaf 100644 --- a/src/hooks/useRoutes.ts +++ b/src/hooks/useRoutes.ts @@ -11,7 +11,7 @@ export const useRoutes = () => { return { root: { - get: () => getUrlWithFilterParams('/'), + get: () => getUrlWithFilterParams('/') }, user: { get: (userOrUsreId: User | string) => diff --git a/src/index.css b/src/index.css index bf3c31b9..0511e406 100755 --- a/src/index.css +++ b/src/index.css @@ -99,12 +99,34 @@ code { } /* #region toast overrides */ +.Toastify__toast-container { + width: auto; + min-width: 320px; + max-width: 480px; +} + +.Toastify__toast { + gap: 5px; + font-size: 16px; + line-height: 1.4; + border: 1px solid; + background: #fff; + border-radius: 5px; + font-family: inherit; + color: rgb(var(--toast-color)); + box-shadow: inset 0 0 0 1000px rgb(var(--toast-color) / 0.2); +} + .Toastify__toast--error { - background: var(--error-background); + --toast-color: 220 53 69; } .Toastify__toast--success { - background: var(--success-background); + --toast-color: 40 167 69; +} + +.Toastify__close-button { + color: inherit; } /* #endregion */ diff --git a/src/types/models.d.ts b/src/types/models.d.ts index 3b9d54b2..86477665 100644 --- a/src/types/models.d.ts +++ b/src/types/models.d.ts @@ -17,6 +17,7 @@ export type User = BaseDBObject & { name: string; title: string; email: string; + email_verified: boolean; tags: string[]; avatar?: string; country: Country; diff --git a/src/utils/auth.js b/src/utils/auth.js index 700c5602..752a8ac3 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -1,4 +1,5 @@ import auth0 from 'auth0-js'; +import ApiService from '../api'; import { isSsr } from '../helpers/ssr'; import { isMentor } from '../helpers/user'; @@ -23,7 +24,7 @@ class Auth { clientID: this.clientId, redirectUri: this.redirectUri, responseType: 'token id_token', - scope: 'openid', + scope: 'openid email', }); this.loadSession(); @@ -141,23 +142,24 @@ class Auth { } catch (error) { reject(error); } - } else if (!this.isAuthenticated()) { + } else { this.auth0.checkSession({}, (err, authResult) => { - if (err) { - reject(err); + if (err && !this.#shouldSkipError(err)) { + return reject(err); } if (authResult && authResult.accessToken && authResult.idToken) { this.setSession(authResult); } resolve(); }); - } else { - resolve(); } }); } - #logout = () => { + /** + * @param {ApiService=} api - it's empty when called from AuthContext because it's hieghr in the Providers tree + */ + forgetUser = (api) => { // Remove tokens and expiry time from memory this.accessToken = null; this.idToken = null; @@ -165,12 +167,19 @@ class Auth { // Remove token from localStorage localStorage.removeItem(storageKey); + if (api) { + api?.clearCurrentUser(); + } else { + ApiService.clearCurrentUserFromStorage(); + } }; // TODO: figure out why the API service needs to clear the current user instead of the Auth class? + /** + * @param {ApiService} api + */ doLogout = (api) => { - this.#logout(); - api.clearCurrentUser(); + this.forgetUser(api); this.auth0.logout({ returnTo: this.redirectUri, }); @@ -182,6 +191,10 @@ class Auth { let expiresAt = this.expiresAt; return new Date().getTime() < expiresAt; } + + #shouldSkipError = (error) => { + return error.code === 'login_required'; + }; } export default Auth; diff --git a/src/utils/maskSansitiveString.ts b/src/utils/maskSansitiveString.ts new file mode 100644 index 00000000..f6dcec1a --- /dev/null +++ b/src/utils/maskSansitiveString.ts @@ -0,0 +1,9 @@ +const replaceWithAsterisk = (str: string) => { + return str.replace(/./g, '*'); +} + +export const maskEmail = (email: string) => { + return email.replace(/(.)(.*)(.@.)(.*)(.\..*)/, (...[, g1l1, g1Rest, at, g2Rest, sufix]) => { + return `${g1l1}${replaceWithAsterisk(g1Rest)}${at}${replaceWithAsterisk(g2Rest)}${sufix}`; + }); +} diff --git a/yarn.lock b/yarn.lock index 96c63a4b..b4f96f7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2699,6 +2699,11 @@ version "4.2.2" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" +"@types/auth0-js@^9.14.6": + version "9.14.6" + resolved "https://registry.yarnpkg.com/@types/auth0-js/-/auth0-js-9.14.6.tgz#a1b0cdaf1c5a7062480455d9b255326bdc748117" + integrity sha512-dQrWP1G5PO5onJcDdto803OKvw1mplNBoRTdMJzRemfLyKSMAShiVBVD12jZ8VbB1RRDlg6R2eheEH/sxv1ZtQ== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.16" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.16.tgz#bc12c74b7d65e82d29876b5d0baf5c625ac58702"