From 0b0c0e68210229ea05acb726b02cc93c688b340d Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Mon, 15 May 2023 22:59:43 -0400 Subject: [PATCH] Feat: Add edit user functionality --- src/components/Account/AccountInfo.tsx | 166 ++++++++++++++++++ src/components/RegisterUserForm.tsx | 10 +- src/pages/account/index.tsx | 116 +----------- src/pages/api/users/[id]/edit.ts | 108 ++++++++++++ src/pages/api/users/check-email.ts | 2 +- src/pages/api/users/register.ts | 2 +- src/requests/sendRegisterUserRequest.ts | 6 +- src/requests/valdiateEmail.ts | 4 +- src/services/User/createNewUser.ts | 2 +- ...hema.ts => CreateUserValidationSchemas.ts} | 32 ++-- 10 files changed, 308 insertions(+), 140 deletions(-) create mode 100644 src/components/Account/AccountInfo.tsx create mode 100644 src/pages/api/users/[id]/edit.ts rename src/services/User/schema/{CreateUserValidationSchema.ts => CreateUserValidationSchemas.ts} (77%) diff --git a/src/components/Account/AccountInfo.tsx b/src/components/Account/AccountInfo.tsx new file mode 100644 index 0000000..52ac646 --- /dev/null +++ b/src/components/Account/AccountInfo.tsx @@ -0,0 +1,166 @@ +import validateEmail from '@/requests/valdiateEmail'; +import validateUsername from '@/requests/validateUsername'; +import { BaseCreateUserSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import { Switch } from '@headlessui/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/router'; +import { FC, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import FormError from '../ui/forms/FormError'; +import FormInfo from '../ui/forms/FormInfo'; +import FormLabel from '../ui/forms/FormLabel'; +import FormTextInput from '../ui/forms/FormTextInput'; + +interface AccountInfoProps { + user: z.infer; +} + +const AccountInfo: FC = ({ user }) => { + const router = useRouter(); + const EditUserSchema = BaseCreateUserSchema.pick({ + username: true, + email: true, + firstName: true, + lastName: true, + }).extend({ + email: z + .string() + .email({ message: 'Email must be a valid email address.' }) + .refine( + async (email) => { + if (user.email === email) return true; + return validateEmail(email); + }, + { message: 'Email is already taken.' }, + ), + username: z + .string() + .min(1, { message: 'Username must not be empty.' }) + .max(20, { message: 'Username must be less than 20 characters.' }) + .refine( + async (username) => { + if (user.username === username) return true; + return validateUsername(username); + }, + { message: 'Username is already taken.' }, + ), + }); + + const { register, handleSubmit, formState, reset } = useForm< + z.infer + >({ + resolver: zodResolver(EditUserSchema), + defaultValues: { + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + }); + + const [inEditMode, setInEditMode] = useState(false); + + const onSubmit = async (data: z.infer) => { + const response = await fetch(`/api/users/${user.id}/edit`, { + body: JSON.stringify(data), + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error('Something went wrong.'); + } + + await response.json(); + + router.reload(); + }; + + return ( +
+
+
+ + +
+ + Username + {formState.errors.username?.message} + + + + Email + {formState.errors.email?.message} + + + +
+
+ + First Name + {formState.errors.firstName?.message} + + +
+
+ + Last Name + {formState.errors.lastName?.message} + + +
+
+
+ {inEditMode && ( + + )} +
+
+
+ ); +}; + +export default AccountInfo; diff --git a/src/components/RegisterUserForm.tsx b/src/components/RegisterUserForm.tsx index a8ecf26..163e540 100644 --- a/src/components/RegisterUserForm.tsx +++ b/src/components/RegisterUserForm.tsx @@ -1,7 +1,5 @@ import sendRegisterUserRequest from '@/requests/sendRegisterUserRequest'; -import CreateUserValidationSchema, { - CreateUserValidationSchemaWithUsernameAndEmailCheck, -} from '@/services/User/schema/CreateUserValidationSchema'; +import { CreateUserValidationSchemaWithUsernameAndEmailCheck } from '@/services/User/schema/CreateUserValidationSchemas'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/router'; import { FC, useState } from 'react'; @@ -19,13 +17,15 @@ import FormTextInput from './ui/forms/FormTextInput'; const RegisterUserForm: FC = () => { const router = useRouter(); const { reset, register, handleSubmit, formState } = useForm< - z.infer + z.infer >({ resolver: zodResolver(CreateUserValidationSchemaWithUsernameAndEmailCheck) }); const { errors } = formState; const [serverResponseError, setServerResponseError] = useState(''); - const onSubmit = async (data: z.infer) => { + const onSubmit = async ( + data: z.infer, + ) => { try { await sendRegisterUserRequest(data); reset(); diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index 0a68738..bebc7d1 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -1,120 +1,17 @@ import withPageAuthRequired from '@/util/withPageAuthRequired'; import { NextPage } from 'next'; -import { FC, useState } from 'react'; -import { Switch, Tab } from '@headlessui/react'; +import { Tab } from '@headlessui/react'; import Head from 'next/head'; -import FormInfo from '@/components/ui/forms/FormInfo'; -import FormLabel from '@/components/ui/forms/FormLabel'; -import FormError from '@/components/ui/forms/FormError'; -import FormTextInput from '@/components/ui/forms/FormTextInput'; -import { zodResolver } from '@hookform/resolvers/zod'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; import DBClient from '@/prisma/DBClient'; +import AccountInfo from '@/components/Account/AccountInfo'; interface AccountPageProps { user: z.infer; } -const AccountInfo: FC<{ - user: z.infer; -}> = ({ user }) => { - const { register, handleSubmit, formState, reset } = useForm< - z.infer - >({ - resolver: zodResolver(GetUserSchema), - defaultValues: { - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - dateOfBirth: user.dateOfBirth, - }, - }); - - const [inEditMode, setInEditMode] = useState(false); - - return ( -
-
-
- - { - setInEditMode((editMode) => !editMode); - reset(); - }} - id="edit-toggle" - /> -
- -
{})}> -
- - Username - {formState.errors.username?.message} - - - - Email - {''} - - - -
-
- - First Name - {formState.errors.firstName?.message} - - -
-
- - Last Name - {formState.errors.lastName?.message} - - -
-
-
- {inEditMode && } -
-
-
- ); -}; - const AccountPage: NextPage = ({ user }) => { return ( <> @@ -126,7 +23,7 @@ const AccountPage: NextPage = ({ user }) => { />
-
+
@@ -141,10 +38,13 @@ const AccountPage: NextPage = ({ user }) => {
- + Account Info - + + Security + + Your Posts diff --git a/src/pages/api/users/[id]/edit.ts b/src/pages/api/users/[id]/edit.ts new file mode 100644 index 0000000..4ef5b14 --- /dev/null +++ b/src/pages/api/users/[id]/edit.ts @@ -0,0 +1,108 @@ +import { UserExtendedNextApiRequest } from '@/config/auth/types'; +import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; +import getCurrentUser from '@/config/nextConnect/middleware/getCurrentUser'; +import validateRequest from '@/config/nextConnect/middleware/validateRequest'; +import ServerError from '@/config/util/ServerError'; +import DBClient from '@/prisma/DBClient'; +import findUserByEmail from '@/services/User/findUserByEmail'; +import findUserById from '@/services/User/findUserById'; +import findUserByUsername from '@/services/User/findUserByUsername'; +import { BaseCreateUserSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; + +import { NextApiResponse } from 'next'; +import { NextHandler, createRouter } from 'next-connect'; +import { z } from 'zod'; + +const EditUserSchema = BaseCreateUserSchema.pick({ + username: true, + email: true, + firstName: true, + lastName: true, +}); + +interface EditUserRequest extends UserExtendedNextApiRequest { + body: z.infer; + query: { + id: string; + }; +} + +const checkIfUserCanEditUser = async ( + req: EditUserRequest, + res: NextApiResponse, + next: NextHandler, +) => { + const authenticatedUser = req.user!; + + const userToUpdate = await findUserById(req.query.id); + if (!userToUpdate) { + throw new ServerError('User not found', 404); + } + + if (authenticatedUser.id !== userToUpdate.id) { + throw new ServerError('You are not permitted to edit this user', 403); + } + + await next(); +}; + +const editUser = async ( + req: EditUserRequest, + res: NextApiResponse>, +) => { + const { email, firstName, lastName, username } = req.body; + + const [usernameIsTaken, emailIsTaken] = await Promise.all([ + findUserByUsername(username), + findUserByEmail(email), + ]); + + const emailChanged = req.user!.email !== email; + const usernameChanged = req.user!.username !== username; + + if (emailIsTaken && emailChanged) { + throw new ServerError('Email is already taken', 400); + } + + if (usernameIsTaken && usernameChanged) { + throw new ServerError('Username is already taken', 400); + } + + const updatedUser = await DBClient.instance.user.update({ + where: { id: req.user!.id }, + data: { + email, + firstName, + lastName, + username, + accountIsVerified: emailChanged ? false : undefined, + }, + }); + + res.json({ + message: 'User edited successfully', + payload: updatedUser, + success: true, + statusCode: 200, + }); +}; + +const router = createRouter< + EditUserRequest, + NextApiResponse> +>(); + +router.put( + getCurrentUser, + validateRequest({ + bodySchema: EditUserSchema, + querySchema: z.object({ id: z.string().uuid() }), + }), + checkIfUserCanEditUser, + editUser, +); + +const handler = router.handler(NextConnectOptions); + +export default handler; diff --git a/src/pages/api/users/check-email.ts b/src/pages/api/users/check-email.ts index d122d1e..4d03c37 100644 --- a/src/pages/api/users/check-email.ts +++ b/src/pages/api/users/check-email.ts @@ -29,7 +29,7 @@ const checkEmail = async (req: NextApiRequest, res: NextApiResponse) => { success: true, payload: { emailIsTaken: !!email }, statusCode: 200, - message: 'Getting username availability.', + message: 'Getting email availability.', }); }; diff --git a/src/pages/api/users/register.ts b/src/pages/api/users/register.ts index 0cd4a77..51b93a6 100644 --- a/src/pages/api/users/register.ts +++ b/src/pages/api/users/register.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import ServerError from '@/config/util/ServerError'; import { createRouter } from 'next-connect'; import createNewUser from '@/services/User/createNewUser'; -import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema'; +import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas'; import NextConnectOptions from '@/config/nextConnect/NextConnectOptions'; import findUserByUsername from '@/services/User/findUserByUsername'; import findUserByEmail from '@/services/User/findUserByEmail'; diff --git a/src/requests/sendRegisterUserRequest.ts b/src/requests/sendRegisterUserRequest.ts index 7e106b4..b29b5e5 100644 --- a/src/requests/sendRegisterUserRequest.ts +++ b/src/requests/sendRegisterUserRequest.ts @@ -1,4 +1,4 @@ -import CreateUserValidationSchema from '@/services/User/schema/CreateUserValidationSchema'; +import { CreateUserValidationSchema } from '@/services/User/schema/CreateUserValidationSchemas'; import GetUserSchema from '@/services/User/schema/GetUserSchema'; import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; import { z } from 'zod'; @@ -6,9 +6,7 @@ import { z } from 'zod'; async function sendRegisterUserRequest(data: z.infer) { const response = await fetch('/api/users/register', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); diff --git a/src/requests/valdiateEmail.ts b/src/requests/valdiateEmail.ts index b10f0a7..dbd8fc0 100644 --- a/src/requests/valdiateEmail.ts +++ b/src/requests/valdiateEmail.ts @@ -12,14 +12,14 @@ const validateEmail = async (email: string) => { } const parsedPayload = z - .object({ usernameIsTaken: z.boolean() }) + .object({ emailIsTaken: z.boolean() }) .safeParse(parsed.data.payload); if (!parsedPayload.success) { return false; } - return !parsedPayload.data.usernameIsTaken; + return !parsedPayload.data.emailIsTaken; }; export default validateEmail; diff --git a/src/services/User/createNewUser.ts b/src/services/User/createNewUser.ts index c77c82f..62e18e5 100644 --- a/src/services/User/createNewUser.ts +++ b/src/services/User/createNewUser.ts @@ -1,7 +1,7 @@ import { hashPassword } from '@/config/auth/passwordFns'; import DBClient from '@/prisma/DBClient'; import { z } from 'zod'; -import CreateUserValidationSchema from './schema/CreateUserValidationSchema'; +import { CreateUserValidationSchema } from './schema/CreateUserValidationSchemas'; import GetUserSchema from './schema/GetUserSchema'; const createNewUser = async ({ diff --git a/src/services/User/schema/CreateUserValidationSchema.ts b/src/services/User/schema/CreateUserValidationSchemas.ts similarity index 77% rename from src/services/User/schema/CreateUserValidationSchema.ts rename to src/services/User/schema/CreateUserValidationSchemas.ts index feffe4a..1488673 100644 --- a/src/services/User/schema/CreateUserValidationSchema.ts +++ b/src/services/User/schema/CreateUserValidationSchemas.ts @@ -3,9 +3,9 @@ import validateUsername from '@/requests/validateUsername'; import sub from 'date-fns/sub'; import { z } from 'zod'; -const minimumDateOfBirth = sub(new Date(), { years: 19 }); -const CreateUserValidationSchema = z.object({ - // use special characters, numbers, and uppercase letters +const MINIMUM_DATE_OF_BIRTH = sub(new Date(), { years: 19 }); + +export const BaseCreateUserSchema = z.object({ password: z .string() .min(8, { message: 'Password must be at least 8 characters.' }) @@ -33,29 +33,25 @@ const CreateUserValidationSchema = z.object({ .refine((lastName) => /^[a-zA-Z]+$/.test(lastName), { message: 'Last name must only contain letters.', }), - dateOfBirth: z.string().refine( - (dateOfBirth) => { - const parsedDateOfBirth = new Date(dateOfBirth); - return parsedDateOfBirth <= minimumDateOfBirth; - }, - { message: 'You must be at least 19 years old to register.' }, - ), -}); - -export default CreateUserValidationSchema.extend({ + dateOfBirth: z + .string() + .refine((dateOfBirth) => new Date(dateOfBirth) <= MINIMUM_DATE_OF_BIRTH, { + message: 'You must be at least 19 years old to register.', + }), username: z .string() .min(1, { message: 'Username must not be empty.' }) .max(20, { message: 'Username must be less than 20 characters.' }), - email: z.string().email({ message: 'Email must be a valid email address.' }), -}).refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match.', - path: ['confirmPassword'], }); +export const CreateUserValidationSchema = BaseCreateUserSchema.refine( + (data) => data.password === data.confirmPassword, + { message: 'Passwords do not match.', path: ['confirmPassword'] }, +); + export const CreateUserValidationSchemaWithUsernameAndEmailCheck = - CreateUserValidationSchema.extend({ + BaseCreateUserSchema.extend({ email: z .string() .email({ message: 'Email must be a valid email address.' })