mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-01 11:50:43 -05:00
feat: added transfer ownership
This commit is contained in:
@@ -18,7 +18,7 @@ export default function DeleteTeam({ environmentId }) {
|
||||
const router = useRouter();
|
||||
const { profile } = useProfile();
|
||||
const { memberships } = useMemberships();
|
||||
const { team, isErrorTeam: isErrorTeamMembers } = useMembers(environmentId);
|
||||
const { team } = useMembers(environmentId);
|
||||
const { team: teamData, isLoadingTeam, isErrorTeam } = useTeam(environmentId);
|
||||
|
||||
const availableTeams = memberships?.length;
|
||||
|
||||
+81
-35
@@ -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,
|
||||
@@ -38,6 +40,7 @@ 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;
|
||||
@@ -48,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",
|
||||
@@ -66,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
|
||||
@@ -89,34 +98,70 @@ 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,8 +258,6 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
console.log(profile, memberships);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 text-right">
|
||||
@@ -268,11 +311,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">
|
||||
@@ -336,21 +381,22 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
deleteWhat={activeMember.name + " from your team"}
|
||||
onDelete={handleDeleteMember}
|
||||
/>
|
||||
<DeleteDialog
|
||||
|
||||
<CustomDialog
|
||||
open={isLeaveTeamModalOpen}
|
||||
setOpen={setLeaveTeamModalOpen}
|
||||
customTitle="Are you sure?"
|
||||
onDelete={handleLeaveTeam}
|
||||
text="You wil leave this team and loose access to all surveys and responses. You can only rejoin if you
|
||||
are invited again."
|
||||
deleteBtnText="Yes, leave team"
|
||||
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}>
|
||||
{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>
|
||||
)}
|
||||
</DeleteDialog>
|
||||
</CustomDialog>
|
||||
|
||||
{showShareInviteModal && (
|
||||
<ShareInviteModal
|
||||
inviteToken={shareInviteToken}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function TransferOwnershipModal({
|
||||
setOpen,
|
||||
open,
|
||||
memberName,
|
||||
onSubmit,
|
||||
}: 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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,22 +6,19 @@ import { Button } from "@formbricks/ui";
|
||||
interface DeleteDialogProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
customTitle?: string;
|
||||
deleteWhat?: string;
|
||||
deleteWhat: string;
|
||||
onDelete: () => void;
|
||||
text?: string;
|
||||
isDeleting?: boolean;
|
||||
useSaveInsteadOfCancel?: boolean;
|
||||
onSave?: () => void;
|
||||
children?: React.ReactNode;
|
||||
deleteBtnText?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function DeleteDialog({
|
||||
open,
|
||||
setOpen,
|
||||
customTitle,
|
||||
deleteWhat,
|
||||
onDelete,
|
||||
text,
|
||||
@@ -29,11 +26,10 @@ export default function DeleteDialog({
|
||||
useSaveInsteadOfCancel = false,
|
||||
onSave,
|
||||
children,
|
||||
deleteBtnText,
|
||||
disabled,
|
||||
}: DeleteDialogProps) {
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={customTitle ? customTitle : `Delete ${deleteWhat}`}>
|
||||
<Modal open={open} setOpen={setOpen} title={`Delete ${deleteWhat}`}>
|
||||
<p>{text || "Are you sure? This action cannot be undone."}</p>
|
||||
<div>{children}</div>
|
||||
<div className="space-x-2 text-right">
|
||||
@@ -48,7 +44,7 @@ export default function DeleteDialog({
|
||||
{useSaveInsteadOfCancel ? "Save" : "Cancel"}
|
||||
</Button>
|
||||
<Button variant="warn" onClick={onDelete} loading={isDeleting} disabled={disabled}>
|
||||
{deleteBtnText || "Delete"}
|
||||
Delete
|
||||
</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}/`, {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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" });
|
||||
}
|
||||
|
||||
const updatedOwnerMembership = await prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId,
|
||||
userId: newOwnerId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
|
||||
const updatedAdminMembership = await prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId,
|
||||
userId: currentUser.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: "admin",
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
message: "Ownership transferred successfully",
|
||||
updatedOwnerMembership,
|
||||
updatedAdminMembership,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user