From cf979b52d3b7896b5e9e85e80c15a69b33470c18 Mon Sep 17 00:00:00 2001 From: bombies Date: Tue, 31 Oct 2023 17:54:53 -0500 Subject: [PATCH] Implement account deletion --- .../components/DeleteAccountButton.tsx | 105 ++++++++++++++++-- .../(site)/components/ConfirmationModal.tsx | 27 ++++- src/app/api/me/route.ts | 8 +- src/app/api/me/self-user.dto.ts | 11 +- src/app/api/me/self-user.service.ts | 48 +++++++- src/utils/client/client-utils.tsx | 2 +- 6 files changed, 182 insertions(+), 19 deletions(-) diff --git a/src/app/(site)/(internal)/settings/account/components/DeleteAccountButton.tsx b/src/app/(site)/(internal)/settings/account/components/DeleteAccountButton.tsx index b9423ae..d5170e8 100644 --- a/src/app/(site)/(internal)/settings/account/components/DeleteAccountButton.tsx +++ b/src/app/(site)/(internal)/settings/account/components/DeleteAccountButton.tsx @@ -1,17 +1,104 @@ "use client" -import { Button } from "@nextui-org/react"; -import {FC} from "react"; +import {Button, Checkbox, Spacer} from "@nextui-org/react"; +import {FC, Fragment, useState} from "react"; +import ConfirmationModal from "@/app/(site)/components/ConfirmationModal"; +import {AnimatePresence, motion} from "framer-motion"; +import Input from "@/app/(site)/components/inputs/Input"; +import {signOut, useSession} from "next-auth/react"; +import {deleteMutator} from "@/utils/client/client-utils"; +import {DeleteSelfDto} from "@/app/api/me/self-user.dto"; +import useSWRMutation from "swr/mutation"; +import {Member} from "@prisma/client"; +import toast from "react-hot-toast"; +import {AxiosError} from "axios"; + +const DeleteAccount = () => ( + useSWRMutation('/api/me', deleteMutator()) +) const DeleteAccountButton: FC = () => { + const {data: session} = useSession() + const [confirmationModelOpen, setConfirmationModelOpen] = useState(false) + const [isConfirmed, setIsConfirmed] = useState(false) + const [password, setPassword] = useState() + const {trigger: deleteAccount, isMutating: isDeleting} = DeleteAccount() + return ( - + + { + await toast.promise( + deleteAccount({ + body: {password} + }) + .then(() => signOut()) + .catch((err: AxiosError) => Promise.reject(err.response?.statusText ?? "There was an error deleting your account!")) + , + { + loading: "Deleting your account...", + success: "Deleted your account!", + error(msg: string) { + return msg + } + } + ) + }} + onReject={() => setConfirmationModelOpen(false)} + acceptContent={"Delete Account"} + > +

Deleting your account is a + permanent action and cannot be undone! Are you sure you want to delete your password?

+ + I'm sure + + + {isConfirmed && session?.user.accountProvider === null && ( + + + + + + + )} + +
+ +
) } diff --git a/src/app/(site)/components/ConfirmationModal.tsx b/src/app/(site)/components/ConfirmationModal.tsx index adecac2..64e6e28 100644 --- a/src/app/(site)/components/ConfirmationModal.tsx +++ b/src/app/(site)/components/ConfirmationModal.tsx @@ -10,10 +10,23 @@ type Props = { size?: "lg" | "xl" | "2xl" onAccept?: () => void, onReject?: () => void, + rejectContent?: ReactElement | ReactElement[] | string + acceptContent?: ReactElement | ReactElement[] | string + controlsDisabled?: boolean, isOpen?: boolean, } & PropsWithChildren -const ConfirmationModal: FC = ({title, children, size, isOpen, onAccept, onReject}) => { +const ConfirmationModal: FC = ({ + title, + children, + size, + isOpen, + onAccept, + onReject, + rejectContent, + acceptContent, + controlsDisabled + }) => { return ( = ({title, children, size, isOpen, onAccept, isDismissable={false} > {children} - +
diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index 386da5b..6596eb7 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -18,4 +18,10 @@ export const PATCH = async (req: Request) => { } } ) -} \ No newline at end of file +} + +export const DELETE = async (req: Request) => ( + authenticated(async (session) => ( + selfUserService.delete(session, new URL(req.url).searchParams) + )) +) \ No newline at end of file diff --git a/src/app/api/me/self-user.dto.ts b/src/app/api/me/self-user.dto.ts index dab8638..b2f9836 100644 --- a/src/app/api/me/self-user.dto.ts +++ b/src/app/api/me/self-user.dto.ts @@ -1,5 +1,6 @@ import {z} from "zod"; import {USERNAME_REGEX} from "@/app/api/auth/register/register.dto"; +import {zfd} from "zod-form-data"; export type PatchSelfDto = Partial<{ username?: string, @@ -13,4 +14,12 @@ export const PatchSelfDtoSchema = z.object({ firstName: z.string().min(1).max(60), lastName: z.string().min(1).max(60), image: z.string() -}).partial() \ No newline at end of file +}).partial() + +export type DeleteSelfDto = { + password?: string +} + +export const DeleteSelfDtoSchema = zfd.formData({ + password: z.string().optional() +}) \ No newline at end of file diff --git a/src/app/api/me/self-user.service.ts b/src/app/api/me/self-user.service.ts index 5e9135d..9d78650 100644 --- a/src/app/api/me/self-user.service.ts +++ b/src/app/api/me/self-user.service.ts @@ -3,7 +3,8 @@ import {Member} from "@prisma/client"; import {Session} from "next-auth"; import {buildFailedValidationResponse, buildResponse} from "@/app/api/utils/types"; import prisma from "@/libs/prisma"; -import {PatchSelfDto, PatchSelfDtoSchema} from "@/app/api/me/self-user.dto"; +import {DeleteSelfDtoSchema, PatchSelfDto, PatchSelfDtoSchema} from "@/app/api/me/self-user.dto"; +import {compare} from "bcrypt"; class SelfUserService { @@ -54,6 +55,51 @@ class SelfUserService { data: updatedUser }) } + + public async delete(session: Session, dto: URLSearchParams): Promise> { + if (!session.user) + return buildResponse({ + status: 403, + message: "Unauthenticated!" + }) + + const dtoValidated = DeleteSelfDtoSchema.safeParse(dto) + if (!dtoValidated.success) + return buildFailedValidationResponse(dtoValidated.error) + + const {password} = dtoValidated.data + const user = await prisma.member.findUnique({ + where: { + id: session.user.id + }, + select: { + password: true, + accountProvider: true + } + }) + + if (!user) + return buildResponse({ + status: 404, + message: "Couldn't find any information for you!" + }) + + if (user.password !== null && !(await compare(password ?? "", user.password))) + return buildResponse({ + status: 400, + message: "Invalid password!" + }) + + const deletedUser = await prisma.member.delete({ + where: { + id: session.user.id + } + }) + + return buildResponse({ + data: deletedUser + }) + } } const selfUserService = new SelfUserService() diff --git a/src/utils/client/client-utils.tsx b/src/utils/client/client-utils.tsx index 4e1cc01..cf23fb8 100644 --- a/src/utils/client/client-utils.tsx +++ b/src/utils/client/client-utils.tsx @@ -21,7 +21,7 @@ export type MutatorArgs = { export const postMutator = () => (url: string, {arg}: MutatorArgs) => axios.post(url, arg.body) export const patchMutator = () => (url: string, {arg}: MutatorArgs) => axios.patch(url, arg.body) -export const deleteMutator = () => (url: string) => axios.delete(url) +export const deleteMutator = , R>() => (url: string, args?: MutatorArgs) => axios.delete(`${url}${args ? "?" + new URLSearchParams(args.arg.body).toString() : ""}`) export function handleAxiosError(error: any): undefined {