From 29a3c96791bc3a11900f7f7b1789c421294a8b05 Mon Sep 17 00:00:00 2001 From: Moshe Feuchtwanger Date: Fri, 11 Apr 2025 09:42:37 +0300 Subject: [PATCH 1/6] client implementation --- pages/{index.js => index.tsx} | 0 src/Me/Me.tsx | 9 ++- src/api/index.ts | 14 +++- src/components/Header/Header.js | 6 +- src/components/MemberArea/MemberArea.js | 20 ++++-- src/components/Modal/Modal.js | 4 +- src/components/layouts/App/App.js | 27 +++++++- .../layouts/App/VerificationModal.tsx | 65 +++++++++++++++++++ src/context/apiContext/ApiContext.tsx | 11 ++-- src/context/authContext/AuthContext.tsx | 59 +++++++++++++++-- src/context/userContext/UserContext.tsx | 34 ++++++++-- src/hooks/useRoutes.ts | 2 +- src/index.css | 26 +++++++- src/types/models.d.ts | 1 + src/utils/auth.js | 31 ++++++--- src/utils/maskSansitiveString.ts | 9 +++ 16 files changed, 276 insertions(+), 42 deletions(-) rename pages/{index.js => index.tsx} (100%) create mode 100644 src/components/layouts/App/VerificationModal.tsx create mode 100644 src/utils/maskSansitiveString.ts 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 b8da5c472..46571f839 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 e3b4b1964..a4b879e76 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -5,6 +5,7 @@ import shuffle from 'lodash/shuffle'; import partition from 'lodash/partition'; import { Application, Mentor, User, MentorshipRequest } from '../types/models'; import type { ApplicationStatus } from '../types/models'; +import Auth from '../utils/auth'; type RequestMethod = 'POST' | 'GET' | 'PUT' | 'DELETE'; type ErrorResponse = { @@ -31,9 +32,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 } @@ -119,6 +120,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); } @@ -348,4 +354,8 @@ export default class ApiService { ); this.storeUserInLocalStorage(); } + + 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 1ce3fc244..3095a9a15 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 d3edb766b..b8a4cd3ed 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', ), @@ -50,9 +51,12 @@ function MemberArea({ onOpenModal }) { <> - currentUser && setIsMemberMenuOpen(!isMemberMenuOpen) - } + onClick={() => { + if (!currentUser || user.isNotYetVerified) { + return; + } + setIsMemberMenuOpen(!isMemberMenuOpen); + }} > {currentUser ? ( )} - - 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 666441f9f..9ebdcb1b2 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 3b1f49012..626a33af8 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 000000000..7a480202a --- /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 fe26657ca..a99f47319 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 e09785ec6..32f52bcad 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 f07b0b8c1..fe27175f3 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,23 +31,36 @@ 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; + } - window.logout = logout; + setEmailVerifiedInfo({ + isVerified: Boolean(user.email_verified), + isRegisteredRecently: daysAgo(user.createdAt) <= 5, + email: user.email, + }); + + updateCurrentUser(user); + } + getCurrentUser(); }, [api]); return ( @@ -46,6 +70,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 271406526..d38ca292d 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: (userOrUserId: User | string) => { diff --git a/src/index.css b/src/index.css index bf3c31b95..0511e406b 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 c1b82a16f..121bc719f 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 700c56024..752a8ac38 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 000000000..f6dcec1aa --- /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}`; + }); +} From 6820897fc974abe2c9292ce6de79fe15acb1297d Mon Sep 17 00:00:00 2001 From: Moshe Feuchtwanger Date: Tue, 15 Apr 2025 00:49:04 +0300 Subject: [PATCH 2/6] consider email_verified prop through all the app - server and client --- netlify.toml | 5 ---- .../functions/common/auth0.service.ts | 24 +++++++++------ .../functions/common/dto/user.dto.ts | 2 +- .../common/interfaces/user.interface.ts | 4 +++ netlify/functions-src/functions/data/users.ts | 7 ++--- .../functions-src/functions/hof/withRouter.ts | 4 +-- .../functions/modules/mentorships/apply.ts | 14 ++++----- .../functions/modules/users/current.ts | 29 +++++++++---------- netlify/functions-src/functions/utils/auth.ts | 10 +++++-- src/api/index.ts | 3 +- 10 files changed, 52 insertions(+), 50 deletions(-) diff --git a/netlify.toml b/netlify.toml index d82e248e5..975608812 100644 --- a/netlify.toml +++ b/netlify.toml @@ -15,8 +15,3 @@ [[plugins]] package = "@netlify/plugin-nextjs" - -[[redirects]] - from = "/api/*" - to = "/.netlify/functions/:splat" - status = 200 \ No newline at end of file diff --git a/netlify/functions-src/functions/common/auth0.service.ts b/netlify/functions-src/functions/common/auth0.service.ts index ce838d807..9de2128a0 100644 --- a/netlify/functions-src/functions/common/auth0.service.ts +++ b/netlify/functions-src/functions/common/auth0.service.ts @@ -1,4 +1,4 @@ -import axios from 'axios' +import axios, { type AxiosResponse } from 'axios' import Config from '../config' export class Auth0Service { @@ -7,7 +7,7 @@ export class Auth0Service { private readonly clientSecret = Config.auth0.backend.CLIENT_SECRET private readonly audience = Config.auth0.backend.AUDIENCE - async getAdminAccessToken(): Promise { + async getAdminAccessToken(): Promise['data']> { try { const response = await axios.post(`https://${this.auth0Domain}/oauth/token`, { client_id: this.clientId, @@ -22,13 +22,19 @@ export class Auth0Service { } } - async getUserProfile(accessToken: string, userId: string): Promise { - const response = await axios.get(`https://${this.auth0Domain}/api/v2/users/${userId}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - return response.data + async getUserProfile(userId: string): Promise['data']> { + try { + const { access_token } = await this.getAdminAccessToken(); + const response = await axios.get(`https://${this.auth0Domain}/api/v2/users/${userId}`, { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + return response.data + } catch (error) { + console.error('getUserProfile, Error:', error) + throw new Error('Error getting user profile') + } } async deleteUser(accessToken: string, userId: string): Promise { diff --git a/netlify/functions-src/functions/common/dto/user.dto.ts b/netlify/functions-src/functions/common/dto/user.dto.ts index 3eaf19029..4c4db67fd 100644 --- a/netlify/functions-src/functions/common/dto/user.dto.ts +++ b/netlify/functions-src/functions/common/dto/user.dto.ts @@ -2,7 +2,7 @@ import type { ObjectId } from 'mongodb' import { Role } from '../interfaces/user.interface' export class UserDto { - _id?: ObjectId + _id: ObjectId auth0Id: string email: string name: string diff --git a/netlify/functions-src/functions/common/interfaces/user.interface.ts b/netlify/functions-src/functions/common/interfaces/user.interface.ts index df128c67a..e44017b62 100644 --- a/netlify/functions-src/functions/common/interfaces/user.interface.ts +++ b/netlify/functions-src/functions/common/interfaces/user.interface.ts @@ -18,6 +18,10 @@ export interface User { tags?: string[]; } +export type ApplicationUser = User & { + email_verified: boolean; +} + export enum Role { ADMIN = 'Admin', MEMBER = 'Member', diff --git a/netlify/functions-src/functions/data/users.ts b/netlify/functions-src/functions/data/users.ts index 25e2d401a..f703f11c7 100644 --- a/netlify/functions-src/functions/data/users.ts +++ b/netlify/functions-src/functions/data/users.ts @@ -89,13 +89,10 @@ export const getUserById = async (id: string, currentUserAuth0Id?: string): Prom return getUserWithoutChannels(id); } -export const getUserByAuthId = async (auth0Id: string) => { +export const getUserBy = async >(prop: T, value: User[T]) => { const user = await getCollection('users') - .findOne({ auth0Id }); + .findOne({ [prop]: value }); - if (!user) { - throw new DataError(404, 'User not found'); - } return user; } diff --git a/netlify/functions-src/functions/hof/withRouter.ts b/netlify/functions-src/functions/hof/withRouter.ts index 7f86b68c6..d92c792cd 100644 --- a/netlify/functions-src/functions/hof/withRouter.ts +++ b/netlify/functions-src/functions/hof/withRouter.ts @@ -49,8 +49,8 @@ const getRouteData = (route: Route, path: string) => { } const getAppPath = (eventPath: string) => { - // event.path = /api/mentorships/:userId/requests - const [,,,...innerSegments] = eventPath.split(nPath.sep); + // event.path = /.netlify/functions/mentorships/:userId/requests + const [,,,,...innerSegments] = eventPath.split(nPath.sep); return `/${innerSegments.join('/')}`; } diff --git a/netlify/functions-src/functions/modules/mentorships/apply.ts b/netlify/functions-src/functions/modules/mentorships/apply.ts index f69e3b75b..aa58ecc54 100644 --- a/netlify/functions-src/functions/modules/mentorships/apply.ts +++ b/netlify/functions-src/functions/modules/mentorships/apply.ts @@ -1,15 +1,15 @@ -import { getUserByAuthId, getUserById } from '../../data/users'; +import { getUserBy, getUserById } from '../../data/users'; import type { ApiHandler } from '../../types'; import { upsertMentorship, findMentorship, getOpenRequestsCount } from '../../data/mentorships'; import { Status, type Mentorship } from '../../interfaces/mentorship'; import { error, success } from '../../utils/response'; import type { CreateEntityPayload } from '../../data/types'; import { EmailService } from '../../common/email.service'; +import type { User } from '../../common/interfaces/user.interface'; const ALLOWED_OPEN_MENTORSHIPS = 5; -const applyForMentorshipHandler: ApiHandler = async (event, context) => { - const currentUserId = context.user!.auth0Id; +const applyForMentorshipHandler: ApiHandler = async (event, context) => { const mentorId = event.queryStringParameters?.mentorId; if (!event.body) { return error('mentorship data is required'); @@ -17,12 +17,12 @@ const applyForMentorshipHandler: ApiHandler = async (event, context) => { // TODO: use event.parsedBody const mentorshipData: CreateEntityPayload = JSON.parse(event.body); - if (!mentorId || !currentUserId) { + if (!mentorId || !context.user.auth0Id) { return error('mentorId and current userId is required'); } const [current, mentor] = await Promise.all([ - getUserByAuthId(currentUserId), + getUserBy('auth0Id', context.user.auth0Id), getUserById(mentorId), ]); @@ -35,11 +35,11 @@ const applyForMentorshipHandler: ApiHandler = async (event, context) => { } if (mentor._id.equals(current!._id)) { - return error(`Are you planning to mentor yourself?`); + return error(`Are you planning to mentor yourself?`, 400); } if (!mentor.available) { - return error('Mentor is not available'); + return error('Mentor is not available', 400); } const mentorship: Mentorship = await findMentorship( diff --git a/netlify/functions-src/functions/modules/users/current.ts b/netlify/functions-src/functions/modules/users/current.ts index 8237c1814..2f8032a57 100644 --- a/netlify/functions-src/functions/modules/users/current.ts +++ b/netlify/functions-src/functions/modules/users/current.ts @@ -1,33 +1,27 @@ import { HandlerEvent } from '@netlify/functions' import { error, success } from '../../utils/response' -import { connectToDatabase, getCollection } from '../../utils/db' import { ApiHandler, type AuthContext } from '../../types' import { UserDto } from '../../common/dto/user.dto' -import { Role, User } from '../../common/interfaces/user.interface' +import { Role, type ApplicationUser, type User } from '../../common/interfaces/user.interface' // TODO: import * as Sentry from '@sentry/node' import { Auth0Service } from '../../common/auth0.service' import { withAuth } from '../../utils/auth' import { send } from '../../email/client' -import { upsertUser } from '../../data/users' - -export const getCurrentUser = async (auth0Id: string): Promise => { - await connectToDatabase() - const usersCollection = getCollection('users') - const currentUser = await usersCollection.findOne({ auth0Id }) +import { getUserBy, upsertUser } from '../../data/users' +export const getCurrentUser = async (auth0Id: string): Promise => { + const currentUser = await getUserBy('auth0Id', auth0Id); if (!currentUser) { // ...existing code for fetching user from Auth0 and handling new user creation... - const auth0Service = new Auth0Service() - const data: any = await auth0Service.getAdminAccessToken() - const user: any = await auth0Service.getUserProfile(data.access_token, auth0Id) + const user = await new Auth0Service().getUserProfile(auth0Id) - const existingMentor = await usersCollection.findOne({ email: user.email }) + const existingMentor = await getUserBy('email', user.email) if (existingMentor) { const userDto: UserDto = new UserDto({ _id: existingMentor._id, auth0Id, }) - await usersCollection.updateOne({ _id: existingMentor._id }, { $set: userDto }) + await upsertUser(userDto); return existingMentor } else { const newUser = await upsertUser({ @@ -58,13 +52,16 @@ export const getCurrentUser = async (auth0Id: string): Promise => { return currentUser } -const getCurrentUserHandler: ApiHandler = async (_event: HandlerEvent, context: AuthContext) => { +const getCurrentUserHandler: ApiHandler = async (_event: HandlerEvent, context: AuthContext) => { const auth0Id = context.user?.auth0Id; if (!auth0Id) { return error('Unauthorized: user not found', 401) } - const result = await getCurrentUser(auth0Id) - return success({ data: result }) + const applicationUser = { + ...await getCurrentUser(auth0Id), + email_verified: context.user?.email_verified, + }; + return success({ data: applicationUser }) } export const handler = withAuth(getCurrentUserHandler) diff --git a/netlify/functions-src/functions/utils/auth.ts b/netlify/functions-src/functions/utils/auth.ts index a5b31efec..aa8ad20c8 100644 --- a/netlify/functions-src/functions/utils/auth.ts +++ b/netlify/functions-src/functions/utils/auth.ts @@ -6,7 +6,7 @@ import jwksClient from 'jwks-rsa' import config from '../config' import { Role } from '../common/interfaces/user.interface' import { getCurrentUser } from '../modules/users/current' -import { getUserByAuthId } from '../data/users' +import { getUserBy } from '../data/users' import { DataError } from '../data/errors' const AUTH0_DOMAIN = config.auth0.backend.DOMAIN @@ -74,9 +74,13 @@ export function withAuth(handler: ApiHandler, options: { if (!decodedToken.sub || decodedToken.aud !== CLIENT_ID || decodedToken.iss !== `https://${AUTH0_DOMAIN}/`) { return error('Unauthorized', 401) } - + if (authRequired && !decodedToken.email_verified) { + return error('Email is not verified', 403) + } context.user = { auth0Id: decodedToken.sub, + // https://chatgpt.com/share/67f93816-4f0c-800c-a8e7-5bbf99d85d4b + email_verified: decodedToken.email_verified, } // TODO: instead, set a custom prop on auth0 - is admin to save the call to the database and get it from the token @@ -89,7 +93,7 @@ export function withAuth(handler: ApiHandler, options: { } if (returnUser && decodedToken.sub) { - const userDto = await getUserByAuthId(decodedToken.sub) + const userDto = await getUserBy('auth0Id', decodedToken.sub) if (!userDto) { return error('User not found', 404) } diff --git a/src/api/index.ts b/src/api/index.ts index a4b879e76..f0d90b628 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,7 +4,6 @@ import messages from '../messages'; import shuffle from 'lodash/shuffle'; import partition from 'lodash/partition'; import { Application, Mentor, User, MentorshipRequest } from '../types/models'; -import type { ApplicationStatus } from '../types/models'; import Auth from '../utils/auth'; type RequestMethod = 'POST' | 'GET' | 'PUT' | 'DELETE'; @@ -54,7 +53,7 @@ export default class ApiService { jsonous = true ): Promise | ErrorResponse | null> => { // public url for ssr - const url = `${process.env.NEXT_PUBLIC_PUBLIC_URL}/api${path}${ + const url = `${process.env.NEXT_PUBLIC_PUBLIC_URL}/.netlify/functions${path}${ method === 'GET' && body ? `?${new URLSearchParams(body)}` : '' }`; const optionBody = jsonous From 33f486ae4ed268c94bbb9c18c2a70be4dc2acc6a Mon Sep 17 00:00:00 2001 From: Moshe Feuchtwanger Date: Tue, 15 Apr 2025 01:15:54 +0300 Subject: [PATCH 3/6] do not required email verification for current endpoint --- api-types/errorCodes.ts | 3 +++ .../functions-src/functions/modules/users/current.ts | 4 +++- netlify/functions-src/functions/utils/auth.ts | 11 +++++++---- netlify/functions-src/functions/utils/response.ts | 5 +++-- 4 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 api-types/errorCodes.ts diff --git a/api-types/errorCodes.ts b/api-types/errorCodes.ts new file mode 100644 index 000000000..9626b9ae2 --- /dev/null +++ b/api-types/errorCodes.ts @@ -0,0 +1,3 @@ +export enum ErrorCodes { + EmailNotVerified = 1, +} diff --git a/netlify/functions-src/functions/modules/users/current.ts b/netlify/functions-src/functions/modules/users/current.ts index 2f8032a57..042a4fc13 100644 --- a/netlify/functions-src/functions/modules/users/current.ts +++ b/netlify/functions-src/functions/modules/users/current.ts @@ -64,4 +64,6 @@ const getCurrentUserHandler: ApiHandler = async (_event: HandlerEvent, context: return success({ data: applicationUser }) } -export const handler = withAuth(getCurrentUserHandler) +export const handler = withAuth(getCurrentUserHandler, { + emailVerificationRequired: false, +}) diff --git a/netlify/functions-src/functions/utils/auth.ts b/netlify/functions-src/functions/utils/auth.ts index aa8ad20c8..38729caf9 100644 --- a/netlify/functions-src/functions/utils/auth.ts +++ b/netlify/functions-src/functions/utils/auth.ts @@ -8,6 +8,7 @@ import { Role } from '../common/interfaces/user.interface' import { getCurrentUser } from '../modules/users/current' import { getUserBy } from '../data/users' import { DataError } from '../data/errors' +import { ErrorCodes } from '../../../../api-types/errorCodes' const AUTH0_DOMAIN = config.auth0.backend.DOMAIN const CLIENT_ID = config.auth0.frontend.CLIENT_ID @@ -51,16 +52,18 @@ export const verifyToken = async (token: string): Promise => { export function withAuth(handler: ApiHandler, options: { role?: Role, authRequired?: boolean, - returnUser?: boolean + returnUser?: boolean, + emailVerificationRequired?: boolean } = { role: undefined, authRequired: true, + emailVerificationRequired: true, returnUser: false }): ApiHandler { return async (event, context): Promise => { try { const authHeader = event.headers.authorization - const { role, authRequired, returnUser } = options + const { role, authRequired, returnUser, emailVerificationRequired } = options if (!authHeader?.startsWith('Bearer ')) { if (authRequired) { @@ -74,8 +77,8 @@ export function withAuth(handler: ApiHandler, options: { if (!decodedToken.sub || decodedToken.aud !== CLIENT_ID || decodedToken.iss !== `https://${AUTH0_DOMAIN}/`) { return error('Unauthorized', 401) } - if (authRequired && !decodedToken.email_verified) { - return error('Email is not verified', 403) + if (emailVerificationRequired && !decodedToken.email_verified) { + return error('Email is not verified', 403, ErrorCodes.EmailNotVerified) } context.user = { auth0Id: decodedToken.sub, diff --git a/netlify/functions-src/functions/utils/response.ts b/netlify/functions-src/functions/utils/response.ts index f85754401..8c46b749d 100644 --- a/netlify/functions-src/functions/utils/response.ts +++ b/netlify/functions-src/functions/utils/response.ts @@ -1,3 +1,4 @@ +import type { ErrorCodes } from '../../../../api-types/errorCodes'; import { ErrorResponse, SuccessResponse, ApiHandler } from '../types' type SuccessPayload = { @@ -13,7 +14,7 @@ export function success(data: SuccessPayload, statusCode = 200): SuccessRe } } -export function error(message: string, statusCode = 400): ErrorResponse { +export function error(message: string, statusCode = 400, errorCode?: ErrorCodes): ErrorResponse { if (process.env.CONTEXT !== 'production') { console.error('===== error ======', message); } @@ -24,7 +25,7 @@ export function error(message: string, statusCode = 400): ErrorResponse { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', }, - body: JSON.stringify({ success: false, error: message }) + body: JSON.stringify({ success: false, error: message, errorCode }) } return response; } From aff582c346a5eeb4e5bf0931c0512ea2649d248e Mon Sep 17 00:00:00 2001 From: Moshe Feuchtwanger Date: Tue, 15 Apr 2025 22:17:30 +0300 Subject: [PATCH 4/6] complete the verification cycle todo: handle user not verified calls nicely. for example when applying to a mentor. also let the user logout or deleting their account --- docs/auth.md | 14 +++ .../functions/common/auth0.service.ts | 38 +++++++- .../functions/common/email.service.ts | 90 ------------------- .../functions-src/functions/config/index.ts | 3 + .../email/interfaces/email.interface.ts | 9 ++ .../functions/email/templates/README.md | 9 +- .../email/templates/email-verification.html | 55 ++++++++++++ .../email/templates/nodemon-emails.json | 5 ++ .../functions/email/templates/show.js | 4 +- .../functions/email/templates/welcome.html | 8 +- .../functions/modules/mentorships/apply.ts | 5 +- .../functions/modules/users/current.ts | 22 ++--- .../functions/modules/users/verify.ts | 26 ++++++ netlify/functions-src/functions/users.ts | 7 +- netlify/functions-src/functions/utils/auth.ts | 8 +- 15 files changed, 185 insertions(+), 118 deletions(-) create mode 100644 docs/auth.md delete mode 100644 netlify/functions-src/functions/common/email.service.ts create mode 100644 netlify/functions-src/functions/email/templates/email-verification.html create mode 100644 netlify/functions-src/functions/email/templates/nodemon-emails.json create mode 100644 netlify/functions-src/functions/modules/users/verify.ts diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 000000000..4940ed44b --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,14 @@ +# Authentication System + +We're using Auth0 to handle authentication in our application. This document outlines the authentication flow and how to set up your environment for development. + +## Registration + +1. User clicks on the "Sign Up" button. +2. User is redirected to the Auth0 login page. +3. User enters their email and password. +4. Auth0 sends a verification email, which we leverage for the welcome email. +5. User clicks on the verification link in the email. +6. User is redirected to the application with a verification token. For more information about the redirection see the [docs](https://auth0.com/docs/customize/email/email-templates#configure-template-fields) - open the "Redirect the URL" section. +> **ℹ️ Info** +> Remember. The application.callback_domain variable will contain the origin of the first URL listed in the application's Allowed Callback URL list \ No newline at end of file diff --git a/netlify/functions-src/functions/common/auth0.service.ts b/netlify/functions-src/functions/common/auth0.service.ts index 9de2128a0..3d57b8fbf 100644 --- a/netlify/functions-src/functions/common/auth0.service.ts +++ b/netlify/functions-src/functions/common/auth0.service.ts @@ -1,7 +1,7 @@ import axios, { type AxiosResponse } from 'axios' import Config from '../config' -export class Auth0Service { +class Auth0Service { private readonly auth0Domain = Config.auth0.backend.DOMAIN private readonly clientId = Config.auth0.backend.CLIENT_ID private readonly clientSecret = Config.auth0.backend.CLIENT_SECRET @@ -22,10 +22,10 @@ export class Auth0Service { } } - async getUserProfile(userId: string): Promise['data']> { + async getUserProfile(auth0Id: string): Promise['data']> { try { const { access_token } = await this.getAdminAccessToken(); - const response = await axios.get(`https://${this.auth0Domain}/api/v2/users/${userId}`, { + const response = await axios.get(`https://${this.auth0Domain}/api/v2/users/${auth0Id}`, { headers: { Authorization: `Bearer ${access_token}`, }, @@ -44,4 +44,36 @@ export class Auth0Service { }, }) } + + async createVerificationEmailTicket( + auth0UserId: string, + ) { + try { + const { access_token: accessToken } = await this.getAdminAccessToken(); + const [provider, userId] = auth0UserId.split('|'); + const payload = { + result_url: Config.urls.CLIENT_BASE_URL, + user_id: auth0UserId, + identity: { user_id: userId, provider }, + }; + + const response = await axios.post( + `https://${Config.auth0.backend.DOMAIN}/api/v2/tickets/email-verification`, + payload, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + }, + ); + + return response.data; + } catch (error) { + console.error('createVerificationEmailTicket, Error:', error) + throw error; + } + } } + +export const auth0Service = new Auth0Service(); \ No newline at end of file diff --git a/netlify/functions-src/functions/common/email.service.ts b/netlify/functions-src/functions/common/email.service.ts deleted file mode 100644 index bbb62d749..000000000 --- a/netlify/functions-src/functions/common/email.service.ts +++ /dev/null @@ -1,90 +0,0 @@ -import Config from '../config' -import * as sgMail from '@sendgrid/mail' -import * as sgClient from '@sendgrid/client' -import { EmailParams, SendData } from '../../email/interfaces/email.interface' -import { promises } from 'fs' -import { compile } from 'ejs' -import { User } from './interfaces/user.interface' - -const isProduction = process.env.NODE_ENV === 'production' -const defaults = { - from: Config.email.FROM, -} - -const DEV_TESTING_LIST = '423467cd-c4bd-410c-ad52-adcd8dfbc389' - -export class EmailService { - constructor() { - sgMail.setApiKey(Config.sendGrid.API_KEY) - sgClient.setApiKey(Config.sendGrid.API_KEY) - } - - static LIST_IDS = { - MENTORS: isProduction - ? '3e581cd7-9b14-4486-933e-1e752557433f' - : DEV_TESTING_LIST, - NEWSLETTER: isProduction - ? '6df91cab-90bd-4eaa-9710-c3804f8aba01' - : DEV_TESTING_LIST, - } - - private getTemplateContent(name: string) { - return promises.readFile(`content/email_templates/${name}.html`, { - encoding: 'utf8', - }) - } - - get layout() { - return this.getTemplateContent('layout') - } - - async send(data: SendData) { - const newData = Object.assign({}, defaults, data) - return await sgMail.send(newData) - } - - async sendLocalTemplate(params: EmailParams) { - const { to, subject, data = {}, name } = params - const content = await this.injectData(name, data) - try { - await sgMail.send({ - to, - subject, - html: content, - from: Config.email.FROM, - }) - } catch (error) { - console.log('Send email error', params, JSON.stringify(error, null, 2)) // tslint:disable-line:no-console - } - } - - async addMentor(contact: User) { - const request = { - json: undefined, - method: 'PUT' as const, - url: '/v3/marketing/contacts', - body: JSON.stringify({ - list_ids: [EmailService.LIST_IDS.MENTORS], - contacts: [ - { - email: contact.email, - first_name: contact.name, - country: contact.country, - custom_fields: { - e2_T: isProduction ? 'production' : 'development', - }, - }, - ], - }), - } - - return await sgClient.request(request) - } - - private async injectData(name: string, data: Record) { - const template = await this.getTemplateContent(name) - const layout = await this.layout - const content = compile(template)(data) - return compile(layout)({ content }) - } -} diff --git a/netlify/functions-src/functions/config/index.ts b/netlify/functions-src/functions/config/index.ts index cc70bb127..9a6e87e11 100644 --- a/netlify/functions-src/functions/config/index.ts +++ b/netlify/functions-src/functions/config/index.ts @@ -33,6 +33,9 @@ const config = { pagination: { limit: 20, }, + urls: { + CLIENT_BASE_URL: process.env.CLIENT_BASE_URL, + }, }; export default config; diff --git a/netlify/functions-src/functions/email/interfaces/email.interface.ts b/netlify/functions-src/functions/email/interfaces/email.interface.ts index 9beddbfcd..af18347fa 100644 --- a/netlify/functions-src/functions/email/interfaces/email.interface.ts +++ b/netlify/functions-src/functions/email/interfaces/email.interface.ts @@ -95,6 +95,14 @@ interface MentorFreeze { }; } +interface EmailVerification { + name: 'email-verification'; + data: { + name: string; + link: string; + }; +} + export type EmailParams = Required> & ( | WelcomePayload @@ -107,6 +115,7 @@ export type EmailParams = Required> & | MentorApplicationDeclined | MentorApplicationApproved | MentorNotActive + | EmailVerification | MentorFreeze ); diff --git a/netlify/functions-src/functions/email/templates/README.md b/netlify/functions-src/functions/email/templates/README.md index bc5d12864..4db5f1d57 100644 --- a/netlify/functions-src/functions/email/templates/README.md +++ b/netlify/functions-src/functions/email/templates/README.md @@ -1,9 +1,13 @@ ### Run ```bash -nodemon --config nodemon-emails.json +nodemon --config netlify/functions-src/functions/email/templates/nodemon-emails.json ``` +> **ℹ️ Info** +> The welcome email template is managed by Auth0 as part of the email verification process. +> You can view and edit the template in the [Auth0 Dashboard](https://manage.auth0.com/dashboard/eu/codingcoach/templates). + ### Links ||| @@ -17,4 +21,5 @@ nodemon --config nodemon-emails.json |Mentorship reminder|http://localhost:3003/mentorship-reminder?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22message%22:%22because%22}| |Mentor application received|http://localhost:3003/mentor-application-received?data={%22name%22:%22Brent%22}| |Mentorship application denied|http://localhost:3003/mentor-application-declined?data={%22name%22:%22Moshe%22,%22reason%22:%22your%20avatar%20is%20not%20you%22}| -|Mentorship application approved|http://localhost:3003/mentor-application-approved?data={%22name%22:%22Moshe%22}| \ No newline at end of file +|Mentorship application approved|http://localhost:3003/mentor-application-approved?data={%22name%22:%22Moshe%22}| +|Email Verification|http://localhost:3003/email-verification?data={%22name%22:%22Moshe%22,%20%22link%22:%20%22https://mentors.codingcoach.io%22}| \ No newline at end of file diff --git a/netlify/functions-src/functions/email/templates/email-verification.html b/netlify/functions-src/functions/email/templates/email-verification.html new file mode 100644 index 000000000..96794ca17 --- /dev/null +++ b/netlify/functions-src/functions/email/templates/email-verification.html @@ -0,0 +1,55 @@ +
+ + + + + + +
+ Illustration +
+

Hey <%= name %>

+

+ You're almost there! +

+

Please click the link below to verify your email

+

+ Verify +

+

+ + (Or copy and paste this url + <%= link %> into your browser) +

+
diff --git a/netlify/functions-src/functions/email/templates/nodemon-emails.json b/netlify/functions-src/functions/email/templates/nodemon-emails.json new file mode 100644 index 000000000..70e61437b --- /dev/null +++ b/netlify/functions-src/functions/email/templates/nodemon-emails.json @@ -0,0 +1,5 @@ +{ + "watch": ["."], + "ext": "html,js", + "exec": "node netlify/functions-src/functions/email/templates/show.js" +} diff --git a/netlify/functions-src/functions/email/templates/show.js b/netlify/functions-src/functions/email/templates/show.js index 4c17e2776..3a9686dd6 100644 --- a/netlify/functions-src/functions/email/templates/show.js +++ b/netlify/functions-src/functions/email/templates/show.js @@ -4,7 +4,7 @@ const fs = require('fs'); const app = express(); const port = 3003; -const layout = fs.readFileSync('content/email_templates/layout.html', { +const layout = fs.readFileSync(`${__dirname}/layout.html`, { encoding: 'utf8', }); @@ -20,7 +20,7 @@ app.get('/:templateName', function (req, res) { if (templateName.includes('.')) return; const { data } = req.query; const template = fs.readFileSync( - `content/email_templates/${templateName}.html`, + `${__dirname}/${templateName}.html`, { encoding: 'utf8' }, ); const content = injectData( diff --git a/netlify/functions-src/functions/email/templates/welcome.html b/netlify/functions-src/functions/email/templates/welcome.html index cafdb81ec..eb267d506 100644 --- a/netlify/functions-src/functions/email/templates/welcome.html +++ b/netlify/functions-src/functions/email/templates/welcome.html @@ -220,12 +220,16 @@ text-align: left; word-wrap: break-word; "> -

+

We're so glad you've joined us. Here is what you can do next: + ">We're so glad you've joined us
Please click here to verify your email. +

+

 

+

+ More things to do:

  • diff --git a/netlify/functions-src/functions/modules/mentorships/apply.ts b/netlify/functions-src/functions/modules/mentorships/apply.ts index aa58ecc54..4d4c004d8 100644 --- a/netlify/functions-src/functions/modules/mentorships/apply.ts +++ b/netlify/functions-src/functions/modules/mentorships/apply.ts @@ -4,7 +4,7 @@ import { upsertMentorship, findMentorship, getOpenRequestsCount } from '../../da import { Status, type Mentorship } from '../../interfaces/mentorship'; import { error, success } from '../../utils/response'; import type { CreateEntityPayload } from '../../data/types'; -import { EmailService } from '../../common/email.service'; +import { send as sendEmail } from '../../email/client'; import type { User } from '../../common/interfaces/user.interface'; const ALLOWED_OPEN_MENTORSHIPS = 5; @@ -68,8 +68,7 @@ const applyForMentorshipHandler: ApiHandler = async (event, context) }); try { - const emailService = new EmailService(); - await emailService.sendLocalTemplate({ + await sendEmail({ name: 'mentorship-requested', to: mentor.email, subject: 'Mentorship Requested', diff --git a/netlify/functions-src/functions/modules/users/current.ts b/netlify/functions-src/functions/modules/users/current.ts index 042a4fc13..d2fa02e2f 100644 --- a/netlify/functions-src/functions/modules/users/current.ts +++ b/netlify/functions-src/functions/modules/users/current.ts @@ -4,16 +4,15 @@ import { ApiHandler, type AuthContext } from '../../types' import { UserDto } from '../../common/dto/user.dto' import { Role, type ApplicationUser, type User } from '../../common/interfaces/user.interface' // TODO: import * as Sentry from '@sentry/node' -import { Auth0Service } from '../../common/auth0.service' +import { auth0Service } from '../../common/auth0.service' import { withAuth } from '../../utils/auth' -import { send } from '../../email/client' import { getUserBy, upsertUser } from '../../data/users' export const getCurrentUser = async (auth0Id: string): Promise => { const currentUser = await getUserBy('auth0Id', auth0Id); if (!currentUser) { // ...existing code for fetching user from Auth0 and handling new user creation... - const user = await new Auth0Service().getUserProfile(auth0Id) + const user = await auth0Service.getUserProfile(auth0Id) const existingMentor = await getUserBy('email', user.email) if (existingMentor) { @@ -36,14 +35,15 @@ export const getCurrentUser = async (auth0Id: string): Promise => { channels: [], }) - send({ - to: user.email, - name: 'welcome', - subject: 'Welcome to Coding Coach! 🥳', - data: { - name: user.nickname, - }, - }) + // no need to send the email. it's sent by auth0 as part of the email verification process + // send({ + // to: user.email, + // name: 'welcome', + // subject: 'Welcome to Coding Coach! 🥳', + // data: { + // name: user.nickname, + // }, + // }) return newUser; } diff --git a/netlify/functions-src/functions/modules/users/verify.ts b/netlify/functions-src/functions/modules/users/verify.ts new file mode 100644 index 000000000..e0f4a6abf --- /dev/null +++ b/netlify/functions-src/functions/modules/users/verify.ts @@ -0,0 +1,26 @@ +import { auth0Service } from '../../common/auth0.service'; +import type { User } from '../../common/interfaces/user.interface'; +import { send as sendEmail } from '../../email/client'; +import type { ApiHandler } from '../../types'; +import { error, success } from '../../utils/response'; + +export const handler: ApiHandler = async (_event, context) => { + try { + const { auth0Id, name, email } = context.user; + const { ticket } = await auth0Service.createVerificationEmailTicket(auth0Id); + await sendEmail({ + name: 'email-verification', + data: { + name, + link: ticket, + }, + to: email, + subject: 'Verify your email', + }); + + return success({ data: { message: 'Verification email sent successfully' } }); + } catch (e) { + console.error('Error sending verification email:', e); + return error('Error sending verification email', 500); + } +} \ No newline at end of file diff --git a/netlify/functions-src/functions/users.ts b/netlify/functions-src/functions/users.ts index e80b2c2b3..750fb8d2a 100644 --- a/netlify/functions-src/functions/users.ts +++ b/netlify/functions-src/functions/users.ts @@ -2,6 +2,7 @@ import type { ApiHandler } from './types'; import { handler as usersCurrentHandler } from './modules/users/current' import { handler as getUserInfoHandler, updateUserInfoHandler } from './modules/users/userInfo' import { handler as deleteUser } from './modules/users/delete' +import { handler as verifyUserHandler } from './modules/users/verify' import { addFavoriteHandler, getFavoritesHandler } from './modules/users/favorites' import { withRouter } from './hof/withRouter'; import { withDB } from './hof/withDB'; @@ -11,9 +12,13 @@ export const handler: ApiHandler = withDB( withRouter([ ['/', 'PUT', withAuth(updateUserInfoHandler)], ['/', 'DELETE', withAuth(deleteUser, { - returnUser: true, + includeFullUser: true, })], ['/current', 'GET', usersCurrentHandler], + ['/verify', 'POST', withAuth(verifyUserHandler, { + emailVerificationRequired: false, + includeFullUser: true, + })], ['/:userId', 'GET', withAuth(getUserInfoHandler, { authRequired: false, })], diff --git a/netlify/functions-src/functions/utils/auth.ts b/netlify/functions-src/functions/utils/auth.ts index 38729caf9..c0bda32ec 100644 --- a/netlify/functions-src/functions/utils/auth.ts +++ b/netlify/functions-src/functions/utils/auth.ts @@ -52,18 +52,18 @@ export const verifyToken = async (token: string): Promise => { export function withAuth(handler: ApiHandler, options: { role?: Role, authRequired?: boolean, - returnUser?: boolean, + includeFullUser?: boolean, emailVerificationRequired?: boolean } = { role: undefined, authRequired: true, emailVerificationRequired: true, - returnUser: false + includeFullUser: false }): ApiHandler { return async (event, context): Promise => { try { const authHeader = event.headers.authorization - const { role, authRequired, returnUser, emailVerificationRequired } = options + const { role, authRequired, includeFullUser, emailVerificationRequired } = options if (!authHeader?.startsWith('Bearer ')) { if (authRequired) { @@ -95,7 +95,7 @@ export function withAuth(handler: ApiHandler, options: { } } - if (returnUser && decodedToken.sub) { + if (includeFullUser && decodedToken.sub) { const userDto = await getUserBy('auth0Id', decodedToken.sub) if (!userDto) { return error('User not found', 404) From e601ff9a51934ecfb6a34c96520fe04da585da5c Mon Sep 17 00:00:00 2001 From: Moshe Feuchtwanger Date: Wed, 16 Apr 2025 17:36:43 +0300 Subject: [PATCH 5/6] verification button hide close button --- src/components/MemberArea/MemberArea.js | 2 +- src/components/Modal/Modal.js | 12 ++++++++---- src/components/layouts/App/App.js | 4 +++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/MemberArea/MemberArea.js b/src/components/MemberArea/MemberArea.js index b8a4cd3ed..0b6e43fba 100644 --- a/src/components/MemberArea/MemberArea.js +++ b/src/components/MemberArea/MemberArea.js @@ -52,7 +52,7 @@ function MemberArea({ onOpenModal }) { { - if (!currentUser || user.isNotYetVerified) { + if (!currentUser) { return; } setIsMemberMenuOpen(!isMemberMenuOpen); diff --git a/src/components/Modal/Modal.js b/src/components/Modal/Modal.js index 9ebdcb1b2..13ae7645a 100644 --- a/src/components/Modal/Modal.js +++ b/src/components/Modal/Modal.js @@ -39,16 +39,20 @@ export default class Modal extends Component { render() { const { isActive, children } = this.state; - const { title, size = '' } = this.props; + const { title, size = '', showCloseButton = true } = this.props; return (
    - + { + showCloseButton && ( + + ) + } { title && (
    diff --git a/src/components/layouts/App/App.js b/src/components/layouts/App/App.js index 626a33af8..0b3f316b8 100644 --- a/src/components/layouts/App/App.js +++ b/src/components/layouts/App/App.js @@ -22,6 +22,7 @@ const App = (props) => { title: null, content: null, onClose: null, + showCloseButton: true, }); const { mentors } = useMentors(); const { emailVerifiedInfo } = useUser(); @@ -29,6 +30,7 @@ const App = (props) => { const showVerifyEmailModal = useCallback(() => { setModal({ + showCloseButton: false, title: 'Verify your email', content: ( { return (
    - {modal?.content} + {modal?.content}
    From cde2f650d4703ec5a4acb0b86ca5b4236137fc81 Mon Sep 17 00:00:00 2001 From: Mosh Feu Date: Wed, 16 Apr 2025 23:44:41 +0300 Subject: [PATCH 6/6] Update netlify/functions-src/functions/email/templates/email-verification.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../functions/email/templates/email-verification.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netlify/functions-src/functions/email/templates/email-verification.html b/netlify/functions-src/functions/email/templates/email-verification.html index 96794ca17..9880af39b 100644 --- a/netlify/functions-src/functions/email/templates/email-verification.html +++ b/netlify/functions-src/functions/email/templates/email-verification.html @@ -50,6 +50,6 @@

    Hey <%= name %>

    (Or copy and paste this url - <%= link %> into your browser) + <%= link %> into your browser)