mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 22:39:54 -06:00
Add roles to users you invite to your team (#404)
* Add method to check if user is admin or owner * Add method to enable role based member invites * Add Select Control element to UI to handle role based invite * Add flag to allow add member feature for owner or admin level users only * Fix error with role select element * Add UI view to modify membership * Add RoleElement component to handle the Role display * Integrate api for Updating Accepted Member Role * Integrate api for Updating Invitee's Role * Resolve PR comments and merge conflicts
This commit is contained in:
@@ -1,17 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@formbricks/ui";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
enum MembershipRole {
|
||||
Admin = "admin",
|
||||
Editor = "editor",
|
||||
Developer = "developer",
|
||||
Viewer = "viewer",
|
||||
}
|
||||
interface MemberModalProps {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
onSubmit: (data: { name: string; email: string }) => void;
|
||||
onSubmit: (data: { name: string; email: string; role: MembershipRole }) => void;
|
||||
}
|
||||
|
||||
export default function AddMemberModal({ open, setOpen, onSubmit }: MemberModalProps) {
|
||||
const { register, getValues, handleSubmit, reset } = useForm<{ name: string; email: string }>();
|
||||
const { register, getValues, handleSubmit, reset, control } = useForm<{
|
||||
name: string;
|
||||
email: string;
|
||||
role: MembershipRole;
|
||||
}>();
|
||||
|
||||
const submitEventClass = async () => {
|
||||
const data = getValues();
|
||||
@@ -41,6 +61,29 @@ export default function AddMemberModal({ open, setOpen, onSubmit }: MemberModalP
|
||||
<Label>Email Adress</Label>
|
||||
<Input type="email" placeholder="hans@wurst.com" {...register("email", { required: true })} />
|
||||
</div>
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<div>
|
||||
<Label>Role</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectValue placeholder={<span className="text-slate-400">Select role</span>} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.values(MembershipRole).map((role) => (
|
||||
<SelectItem className="capitalize" key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { addMember, deleteInvite, removeMember, resendInvite, useMembers } from "@/lib/members";
|
||||
import {
|
||||
addMember,
|
||||
deleteInvite,
|
||||
removeMember,
|
||||
resendInvite,
|
||||
updateInviteeRole,
|
||||
updateMemberRole,
|
||||
useMembers,
|
||||
} from "@/lib/members";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
ProfileAvatar,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -18,11 +33,95 @@ import toast from "react-hot-toast";
|
||||
import AddMemberModal from "./AddMemberModal";
|
||||
import CreateTeamModal from "@/components/team/CreateTeamModal";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
type EditMembershipsProps = {
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
interface Role {
|
||||
isAdminOrOwner: boolean;
|
||||
memberRole: MembershipRole;
|
||||
teamId: string;
|
||||
memberId: string;
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
memberAccepted: boolean;
|
||||
inviteId: string;
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
Admin = "admin",
|
||||
Editor = "editor",
|
||||
Developer = "developer",
|
||||
Viewer = "viewer",
|
||||
}
|
||||
|
||||
function RoleElement({
|
||||
isAdminOrOwner,
|
||||
memberRole,
|
||||
teamId,
|
||||
memberId,
|
||||
environmentId,
|
||||
userId,
|
||||
memberAccepted,
|
||||
inviteId,
|
||||
}: Role) {
|
||||
const { mutateTeam } = useMembers(environmentId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const disableRole =
|
||||
memberRole && memberId && userId
|
||||
? memberRole === ("owner" as MembershipRole) || memberId === userId
|
||||
: false;
|
||||
|
||||
const handleMemberRoleUpdate = async (role: string) => {
|
||||
setLoading(true);
|
||||
if (memberAccepted) {
|
||||
await updateMemberRole(teamId, memberId, role);
|
||||
} else {
|
||||
await updateInviteeRole(teamId, inviteId, role);
|
||||
}
|
||||
setLoading(false);
|
||||
mutateTeam();
|
||||
};
|
||||
|
||||
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>
|
||||
<DropdownMenuLabel className="text-center">Select Role</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge text={capitalizeFirstLetter(memberRole)} type="gray" size="tiny" />;
|
||||
}
|
||||
|
||||
export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
const { team, isErrorTeam, isLoadingTeam, mutateTeam } = useMembers(environmentId);
|
||||
|
||||
@@ -31,6 +130,10 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
|
||||
const [activeMember, setActiveMember] = useState({} as any);
|
||||
const { profile } = useProfile();
|
||||
|
||||
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
|
||||
const isAdminOrOwner = role === "admin" || role === "owner";
|
||||
|
||||
const handleOpenDeleteMemberModal = (e, member) => {
|
||||
e.preventDefault();
|
||||
@@ -78,13 +181,15 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
}}>
|
||||
Create New Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setAddMemberModalOpen(true);
|
||||
}}>
|
||||
Add Member
|
||||
</Button>
|
||||
{isAdminOrOwner && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
setAddMemberModalOpen(true);
|
||||
}}>
|
||||
Add Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
@@ -109,7 +214,16 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
|
||||
{member.email}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex flex-col items-start justify-center break-all">
|
||||
<Badge text={capitalizeFirstLetter(member.role)} type="gray" size="tiny" />
|
||||
<RoleElement
|
||||
isAdminOrOwner={isAdminOrOwner}
|
||||
memberRole={member.role}
|
||||
memberId={member.userId}
|
||||
teamId={team.teamId}
|
||||
environmentId={environmentId}
|
||||
userId={profile?.id}
|
||||
memberAccepted={member.accepted}
|
||||
inviteId={member?.inviteId}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center justify-end gap-x-6 pr-6">
|
||||
{!member.accepted && <Badge type="warning" text="Pending" size="tiny" />}
|
||||
|
||||
@@ -126,3 +126,18 @@ export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse
|
||||
}
|
||||
if (session && "user" in session) return session.user;
|
||||
};
|
||||
|
||||
export const isAdminOrOwner = async (user, teamId) => {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: user.id,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership && (membership.role === "admin" || membership.role === "owner")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,20 @@ export const useMembers = (environmentId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const updateMemberRole = async (teamId: string, userId: string, role: string) => {
|
||||
try {
|
||||
const result = await fetch(`/api/v1/teams/${teamId}/members/${userId}/`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
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}/`, {
|
||||
@@ -29,6 +43,21 @@ export const removeMember = async (teamId: string, userId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// update invitee's role
|
||||
export const updateInviteeRole = async (teamId: string, inviteId: string, role: string) => {
|
||||
try {
|
||||
const result = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}/`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
return result.status === 200;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteInvite = async (teamId: string, inviteId: string) => {
|
||||
try {
|
||||
const result = await fetch(`/api/v1/teams/${teamId}/invite/${inviteId}/`, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSessionUser } from "@/lib/api/apiHelper";
|
||||
import { getSessionUser, isAdminOrOwner } from "@/lib/api/apiHelper";
|
||||
import { sendInviteMemberEmail } from "@/lib/email";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
@@ -20,6 +20,43 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ message: "Missing inviteId" });
|
||||
}
|
||||
|
||||
const hasOwnerOrAdminAccess = await isAdminOrOwner(currentUser, teamId);
|
||||
if (!hasOwnerOrAdminAccess) {
|
||||
return res.status(403).json({ message: "You are not allowed to create or modify invites in this team" });
|
||||
}
|
||||
|
||||
// PATCH /api/v1/teams/[teamId]/invite/[inviteId]
|
||||
// Update an invited member's role
|
||||
if (req.method === "PATCH") {
|
||||
const { role } = req.body;
|
||||
// check if invite exists
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
creator: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
return res.status(403).json({ message: "You are not allowed to update this invite", invite });
|
||||
}
|
||||
|
||||
// update invite with new role
|
||||
const updatedInvite = await prisma.invite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
return res.status(200).json(updatedInvite);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/teams/[teamId]/invite/[inviteId]
|
||||
// Remove a member from a team
|
||||
if (req.method === "DELETE") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSessionUser, hasTeamAccess } from "@/lib/api/apiHelper";
|
||||
import { getSessionUser, hasTeamAccess, isAdminOrOwner } from "@/lib/api/apiHelper";
|
||||
import { sendInviteMemberEmail } from "@/lib/email";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
@@ -19,11 +19,15 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ message: "Not authorized" });
|
||||
}
|
||||
// TODO check if User is ADMIN or OWNER
|
||||
|
||||
const hasOwnerOrAdminAccess = await isAdminOrOwner(currentUser, teamId);
|
||||
if (!hasOwnerOrAdminAccess) {
|
||||
return res.status(403).json({ message: "Not authorized" });
|
||||
}
|
||||
|
||||
// POST /api/v1/teams/[teamId]/invite
|
||||
if (req.method === "POST") {
|
||||
let { email, name } = req.body;
|
||||
let { email, name, role } = req.body;
|
||||
email = email.toLowerCase();
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
@@ -49,6 +53,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
team: { connect: { id: teamId } },
|
||||
creator: { connect: { id: currentUser.id } },
|
||||
acceptor: user ? { connect: { id: user.id } } : undefined,
|
||||
role,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSessionUser, hasTeamAccess } from "@/lib/api/apiHelper";
|
||||
import { getSessionUser, hasTeamAccess, isAdminOrOwner } from "@/lib/api/apiHelper";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
@@ -24,6 +24,34 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ message: "Missing userId" });
|
||||
}
|
||||
|
||||
// PATCH /api/v1/teams/[teamId]/members/[userId]
|
||||
// Update a member's role
|
||||
|
||||
if (req.method === "PATCH") {
|
||||
const hasOwnerOrAdminAccess = await isAdminOrOwner(currentUser, teamId);
|
||||
if (!hasOwnerOrAdminAccess) {
|
||||
return res.status(403).json({ message: "You are not allowed to update member's role in this team" });
|
||||
}
|
||||
|
||||
if (userId === currentUser.id) {
|
||||
return res.status(403).json({ message: "You cannot update your own role in this team" });
|
||||
}
|
||||
|
||||
const { role } = req.body;
|
||||
const updatedMembership = await prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
return res.json(updatedMembership);
|
||||
}
|
||||
|
||||
// DELETE /api/v1/teams/[teamId]/members/[userId]
|
||||
// Remove a member from a team
|
||||
if (req.method === "DELETE") {
|
||||
|
||||
Reference in New Issue
Block a user