mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
Delete Team, Transfer Ownership and Leave Team functionality
Delete Team, Transfer Ownership and Leave Team functionality
This commit is contained in:
@@ -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's gone, it'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's gone, it'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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
54
apps/web/components/shared/CustomDialog.tsx
Normal file
54
apps/web/components/shared/CustomDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}/`, {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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
2
pnpm-lock.yaml
generated
@@ -1,4 +1,4 @@
|
||||
lockfileVersion: '6.0'
|
||||
lockfileVersion: '6.1'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
|
||||
Reference in New Issue
Block a user