From fac4de1144ac6ee5b1a41bebadfec84e7405e732 Mon Sep 17 00:00:00 2001 From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> Date: Fri, 14 Jan 2022 19:19:15 +0530 Subject: [PATCH] Enhancement/cal 708 delete account (#1403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * --WIP * --WIP * --WIP * added prisma migration and delete cascade for user * stripe customer removal and other --wip * --wip * added stripe user delete * removed log remnants * fixed signout import * cleanup * Changes requested * fixed common-json apostrophe * Simplifies account deletion logic and add e2e tests * Cleanup Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Omar López --- .../dialog/ConfirmationDialogContent.tsx | 16 ++++--- ee/lib/stripe/server.ts | 41 ++++++++++++++++ .../api/integrations/stripepayment/portal.ts | 18 +------ pages/api/user/[id].ts | 4 -- pages/api/user/me.ts | 42 +++++++++++++++++ pages/settings/profile.tsx | 47 ++++++++++++++++++- playwright/auth/delete-account.test.ts | 27 +++++++++++ .../migration.sql | 35 ++++++++++++++ prisma/schema.prisma | 12 ++--- public/static/locales/en/common.json | 3 ++ scripts/seed.ts | 11 +++++ server/routers/viewer.tsx | 13 +++++ 12 files changed, 235 insertions(+), 34 deletions(-) create mode 100644 pages/api/user/me.ts create mode 100644 playwright/auth/delete-account.test.ts create mode 100644 prisma/migrations/20211231142312_add_user_on_delete_cascade/migration.sql diff --git a/components/dialog/ConfirmationDialogContent.tsx b/components/dialog/ConfirmationDialogContent.tsx index ec1097c5ba..cbc648efdf 100644 --- a/components/dialog/ConfirmationDialogContent.tsx +++ b/components/dialog/ConfirmationDialogContent.tsx @@ -1,7 +1,7 @@ import { ExclamationIcon } from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/solid"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import React, { PropsWithChildren } from "react"; +import React, { PropsWithChildren, ReactNode } from "react"; import { useLocale } from "@lib/hooks/useLocale"; @@ -9,6 +9,7 @@ import { DialogClose, DialogContent } from "@components/Dialog"; import { Button } from "@components/ui/Button"; export type ConfirmationDialogContentProps = { + confirmBtn?: ReactNode; confirmBtnText?: string; cancelBtnText?: string; onConfirm?: (event: React.MouseEvent) => void; @@ -21,6 +22,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren {variety === "danger" && ( -
+
)} {variety === "warning" && ( -
+
)} {variety === "success" && ( -
+
)}
)}
- + {title} - + {children}
- + {confirmBtn || } diff --git a/ee/lib/stripe/server.ts b/ee/lib/stripe/server.ts index eb762be1de..93eee2077f 100644 --- a/ee/lib/stripe/server.ts +++ b/ee/lib/stripe/server.ts @@ -168,4 +168,45 @@ async function handleRefundError(opts: { event: CalendarEvent; reason: string; p }); } +const userType = Prisma.validator()({ + select: { + email: true, + metadata: true, + }, +}); + +type UserType = Prisma.UserGetPayload; +export async function getStripeCustomerId(user: UserType): Promise { + let customerId: string | null = null; + + if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) { + customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string; + } else { + /* We fallback to finding the customer by email (which is not optimal) */ + const customersReponse = await stripe.customers.list({ + email: user.email, + limit: 1, + }); + if (customersReponse.data[0]?.id) { + customerId = customersReponse.data[0].id; + } + } + + return customerId; +} + +export async function deleteStripeCustomer(user: UserType): Promise { + const customerId = await getStripeCustomerId(user); + + if (!customerId) { + console.warn("No stripe customer found for user:" + user.email); + return null; + } + + //delete stripe customer + const deletedCustomer = await stripe.customers.del(customerId); + + return deletedCustomer.id; +} + export default stripe; diff --git a/ee/pages/api/integrations/stripepayment/portal.ts b/ee/pages/api/integrations/stripepayment/portal.ts index 759a985d5b..ec0dc3d666 100644 --- a/ee/pages/api/integrations/stripepayment/portal.ts +++ b/ee/pages/api/integrations/stripepayment/portal.ts @@ -1,7 +1,6 @@ -import { Prisma } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; -import stripe from "@ee/lib/stripe/server"; +import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server"; import { getSession } from "@lib/auth"; import prisma from "@lib/prisma"; @@ -33,20 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) message: "User email not found", }); - let customerId = ""; - - if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) { - customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string; - } else { - /* We fallback to finding the customer by email (which is not optimal) */ - const customersReponse = await stripe.customers.list({ - email: user.email, - limit: 1, - }); - if (customersReponse.data[0]?.id) { - customerId = customersReponse.data[0].id; - } - } + const customerId = await getStripeCustomerId(user); if (!customerId) return res.status(404).json({ diff --git a/pages/api/user/[id].ts b/pages/api/user/[id].ts index 8eb5e53b7a..252f9279b2 100644 --- a/pages/api/user/[id].ts +++ b/pages/api/user/[id].ts @@ -32,10 +32,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ message: "Method Not Allowed" }); } - if (req.method === "DELETE") { - return res.status(405).json({ message: "Method Not Allowed" }); - } - if (req.method === "PATCH") { const updatedUser = await prisma.user.update({ where: { diff --git a/pages/api/user/me.ts b/pages/api/user/me.ts new file mode 100644 index 0000000000..376f7d2c69 --- /dev/null +++ b/pages/api/user/me.ts @@ -0,0 +1,42 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { deleteStripeCustomer } from "@ee/lib/stripe/server"; + +import { getSession } from "@lib/auth"; +import prisma from "@lib/prisma"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getSession({ req }); + + if (!session?.user.id) { + return res.status(401).json({ message: "Not authenticated" }); + } + + if (req.method !== "DELETE") { + return res.status(405).json({ message: "Method Not Allowed" }); + } + + if (req.method === "DELETE") { + // Get user + const user = await prisma.user.findUnique({ + rejectOnNotFound: true, + where: { + id: session.user?.id, + }, + select: { + email: true, + metadata: true, + }, + }); + // Delete from stripe + await deleteStripeCustomer(user).catch(console.warn); + // Delete from Cal + await prisma.user.delete({ + where: { + id: session?.user.id, + }, + }); + + return res.status(204).end(); + } +} diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index cf0cb8f717..aff6608d00 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -1,6 +1,8 @@ import { InformationCircleIcon } from "@heroicons/react/outline"; +import { TrashIcon } from "@heroicons/react/solid"; import crypto from "crypto"; import { GetServerSidePropsContext } from "next"; +import { signOut } from "next-auth/react"; import { useRouter } from "next/router"; import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react"; import Select from "react-select"; @@ -17,10 +19,11 @@ import prisma from "@lib/prisma"; import { trpc } from "@lib/trpc"; import { inferSSRProps } from "@lib/types/inferSSRProps"; -import { Dialog, DialogClose, DialogContent } from "@components/Dialog"; +import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@components/Dialog"; import ImageUploader from "@components/ImageUploader"; import SettingsShell from "@components/SettingsShell"; import Shell from "@components/Shell"; +import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; import { TextField } from "@components/form/fields"; import { Alert } from "@components/ui/Alert"; import Avatar from "@components/ui/Avatar"; @@ -112,6 +115,19 @@ function SettingsView(props: ComponentProps & { localeProp: str }, }); + const deleteAccount = async () => { + await fetch("/api/user/me", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }).catch((e) => { + console.error(`Error Removing user: ${props.user.id}, email: ${props.user.email} :`, e); + }); + // signout; + signOut({ callbackUrl: "/auth/logout" }); + }; + const localeOptions = useMemo(() => { return (router.locales || []).map((locale) => ({ value: locale, @@ -409,6 +425,34 @@ function SettingsView(props: ComponentProps & { localeProp: str
+

{t("danger_zone")}

+
+
+ + + + + + {t("confirm_delete_account")} + + } + onConfirm={() => deleteAccount()}> + {t("delete_account_confirmation_message")} + + +
+

@@ -460,6 +504,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => theme: true, plan: true, brandColor: true, + metadata: true, }, }); diff --git a/playwright/auth/delete-account.test.ts b/playwright/auth/delete-account.test.ts new file mode 100644 index 0000000000..478614e547 --- /dev/null +++ b/playwright/auth/delete-account.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +test("Can delete user account", async ({ page }) => { + // Login to account to delete + await page.goto(`/auth/login`); + // Click input[name="email"] + await page.click('input[name="email"]'); + // Fill input[name="email"] + await page.fill('input[name="email"]', `delete-me@example.com`); + // Press Tab + await page.press('input[name="email"]', "Tab"); + // Fill input[name="password"] + await page.fill('input[name="password"]', "delete-me"); + // Press Enter + await page.press('input[name="password"]', "Enter"); + await page.waitForSelector("[data-testid=dashboard-shell]"); + + await page.goto(`/settings/profile`); + await page.click("[data-testid=delete-account]"); + expect(page.locator(`[data-testid=delete-account-confirm]`)).toBeVisible(); + + await Promise.all([ + page.waitForNavigation({ url: "/auth/logout" }), + await page.click("[data-testid=delete-account-confirm]"), + ]); + expect(page.locator(`[id="modal-title"]`)).toHaveText("You've been logged out"); +}); diff --git a/prisma/migrations/20211231142312_add_user_on_delete_cascade/migration.sql b/prisma/migrations/20211231142312_add_user_on_delete_cascade/migration.sql new file mode 100644 index 0000000000..b9060efe68 --- /dev/null +++ b/prisma/migrations/20211231142312_add_user_on_delete_cascade/migration.sql @@ -0,0 +1,35 @@ +-- DropForeignKey +ALTER TABLE "Availability" DROP CONSTRAINT "Availability_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Credential" DROP CONSTRAINT "Credential_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Membership" DROP CONSTRAINT "Membership_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Schedule" DROP CONSTRAINT "Schedule_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "SelectedCalendar" DROP CONSTRAINT "SelectedCalendar_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Webhook" DROP CONSTRAINT "Webhook_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Availability" ADD CONSTRAINT "Availability_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SelectedCalendar" ADD CONSTRAINT "SelectedCalendar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1c99a0edc..e507603098 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,7 +61,7 @@ model Credential { id Int @id @default(autoincrement()) type String key Json - user User? @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int? } @@ -156,7 +156,7 @@ model Membership { accepted Boolean @default(false) role MembershipRole team Team @relation(fields: [teamId], references: [id]) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, teamId]) } @@ -234,7 +234,7 @@ model Booking { model Schedule { id Int @id @default(autoincrement()) - user User? @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int? eventType EventType? @relation(fields: [eventTypeId], references: [id]) eventTypeId Int? @@ -245,7 +245,7 @@ model Schedule { model Availability { id Int @id @default(autoincrement()) label String? - user User? @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int? eventType EventType? @relation(fields: [eventTypeId], references: [id]) eventTypeId Int? @@ -256,7 +256,7 @@ model Availability { } model SelectedCalendar { - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int integration String externalId String @@ -334,5 +334,5 @@ model Webhook { createdAt DateTime @default(now()) active Boolean @default(true) eventTriggers WebhookTriggerEvents[] - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json index d31767923d..81b50b9745 100644 --- a/public/static/locales/en/common.json +++ b/public/static/locales/en/common.json @@ -550,6 +550,9 @@ "delete_event_type_description": "Are you sure you want to delete this event type? Anyone who you've shared this link with will no longer be able to book using it.", "delete_event_type": "Delete Event Type", "confirm_delete_event_type": "Yes, delete event type", + "delete_account": "Delete account", + "confirm_delete_account": "Yes, delete account", + "delete_account_confirmation_message":"Are you sure you want to delete your Cal.com account? Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.", "integrations": "Integrations", "settings": "Settings", "event_type_moved_successfully": "Event type has been moved successfully", diff --git a/scripts/seed.ts b/scripts/seed.ts index a4f0bf4cc6..df41bf4616 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -154,6 +154,17 @@ async function createTeamAndAddUsers( } async function main() { + await createUserAndEventType({ + user: { + email: "delete-me@example.com", + password: "delete-me", + username: "delete-me", + name: "delete-me", + plan: "FREE", + }, + eventTypes: [], + }); + await createUserAndEventType({ user: { email: "onboarding@example.com", diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index 37482ac39d..ff675d0351 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -102,6 +102,19 @@ const loggedInViewerRouter = createProtectedRouter() return me; }, }) + .mutation("deleteMe", { + async resolve({ ctx }) { + // Remove me from Stripe + + // Remove my account + await ctx.prisma.user.delete({ + where: { + id: ctx.user.id, + }, + }); + return; + }, + }) .mutation("away", { input: z.object({ away: z.boolean(),