From f4e6a307f2663a3029883f4566e1c11778f17134 Mon Sep 17 00:00:00 2001 From: Aaron William Po Date: Sun, 28 May 2023 20:05:00 -0400 Subject: [PATCH] feat: implement change password --- next.config.js | 1 + package-lock.json | 77 +++++++++++++++++++ package.json | 2 + src/components/Account/Security.tsx | 77 +++++++++++++++++++ src/pages/account/index.tsx | 6 +- src/pages/api/users/edit-password.ts | 60 +++++++++++++++ .../User/sendUpdatePasswordRequest.ts | 33 ++++++++ src/services/User/createNewUser.ts | 1 + .../schema/CreateUserValidationSchemas.ts | 8 ++ src/services/User/schema/GetUserSchema.ts | 2 +- 10 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 src/components/Account/Security.tsx create mode 100644 src/pages/api/users/edit-password.ts create mode 100644 src/requests/User/sendUpdatePasswordRequest.ts diff --git a/next.config.js b/next.config.js index 39d2b47..35d5fb2 100644 --- a/next.config.js +++ b/next.config.js @@ -4,6 +4,7 @@ const nextConfig = { images: { domains: ['picsum.photos', 'res.cloudinary.com'], }, + swcMinify: true, }; const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', diff --git a/package-lock.json b/package-lock.json index 1cfce31..567b219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "multer-storage-cloudinary": "^4.0.0", "next": "^13.3.4", "next-connect": "^1.0.0-next.3", + "onchange": "^7.1.0", "passport": "^0.6.0", "passport-local": "^1.0.0", "pino": "^8.12.0", @@ -202,6 +203,16 @@ "node": ">=6.9.0" } }, + "node_modules/@blakeembrey/deque": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz", + "integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==" + }, + "node_modules/@blakeembrey/template": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.1.0.tgz", + "integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -8288,6 +8299,28 @@ "wrappy": "1" } }, + "node_modules/onchange": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz", + "integrity": "sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg==", + "dependencies": { + "@blakeembrey/deque": "^1.0.5", + "@blakeembrey/template": "^1.0.0", + "arg": "^4.1.3", + "chokidar": "^3.3.1", + "cross-spawn": "^7.0.1", + "ignore": "^5.1.4", + "tree-kill": "^1.2.2" + }, + "bin": { + "onchange": "dist/bin.js" + } + }, + "node_modules/onchange/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -10741,6 +10774,14 @@ "node": "*" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/tree-node-cli": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tree-node-cli/-/tree-node-cli-1.6.0.tgz", @@ -11669,6 +11710,16 @@ "regenerator-runtime": "^0.13.11" } }, + "@blakeembrey/deque": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz", + "integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==" + }, + "@blakeembrey/template": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.1.0.tgz", + "integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==" + }, "@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -17455,6 +17506,27 @@ "wrappy": "1" } }, + "onchange": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz", + "integrity": "sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg==", + "requires": { + "@blakeembrey/deque": "^1.0.5", + "@blakeembrey/template": "^1.0.0", + "arg": "^4.1.3", + "chokidar": "^3.3.1", + "cross-spawn": "^7.0.1", + "ignore": "^5.1.4", + "tree-kill": "^1.2.2" + }, + "dependencies": { + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" + } + } + }, "onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -19181,6 +19253,11 @@ "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==" }, + "tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==" + }, "tree-node-cli": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tree-node-cli/-/tree-node-cli-1.6.0.tgz", diff --git a/package.json b/package.json index ae633c8..a5bade0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "next lint", "format": "npx prettier . --write", + "format-watch": "npx onchange \"**/*\" -- prettier --write --ignore-unknown {{changed}}", "seed": "npx --max-old-space-size=4096 ts-node ./src/prisma/seed/index.ts" }, "dependencies": { @@ -80,6 +81,7 @@ "prettier": "^2.8.8", "prettier-plugin-jsdoc": "^0.4.2", "prettier-plugin-tailwindcss": "^0.2.8", + "onchange": "^7.1.0", "prisma": "^4.13.0", "tailwindcss": "^3.3.2", "tailwindcss-animate": "^1.0.5", diff --git a/src/components/Account/Security.tsx b/src/components/Account/Security.tsx new file mode 100644 index 0000000..283b95a --- /dev/null +++ b/src/components/Account/Security.tsx @@ -0,0 +1,77 @@ +import { Switch } from '@headlessui/react'; +import { FunctionComponent, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import sendUpdatePasswordRequest from '@/requests/User/sendUpdatePasswordRequest'; +import FormError from '../ui/forms/FormError'; +import FormInfo from '../ui/forms/FormInfo'; +import FormLabel from '../ui/forms/FormLabel'; +import FormTextInput from '../ui/forms/FormTextInput'; + +const Security: FunctionComponent = () => { + const [editToggled, setEditToggled] = useState(false); + const { register, handleSubmit, formState, reset } = useForm< + z.infer + >({ + resolver: zodResolver(UpdatePasswordSchema), + }); + + const onSubmit: SubmitHandler> = async (data) => { + await sendUpdatePasswordRequest(data) + + reset(); + }; + + return ( +
+
+
+

Change Your Password

+

Update your password to maintain the safety of your account.

+
+
+ setEditToggled((val) => !val)} + /> +
+
+ {editToggled && ( +
+ + New Password + {formState.errors.password?.message} + + + + Confirm Password + {formState.errors.confirmPassword?.message} + + + + + + )} +
+ ); +}; + +export default Security; diff --git a/src/pages/account/index.tsx b/src/pages/account/index.tsx index eb45097..05dacfd 100644 --- a/src/pages/account/index.tsx +++ b/src/pages/account/index.tsx @@ -6,6 +6,7 @@ import Head from 'next/head'; import AccountInfo from '@/components/Account/AccountInfo'; import { useContext } from 'react'; import UserContext from '@/contexts/UserContext'; +import Security from '@/components/Account/Security'; const AccountPage: NextPage = () => { const { user } = useContext(UserContext); @@ -50,7 +51,10 @@ const AccountPage: NextPage = () => { - Content 3 + + + + Your posts! diff --git a/src/pages/api/users/edit-password.ts b/src/pages/api/users/edit-password.ts new file mode 100644 index 0000000..5f29441 --- /dev/null +++ b/src/pages/api/users/edit-password.ts @@ -0,0 +1,60 @@ +import { hashPassword } from '@/config/auth/passwordFns'; +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 DBClient from '@/prisma/DBClient'; +import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; +import { z } from 'zod'; + +interface UpdatePasswordRequest extends UserExtendedNextApiRequest { + body: z.infer; +} + +const updatePassword = async ( + req: UpdatePasswordRequest, + res: NextApiResponse>, +) => { + const { password } = req.body; + const hash = await hashPassword(password); + + const user = req.user!; + const updatedUser: z.infer = await DBClient.instance.user.update({ + data: { hash }, + where: { id: user.id }, + select: { + id: true, + username: true, + createdAt: true, + updatedAt: true, + email: true, + firstName: true, + lastName: true, + dateOfBirth: true, + accountIsVerified: true, + }, + }); + res.json({ + message: 'Updated user password.', + statusCode: 200, + success: true, + payload: updatedUser, + }); +}; +const router = createRouter< + UpdatePasswordRequest, + NextApiResponse> +>(); + +router.put( + validateRequest({ bodySchema: UpdatePasswordSchema }), + getCurrentUser, + updatePassword, +); + +const handler = router.handler(NextConnectOptions); +export default handler; diff --git a/src/requests/User/sendUpdatePasswordRequest.ts b/src/requests/User/sendUpdatePasswordRequest.ts new file mode 100644 index 0000000..1c17ef6 --- /dev/null +++ b/src/requests/User/sendUpdatePasswordRequest.ts @@ -0,0 +1,33 @@ +import { UpdatePasswordSchema } from '@/services/User/schema/CreateUserValidationSchemas'; +import GetUserSchema from '@/services/User/schema/GetUserSchema'; +import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema'; +import { z } from 'zod'; + +const sendUpdatePasswordRequest = async (data: z.infer) => { + const response = await fetch('/api/users/edit-password', { + body: JSON.stringify(data), + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + }); + + 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.'); + } + + const parsedPayload = GetUserSchema.safeParse(parsed.data.payload); + + if (!parsedPayload.success) { + throw new Error('API payload validation failed.'); + } + + return parsedPayload.data; +}; + +export default sendUpdatePasswordRequest; diff --git a/src/services/User/createNewUser.ts b/src/services/User/createNewUser.ts index 62e18e5..b07124c 100644 --- a/src/services/User/createNewUser.ts +++ b/src/services/User/createNewUser.ts @@ -31,6 +31,7 @@ const createNewUser = async ({ dateOfBirth: true, createdAt: true, accountIsVerified: true, + updatedAt: true, }, }); diff --git a/src/services/User/schema/CreateUserValidationSchemas.ts b/src/services/User/schema/CreateUserValidationSchemas.ts index f9b421d..485edcc 100644 --- a/src/services/User/schema/CreateUserValidationSchemas.ts +++ b/src/services/User/schema/CreateUserValidationSchemas.ts @@ -69,3 +69,11 @@ export const CreateUserValidationSchemaWithUsernameAndEmailCheck = message: 'Passwords do not match.', path: ['confirmPassword'], }); + +export const UpdatePasswordSchema = BaseCreateUserSchema.pick({ + password: true, + confirmPassword: true, +}).refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], +}); diff --git a/src/services/User/schema/GetUserSchema.ts b/src/services/User/schema/GetUserSchema.ts index 5e1df4e..aa625c6 100644 --- a/src/services/User/schema/GetUserSchema.ts +++ b/src/services/User/schema/GetUserSchema.ts @@ -4,7 +4,7 @@ const GetUserSchema = z.object({ id: z.string().uuid(), username: z.string(), createdAt: z.coerce.date(), - updatedAt: z.coerce.date().optional(), + updatedAt: z.coerce.date().nullable(), email: z.string().email(), firstName: z.string(), lastName: z.string(),