Add Delete Account functionality (#363)

* feat: add deletes account button on profile section.

* feat: add delete account action when user click on delete account button

* feat:logout user when his account is deleted

* feat: added warning message before user deletes account

* feat: add description to Delete account
 section

* fix: fix: build issue.

* fix: avoid giving the ownership of a team to a member who is not an admin

* fix: merge conflict

* fix: use !== in delete button disabled prop

* fix: typo semething -> Something

* refactor: simplified user deletion logic

* refactor: explain user deletion logic

* refactor: remove unecessary delete membership queries

* feat: add deletes account button on profile section.

* feat: add delete account action when user click on delete account button

* feat:logout user when his account is deleted

* feat: added warning message before user deletes account

* fix merge conlicts

* update to  delete info text

* feat: delete the team if the owner deletes his account and the team has no admins

* add await
This commit is contained in:
Francois Disubi
2023-06-20 13:33:14 +01:00
committed by GitHub
parent 27023eacf8
commit 8f1b7ae83a
8 changed files with 254 additions and 34 deletions

View File

@@ -1,7 +1,13 @@
import { getSessionUser } from "@/lib/api/apiHelper";
import { MembershipRole } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { NextRequest, NextResponse } from "next/server";
interface Membership {
role: MembershipRole;
userId: string;
}
export async function GET() {
const sessionUser = await getSessionUser();
if (!sessionUser) {
@@ -37,3 +43,94 @@ export async function PUT(request: NextRequest) {
return NextResponse.json(user);
}
const deleteUser = async (userId: string) => {
await prisma.user.delete({
where: {
id: userId,
},
});
};
const updateUserMembership = async (teamId: string, userId: string, role: MembershipRole) => {
await prisma.membership.update({
where: {
userId_teamId: {
userId,
teamId,
},
},
data: {
role,
},
});
};
const getAdminMemberships = (memberships: Membership[]) =>
memberships.filter((membership) => membership.role === MembershipRole.admin);
const deleteTeam = async (teamId: string) => {
await prisma.team.delete({
where: {
id: teamId,
},
});
};
export async function DELETE() {
try {
const currentUser = await getSessionUser();
if (!currentUser) {
return new Response("Not authenticated", {
status: 401,
});
}
const currentUserMemberships = await prisma.membership.findMany({
where: {
userId: currentUser.id,
},
include: {
team: {
select: {
id: true,
name: true,
memberships: {
select: {
userId: true,
role: true,
},
},
},
},
},
});
for (const currentUserMembership of currentUserMemberships) {
const teamMemberships = currentUserMembership.team.memberships;
const role = currentUserMembership.role;
const teamId = currentUserMembership.teamId;
const teamAdminMemberships = getAdminMemberships(teamMemberships);
const teamHasAtLeastOneAdmin = teamAdminMemberships.length > 0;
const teamHasOnlyOneMember = teamMemberships.length === 1;
const currentUserIsTeamOwner = role === MembershipRole.owner;
if (teamHasOnlyOneMember) {
await deleteTeam(teamId);
} else if (currentUserIsTeamOwner && teamHasAtLeastOneAdmin) {
const firstAdmin = teamAdminMemberships[0];
await updateUserMembership(teamId, firstAdmin.userId, MembershipRole.owner);
} else if (currentUserIsTeamOwner) {
await deleteTeam(teamId);
}
}
await deleteUser(currentUser.id);
return NextResponse.json({ deletedUser: currentUser }, { status: 200 });
} catch (error) {
return NextResponse.json({ message: error.message }, { status: 500 });
}
}

View File

@@ -7,18 +7,25 @@ export default function SettingsCard({
children,
soon = false,
noPadding = false,
dangerZone,
}: {
title: string;
description: string;
children: any;
soon?: boolean;
noPadding?: boolean;
dangerZone?: boolean;
}) {
return (
<div className="my-4 w-full bg-white shadow sm:rounded-lg">
<div className="rounded-t-lg border-b border-slate-200 bg-slate-100 px-6 py-5">
<div className="flex">
<h3 className="mr-2 text-lg font-medium leading-6 text-slate-900">{title}</h3>
<h3
className={`${
dangerZone ? "text-red-600" : "text-slate-900"
} "mr-2 text-lg font-medium leading-6 `}>
{title}
</h3>
{soon && <Badge text="coming soon" size="normal" type="success" />}
</div>
<p className="mt-1 text-sm text-slate-500">{description}</p>

View File

@@ -1,11 +1,17 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import { formbricksLogout } from "@/lib/formbricks";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { useProfile } from "@/lib/profile/profile";
import { deleteProfile } from "@/lib/users/users";
import { Button, ErrorComponent, Input, Label, ProfileAvatar } from "@formbricks/ui";
import { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { Dispatch, SetStateAction, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
@@ -69,3 +75,86 @@ export function EditAvatar({ session }) {
</div>
);
}
interface DeleteAccounModaltProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
session: Session;
}
function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps) {
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
const deleteAccount = async () => {
try {
setDeleting(true);
await deleteProfile();
await signOut();
await formbricksLogout();
} catch (error) {
toast.error("Something went wrong");
} finally {
setDeleting(false);
setOpen(false);
}
};
return (
<DeleteDialog
open={open}
setOpen={setOpen}
deleteWhat="account"
onDelete={() => deleteAccount()}
text="Before you proceed with deleting your account, please be aware of the following consequences:"
isDeleting={deleting}
disabled={inputValue !== session.user.email}>
<div className="py-5">
<p>
Deleting your account will result in the permanent removal of all your personal information, saved
preferences, and access to team data. If you are the owner of a team with other admins, the
ownership of that team will be transferred to another admin.
</p>
<p className="py-5">
Please note, however, that if you are the only member of a team or there is no other admin present,
the team will be irreversibly deleted along with all associated data.
</p>
<form>
<label htmlFor="deleteAccountConfirmation">
Please enter <span className="font-bold">{session.user.email}</span> in the following field to
confirm the definitive deletion of your account.
</label>
<Input
value={inputValue}
onChange={handleInputChange}
className="mt-5"
type="text"
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
</form>
</div>
</DeleteDialog>
);
}
export function DeleteAccount({ session }: { session: Session | null }) {
const [isModalOpen, setModalOpen] = useState(false);
if (!session) {
return null;
}
return (
<div>
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} />
<Button className="mt-4" variant="warn" onClick={() => setModalOpen(!isModalOpen)}>
Delete my account
</Button>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { getServerSession } from "next-auth";
import { EditName, EditAvatar } from "./editProfile";
import { EditName, EditAvatar, DeleteAccount } from "./editProfile";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
export default async function ProfileSettingsPage() {
@@ -15,6 +15,12 @@ export default async function ProfileSettingsPage() {
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
<EditAvatar session={session} />
</SettingsCard>
<SettingsCard
title="Delete account"
description="Delete your account, your personal information, your preferences and access to your data"
dangerZone>
<DeleteAccount session={session} />
</SettingsCard>
</div>
);
}

View File

@@ -12,6 +12,8 @@ interface DeleteDialogProps {
isDeleting?: boolean;
useSaveInsteadOfCancel?: boolean;
onSave?: () => void;
children?: React.ReactNode;
disabled?: boolean;
}
export default function DeleteDialog({
@@ -23,10 +25,13 @@ export default function DeleteDialog({
isDeleting,
useSaveInsteadOfCancel = false,
onSave,
children,
disabled,
}: DeleteDialogProps) {
return (
<Modal open={open} setOpen={setOpen} title={`Delete ${deleteWhat}`}>
<p>{text || "Are you sure? This action cannot be undone."}</p>
<div>{children}</div>
<div className="my-4 space-x-2 text-right">
<Button
variant="secondary"
@@ -38,7 +43,7 @@ export default function DeleteDialog({
}}>
{useSaveInsteadOfCancel ? "Save" : "Cancel"}
</Button>
<Button variant="warn" onClick={onDelete} loading={isDeleting}>
<Button variant="warn" onClick={onDelete} loading={isDeleting} disabled={disabled}>
Delete
</Button>
</div>

View File

@@ -87,3 +87,19 @@ export const resetPassword = async (token: string, password: string): Promise<an
throw Error(`${error.message}`);
}
};
export const deleteProfile = async (): Promise<any> => {
try {
const res = await fetch("/api/v1/users/me/", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
if (res.status !== 200) {
const json = await res.json();
throw Error(json.error);
}
return await res.json();
} catch (error) {
throw Error(`${error.message}`);
}
};

View File

@@ -1,4 +1,4 @@
export const fetchRessource = async (url) => {
export const fetchRessource = async (url: string) => {
const res = await fetch(url);
// If the status code is not in the range 200-299,
@@ -14,7 +14,7 @@ export const fetchRessource = async (url) => {
return res.json();
};
export const fetcher = async (url) => {
export const fetcher = async (url: string) => {
const res = await fetch(url);
// If the status code is not in the range 200-299,
@@ -30,7 +30,7 @@ export const fetcher = async (url) => {
return res.json();
};
export const updateRessource = async (url, { arg }) => {
export const updateRessource = async (url: string, { arg }: { arg: any }) => {
return fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },

56
pnpm-lock.yaml generated
View File

@@ -1,4 +1,4 @@
lockfileVersion: '6.0'
lockfileVersion: '6.1'
settings:
autoInstallPeers: true
@@ -22,7 +22,7 @@ importers:
version: 3.12.6
turbo:
specifier: latest
version: 1.10.3
version: 1.10.2
apps/demo:
dependencies:
@@ -496,7 +496,7 @@ importers:
version: 8.8.0(eslint@8.41.0)
eslint-config-turbo:
specifier: latest
version: 1.8.8(eslint@8.41.0)
version: 1.10.2(eslint@8.41.0)
eslint-plugin-react:
specifier: 7.32.2
version: 7.32.2(eslint@8.41.0)
@@ -10350,13 +10350,13 @@ packages:
eslint: 8.41.0
dev: false
/eslint-config-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
/eslint-config-turbo@1.10.2(eslint@8.41.0):
resolution: {integrity: sha512-BaCnpn2GM0rTFLuTVplqY8n+3ttWcu/vEmfjJ2BNBVmwX6ALZoJQfL26ZW6VucRk0psTUJALeo+aPrf3VKEJXA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.41.0
eslint-plugin-turbo: 1.8.8(eslint@8.41.0)
eslint-plugin-turbo: 1.10.2(eslint@8.41.0)
dev: false
/eslint-import-resolver-node@0.3.6:
@@ -10575,8 +10575,8 @@ packages:
string.prototype.matchall: 4.0.8
dev: true
/eslint-plugin-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
/eslint-plugin-turbo@1.10.2(eslint@8.41.0):
resolution: {integrity: sha512-Kxsy4zlKLrGkEqZgcAQtu16YqU/g0mV1vYa9/VweF+MSnWWQsEzsJ1qlzTfXV6N9VqGmkuLiyWOA84sRUklOOg==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
@@ -20390,65 +20390,65 @@ packages:
dependencies:
safe-buffer: 5.2.1
/turbo-darwin-64@1.10.3:
resolution: {integrity: sha512-IIB9IomJGyD3EdpSscm7Ip1xVWtYb7D0x7oH3vad3gjFcjHJzDz9xZ/iw/qItFEW+wGFcLSRPd+1BNnuLM8AsA==}
/turbo-darwin-64@1.10.2:
resolution: {integrity: sha512-sVLpVVANByfMgqf7OYPcZM4KiDnjGu7ITvAzBSa9Iwe14yoWLn8utrNsWCRaQEB6kEqBGLPmvL7AKwkl8M2Gqg==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-darwin-arm64@1.10.3:
resolution: {integrity: sha512-SBNmOZU9YEB0eyNIxeeQ+Wi0Ufd+nprEVp41rgUSRXEIpXjsDjyBnKnF+sQQj3+FLb4yyi/yZQckB+55qXWEsw==}
/turbo-darwin-arm64@1.10.2:
resolution: {integrity: sha512-TKG91DSoYQjsCft4XBx4lYycVT5n3UQB/nOKgv/WJCSfwshLWulya3yhP8JT5erv9rPF8gwgnx87lrCmT4EAVA==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-linux-64@1.10.3:
resolution: {integrity: sha512-kvAisGKE7xHJdyMxZLvg53zvHxjqPK1UVj4757PQqtx9dnjYHSc8epmivE6niPgDHon5YqImzArCjVZJYpIGHQ==}
/turbo-linux-64@1.10.2:
resolution: {integrity: sha512-ZIzAkfrzjJFkSM/uEfxU6JjseCsT5PHRu0s0lmYce37ApQbv/HC7tI0cFhuosI30+O8109/mkyZykKE7AQfgqA==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-linux-arm64@1.10.3:
resolution: {integrity: sha512-Qgaqln0IYRgyL0SowJOi+PNxejv1I2xhzXOI+D+z4YHbgSx87ox1IsALYBlK8VRVYY8VCXl+PN12r1ioV09j7A==}
/turbo-linux-arm64@1.10.2:
resolution: {integrity: sha512-G4uZA+RBQ5S1X/oUxO5KoLL2NDMkrrBZF52+00jQv6UEb9lWDgwzqSwoAGjdXxeDCrqMW5rBVwb/IBIF2/yhwA==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-windows-64@1.10.3:
resolution: {integrity: sha512-rbH9wManURNN8mBnN/ZdkpUuTvyVVEMiUwFUX4GVE5qmV15iHtZfDLUSGGCP2UFBazHcpNHG1OJzgc55GFFrUw==}
/turbo-windows-64@1.10.2:
resolution: {integrity: sha512-ObfQO37kGu1jBzFs/L+hybrCXBwdnimotJwzg7pCoSyGijKITlugrpJoPDKlg0eMr3/1Y6KUeHy26vZaDXrbuQ==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo-windows-arm64@1.10.3:
resolution: {integrity: sha512-ThlkqxhcGZX39CaTjsHqJnqVe+WImjX13pmjnpChz6q5HHbeRxaJSFzgrHIOt0sUUVx90W/WrNRyoIt/aafniw==}
/turbo-windows-arm64@1.10.2:
resolution: {integrity: sha512-7S6dx4738R/FIT2cxbsunqgHN5LelXzuzkcaZgdkU33oswRf/6KOfOABzQLdTX7Uos59cBSdwayf6KQJxuOXUg==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo@1.10.3:
resolution: {integrity: sha512-U4gKCWcKgLcCjQd4Pl8KJdfEKumpyWbzRu75A6FCj6Ctea1PIm58W6Ltw1QXKqHrl2pF9e1raAskf/h6dlrPCA==}
/turbo@1.10.2:
resolution: {integrity: sha512-m9sR5XHhuzxUQACf0vI2qCG5OqDYAZiPTaAsTwECnwUF4/cXwEmcYddbLJnO+K9orNvcnjjent5oBNBVQ/o0ow==}
hasBin: true
requiresBuild: true
optionalDependencies:
turbo-darwin-64: 1.10.3
turbo-darwin-arm64: 1.10.3
turbo-linux-64: 1.10.3
turbo-linux-arm64: 1.10.3
turbo-windows-64: 1.10.3
turbo-windows-arm64: 1.10.3
turbo-darwin-64: 1.10.2
turbo-darwin-arm64: 1.10.2
turbo-linux-64: 1.10.2
turbo-linux-arm64: 1.10.2
turbo-windows-64: 1.10.2
turbo-windows-arm64: 1.10.2
dev: true
/tween-functions@1.2.0: