diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/DeleteTeam.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/members/DeleteTeam.tsx
new file mode 100644
index 0000000000..31ae560a53
--- /dev/null
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/members/DeleteTeam.tsx
@@ -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 ;
+ }
+ if (isErrorTeam) {
+ return ;
+ }
+
+ 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 (
+
+ {!isDeleteDisabled && (
+
+
+ This action cannot be undone. If it's gone, it's gone.
+
+
setIsDeleteDialogOpen(true)}>
+ Delete
+
+
+ )}
+ {isDeleteDisabled && (
+
+ {!isUserOwner
+ ? "Only Owner can delete the team."
+ : "This is your only team, it cannot be deleted. Create a new team first."}
+
+ )}
+
+
+ );
+}
+
+interface DeleteTeamModalProps {
+ open: boolean;
+ setOpen: Dispatch>;
+ 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 (
+
+
+
+
+ Permanent removal of all products linked to this team . This includes all surveys,
+ responses, user actions and attributes associated with these products.
+
+ This action cannot be undone. If it's gone, it's gone.
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/EditMemberships.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/members/EditMemberships.tsx
index 562a9426c1..20bbe9e134 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/members/EditMemberships.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/members/EditMemberships.tsx
@@ -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 (
-
-
-
- {capitalizeFirstLetter(memberRole)}
-
-
-
- {!disableRole && (
-
- handleMemberRoleUpdate(value.toLowerCase())}>
- {Object.keys(MembershipRole).map((role) => (
-
- {capitalizeFirstLetter(role)}
-
- ))}
-
-
- )}
-
+ <>
+
+
+
+ {capitalizeFirstLetter(memberRole)}
+
+
+
+ {!disableRole && (
+
+ handleRoleChange(value.toLowerCase())}>
+ {getMembershipRoles().map((role) => (
+
+ {capitalizeFirstLetter(role)}
+
+ ))}
+
+
+ )}
+
+
+ >
);
}
@@ -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("");
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 (
<>
+ {role !== "owner" && (
+ setLeaveTeamModalOpen(true)}>
+ Leave Team
+
+ )}
@@ -310,6 +386,23 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
deleteWhat={activeMember.name + " from your team"}
onDelete={handleDeleteMember}
/>
+
+
+ {isLeaveTeamDisabled && (
+
+ You cannot leave this team as it is your only team. Create a new team first.
+
+ )}
+
+
{showShareInviteModal && (
>;
+ 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 (
+
+
+
+
+ There can only be one owner of each team. If you transfer your ownership to {memberName} ,
+ you will lose all of your ownership rights.
+
+ When you transfer the ownership, you will remain an Admin of the team.
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/members/page.tsx
index 83a3524346..481ffb3dee 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/members/page.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/members/page.tsx
@@ -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 }) {
+
+
+
);
}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/profile/editProfile.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/profile/editProfile.tsx
index 7d20959cd0..0eabc98196 100644
--- a/apps/web/app/(app)/environments/[environmentId]/settings/profile/editProfile.tsx
+++ b/apps/web/app/(app)/environments/[environmentId]/settings/profile/editProfile.tsx
@@ -99,13 +99,13 @@ export function EditAvatar({ session }) {
);
}
-interface DeleteAccounModaltProps {
+interface DeleteAccountModalProps {
open: boolean;
setOpen: Dispatch>;
session: Session;
}
-function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps) {
+function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) {
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
diff --git a/apps/web/components/shared/CustomDialog.tsx b/apps/web/components/shared/CustomDialog.tsx
new file mode 100644
index 0000000000..0bec931d45
--- /dev/null
+++ b/apps/web/components/shared/CustomDialog.tsx
@@ -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 (
+
+ {text}
+ {children}
+
+ {
+ if (onCancel) {
+ onCancel();
+ }
+ setOpen(false);
+ }}>
+ {cancelBtnText || "Cancel"}
+
+
+ {okBtnText || "Yes"}
+
+
+
+ );
+}
diff --git a/apps/web/lib/api/apiHelper.ts b/apps/web/lib/api/apiHelper.ts
index 1e4e22f52c..010ebe48a8 100644
--- a/apps/web/lib/api/apiHelper.ts
+++ b/apps/web/lib/api/apiHelper.ts
@@ -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: {
diff --git a/apps/web/lib/members.ts b/apps/web/lib/members.ts
index 414dd1f901..b8d82abc70 100644
--- a/apps/web/lib/members.ts
+++ b/apps/web/lib/members.ts
@@ -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}/`, {
diff --git a/apps/web/lib/teams/teams.ts b/apps/web/lib/teams/teams.ts
index 95d9212b0f..eca8d32d8a 100644
--- a/apps/web/lib/teams/teams.ts
+++ b/apps/web/lib/teams/teams.ts
@@ -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();
+};
diff --git a/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts b/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts
index 3af4f870b7..f24aa5e3bc 100644
--- a/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts
+++ b/apps/web/pages/api/v1/environments/[environmentId]/team/index.ts
@@ -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.`);
diff --git a/apps/web/pages/api/v1/teams/[teamId]/transfer-ownership/index.ts b/apps/web/pages/api/v1/teams/[teamId]/transfer-ownership/index.ts
new file mode 100644
index 0000000000..d4afcecebc
--- /dev/null
+++ b/apps/web/pages/api/v1/teams/[teamId]/transfer-ownership/index.ts
@@ -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" });
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3b99627b33..02feadf84a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1,4 +1,4 @@
-lockfileVersion: '6.0'
+lockfileVersion: '6.1'
settings:
autoInstallPeers: true