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:
Neil Chauhan
2023-06-20 15:51:25 +05:30
committed by GitHub
parent 4348c905f0
commit 27023eacf8
7 changed files with 289 additions and 18 deletions

View File

@@ -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">

View File

@@ -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" />}

View File

@@ -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;
};

View File

@@ -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}/`, {

View File

@@ -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") {

View File

@@ -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,
},
});

View File

@@ -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") {