Enhancement/cal 708 delete account (#1403)

* --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 <zomars@me.com>
This commit is contained in:
Syed Ali Shahbaz 2022-01-14 19:19:15 +05:30 committed by GitHub
parent e5f8437282
commit fac4de1144
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 235 additions and 34 deletions

View File

@ -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<HTMLElement, MouseEvent>) => void;
@ -21,6 +22,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
const {
title,
variety,
confirmBtn = null,
confirmBtnText = t("confirm"),
cancelBtnText = t("cancel"),
onConfirm,
@ -33,34 +35,34 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
{variety && (
<div className="mr-3 mt-0.5">
{variety === "danger" && (
<div className="text-center p-2 rounded-full mx-auto bg-red-100">
<div className="p-2 mx-auto text-center bg-red-100 rounded-full">
<ExclamationIcon className="w-5 h-5 text-red-600" />
</div>
)}
{variety === "warning" && (
<div className="text-center p-2 rounded-full mx-auto bg-orange-100">
<div className="p-2 mx-auto text-center bg-orange-100 rounded-full">
<ExclamationIcon className="w-5 h-5 text-orange-600" />
</div>
)}
{variety === "success" && (
<div className="text-center p-2 rounded-full mx-auto bg-green-100">
<div className="p-2 mx-auto text-center bg-green-100 rounded-full">
<CheckIcon className="w-5 h-5 text-green-600" />
</div>
)}
</div>
)}
<div>
<DialogPrimitive.Title className="font-cal text-xl font-bold text-gray-900">
<DialogPrimitive.Title className="text-xl font-bold text-gray-900 font-cal">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-neutral-500 text-sm">
<DialogPrimitive.Description className="text-sm text-neutral-500">
{children}
</DialogPrimitive.Description>
</div>
</div>
<div className="mt-5 sm:mt-8 sm:flex sm:flex-row-reverse gap-x-2">
<DialogClose onClick={onConfirm} asChild>
<Button color="primary">{confirmBtnText}</Button>
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
</DialogClose>
<DialogClose asChild>
<Button color="secondary">{cancelBtnText}</Button>

View File

@ -168,4 +168,45 @@ async function handleRefundError(opts: { event: CalendarEvent; reason: string; p
});
}
const userType = Prisma.validator<Prisma.UserArgs>()({
select: {
email: true,
metadata: true,
},
});
type UserType = Prisma.UserGetPayload<typeof userType>;
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
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<string | null> {
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;

View File

@ -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({

View File

@ -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: {

42
pages/api/user/me.ts Normal file
View File

@ -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();
}
}

View File

@ -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<typeof Settings> & { 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<typeof Settings> & { localeProp: str
</div>
</div>
</div>
<h3 className="font-bold leading-6 text-red-700 mt-7 text-md">{t("danger_zone")}</h3>
<div>
<div className="relative flex items-start">
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
color="warn"
StartIcon={TrashIcon}
className="text-red-700 border-2 border-red-700"
data-testid="delete-account">
{t("delete_account")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("delete_account")}
confirmBtn={
<Button color="warn" data-testid="delete-account-confirm">
{t("confirm_delete_account")}
</Button>
}
onConfirm={() => deleteAccount()}>
{t("delete_account_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
</div>
</div>
</div>
</div>
<hr className="mt-8" />
@ -460,6 +504,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: true,
plan: true,
brandColor: true,
metadata: true,
},
});

View File

@ -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");
});

View File

@ -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;

View File

@ -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)
}

View File

@ -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",

View File

@ -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",

View File

@ -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(),