From cb8c2d5f103f516159699b2dcc9c72ba863daf18 Mon Sep 17 00:00:00 2001 From: daniel31x13 Date: Tue, 24 Oct 2023 15:57:37 -0400 Subject: [PATCH] finished adding profile deletion functionality + bug fix --- .env.sample | 1 + components/Search.tsx | 2 +- layouts/CenteredForm.tsx | 4 +- .../users/userId/deleteUserById.ts | 62 ++++--- pages/api/v1/migration/index.ts | 8 + pages/choose-username.tsx | 8 +- pages/collections/[id].tsx | 2 +- pages/collections/index.tsx | 2 +- pages/confirmation.tsx | 17 +- pages/dashboard.tsx | 2 +- pages/forgot.tsx | 11 +- pages/links.tsx | 2 +- pages/login.tsx | 8 +- pages/register.tsx | 133 +++++++-------- pages/search/[query].tsx | 117 -------------- pages/search/index.tsx | 117 +++++++++++++- pages/settings/account.tsx | 29 +++- pages/settings/delete.tsx | 152 ++++++++++++++++++ pages/settings/password.tsx | 2 +- pages/subscribe.tsx | 43 ++--- pages/tags/[id].tsx | 4 +- styles/globals.css | 8 + types/enviornment.d.ts | 1 + types/global.ts | 9 ++ 24 files changed, 477 insertions(+), 267 deletions(-) delete mode 100644 pages/search/[query].tsx create mode 100644 pages/settings/delete.tsx diff --git a/.env.sample b/.env.sample index 4e1966e..39e1e43 100644 --- a/.env.sample +++ b/.env.sample @@ -8,6 +8,7 @@ PAGINATION_TAKE_COUNT= STORAGE_FOLDER= AUTOSCROLL_TIMEOUT= NEXT_PUBLIC_DISABLE_REGISTRATION= +IMPORT_SIZE_LIMIT= # AWS S3 Settings SPACES_KEY= diff --git a/components/Search.tsx b/components/Search.tsx index fe61ef4..842b205 100644 --- a/components/Search.tsx +++ b/components/Search.tsx @@ -41,7 +41,7 @@ export default function Search() { }} onKeyDown={(e) => e.key === "Enter" && - router.push("/search/" + encodeURIComponent(searchQuery)) + router.push("/search?q=" + encodeURIComponent(searchQuery)) } autoFocus={searchBox} className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800" diff --git a/layouts/CenteredForm.tsx b/layouts/CenteredForm.tsx index 45f8ed5..03052ec 100644 --- a/layouts/CenteredForm.tsx +++ b/layouts/CenteredForm.tsx @@ -12,7 +12,7 @@ export default function CenteredForm({ text, children }: Props) { const { theme } = useTheme(); return (
-
+
{theme === "dark" ? ( )} {text ? ( -

+

{text}

) : undefined} diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 21262b5..731e877 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -2,14 +2,8 @@ import { prisma } from "@/lib/api/db"; import bcrypt from "bcrypt"; import removeFolder from "@/lib/api/storage/removeFolder"; import Stripe from "stripe"; - -type DeleteUserBody = { - password: string; - cancellation_details?: { - comment?: string; - feedback?: Stripe.SubscriptionCancelParams.CancellationDetails.Feedback; - }; -}; +import { DeleteUserBody } from "@/types/global"; +import removeFile from "@/lib/api/storage/removeFile"; export default async function deleteUserById( userId: number, @@ -22,7 +16,7 @@ export default async function deleteUserById( if (!user) { return { - response: "User not found.", + response: "Invalid credentials.", status: 404, }; } @@ -32,7 +26,7 @@ export default async function deleteUserById( if (!isPasswordValid) { return { - response: "Invalid password.", + response: "Invalid credentials.", status: 401, // Unauthorized }; } @@ -54,7 +48,7 @@ export default async function deleteUserById( where: { ownerId: userId }, }); - // Delete collections + // Find collections that the user owns const collections = await prisma.collection.findMany({ where: { ownerId: userId }, }); @@ -65,7 +59,7 @@ export default async function deleteUserById( where: { collectionId: collection.id }, }); - // Optionally delete archive folders associated with collections + // Delete archive folders associated with collections removeFolder({ filePath: `archives/${collection.id}` }); } @@ -74,8 +68,8 @@ export default async function deleteUserById( where: { ownerId: userId }, }); - // Optionally delete user's avatar - removeFolder({ filePath: `uploads/avatar/${userId}.jpg` }); + // Delete user's avatar + removeFile({ filePath: `uploads/avatar/${userId}.jpg` }); // Finally, delete the user await prisma.user.delete({ @@ -88,26 +82,30 @@ export default async function deleteUserById( apiVersion: "2022-11-15", }); - const listByEmail = await stripe.customers.list({ - email: user.email?.toLowerCase(), - expand: ["data.subscriptions"], - }); + try { + const listByEmail = await stripe.customers.list({ + email: user.email?.toLowerCase(), + expand: ["data.subscriptions"], + }); - if (listByEmail.data[0].subscriptions?.data[0].id) { - const deleted = await stripe.subscriptions.cancel( - listByEmail.data[0].subscriptions?.data[0].id, - { - cancellation_details: { - comment: body.cancellation_details?.comment, - feedback: body.cancellation_details?.feedback, - }, - } - ); + if (listByEmail.data[0].subscriptions?.data[0].id) { + const deleted = await stripe.subscriptions.cancel( + listByEmail.data[0].subscriptions?.data[0].id, + { + cancellation_details: { + comment: body.cancellation_details?.comment, + feedback: body.cancellation_details?.feedback, + }, + } + ); - return { - response: deleted, - status: 200, - }; + return { + response: deleted, + status: 200, + }; + } + } catch (err) { + console.log(err); } } diff --git a/pages/api/v1/migration/index.ts b/pages/api/v1/migration/index.ts index a881e48..628a0d2 100644 --- a/pages/api/v1/migration/index.ts +++ b/pages/api/v1/migration/index.ts @@ -6,6 +6,14 @@ import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFi import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden"; import { MigrationFormat, MigrationRequest } from "@/types/global"; +export const config = { + api: { + bodyParser: { + sizeLimit: process.env.IMPORT_SIZE_LIMIT || "2mb", + }, + }, +}; + export default async function users(req: NextApiRequest, res: NextApiResponse) { const session = await getServerSession(req, res, authOptions); diff --git a/pages/choose-username.tsx b/pages/choose-username.tsx index 1b4d9be..ff56fb3 100644 --- a/pages/choose-username.tsx +++ b/pages/choose-username.tsx @@ -41,11 +41,13 @@ export default function ChooseUsername() { return (
-
-

- Choose a Username (Last step) +

+

+ Choose a Username

+
+

Username diff --git a/pages/collections/[id].tsx b/pages/collections/[id].tsx index 8e0bc06..d4ca3db 100644 --- a/pages/collections/[id].tsx +++ b/pages/collections/[id].tsx @@ -72,7 +72,7 @@ export default function Index() { style={{ color: activeCollection?.color }} className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow" /> -

+

{activeCollection?.name}

diff --git a/pages/collections/index.tsx b/pages/collections/index.tsx index 6b60e48..806562a 100644 --- a/pages/collections/index.tsx +++ b/pages/collections/index.tsx @@ -42,7 +42,7 @@ export default function Collections() { icon={faFolder} className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow" /> -

+

All Collections

diff --git a/pages/confirmation.tsx b/pages/confirmation.tsx index c9cc95b..76479a2 100644 --- a/pages/confirmation.tsx +++ b/pages/confirmation.tsx @@ -5,22 +5,21 @@ import React from "react"; export default function EmailConfirmaion() { return ( -
-

+

+

Please check your Email

+ +
+

A sign in link has been sent to your email address.

-

You can safely close this page.

-
- -

- Didn't find the email in your inbox? Check your spam folder or - visit the{" "} +

+ Didn't see the email? Check your spam folder or visit the{" "} Password Recovery {" "} - page to resend the sign-in link by entering your email. + page to resend the link.

diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 2dba625..8970883 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -124,7 +124,7 @@ export default function Dashboard() { icon={faChartSimple} className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow" /> -

+

Dashboard

diff --git a/pages/forgot.tsx b/pages/forgot.tsx index d2fc8c7..e09f81c 100644 --- a/pages/forgot.tsx +++ b/pages/forgot.tsx @@ -43,13 +43,16 @@ export default function Forgot() { return ( -
-

+

+

Password Recovery

+ +
+
-

- Enter your Email so we can send you a link to recover your +

+ Enter your email so we can send you a link to recover your account. Make sure to change your password in the profile settings afterwards.

diff --git a/pages/links.tsx b/pages/links.tsx index 9925193..dbfec53 100644 --- a/pages/links.tsx +++ b/pages/links.tsx @@ -26,7 +26,7 @@ export default function Links() { icon={faLink} className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow" /> -

+

All Links

diff --git a/pages/login.tsx b/pages/login.tsx index 6c9c402..b66f4e0 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -50,11 +50,13 @@ export default function Login() { return ( -
-

+

+

Enter your credentials

+
+

Username @@ -70,7 +72,7 @@ export default function Login() { />

-
+

Password

diff --git a/pages/register.tsx b/pages/register.tsx index bd82b2e..62ce26d 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -32,62 +32,64 @@ export default function Register() { async function registerUser(event: FormEvent) { event.preventDefault(); - const checkFields = () => { - if (emailEnabled) { - return ( - form.name !== "" && - form.email !== "" && - form.password !== "" && - form.passwordConfirmation !== "" - ); + if (!submitLoader) { + const checkFields = () => { + if (emailEnabled) { + return ( + form.name !== "" && + form.email !== "" && + form.password !== "" && + form.passwordConfirmation !== "" + ); + } else { + return ( + form.name !== "" && + form.username !== "" && + form.password !== "" && + form.passwordConfirmation !== "" + ); + } + }; + + if (checkFields()) { + if (form.password !== form.passwordConfirmation) + return toast.error("Passwords do not match."); + else if (form.password.length < 8) + return toast.error("Passwords must be at least 8 characters."); + const { passwordConfirmation, ...request } = form; + + setSubmitLoader(true); + + const load = toast.loading("Creating Account..."); + + const response = await fetch("/api/v1/users", { + body: JSON.stringify(request), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); + + const data = await response.json(); + + toast.dismiss(load); + setSubmitLoader(false); + + if (response.ok) { + if (form.email && emailEnabled) + await signIn("email", { + email: form.email, + callbackUrl: "/", + }); + else if (!emailEnabled) router.push("/login"); + + toast.success("User Created!"); + } else { + toast.error(data.response); + } } else { - return ( - form.name !== "" && - form.username !== "" && - form.password !== "" && - form.passwordConfirmation !== "" - ); + toast.error("Please fill out all the fields."); } - }; - - if (checkFields()) { - if (form.password !== form.passwordConfirmation) - return toast.error("Passwords do not match."); - else if (form.password.length < 8) - return toast.error("Passwords must be at least 8 characters."); - const { passwordConfirmation, ...request } = form; - - setSubmitLoader(true); - - const load = toast.loading("Creating Account..."); - - const response = await fetch("/api/v1/users", { - body: JSON.stringify(request), - headers: { - "Content-Type": "application/json", - }, - method: "POST", - }); - - const data = await response.json(); - - toast.dismiss(load); - setSubmitLoader(false); - - if (response.ok) { - if (form.email && emailEnabled) - await signIn("email", { - email: form.email, - callbackUrl: "/", - }); - else if (!emailEnabled) router.push("/login"); - - toast.success("User Created!"); - } else { - toast.error(data.response); - } - } else { - toast.error("Please fill out all the fields."); } } @@ -95,14 +97,14 @@ export default function Register() { {process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? ( -
+

Registration is disabled for this instance, please contact the admin in case of any issues. @@ -110,10 +112,13 @@ export default function Register() {

) : ( -
-

+

+

Enter your details

+ +
+

Display Name @@ -223,12 +228,12 @@ export default function Register() {

) : undefined} - + className={`border primary-btn-gradient select-none duration-100 bg-black border-[#0071B7] hover:border-[#059bf8] rounded-lg text-center px-4 py-2 text-slate-200 hover:text-white `} + > +

Sign Up

+

Already have an account? diff --git a/pages/search/[query].tsx b/pages/search/[query].tsx deleted file mode 100644 index ad04ed7..0000000 --- a/pages/search/[query].tsx +++ /dev/null @@ -1,117 +0,0 @@ -import FilterSearchDropdown from "@/components/FilterSearchDropdown"; -import LinkCard from "@/components/LinkCard"; -import SortDropdown from "@/components/SortDropdown"; -import useLinks from "@/hooks/useLinks"; -import MainLayout from "@/layouts/MainLayout"; -import useLinkStore from "@/store/links"; -import { Sort } from "@/types/global"; -import { faFilter, faSearch, faSort } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useRouter } from "next/router"; -import { useState } from "react"; - -export default function Links() { - const { links } = useLinkStore(); - - const router = useRouter(); - - const [searchFilter, setSearchFilter] = useState({ - name: true, - url: true, - description: true, - tags: true, - }); - - const [filterDropdown, setFilterDropdown] = useState(false); - const [sortDropdown, setSortDropdown] = useState(false); - const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); - - useLinks({ - sort: sortBy, - searchQueryString: router.query.query as string, - searchByName: searchFilter.name, - searchByUrl: searchFilter.url, - searchByDescription: searchFilter.description, - searchByTags: searchFilter.tags, - }); - - return ( - -

-
-
-
- -

- Search Results -

-
-
- -
-
-
setFilterDropdown(!filterDropdown)} - id="filter-dropdown" - className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" - > - -
- - {filterDropdown ? ( - - ) : null} -
- -
-
setSortDropdown(!sortDropdown)} - id="sort-dropdown" - className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" - > - -
- - {sortDropdown ? ( - setSortDropdown(!sortDropdown)} - /> - ) : null} -
-
-
- {links[0] ? ( -
- {links.map((e, i) => { - return ; - })} -
- ) : ( -

- Nothing found.{" "} - - ¯\_(ツ)_/¯ - -

- )} -
- - ); -} diff --git a/pages/search/index.tsx b/pages/search/index.tsx index f1b1777..01ca6ef 100644 --- a/pages/search/index.tsx +++ b/pages/search/index.tsx @@ -1,10 +1,117 @@ +import FilterSearchDropdown from "@/components/FilterSearchDropdown"; +import LinkCard from "@/components/LinkCard"; +import SortDropdown from "@/components/SortDropdown"; +import useLinks from "@/hooks/useLinks"; +import MainLayout from "@/layouts/MainLayout"; +import useLinkStore from "@/store/links"; +import { Sort } from "@/types/global"; +import { faFilter, faSearch, faSort } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRouter } from "next/router"; -import { useEffect } from "react"; +import { useState } from "react"; + +export default function Search() { + const { links } = useLinkStore(); -export default function Home() { const router = useRouter(); - useEffect(() => { - router.push("/links"); - }, []); + const [searchFilter, setSearchFilter] = useState({ + name: true, + url: true, + description: true, + tags: true, + }); + + const [filterDropdown, setFilterDropdown] = useState(false); + const [sortDropdown, setSortDropdown] = useState(false); + const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + + useLinks({ + sort: sortBy, + searchQueryString: decodeURIComponent(router.query.q as string), + searchByName: searchFilter.name, + searchByUrl: searchFilter.url, + searchByDescription: searchFilter.description, + searchByTags: searchFilter.tags, + }); + + return ( + +
+
+
+
+ +

+ Search Results +

+
+
+ +
+
+
setFilterDropdown(!filterDropdown)} + id="filter-dropdown" + className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" + > + +
+ + {filterDropdown ? ( + + ) : null} +
+ +
+
setSortDropdown(!sortDropdown)} + id="sort-dropdown" + className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" + > + +
+ + {sortDropdown ? ( + setSortDropdown(!sortDropdown)} + /> + ) : null} +
+
+
+ {links[0] ? ( +
+ {links.map((e, i) => { + return ; + })} +
+ ) : ( +

+ Nothing found.{" "} + + ¯\_(ツ)_/¯ + +

+ )} +
+
+ ); } diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index 5850efa..20434ea 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -269,7 +269,7 @@ export default function Account() { Import your data from other platforms.

setImportDropdown(!importDropdown)} + onClick={() => setImportDropdown(true)} className="w-fit relative" id="import-dropdown" > @@ -387,6 +387,33 @@ export default function Account() { label="Save" className="mt-2 mx-auto lg:mx-0" /> + +
+
+

+ Delete Account +

+
+ +
+ +

+ This will permanently delete ALL the Links, Collections, Tags, and + archived data you own.{" "} + {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE + ? "It will also cancel your subscription. " + : undefined}{" "} + You will be prompted to enter your password before the deletion + process. +

+ + +

Delete Your Account

+ +
); diff --git a/pages/settings/delete.tsx b/pages/settings/delete.tsx new file mode 100644 index 0000000..e072ab1 --- /dev/null +++ b/pages/settings/delete.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { toast } from "react-hot-toast"; +import TextInput from "@/components/TextInput"; +import CenteredForm from "@/layouts/CenteredForm"; +import { signOut, useSession } from "next-auth/react"; +import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; + +export default function Password() { + const [password, setPassword] = useState(""); + const [comment, setComment] = useState(); + const [feedback, setFeedback] = useState(); + + const [submitLoader, setSubmitLoader] = useState(false); + + const { data } = useSession(); + + const submit = async () => { + const body = { + password, + cancellation_details: { + comment, + feedback, + }, + }; + + if (password == "") { + return toast.error("Please fill the required fields."); + } + + setSubmitLoader(true); + + const load = toast.loading("Deleting everything, please wait..."); + + const response = await fetch(`/api/v1/users/${data?.user.id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const message = (await response.json()).response; + + toast.dismiss(load); + + if (response.ok) { + signOut(); + } else toast.error(message); + + setSubmitLoader(false); + }; + + return ( + +
+ + + +
+

+ Delete Account +

+
+ +
+ +

+ This will permanently delete all the Links, Collections, Tags, and + archived data you own. It will also log you out + {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE + ? " and cancel your subscription" + : undefined} + . This action is irreversible! +

+ +
+

+ Confirm Your Password +

+ + setPassword(e.target.value)} + placeholder="••••••••••••••" + type="password" + /> +
+ + {process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? ( +
+ + Optional{" "} + + (but it really helps us improve!) + + + +
+

+ More information (the more details, the more helpful it'd be) +

+ +