From 06ae380b8f24b89f340d57d4be49d30058a39f15 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 29 May 2023 15:51:59 -0400 Subject: [PATCH] feat: create confirm user page, option to resend email if link expires --- src/config/jwt/index.ts | 33 ++++-- src/hooks/auth/useUser.ts | 2 - src/pages/api/users/confirm.ts | 10 +- src/pages/api/users/resend-confirmation.ts | 32 ++++++ src/pages/users/[id].tsx | 7 +- src/pages/users/confirm.tsx | 125 +++++++++++++++++++++ src/pages/users/current.tsx | 38 ++++--- src/services/User/findUserById.ts | 2 +- src/services/User/sendConfirmationEmail.ts | 2 +- 9 files changed, 210 insertions(+), 41 deletions(-) create mode 100644 src/pages/api/users/resend-confirmation.ts create mode 100644 src/pages/users/confirm.tsx diff --git a/src/config/jwt/index.ts b/src/config/jwt/index.ts index 54a659f..add709b 100644 --- a/src/config/jwt/index.ts +++ b/src/config/jwt/index.ts @@ -2,22 +2,31 @@ import { BasicUserInfoSchema } from '@/config/auth/types'; import jwt from 'jsonwebtoken'; import { z } from 'zod'; import { CONFIRMATION_TOKEN_SECRET } from '../env'; +import ServerError from '../util/ServerError'; -type User = z.infer; - -export const generateConfirmationToken = (user: User) => { - const token = jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '30m' }); - return token; +export const generateConfirmationToken = (user: z.infer) => { + return jwt.sign(user, CONFIRMATION_TOKEN_SECRET, { expiresIn: '3m' }); }; -export const verifyConfirmationToken = (token: string) => { - const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET); +export const verifyConfirmationToken = async (token: string) => { + try { + const decoded = jwt.verify(token, CONFIRMATION_TOKEN_SECRET); - const parsed = BasicUserInfoSchema.safeParse(decoded); + const parsed = BasicUserInfoSchema.safeParse(decoded); - if (!parsed.success) { - throw new Error('Invalid token'); + if (!parsed.success) { + throw new Error('Invalid token'); + } + + return parsed.data; + } catch (error) { + if (error instanceof Error && error.message === 'jwt expired') { + throw new ServerError( + 'Your confirmation token is expired. Please generate a new one.', + 401, + ); + } + + throw new ServerError('Something went wrong', 500); } - - return parsed.data; }; diff --git a/src/hooks/auth/useUser.ts b/src/hooks/auth/useUser.ts index f1340df..86c4a9f 100644 --- a/src/hooks/auth/useUser.ts +++ b/src/hooks/auth/useUser.ts @@ -39,7 +39,6 @@ const useUser = () => { const parsedPayload = GetUserSchema.safeParse(parsed.data.payload); - console.log(parsedPayload) if (!parsedPayload.success) { throw new Error(parsedPayload.error.message); } @@ -47,7 +46,6 @@ const useUser = () => { return parsedPayload.data; }); - return { user, isLoading, error: error as unknown, mutate }; }; diff --git a/src/pages/api/users/confirm.ts b/src/pages/api/users/confirm.ts index af3f7fa..9186631 100644 --- a/src/pages/api/users/confirm.ts +++ b/src/pages/api/users/confirm.ts @@ -21,16 +21,16 @@ const confirmUser = async (req: ConfirmUserRequest, res: NextApiResponse) => { const { token } = req.query; const user = req.user!; - const { id } = verifyConfirmationToken(token); + const { id } = await verifyConfirmationToken(token); + + if (user.accountIsVerified) { + throw new ServerError('Your account is already verified.', 400); + } if (user.id !== id) { throw new ServerError('Could not confirm user.', 401); } - if (user.accountIsVerified) { - throw new ServerError('User is already verified.', 400); - } - await updateUserToBeConfirmedById(id); res.status(200).json({ diff --git a/src/pages/api/users/resend-confirmation.ts b/src/pages/api/users/resend-confirmation.ts new file mode 100644 index 0000000..d422c29 --- /dev/null +++ b/src/pages/api/users/resend-confirmation.ts @@ -0,0 +1,32 @@ +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; +import sendConfirmationEmail from '@/services/User/sendConfirmationEmail'; + +const resendConfirmation = async ( + req: UserExtendedNextApiRequest, + res: NextApiResponse, +) => { + const user = req.user!; + + await sendConfirmationEmail(user); + res.status(200).json({ + message: `Resent the confirmation email for ${user.username}.`, + statusCode: 200, + success: true, + }); +}; + +const router = createRouter< + UserExtendedNextApiRequest, + NextApiResponse> +>(); + +router.post(getCurrentUser, resendConfirmation); + +const handler = router.handler(NextConnectOptions); +export default handler; diff --git a/src/pages/users/[id].tsx b/src/pages/users/[id].tsx index 570a3c8..f7550d1 100644 --- a/src/pages/users/[id].tsx +++ b/src/pages/users/[id].tsx @@ -1,6 +1,5 @@ -import { FC } from "react" +import { FC } from 'react'; -const UserInfoPage: FC = () => null +const UserInfoPage: FC = () => null; - -export default UserInfoPage \ No newline at end of file +export default UserInfoPage; diff --git a/src/pages/users/confirm.tsx b/src/pages/users/confirm.tsx new file mode 100644 index 0000000..3ab094a --- /dev/null +++ b/src/pages/users/confirm.tsx @@ -0,0 +1,125 @@ +import UserContext from '@/contexts/UserContext'; +import createErrorToast from '@/util/createErrorToast'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { FC, useContext, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import useSWR from 'swr'; + +const useSendConfirmUserRequest = () => { + const router = useRouter(); + const token = router.query.token as string | undefined; + + const { data, error } = useSWR(`/api/users/confirm?token=${token}`, async (url) => { + if (!token) { + throw new Error('Token must be provided.'); + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(response.statusText); + } + const json = await response.json(); + + const parsed = APIResponseValidationSchema.safeParse(json); + + if (!parsed.success) { + throw new Error('API response validation failed.'); + } + return parsed.data; + }); + + return { data, error: error as unknown }; +}; + +const ConfirmUserPage: FC = () => { + const router = useRouter(); + const { error, data } = useSendConfirmUserRequest(); + const { user } = useContext(UserContext); + + const needsToLogin = + error instanceof Error && error.message === 'Unauthorized' && !user; + const tokenExpired = error instanceof Error && error.message === 'Unauthorized' && user; + const [confirmationResent, setConfirmationResent] = useState(false); + + if (user?.accountIsVerified) { + router.push('/users/current'); + return null; + } + + if (data) { + router.push('/users/current'); + return null; + } + + if (needsToLogin) { + return ( + <> + + Confirm User | The Biergarten App + +
+

+ Please login to confirm your account. +

+
+ + ); + } + if (tokenExpired) { + const onClick = async () => { + const loadingToast = toast.loading('Resending your confirmation email.'); + try { + const response = await fetch('/api/users/resend-confirmation', { + method: 'POST', + }); + if (!response.ok) { + throw new Error('Something went wrong.'); + } + + toast.remove(loadingToast); + toast.success('Sent a new confirmation email.'); + + setConfirmationResent(true); + } catch (err) { + createErrorToast(err); + } + }; + + return ( + <> + + Confirm User | The Biergarten App + +
+ {!confirmationResent ? ( + <> +

+ Your confirmation token is expired. +

+ + + ) : ( + <> +

+ Resent your confirmation link. +

+

Please check your email.

+ + )} +
+ + ); + } + + return null; +}; + +export default ConfirmUserPage; diff --git a/src/pages/users/current.tsx b/src/pages/users/current.tsx index 0d0f5c4..58980d2 100644 --- a/src/pages/users/current.tsx +++ b/src/pages/users/current.tsx @@ -5,6 +5,7 @@ import UserContext from '@/contexts/UserContext'; import { GetServerSideProps, NextPage } from 'next'; import { useContext } from 'react'; import useMediaQuery from '@/hooks/utilities/useMediaQuery'; +import Head from 'next/head'; const ProtectedPage: NextPage = () => { const { user, isLoading } = useContext(UserContext); @@ -17,22 +18,27 @@ const ProtectedPage: NextPage = () => { const isDesktop = useMediaQuery('(min-width: 768px)'); return ( -
- {isLoading && } - {user && !isLoading && ( - <> -

- Good {isMorning && 'morning'} - {isAfternoon && 'afternoon'} - {isEvening && 'evening'} - {`, ${user?.firstName}!`} -

-

- Welcome to the Biergarten App! -

- - )} -
+ <> + + Hello, {user?.firstName}! | The Biergarten App + +
+ {isLoading && } + {user && !isLoading && ( + <> +

+ Good {isMorning && 'morning'} + {isAfternoon && 'afternoon'} + {isEvening && 'evening'} + {`, ${user?.firstName}!`} +

+

+ Welcome to the Biergarten App! +

+ + )} +
+ ); }; diff --git a/src/services/User/findUserById.ts b/src/services/User/findUserById.ts index f120404..fb962b2 100644 --- a/src/services/User/findUserById.ts +++ b/src/services/User/findUserById.ts @@ -15,7 +15,7 @@ const findUserById = async (id: string) => { dateOfBirth: true, createdAt: true, accountIsVerified: true, - updatedAt: true + updatedAt: true, }, }); diff --git a/src/services/User/sendConfirmationEmail.ts b/src/services/User/sendConfirmationEmail.ts index c45bc1a..8abcfed 100644 --- a/src/services/User/sendConfirmationEmail.ts +++ b/src/services/User/sendConfirmationEmail.ts @@ -14,7 +14,7 @@ const sendConfirmationEmail = async ({ id, username, email }: UserSchema) => { const subject = 'Confirm your email'; const name = username; - const url = `${BASE_URL}/api/users/confirm?token=${confirmationToken}`; + const url = `${BASE_URL}/users/confirm?token=${confirmationToken}`; const address = email; const html = render(Welcome({ name, url, subject })!);