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. +

+ +
+ )} + {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 ( - - - - - {!disableRole && ( - - handleMemberRoleUpdate(value.toLowerCase())}> - {Object.keys(MembershipRole).map((role) => ( - - {capitalizeFirstLetter(role)} - - ))} - - - )} - + <> + + + + + {!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" && ( + + )}
@@ -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}
+
+ + +
+
+ ); +} 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