Skip to content

Commit

Permalink
Implement account deletion
Browse files Browse the repository at this point in the history
  • Loading branch information
bombies committed Oct 31, 2023
1 parent 22a55f2 commit cf979b5
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -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<DeleteSelfDto, Member>())
)

const DeleteAccountButton: FC = () => {
const {data: session} = useSession()
const [confirmationModelOpen, setConfirmationModelOpen] = useState(false)
const [isConfirmed, setIsConfirmed] = useState(false)
const [password, setPassword] = useState<string>()
const {trigger: deleteAccount, isMutating: isDeleting} = DeleteAccount()

return (
<Button
color="danger"
variant="bordered"
size="lg"
>
Delete Account
</Button>
<Fragment>
<ConfirmationModal
controlsDisabled={!isConfirmed || (session?.user.accountProvider === null && (!password || !password.length))}
isOpen={confirmationModelOpen}
title="Delete Your Account"
size="2xl"
onAccept={async () => {
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"}
>
<p className="font-bold p-6 bg-danger/30 rounded-3xl w-fit phone:w-3/4 mb-6">Deleting your account is a
permanent action and cannot be undone! Are you sure you want to delete your password?</p>
<Checkbox
isSelected={isConfirmed}
onValueChange={setIsConfirmed}
>
I&apos;m sure
</Checkbox>
<AnimatePresence>
{isConfirmed && session?.user.accountProvider === null && (
<motion.div layout>
<Spacer y={6}/>
<motion.div
initial={{
opacity: 0,
y: -50
}}
animate={{
opacity: 1,
y: 0
}}
exit={{
opacity: 0,
y: -50
}}
>
<Input
id="password"
type="password"
label="Password"
value={password}
onValueChange={setPassword}
isRequired
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</ConfirmationModal>
<Button
color="danger"
variant="bordered"
size="lg"
onPress={() => setConfirmationModelOpen(true)}
>
Delete Account
</Button>
</Fragment>
)
}

Expand Down
27 changes: 21 additions & 6 deletions src/app/(site)/components/ConfirmationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = ({title, children, size, isOpen, onAccept, onReject}) => {
const ConfirmationModal: FC<Props> = ({
title,
children,
size,
isOpen,
onAccept,
onReject,
rejectContent,
acceptContent,
controlsDisabled
}) => {
return (
<Modal
placement="center"
Expand All @@ -24,23 +37,25 @@ const ConfirmationModal: FC<Props> = ({title, children, size, isOpen, onAccept,
isDismissable={false}
>
{children}
<Divider className="my-6" />
<Divider className="my-6"/>
<div className="flex justify-end gap-4">
<Button
isDisabled={controlsDisabled}
color="primary"
variant="flat"
onPress={onAccept}
startContent={<CheckIcon />}
startContent={<CheckIcon/>}
>
I&apos;m sure
{acceptContent ?? "I'm sure"}
</Button>
<Button
isDisabled={controlsDisabled}
color="danger"
variant="shadow"
onPress={onReject}
startContent={<CloseIcon />}
startContent={<CloseIcon/>}
>
Nevermind
{rejectContent ?? "Nevermind"}
</Button>
</div>
</Modal>
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/me/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@ export const PATCH = async (req: Request) => {
}
}
)
}
}

export const DELETE = async (req: Request) => (
authenticated(async (session) => (
selfUserService.delete(session, new URL(req.url).searchParams)
))
)
11 changes: 10 additions & 1 deletion src/app/api/me/self-user.dto.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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()
}).partial()

export type DeleteSelfDto = {
password?: string
}

export const DeleteSelfDtoSchema = zfd.formData({
password: z.string().optional()
})
48 changes: 47 additions & 1 deletion src/app/api/me/self-user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -54,6 +55,51 @@ class SelfUserService {
data: updatedUser
})
}

public async delete(session: Session, dto: URLSearchParams): Promise<NextResponse<Member | null>> {
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()
Expand Down
2 changes: 1 addition & 1 deletion src/utils/client/client-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type MutatorArgs<T> = {
export const postMutator = <B, R>() => (url: string, {arg}: MutatorArgs<B>) => axios.post<R>(url, arg.body)
export const patchMutator = <B, R>() => (url: string, {arg}: MutatorArgs<B>) => axios.patch<R>(url, arg.body)

export const deleteMutator = <B,>() => (url: string) => axios.delete<B>(url)
export const deleteMutator = <B extends Record<string, string>, R>() => (url: string, args?: MutatorArgs<B>) => axios.delete<R>(`${url}${args ? "?" + new URLSearchParams(args.arg.body).toString() : ""}`)


export function handleAxiosError(error: any): undefined {
Expand Down

0 comments on commit cf979b5

Please sign in to comment.