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:
parent
e5f8437282
commit
fac4de1144
12 changed files with 235 additions and 34 deletions
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
42
pages/api/user/me.ts
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
27
playwright/auth/delete-account.test.ts
Normal file
27
playwright/auth/delete-account.test.ts
Normal 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");
|
||||
});
|
|
@ -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;
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in a new issue