mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
56
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user