Delete Team, Transfer Ownership and Leave Team functionality

Delete Team, Transfer Ownership and Leave Team functionality
This commit is contained in:
Johannes
2023-08-08 10:51:44 +02:00
committed by GitHub
12 changed files with 556 additions and 31 deletions

View File

@@ -0,0 +1,136 @@
"use client";
import toast from "react-hot-toast";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useState, Dispatch, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import { useMembers } from "@/lib/members";
import { useProfile } from "@/lib/profile";
import { Button, ErrorComponent, Input } from "@formbricks/ui";
import { useTeam, deleteTeam } from "@/lib/teams/teams";
import { useMemberships } from "@/lib/memberships";
export default function DeleteTeam({ environmentId }) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const { profile } = useProfile();
const { memberships } = useMemberships();
const { team } = useMembers(environmentId);
const { team: teamData, isLoadingTeam, isErrorTeam } = useTeam(environmentId);
const availableTeams = memberships?.length;
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
const isUserOwner = role === "owner";
const isDeleteDisabled = availableTeams <= 1 || !isUserOwner;
if (isLoadingTeam) {
return <LoadingSpinner />;
}
if (isErrorTeam) {
return <ErrorComponent />;
}
const handleDeleteTeam = async () => {
setIsDeleting(true);
const deleteTeamRes = await deleteTeam(environmentId);
setIsDeleteDialogOpen(false);
setIsDeleting(false);
if (deleteTeamRes?.deletedTeam?.id?.length > 0) {
toast.success("Team deleted successfully.");
router.push("/");
} else if (deleteTeamRes?.message?.length > 0) {
toast.error(deleteTeamRes.message);
} else {
toast.error("Error deleting team. Please try again.");
}
};
return (
<div>
{!isDeleteDisabled && (
<div>
<p className="text-sm text-slate-900">
This action cannot be undone. If it&apos;s gone, it&apos;s gone.
</p>
<Button
disabled={isDeleteDisabled}
variant="warn"
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
onClick={() => setIsDeleteDialogOpen(true)}>
Delete
</Button>
</div>
)}
{isDeleteDisabled && (
<p className="text-sm text-red-700">
{!isUserOwner
? "Only Owner can delete the team."
: "This is your only team, it cannot be deleted. Create a new team first."}
</p>
)}
<DeleteTeamModal
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
teamData={teamData}
deleteTeam={handleDeleteTeam}
isDeleting={isDeleting}
/>
</div>
);
}
interface DeleteTeamModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
teamData: { name: string; id: string; plan: string };
deleteTeam: () => void;
isDeleting?: boolean;
}
function DeleteTeamModal({ setOpen, open, teamData, deleteTeam, isDeleting }: DeleteTeamModalProps) {
const [inputValue, setInputValue] = useState("");
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
return (
<DeleteDialog
open={open}
setOpen={setOpen}
deleteWhat="team"
onDelete={deleteTeam}
text="Before you proceed with deleting this team, please be aware of the following consequences:"
disabled={inputValue !== teamData?.name}
isDeleting={isDeleting}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
Permanent removal of all <b>products linked to this team</b>. This includes all surveys,
responses, user actions and attributes associated with these products.
</li>
<li>This action cannot be undone. If it&apos;s gone, it&apos;s gone.</li>
</ul>
<form>
<label htmlFor="deleteTeamConfirmation">
Please enter <b>{teamData?.name}</b> in the following field to confirm the definitive deletion of
this team:
</label>
<Input
value={inputValue}
onChange={handleInputChange}
placeholder={teamData?.name}
className="mt-5"
type="text"
id="deleteTeamConfirmation"
name="deleteTeamConfirmation"
/>
</form>
</div>
</DeleteDialog>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/ShareInviteModal";
import TransferOwnershipModal from "@/app/(app)/environments/[environmentId]/settings/members/TransferOwnershipModal";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import CreateTeamModal from "@/components/team/CreateTeamModal";
@@ -11,6 +12,7 @@ import {
removeMember,
resendInvite,
shareInvite,
transferOwnership,
updateInviteeRole,
updateMemberRole,
useMembers,
@@ -36,6 +38,9 @@ import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/out
import { useState } from "react";
import toast from "react-hot-toast";
import AddMemberModal from "./AddMemberModal";
import { useRouter } from "next/navigation";
import { useMemberships } from "@/lib/memberships";
import CustomDialog from "@/components/shared/CustomDialog";
type EditMembershipsProps = {
environmentId: string;
@@ -46,13 +51,16 @@ interface Role {
memberRole: MembershipRole;
teamId: string;
memberId: string;
memberName: string;
environmentId: string;
userId: string;
memberAccepted: boolean;
inviteId: string;
currentUserRole: string;
}
enum MembershipRole {
Owner = "owner",
Admin = "admin",
Editor = "editor",
Developer = "developer",
@@ -64,13 +72,16 @@ function RoleElement({
memberRole,
teamId,
memberId,
memberName,
environmentId,
userId,
memberAccepted,
inviteId,
currentUserRole,
}: Role) {
const { mutateTeam } = useMembers(environmentId);
const [loading, setLoading] = useState(false);
const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false);
const disableRole =
memberRole && memberId && userId
? memberRole === ("owner" as MembershipRole) || memberId === userId
@@ -87,34 +98,71 @@ function RoleElement({
mutateTeam();
};
const handleOwnershipTransfer = async () => {
setLoading(true);
const isTransfered = await transferOwnership(teamId, memberId);
if (isTransfered) {
toast.success("Ownership transferred successfully");
} else {
toast.error("Something went wrong");
}
setTransferOwnershipModalOpen(false);
setLoading(false);
mutateTeam();
};
const handleRoleChange = (role: string) => {
if (role === "owner") {
setTransferOwnershipModalOpen(true);
} else {
handleMemberRoleUpdate(role);
}
};
const getMembershipRoles = () => {
if (currentUserRole === "owner" && memberAccepted) {
return Object.keys(MembershipRole);
}
return Object.keys(MembershipRole).filter((role) => role !== "Owner");
};
if (isAdminOrOwner) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disableRole}
variant="secondary"
className="flex items-center gap-1 p-1.5 text-xs"
loading={loading}
size="sm">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{!disableRole && (
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={capitalizeFirstLetter(memberRole)}
onValueChange={(value) => handleMemberRoleUpdate(value.toLowerCase())}>
{Object.keys(MembershipRole).map((role) => (
<DropdownMenuRadioItem key={role} value={role}>
{capitalizeFirstLetter(role)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
)}
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disableRole}
variant="secondary"
className="flex items-center gap-1 p-1.5 text-xs"
loading={loading}
size="sm">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{!disableRole && (
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={capitalizeFirstLetter(memberRole)}
onValueChange={(value) => handleRoleChange(value.toLowerCase())}>
{getMembershipRoles().map((role) => (
<DropdownMenuRadioItem key={role} value={role}>
{capitalizeFirstLetter(role)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
)}
</DropdownMenu>
<TransferOwnershipModal
open={isTransferOwnershipModalOpen}
setOpen={setTransferOwnershipModalOpen}
memberName={memberName}
onSubmit={handleOwnershipTransfer}
isLoading={loading}
/>
</>
);
}
@@ -124,18 +172,26 @@ function RoleElement({
export function EditMemberships({ environmentId }: EditMembershipsProps) {
const { team, isErrorTeam, isLoadingTeam, mutateTeam } = useMembers(environmentId);
const [loading, setLoading] = useState(false);
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
const [isLeaveTeamModalOpen, setLeaveTeamModalOpen] = useState(false);
const [shareInviteToken, setShareInviteToken] = useState<string>("");
const [activeMember, setActiveMember] = useState({} as any);
const { profile } = useProfile();
const { memberships } = useMemberships();
const router = useRouter();
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
const isAdminOrOwner = role === "admin" || role === "owner";
const availableTeams = memberships?.length;
const isLeaveTeamDisabled = availableTeams <= 1;
const handleOpenDeleteMemberModal = (e, member) => {
e.preventDefault();
setActiveMember(member);
@@ -194,9 +250,27 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
return now > expiresAt;
};
const handleLeaveTeam = async () => {
setLoading(true);
const result = await removeMember(team.teamId, profile?.id);
setLeaveTeamModalOpen(false);
setLoading(false);
if (!result) {
toast.error("Something went wrong");
} else {
toast.success("You left the team successfully");
router.push("/");
}
};
return (
<>
<div className="mb-6 text-right">
{role !== "owner" && (
<Button variant="minimal" className="mr-2" onClick={() => setLeaveTeamModalOpen(true)}>
Leave Team
</Button>
)}
<Button
variant="secondary"
className="mr-2"
@@ -242,11 +316,13 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
isAdminOrOwner={isAdminOrOwner}
memberRole={member.role}
memberId={member.userId}
memberName={member.name}
teamId={team.teamId}
environmentId={environmentId}
userId={profile?.id}
memberAccepted={member.accepted}
inviteId={member?.inviteId}
currentUserRole={role}
/>
</div>
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
@@ -310,6 +386,23 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
deleteWhat={activeMember.name + " from your team"}
onDelete={handleDeleteMember}
/>
<CustomDialog
open={isLeaveTeamModalOpen}
setOpen={setLeaveTeamModalOpen}
title="Are you sure?"
text="You wil leave this team and loose access to all surveys and responses. You can only rejoin if you are invited again."
onOk={handleLeaveTeam}
okBtnText="Yes, leave team"
disabled={isLeaveTeamDisabled}
isLoading={loading}>
{isLeaveTeamDisabled && (
<p className="mt-2 text-sm text-red-700">
You cannot leave this team as it is your only team. Create a new team first.
</p>
)}
</CustomDialog>
{showShareInviteModal && (
<ShareInviteModal
inviteToken={shareInviteToken}

View File

@@ -0,0 +1,61 @@
import CustomDialog from "@/components/shared/CustomDialog";
import { Input } from "@formbricks/ui";
import { Dispatch, SetStateAction, useState } from "react";
interface TransferOwnershipModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
memberName: string;
onSubmit: () => void;
isLoading?: boolean;
}
export default function TransferOwnershipModal({
setOpen,
open,
memberName,
onSubmit,
isLoading,
}: TransferOwnershipModalProps) {
const [inputValue, setInputValue] = useState("");
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
return (
<CustomDialog
open={open}
setOpen={setOpen}
onOk={onSubmit}
okBtnText="Transfer ownership"
title="There can only be ONE owner! Are you sure?"
cancelBtnText="CANCEL"
disabled={inputValue !== memberName}
isLoading={isLoading}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
There can only be one owner of each team. If you transfer your ownership to <b>{memberName}</b>,
you will lose all of your ownership rights.
</li>
<li>When you transfer the ownership, you will remain an Admin of the team.</li>
</ul>
<form>
<label htmlFor="transferOwnershipConfirmation">
Type in <b>{memberName}</b> to confirm:
</label>
<Input
value={inputValue}
onChange={handleInputChange}
placeholder={memberName}
className="mt-5"
type="text"
id="transferOwnershipConfirmation"
name="transferOwnershipConfirmation"
/>
</form>
</div>
</CustomDialog>
);
}

View File

@@ -2,6 +2,7 @@ import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { EditMemberships } from "./EditMemberships";
import EditTeamName from "./EditTeamName";
import DeleteTeam from "./DeleteTeam";
export default function MembersSettingsPage({ params }) {
return (
@@ -13,6 +14,11 @@ export default function MembersSettingsPage({ params }) {
<SettingsCard title="Team Name" description="Give your team a descriptive name.">
<EditTeamName environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
title="Delete Team"
description="Delete team with all its products including all surveys, responses, people, actions and attributes">
<DeleteTeam environmentId={params.environmentId} />
</SettingsCard>
</div>
);
}

View File

@@ -99,13 +99,13 @@ export function EditAvatar({ session }) {
);
}
interface DeleteAccounModaltProps {
interface DeleteAccountModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
session: Session;
}
function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps) {
function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) {
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");

View File

@@ -0,0 +1,54 @@
"use client";
import Modal from "@/components/shared/Modal";
import { Button } from "@formbricks/ui";
interface CustomDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
title?: string;
text?: string;
isLoading?: boolean;
children?: React.ReactNode;
onOk: () => void;
okBtnText?: string;
onCancel?: () => void;
cancelBtnText?: string;
disabled?: boolean;
}
export default function CustomDialog({
open,
setOpen,
title,
text,
isLoading,
children,
onOk,
okBtnText,
onCancel,
cancelBtnText,
disabled,
}: CustomDialogProps) {
return (
<Modal open={open} setOpen={setOpen} title={title}>
<p>{text}</p>
<div>{children}</div>
<div className="my-4 space-x-2 text-right">
<Button
variant="secondary"
onClick={() => {
if (onCancel) {
onCancel();
}
setOpen(false);
}}>
{cancelBtnText || "Cancel"}
</Button>
<Button variant="warn" onClick={onOk} loading={isLoading} disabled={disabled}>
{okBtnText || "Yes"}
</Button>
</div>
</Modal>
);
}

View File

@@ -131,6 +131,21 @@ export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse
if (session && "user" in session) return session.user;
};
export const isOwner = async (user, teamId) => {
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: user.id,
teamId: teamId,
},
},
});
if (membership && membership.role === "owner") {
return true;
}
return false;
};
export const isAdminOrOwner = async (user, teamId) => {
const membership = await prisma.membership.findUnique({
where: {

View File

@@ -30,6 +30,20 @@ export const updateMemberRole = async (teamId: string, userId: string, role: str
}
};
export const transferOwnership = async (teamId: string, userId: string) => {
try {
const result = await fetch(`/api/v1/teams/${teamId}/transfer-ownership/`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
return result.status === 200;
} catch (error) {
console.error(error);
return false;
}
};
export const removeMember = async (teamId: string, userId: string) => {
try {
const result = await fetch(`/api/v1/teams/${teamId}/members/${userId}/`, {

View File

@@ -15,3 +15,10 @@ export const useTeam = (environmentId: string) => {
mutateTeam: mutate,
};
};
export const deleteTeam = async (environmentId: string) => {
const response = await fetch(`/api/v1/environments/${environmentId}/team/`, {
method: "DELETE",
});
return response.json();
};

View File

@@ -1,8 +1,12 @@
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { getSessionUser, hasEnvironmentAccess, isOwner } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const currentUser: any = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
const environmentId = req.query?.environmentId?.toString();
if (!environmentId) {
@@ -43,10 +47,61 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
});
if (team === null) {
return res.status(404).json({ message: "This product doesn't exist" });
return res.status(404).json({ message: "This team doesn't exist" });
}
return res.json(team);
}
// DELETE
else if (req.method === "DELETE") {
try {
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
teamId: true,
},
},
},
});
if (environment === null) {
return res.status(404).json({ message: "This environment doesn't exist" });
}
const team = await prisma.team.findUnique({
where: {
id: environment.product.teamId,
},
select: {
id: true,
name: true,
stripeCustomerId: true,
plan: true,
},
});
if (team === null) {
return res.status(404).json({ message: "This team doesn't exist" });
}
const hasOwnership = isOwner(currentUser, team.id);
if (!hasOwnership) {
return res.status(403).json({ message: "You are not allowed to delete this team" });
}
const prismaRes = await prisma.team.delete({
where: {
id: team.id,
},
});
return res.status(200).json({ deletedTeam: prismaRes });
} catch (error) {
return res.status(500).json({ message: error.message });
}
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);

View File

@@ -0,0 +1,84 @@
import { getSessionUser, hasTeamAccess, isOwner } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// Check Authentication
const currentUser: any = await getSessionUser(req, res);
if (!currentUser) {
return res.status(401).json({ message: "Not authenticated" });
}
const teamId = req.query.teamId?.toString();
if (teamId === undefined) {
return res.status(400).json({ message: "Missing teamId" });
}
const hasAccess = await hasTeamAccess(currentUser, teamId);
if (!hasAccess) {
return res.status(403).json({ message: "Not authorized" });
}
/**
* Transfer ownership of a team to another member
* @route PATCH /api/v1/teams/{teamId}/transfer-ownership
* @param {string} teamId - The id of the team to transfer ownership of
* @param {string} userId - The id of the user to transfer ownership to
* @returns {object} - The updated membership of the new owner and the updated membership of the old owner
*
*/
if (req.method === "PATCH") {
const { userId: newOwnerId } = req.body;
const hasOwnerAccess = await isOwner(currentUser, teamId);
if (!hasOwnerAccess) {
return res.status(403).json({ message: "You must be the owner of this team to transfer ownership" });
}
if (newOwnerId === currentUser.id) {
return res.status(403).json({ message: "You cannot transfer ownership to yourself" });
}
const isMember = await prisma.membership.findFirst({
where: {
userId: newOwnerId,
teamId,
},
});
if (!isMember) {
return res.status(403).json({ message: "The new owner must be a member of the team" });
}
try {
await prisma.$transaction([
prisma.membership.update({
where: {
userId_teamId: {
teamId,
userId: currentUser.id,
},
},
data: {
role: "admin",
},
}),
prisma.membership.update({
where: {
userId_teamId: {
teamId,
userId: newOwnerId,
},
},
data: {
role: "owner",
},
}),
]);
} catch (error) {
return res.status(500).json({ message: "Something went wrong" });
}
return res.json({ message: "Ownership transferred successfully" });
}
}

2
pnpm-lock.yaml generated
View File

@@ -1,4 +1,4 @@
lockfileVersion: '6.0'
lockfileVersion: '6.1'
settings:
autoInstallPeers: true