Skip to content

Commit fac4de1

Browse files
alishaz-polymathkodiakhq[bot]zomars
authored
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>
1 parent e5f8437 commit fac4de1

File tree

12 files changed

+235
-34
lines changed

12 files changed

+235
-34
lines changed

components/dialog/ConfirmationDialogContent.tsx

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { ExclamationIcon } from "@heroicons/react/outline";
22
import { CheckIcon } from "@heroicons/react/solid";
33
import * as DialogPrimitive from "@radix-ui/react-dialog";
4-
import React, { PropsWithChildren } from "react";
4+
import React, { PropsWithChildren, ReactNode } from "react";
55

66
import { useLocale } from "@lib/hooks/useLocale";
77

88
import { DialogClose, DialogContent } from "@components/Dialog";
99
import { Button } from "@components/ui/Button";
1010

1111
export type ConfirmationDialogContentProps = {
12+
confirmBtn?: ReactNode;
1213
confirmBtnText?: string;
1314
cancelBtnText?: string;
1415
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
@@ -21,6 +22,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
2122
const {
2223
title,
2324
variety,
25+
confirmBtn = null,
2426
confirmBtnText = t("confirm"),
2527
cancelBtnText = t("cancel"),
2628
onConfirm,
@@ -33,34 +35,34 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
3335
{variety && (
3436
<div className="mr-3 mt-0.5">
3537
{variety === "danger" && (
36-
<div className="text-center p-2 rounded-full mx-auto bg-red-100">
38+
<div className="p-2 mx-auto text-center bg-red-100 rounded-full">
3739
<ExclamationIcon className="w-5 h-5 text-red-600" />
3840
</div>
3941
)}
4042
{variety === "warning" && (
41-
<div className="text-center p-2 rounded-full mx-auto bg-orange-100">
43+
<div className="p-2 mx-auto text-center bg-orange-100 rounded-full">
4244
<ExclamationIcon className="w-5 h-5 text-orange-600" />
4345
</div>
4446
)}
4547
{variety === "success" && (
46-
<div className="text-center p-2 rounded-full mx-auto bg-green-100">
48+
<div className="p-2 mx-auto text-center bg-green-100 rounded-full">
4749
<CheckIcon className="w-5 h-5 text-green-600" />
4850
</div>
4951
)}
5052
</div>
5153
)}
5254
<div>
53-
<DialogPrimitive.Title className="font-cal text-xl font-bold text-gray-900">
55+
<DialogPrimitive.Title className="text-xl font-bold text-gray-900 font-cal">
5456
{title}
5557
</DialogPrimitive.Title>
56-
<DialogPrimitive.Description className="text-neutral-500 text-sm">
58+
<DialogPrimitive.Description className="text-sm text-neutral-500">
5759
{children}
5860
</DialogPrimitive.Description>
5961
</div>
6062
</div>
6163
<div className="mt-5 sm:mt-8 sm:flex sm:flex-row-reverse gap-x-2">
6264
<DialogClose onClick={onConfirm} asChild>
63-
<Button color="primary">{confirmBtnText}</Button>
65+
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
6466
</DialogClose>
6567
<DialogClose asChild>
6668
<Button color="secondary">{cancelBtnText}</Button>

ee/lib/stripe/server.ts

+41
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,45 @@ async function handleRefundError(opts: { event: CalendarEvent; reason: string; p
168168
});
169169
}
170170

171+
const userType = Prisma.validator<Prisma.UserArgs>()({
172+
select: {
173+
email: true,
174+
metadata: true,
175+
},
176+
});
177+
178+
type UserType = Prisma.UserGetPayload<typeof userType>;
179+
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
180+
let customerId: string | null = null;
181+
182+
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
183+
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
184+
} else {
185+
/* We fallback to finding the customer by email (which is not optimal) */
186+
const customersReponse = await stripe.customers.list({
187+
email: user.email,
188+
limit: 1,
189+
});
190+
if (customersReponse.data[0]?.id) {
191+
customerId = customersReponse.data[0].id;
192+
}
193+
}
194+
195+
return customerId;
196+
}
197+
198+
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
199+
const customerId = await getStripeCustomerId(user);
200+
201+
if (!customerId) {
202+
console.warn("No stripe customer found for user:" + user.email);
203+
return null;
204+
}
205+
206+
//delete stripe customer
207+
const deletedCustomer = await stripe.customers.del(customerId);
208+
209+
return deletedCustomer.id;
210+
}
211+
171212
export default stripe;

ee/pages/api/integrations/stripepayment/portal.ts

+2-16
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { Prisma } from "@prisma/client";
21
import type { NextApiRequest, NextApiResponse } from "next";
32

4-
import stripe from "@ee/lib/stripe/server";
3+
import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server";
54

65
import { getSession } from "@lib/auth";
76
import prisma from "@lib/prisma";
@@ -33,20 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
3332
message: "User email not found",
3433
});
3534

36-
let customerId = "";
37-
38-
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
39-
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
40-
} else {
41-
/* We fallback to finding the customer by email (which is not optimal) */
42-
const customersReponse = await stripe.customers.list({
43-
email: user.email,
44-
limit: 1,
45-
});
46-
if (customersReponse.data[0]?.id) {
47-
customerId = customersReponse.data[0].id;
48-
}
49-
}
35+
const customerId = await getStripeCustomerId(user);
5036

5137
if (!customerId)
5238
return res.status(404).json({

pages/api/user/[id].ts

-4
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
3232
return res.status(405).json({ message: "Method Not Allowed" });
3333
}
3434

35-
if (req.method === "DELETE") {
36-
return res.status(405).json({ message: "Method Not Allowed" });
37-
}
38-
3935
if (req.method === "PATCH") {
4036
const updatedUser = await prisma.user.update({
4137
where: {

pages/api/user/me.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { NextApiRequest, NextApiResponse } from "next";
2+
3+
import { deleteStripeCustomer } from "@ee/lib/stripe/server";
4+
5+
import { getSession } from "@lib/auth";
6+
import prisma from "@lib/prisma";
7+
8+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
9+
const session = await getSession({ req });
10+
11+
if (!session?.user.id) {
12+
return res.status(401).json({ message: "Not authenticated" });
13+
}
14+
15+
if (req.method !== "DELETE") {
16+
return res.status(405).json({ message: "Method Not Allowed" });
17+
}
18+
19+
if (req.method === "DELETE") {
20+
// Get user
21+
const user = await prisma.user.findUnique({
22+
rejectOnNotFound: true,
23+
where: {
24+
id: session.user?.id,
25+
},
26+
select: {
27+
email: true,
28+
metadata: true,
29+
},
30+
});
31+
// Delete from stripe
32+
await deleteStripeCustomer(user).catch(console.warn);
33+
// Delete from Cal
34+
await prisma.user.delete({
35+
where: {
36+
id: session?.user.id,
37+
},
38+
});
39+
40+
return res.status(204).end();
41+
}
42+
}

pages/settings/profile.tsx

+46-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { InformationCircleIcon } from "@heroicons/react/outline";
2+
import { TrashIcon } from "@heroicons/react/solid";
23
import crypto from "crypto";
34
import { GetServerSidePropsContext } from "next";
5+
import { signOut } from "next-auth/react";
46
import { useRouter } from "next/router";
57
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
68
import Select from "react-select";
@@ -17,10 +19,11 @@ import prisma from "@lib/prisma";
1719
import { trpc } from "@lib/trpc";
1820
import { inferSSRProps } from "@lib/types/inferSSRProps";
1921

20-
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
22+
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@components/Dialog";
2123
import ImageUploader from "@components/ImageUploader";
2224
import SettingsShell from "@components/SettingsShell";
2325
import Shell from "@components/Shell";
26+
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
2427
import { TextField } from "@components/form/fields";
2528
import { Alert } from "@components/ui/Alert";
2629
import Avatar from "@components/ui/Avatar";
@@ -112,6 +115,19 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
112115
},
113116
});
114117

118+
const deleteAccount = async () => {
119+
await fetch("/api/user/me", {
120+
method: "DELETE",
121+
headers: {
122+
"Content-Type": "application/json",
123+
},
124+
}).catch((e) => {
125+
console.error(`Error Removing user: ${props.user.id}, email: ${props.user.email} :`, e);
126+
});
127+
// signout;
128+
signOut({ callbackUrl: "/auth/logout" });
129+
};
130+
115131
const localeOptions = useMemo(() => {
116132
return (router.locales || []).map((locale) => ({
117133
value: locale,
@@ -409,6 +425,34 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
409425
</div>
410426
</div>
411427
</div>
428+
<h3 className="font-bold leading-6 text-red-700 mt-7 text-md">{t("danger_zone")}</h3>
429+
<div>
430+
<div className="relative flex items-start">
431+
<Dialog>
432+
<DialogTrigger asChild>
433+
<Button
434+
type="button"
435+
color="warn"
436+
StartIcon={TrashIcon}
437+
className="text-red-700 border-2 border-red-700"
438+
data-testid="delete-account">
439+
{t("delete_account")}
440+
</Button>
441+
</DialogTrigger>
442+
<ConfirmationDialogContent
443+
variety="danger"
444+
title={t("delete_account")}
445+
confirmBtn={
446+
<Button color="warn" data-testid="delete-account-confirm">
447+
{t("confirm_delete_account")}
448+
</Button>
449+
}
450+
onConfirm={() => deleteAccount()}>
451+
{t("delete_account_confirmation_message")}
452+
</ConfirmationDialogContent>
453+
</Dialog>
454+
</div>
455+
</div>
412456
</div>
413457
</div>
414458
<hr className="mt-8" />
@@ -460,6 +504,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
460504
theme: true,
461505
plan: true,
462506
brandColor: true,
507+
metadata: true,
463508
},
464509
});
465510

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
test("Can delete user account", async ({ page }) => {
4+
// Login to account to delete
5+
await page.goto(`/auth/login`);
6+
// Click input[name="email"]
7+
await page.click('input[name="email"]');
8+
// Fill input[name="email"]
9+
await page.fill('input[name="email"]', `delete-me@example.com`);
10+
// Press Tab
11+
await page.press('input[name="email"]', "Tab");
12+
// Fill input[name="password"]
13+
await page.fill('input[name="password"]', "delete-me");
14+
// Press Enter
15+
await page.press('input[name="password"]', "Enter");
16+
await page.waitForSelector("[data-testid=dashboard-shell]");
17+
18+
await page.goto(`/settings/profile`);
19+
await page.click("[data-testid=delete-account]");
20+
expect(page.locator(`[data-testid=delete-account-confirm]`)).toBeVisible();
21+
22+
await Promise.all([
23+
page.waitForNavigation({ url: "/auth/logout" }),
24+
await page.click("[data-testid=delete-account-confirm]"),
25+
]);
26+
expect(page.locator(`[id="modal-title"]`)).toHaveText("You've been logged out");
27+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
-- DropForeignKey
2+
ALTER TABLE "Availability" DROP CONSTRAINT "Availability_userId_fkey";
3+
4+
-- DropForeignKey
5+
ALTER TABLE "Credential" DROP CONSTRAINT "Credential_userId_fkey";
6+
7+
-- DropForeignKey
8+
ALTER TABLE "Membership" DROP CONSTRAINT "Membership_userId_fkey";
9+
10+
-- DropForeignKey
11+
ALTER TABLE "Schedule" DROP CONSTRAINT "Schedule_userId_fkey";
12+
13+
-- DropForeignKey
14+
ALTER TABLE "SelectedCalendar" DROP CONSTRAINT "SelectedCalendar_userId_fkey";
15+
16+
-- DropForeignKey
17+
ALTER TABLE "Webhook" DROP CONSTRAINT "Webhook_userId_fkey";
18+
19+
-- AddForeignKey
20+
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
21+
22+
-- AddForeignKey
23+
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
24+
25+
-- AddForeignKey
26+
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
27+
28+
-- AddForeignKey
29+
ALTER TABLE "Availability" ADD CONSTRAINT "Availability_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
30+
31+
-- AddForeignKey
32+
ALTER TABLE "SelectedCalendar" ADD CONSTRAINT "SelectedCalendar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
33+
34+
-- AddForeignKey
35+
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

+6-6
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ model Credential {
6161
id Int @id @default(autoincrement())
6262
type String
6363
key Json
64-
user User? @relation(fields: [userId], references: [id])
64+
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
6565
userId Int?
6666
}
6767

@@ -156,7 +156,7 @@ model Membership {
156156
accepted Boolean @default(false)
157157
role MembershipRole
158158
team Team @relation(fields: [teamId], references: [id])
159-
user User @relation(fields: [userId], references: [id])
159+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
160160
161161
@@id([userId, teamId])
162162
}
@@ -234,7 +234,7 @@ model Booking {
234234

235235
model Schedule {
236236
id Int @id @default(autoincrement())
237-
user User? @relation(fields: [userId], references: [id])
237+
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
238238
userId Int?
239239
eventType EventType? @relation(fields: [eventTypeId], references: [id])
240240
eventTypeId Int?
@@ -245,7 +245,7 @@ model Schedule {
245245
model Availability {
246246
id Int @id @default(autoincrement())
247247
label String?
248-
user User? @relation(fields: [userId], references: [id])
248+
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
249249
userId Int?
250250
eventType EventType? @relation(fields: [eventTypeId], references: [id])
251251
eventTypeId Int?
@@ -256,7 +256,7 @@ model Availability {
256256
}
257257

258258
model SelectedCalendar {
259-
user User @relation(fields: [userId], references: [id])
259+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
260260
userId Int
261261
integration String
262262
externalId String
@@ -334,5 +334,5 @@ model Webhook {
334334
createdAt DateTime @default(now())
335335
active Boolean @default(true)
336336
eventTriggers WebhookTriggerEvents[]
337-
user User @relation(fields: [userId], references: [id])
337+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
338338
}

0 commit comments

Comments
 (0)