diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx index 187334f6..df6e9d43 100644 --- a/src/hooks/useUser.tsx +++ b/src/hooks/useUser.tsx @@ -16,9 +16,9 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {}) if ( // If redirectTo is set, redirect if the user was not found. - (redirectTo && !redirectIfFound && !user) || + (redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) || // If redirectIfFound is also set, redirect if the user was found - (redirectIfFound && user) + (redirectIfFound && user && user.isVerified) ) { Router.push(redirectTo); } diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index def62352..23ea30a4 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -12,6 +12,7 @@ export interface User { desiredLevels: {[key in Module]: number}; type: Type; bio: string; + isVerified: boolean; } export interface Stat { diff --git a/src/pages/action.tsx b/src/pages/action.tsx new file mode 100644 index 00000000..f7ebc16a --- /dev/null +++ b/src/pages/action.tsx @@ -0,0 +1,153 @@ +/* eslint-disable @next/next/no-img-element */ +import {User} from "@/interfaces/user"; +import {toast, ToastContainer} from "react-toastify"; +import axios from "axios"; +import {FormEvent, useEffect, useState} from "react"; +import Head from "next/head"; +import useUser from "@/hooks/useUser"; +import {Divider} from "primereact/divider"; +import Button from "@/components/Low/Button"; +import {BsArrowRepeat, BsCheck} from "react-icons/bs"; +import Link from "next/link"; +import Input from "@/components/Low/Input"; +import clsx from "clsx"; +import {useRouter} from "next/router"; + +export function getServerSideProps({query, res}: {query: {oobCode: string; mode: string; apiKey?: string; continueUrl?: string}; res: any}) { + if (!query || !query.oobCode || !query.mode) { + res.setHeader("location", "/login"); + res.statusCode = 302; + res.end(); + return { + props: {}, + }; + } + + return { + props: { + code: query.oobCode, + mode: query.mode, + apiKey: query.apiKey, + continueUrl: query.continueUrl, + }, + }; +} + +export default function Reset({code, mode, apiKey, continueUrl}: {code: string; mode: string; apiKey?: string; continueUrl?: string}) { + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const router = useRouter(); + + useUser({ + redirectTo: "/", + redirectIfFound: true, + }); + + useEffect(() => { + if (mode === "signIn") { + axios + .post<{ok: boolean}>("/api/reset/verify", { + link: `https://encoach.com/action?apiKey=${apiKey}&mode=${mode}&oobCode=${code}&continueUrl=${continueUrl}`, + }) + .then((response) => { + if (response.data.ok) { + toast.success("Your account has been verified!", {toastId: "verify-successful"}); + setTimeout(() => { + router.push("/"); + }, 2000); + return; + } + + toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "verify-error"}); + }) + .catch(() => { + toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "verify-error"}); + }) + .finally(() => setIsLoading(false)); + } + }, [apiKey, code, continueUrl, mode, router]); + + const login = (e: FormEvent) => { + e.preventDefault(); + + setIsLoading(true); + axios + .post<{ok: boolean}>("/api/reset/confirm", {code, password}) + .then((response) => { + if (response.data.ok) { + toast.success("Your password has been reset!", {toastId: "reset-successful"}); + setTimeout(() => { + router.push("/login"); + }, 2000); + return; + } + + toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"}); + }) + .catch(() => { + toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"}); + }) + .finally(() => setIsLoading(false)); + }; + + return ( + <> + + Reset | EnCoach + + + + +
+ +
+
+ People smiling looking at a tablet +
+ {mode === "reset" && ( +
+
+ EnCoach's Logo +

Reset your password

+

to your registered Email Address

+
+ +
+ setPassword(e)} placeholder="Password" /> + + +
+ + Don't have an account?{" "} + + Sign up + + +
+ )} + {mode === "signIn" && ( +
+
+ EnCoach's Logo +

Confirm your account

+

to your registered Email Address

+
+ +
+ Your e-mail is currently being verified, please wait a second.

+ Once it has been verified, you will be redirected to the home page. +
+
+ )} +
+ + ); +} diff --git a/src/pages/api/reset/confirm.ts b/src/pages/api/reset/confirm.ts index c61c041e..f56e094e 100644 --- a/src/pages/api/reset/confirm.ts +++ b/src/pages/api/reset/confirm.ts @@ -1,5 +1,5 @@ import {NextApiRequest, NextApiResponse} from "next"; -import {getAuth, sendPasswordResetEmail, confirmPasswordReset} from "firebase/auth"; +import {getAuth, confirmPasswordReset} from "firebase/auth"; import {app} from "@/firebase"; import {sessionOptions} from "@/lib/session"; import {withIronSessionApiRoute} from "iron-session/next"; diff --git a/src/pages/api/reset/sendVerification.ts b/src/pages/api/reset/sendVerification.ts new file mode 100644 index 00000000..41d99ef6 --- /dev/null +++ b/src/pages/api/reset/sendVerification.ts @@ -0,0 +1,24 @@ +import {NextApiRequest, NextApiResponse} from "next"; +import {getAuth, sendSignInLinkToEmail, User} from "firebase/auth"; +import {app} from "@/firebase"; +import {sessionOptions} from "@/lib/session"; +import {withIronSessionApiRoute} from "iron-session/next"; + +const auth = getAuth(app); + +export default withIronSessionApiRoute(sendVerification, sessionOptions); + +async function sendVerification(req: NextApiRequest, res: NextApiResponse) { + console.log(auth.currentUser); + if (req.session.user) { + sendSignInLinkToEmail(auth, req.session.user.email, { + url: "https://encoach.com/", + handleCodeInApp: true, + }) + .then(() => res.status(200).json({ok: true})) + .catch((e) => { + console.log(e); + res.status(404).json({ok: false}); + }); + } +} diff --git a/src/pages/api/reset/verify.ts b/src/pages/api/reset/verify.ts new file mode 100644 index 00000000..c641a68a --- /dev/null +++ b/src/pages/api/reset/verify.ts @@ -0,0 +1,29 @@ +import {NextApiRequest, NextApiResponse} from "next"; +import {getAuth, signInWithEmailLink} from "firebase/auth"; +import {app} from "@/firebase"; +import {sessionOptions} from "@/lib/session"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {doc, getFirestore, setDoc} from "firebase/firestore"; + +const auth = getAuth(app); +const db = getFirestore(app); + +export default withIronSessionApiRoute(verify, sessionOptions); + +async function verify(req: NextApiRequest, res: NextApiResponse) { + const {link} = req.body as {link: string}; + + if (req.session.user) { + signInWithEmailLink(auth, req.session.user.email, link) + .then(async () => { + const userRef = doc(db, "users", req.session.user!.id); + await setDoc(userRef, {isVerified: true}, {merge: true}); + + req.session.user = {...req.session.user!, isVerified: true}; + await req.session.save(); + + res.status(200).json({ok: true}); + }) + .catch(() => res.status(404).json({ok: false})); + } +} diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx index 5c191a93..8cc69f1d 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -35,7 +35,7 @@ import AbandonPopup from "@/components/AbandonPopup"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end(); diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx index d50d3840..7bba71a4 100644 --- a/src/pages/exercises.tsx +++ b/src/pages/exercises.tsx @@ -38,7 +38,7 @@ import AbandonPopup from "@/components/AbandonPopup"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end(); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index dc24f802..c7f60de5 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -21,7 +21,7 @@ import axios from "axios"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end(); diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 2f12d57f..c5cd4dda 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -11,6 +11,7 @@ import {BsArrowRepeat, BsCheck} from "react-icons/bs"; import Link from "next/link"; import Input from "@/components/Low/Input"; import clsx from "clsx"; +import {useRouter} from "next/router"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g); @@ -20,7 +21,9 @@ export default function Login() { const [rememberPassword, setRememberPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); - const {mutateUser} = useUser({ + const router = useRouter(); + + const {user, mutateUser} = useUser({ redirectTo: "/", redirectIfFound: true, }); @@ -64,6 +67,23 @@ export default function Login() { }); }; + const sendEmailVerification = () => { + setIsLoading(true); + axios + .post<{ok: boolean}>("/api/reset/sendVerification", {}) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"}); + }) + .finally(() => setIsLoading(false)); + }; + + const logout = async () => { + axios.post("/api/logout").finally(() => { + setTimeout(() => router.reload(), 500); + }); + }; + return ( <> @@ -85,41 +105,68 @@ export default function Login() {

with your registered Email Address

-
- setEmail(e)} placeholder="Enter email address" /> - setPassword(e)} placeholder="Password" /> -
-
setRememberPassword((prev) => !prev)}> - -
- + {!user && ( + <> + + setEmail(e)} placeholder="Enter email address" /> + setPassword(e)} placeholder="Password" /> +
+
setRememberPassword((prev) => !prev)}> + +
+ +
+ Remember my password +
+ + Forgot Password? +
- Remember my password -
- - Forgot Password? + + + + Don't have an account?{" "} + + Sign up + -
- - - - Don't have an account?{" "} - - Sign up - - + + )} + {user && ( + <> +
+

Please confirm your account!

+ + An e-mail has been sent to {user.email}, please click the + link in it to confirm your account to be able to use the application.

+ Please refresh this page once it has been verified. +
+ +
+ + + + + + )} diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index 34606c04..5fce5a77 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -26,7 +26,7 @@ import {ErrorMessage} from "@/constants/errors"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end(); diff --git a/src/pages/record.tsx b/src/pages/record.tsx index b9443dfb..e32abc99 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -23,7 +23,7 @@ import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end(); diff --git a/src/pages/register.tsx b/src/pages/register.tsx index 3a53d1d5..588a2943 100644 --- a/src/pages/register.tsx +++ b/src/pages/register.tsx @@ -8,6 +8,9 @@ import {BsArrowRepeat} from "react-icons/bs"; import Link from "next/link"; import Input from "@/components/Low/Input"; import axios from "axios"; +import {Divider} from "primereact/divider"; +import {useRouter} from "next/router"; +import clsx from "clsx"; export default function Register() { const [name, setName] = useState(""); @@ -16,7 +19,9 @@ export default function Register() { const [confirmPassword, setConfirmPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); - const {mutateUser} = useUser({ + const router = useRouter(); + + const {user, mutateUser} = useUser({ redirectTo: "/", redirectIfFound: true, }); @@ -32,7 +37,9 @@ export default function Register() { setIsLoading(true); axios .post("/api/register", {name, email, password, profilePicture: "/defaultAvatar.png"}) - .then((response) => mutateUser(response.data.user)) + .then((response) => { + mutateUser(response.data.user).then(sendEmailVerification); + }) .catch((error) => { console.log(error.response.data); @@ -45,6 +52,23 @@ export default function Register() { .finally(() => setIsLoading(false)); }; + const sendEmailVerification = () => { + setIsLoading(true); + axios + .post<{ok: boolean}>("/api/reset/sendVerification", {}) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"}); + }) + .finally(() => setIsLoading(false)); + }; + + const logout = async () => { + axios.post("/api/logout").finally(() => { + setTimeout(() => router.reload(), 500); + }); + }; + return ( <> @@ -60,33 +84,59 @@ export default function Register() { People smiling looking at a tablet
-
+
EnCoach's Logo

Create new account

-
- setName(e)} placeholder="Enter your name" required /> - setEmail(e)} placeholder="Enter email address" required /> - setPassword(e)} placeholder="Enter your password" required /> - setConfirmPassword(e)} - placeholder="Confirm your password" - required - /> - -
- - Sign in instead - + {!user && ( + <> +
+ setName(e)} placeholder="Enter your name" required /> + setEmail(e)} placeholder="Enter email address" required /> + setPassword(e)} placeholder="Enter your password" required /> + setConfirmPassword(e)} + placeholder="Confirm your password" + required + /> + +
+ + Sign in instead + + + )} + {user && ( + <> + +
+

Please confirm your account!

+ + An e-mail has been sent to {user.email}, please click the + link in it to confirm your account to be able to use the application.

+ Please refresh this page once it has been verified. +
+ +
+ + + + + + )}
diff --git a/src/pages/reset.tsx b/src/pages/reset.tsx deleted file mode 100644 index a5c9ebc3..00000000 --- a/src/pages/reset.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import {User} from "@/interfaces/user"; -import {toast, ToastContainer} from "react-toastify"; -import axios from "axios"; -import {FormEvent, useState} from "react"; -import Head from "next/head"; -import useUser from "@/hooks/useUser"; -import {Divider} from "primereact/divider"; -import Button from "@/components/Low/Button"; -import {BsArrowRepeat, BsCheck} from "react-icons/bs"; -import Link from "next/link"; -import Input from "@/components/Low/Input"; -import clsx from "clsx"; -import {useRouter} from "next/router"; - -export function getServerSideProps({query, res}: {query: {oobCode: string}; res: any}) { - if (!query || !query.oobCode) { - res.setHeader("location", "/login"); - res.statusCode = 302; - res.end(); - return { - props: {}, - }; - } - - return { - props: { - code: query.oobCode, - }, - }; -} - -export default function Reset({code}: {code: string}) { - const [password, setPassword] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const router = useRouter(); - - useUser({ - redirectTo: "/", - redirectIfFound: true, - }); - - const login = (e: FormEvent) => { - e.preventDefault(); - - setIsLoading(true); - axios - .post<{ok: boolean}>("/api/reset/confirm", {code, password}) - .then((response) => { - if (response.data.ok) { - toast.success("Your password has been reset!", {toastId: "reset-successful"}); - setTimeout(() => { - router.push("/login"); - }, 2000); - return; - } - - toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"}); - }) - .catch(() => { - toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"}); - }) - .finally(() => setIsLoading(false)); - }; - - return ( - <> - - Reset | EnCoach - - - - -
- -
-
- People smiling looking at a tablet -
-
-
- EnCoach's Logo -

Reset your password

-

to your registered Email Address

-
- -
- setPassword(e)} placeholder="Password" /> - - -
- - Don't have an account?{" "} - - Sign up - - -
-
- - ); -} diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx index 17d3cc30..3acce51b 100644 --- a/src/pages/stats.tsx +++ b/src/pages/stats.tsx @@ -35,7 +35,7 @@ const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8"]; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end(); diff --git a/src/pages/test.tsx b/src/pages/test.tsx index 650da705..c8b17112 100644 --- a/src/pages/test.tsx +++ b/src/pages/test.tsx @@ -20,7 +20,7 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end(); diff --git a/src/pages/users.tsx b/src/pages/users.tsx index 45ee90a6..f6a692c5 100644 --- a/src/pages/users.tsx +++ b/src/pages/users.tsx @@ -15,7 +15,7 @@ import {Dropdown} from "primereact/dropdown"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; - if (!user) { + if (!user || !user.isVerified) { res.setHeader("location", "/login"); res.statusCode = 302; res.end();