fix: lower role user cannot invite higher role user (#1302)

This commit is contained in:
Aaryan Khandelwal 2023-06-16 19:06:34 +05:30 committed by GitHub
parent 81f6562168
commit 0dfa06e55b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 416 additions and 338 deletions

View File

@ -9,11 +9,13 @@ import { useForm, Controller } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { CustomSelect, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import projectService from "services/project.service";
import workspaceService from "services/workspace.service";
// contexts
import { useProjectMyMembership } from "contexts/project-member.context";
// hooks
import useToast from "hooks/use-toast";
// types
import { ICurrentUserResponse, IProjectMemberInvitation } from "types";
// fetch-keys
@ -46,6 +48,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const { memberDetails } = useProjectMyMembership();
const { data: people } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null,
@ -202,11 +205,15 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
input
width="w-full"
>
{Object.entries(ROLE).map(([key, label]) => (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
))}
{Object.entries(ROLE).map(([key, label]) => {
if (parseInt(key) > (memberDetails?.role ?? 5)) return null;
return (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
);
})}
</CustomSelect>
)}
/>

View File

@ -1,17 +1,22 @@
import React from "react";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless
import { Dialog, Transition } from "@headlessui/react";
// services
import workspaceService from "services/workspace.service";
// ui
import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui";
// contexts
import { useWorkspaceMyMembership } from "contexts/workspace-member.context";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
import { ICurrentUserResponse, IWorkspaceMemberInvitation } from "types";
// fetch keys
// fetch-keys
import { WORKSPACE_INVITATIONS } from "constants/fetch-keys";
// constants
import { ROLE } from "constants/workspace";
@ -37,6 +42,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = ({
user,
}) => {
const { setToastAlert } = useToast();
const { memberDetails } = useWorkspaceMyMembership();
const {
control,
@ -145,11 +151,15 @@ const SendWorkspaceInvitationModal: React.FC<Props> = ({
width="w-full"
input
>
{Object.entries(ROLE).map(([key, value]) => (
<CustomSelect.Option key={key} value={key}>
{value}
</CustomSelect.Option>
))}
{Object.entries(ROLE).map(([key, value]) => {
if (parseInt(key) > (memberDetails?.role ?? 5)) return null;
return (
<CustomSelect.Option key={key} value={key}>
{value}
</CustomSelect.Option>
);
})}
</CustomSelect>
)}
/>

View File

@ -0,0 +1,61 @@
import { createContext, useContext } from "react";
// next
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import workspaceService from "services/workspace.service";
// types
import { IWorkspaceMember } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
type ContextType = {
loading: boolean;
memberDetails?: IWorkspaceMember;
error: any;
};
export const WorkspaceMemberContext = createContext<ContextType>({} as ContextType);
type Props = {
children: React.ReactNode;
};
export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const { children } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: memberDetails, error } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug.toString()) : null,
workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug.toString()) : null
);
const loading = !memberDetails && !error;
return (
<WorkspaceMemberContext.Provider value={{ loading, memberDetails, error }}>
{children}
</WorkspaceMemberContext.Provider>
);
};
export const useWorkspaceMyMembership = () => {
const context = useContext(WorkspaceMemberContext);
if (context === undefined)
throw new Error(`useWorkspaceMember must be used within a WorkspaceMemberProvider.`);
return {
...context,
memberRole: {
isOwner: context.memberDetails?.role === 20,
isMember: context.memberDetails?.role === 15,
isViewer: context.memberDetails?.role === 10,
isGuest: context.memberDetails?.role === 5,
},
};
};

View File

@ -7,6 +7,8 @@ import useSWR from "swr";
// services
import workspaceServices from "services/workspace.service";
// contexts
import { WorkspaceMemberProvider } from "contexts/workspace-member.context";
// layouts
import AppSidebar from "layouts/app-layout/app-sidebar";
import AppHeader from "layouts/app-layout/app-header";
@ -78,48 +80,50 @@ export const WorkspaceAuthorizationLayout: React.FC<Props> = ({
return (
<UserAuthorizationLayout>
<CommandPalette />
<div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
{settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
<NotAuthorizedView
actionButton={
<Link href={`/${workspaceSlug}`}>
<a>
<PrimaryButton className="flex items-center gap-1">
<LayerDiagonalIcon height={16} width={16} color="white" /> Go to workspace
</PrimaryButton>
</a>
</Link>
}
type="workspace"
/>
) : (
<main
className={`relative flex h-full w-full flex-col overflow-hidden ${
bg === "primary"
? "bg-brand-surface-1"
: bg === "secondary"
? "bg-brand-sidebar"
: "bg-brand-base"
}`}
>
{!noHeader && (
<AppHeader
breadcrumbs={breadcrumbs}
left={left}
right={right}
setToggleSidebar={setToggleSidebar}
/>
)}
<div className="h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
{children}
<WorkspaceMemberProvider>
<CommandPalette />
<div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar toggleSidebar={toggleSidebar} setToggleSidebar={setToggleSidebar} />
{settingsLayout && (memberType?.isGuest || memberType?.isViewer) ? (
<NotAuthorizedView
actionButton={
<Link href={`/${workspaceSlug}`}>
<a>
<PrimaryButton className="flex items-center gap-1">
<LayerDiagonalIcon height={16} width={16} color="white" /> Go to workspace
</PrimaryButton>
</a>
</Link>
}
type="workspace"
/>
) : (
<main
className={`relative flex h-full w-full flex-col overflow-hidden ${
bg === "primary"
? "bg-brand-surface-1"
: bg === "secondary"
? "bg-brand-sidebar"
: "bg-brand-base"
}`}
>
{!noHeader && (
<AppHeader
breadcrumbs={breadcrumbs}
left={left}
right={right}
setToggleSidebar={setToggleSidebar}
/>
)}
<div className="h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
{children}
</div>
</div>
</div>
</main>
)}
</div>
</main>
)}
</div>
</WorkspaceMemberProvider>
</UserAuthorizationLayout>
);
};

View File

@ -89,7 +89,17 @@ const MembersSettings: NextPage = () => {
const currentUser = projectMembers?.find((item) => item.member.id === user?.id);
return (
<>
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${projectDetails?.name ?? "Project"}`}
link={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}
/>
<BreadcrumbItem title="Members Settings" />
</Breadcrumbs>
}
>
<ConfirmProjectMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
@ -136,149 +146,137 @@ const MembersSettings: NextPage = () => {
members={members}
user={user}
/>
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${projectDetails?.name ?? "Project"}`}
link={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}
/>
<BreadcrumbItem title="Members Settings" />
</Breadcrumbs>
}
>
<div className="p-8">
<SettingsHeader />
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
{!projectMembers || !projectInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-6 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<img
src={member.avatar}
alt={member.first_name}
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="mt-0.5 text-xs text-brand-secondary">{member.email}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!member.member && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
Pending
</div>
<div className="p-8">
<SettingsHeader />
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
{!projectMembers || !projectInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-6 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<img
src={member.avatar}
alt={member.first_name}
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!activeWorkspace || !projectDetails) return;
mutateMembers(
(prevData: any) =>
prevData.map((m: any) =>
m.id === member.id ? { ...m, role: value } : m
),
false
);
projectService
.updateProjectMember(
activeWorkspace.slug,
projectDetails.id,
member.id,
{
role: value,
}
)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message:
"An error occurred while updating member role. Please try again.",
});
});
}}
position="right"
disabled={
member.memberId === user?.id ||
!member.member ||
(currentUser &&
currentUser.role !== 20 &&
currentUser.role < member.role)
}
>
{Object.keys(ROLE).map((key) => {
if (
currentUser &&
currentUser.role !== 20 &&
currentUser.role < parseInt(key)
)
return null;
return (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) setSelectedRemoveMember(member.id);
else setSelectedInviteRemoveMember(member.id);
}}
>
<span className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove member</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="mt-0.5 text-xs text-brand-secondary">{member.email}</p>
</div>
</div>
))
: null}
</div>
)}
</section>
</div>
</ProjectAuthorizationWrapper>
</>
<div className="flex items-center gap-2 text-xs">
{!member.member && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
Pending
</div>
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!activeWorkspace || !projectDetails) return;
mutateMembers(
(prevData: any) =>
prevData.map((m: any) =>
m.id === member.id ? { ...m, role: value } : m
),
false
);
projectService
.updateProjectMember(
activeWorkspace.slug,
projectDetails.id,
member.id,
{
role: value,
}
)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message:
"An error occurred while updating member role. Please try again.",
});
});
}}
position="right"
disabled={
member.memberId === user?.id ||
!member.member ||
(currentUser &&
currentUser.role !== 20 &&
currentUser.role < member.role)
}
>
{Object.keys(ROLE).map((key) => {
if (
currentUser &&
currentUser.role !== 20 &&
currentUser.role < parseInt(key)
)
return null;
return (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) setSelectedRemoveMember(member.id);
else setSelectedInviteRemoveMember(member.id);
}}
>
<span className="flex items-center justify-start gap-2">
<XMarkIcon className="h-4 w-4" />
<span>Remove member</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
</div>
)}
</section>
</div>
</ProjectAuthorizationWrapper>
);
};

View File

@ -85,7 +85,17 @@ const MembersSettings: NextPage = () => {
const currentUser = workspaceMembers?.find((item) => item.member?.id === user?.id);
return (
<>
<WorkspaceAuthorizationLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeWorkspace?.name ?? "Workspace"}`}
link={`/${workspaceSlug}`}
/>
<BreadcrumbItem title="Members Settings" />
</Breadcrumbs>
}
>
<ConfirmWorkspaceMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
@ -137,149 +147,137 @@ const MembersSettings: NextPage = () => {
members={members}
user={user}
/>
<WorkspaceAuthorizationLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${activeWorkspace?.name ?? "Workspace"}`}
link={`/${workspaceSlug}`}
/>
<BreadcrumbItem title="Members Settings" />
</Breadcrumbs>
}
>
<div className="p-8">
<SettingsHeader />
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
{!workspaceMembers || !workspaceInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-8 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<img
src={member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
alt={member.first_name}
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="text-xs text-brand-secondary">{member.email}</p>
</div>
<div className="p-8">
<SettingsHeader />
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
<h3 className="text-2xl font-semibold">Members</h3>
<button
type="button"
className="flex items-center gap-2 text-brand-accent outline-none"
onClick={() => setInviteModal(true)}
>
<PlusIcon className="h-4 w-4" />
Add Member
</button>
</div>
{!workspaceMembers || !workspaceInvitations ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-brand-base rounded-[10px] border border-brand-base bg-brand-base px-6">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="flex items-center justify-between py-6">
<div className="flex items-center gap-x-8 gap-y-2">
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 p-4 capitalize text-white">
{member.avatar && member.avatar !== "" ? (
<img
src={member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
alt={member.first_name}
/>
) : member.first_name !== "" ? (
member.first_name.charAt(0)
) : (
member.email.charAt(0)
)}
</div>
<div className="flex items-center gap-2 text-xs">
{!member?.status && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
<p>Pending</p>
</div>
)}
{member?.status && !member?.accountCreated && (
<div className="mr-2 flex items-center justify-center rounded-full bg-blue-500/20 px-2 py-1 text-center text-xs text-blue-500">
<p>Account not created</p>
</div>
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: any) => {
if (!workspaceSlug) return;
mutateMembers(
(prevData) =>
prevData?.map((m) =>
m.id === member.id ? { ...m, role: value } : m
),
false
);
workspaceService
.updateWorkspaceMember(workspaceSlug?.toString(), member.id, {
role: value,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message:
"An error occurred while updating member role. Please try again.",
});
});
}}
position="right"
disabled={
member.memberId === currentUser?.member.id ||
!member.status ||
(currentUser &&
currentUser.role !== 20 &&
currentUser.role < member.role)
}
>
{Object.keys(ROLE).map((key) => {
if (
currentUser &&
currentUser.role !== 20 &&
currentUser.role < parseInt(key)
)
return null;
return (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) {
setSelectedRemoveMember(member.id);
} else {
setSelectedInviteRemoveMember(member.id);
}
}}
>
Remove member
</CustomMenu.MenuItem>
</CustomMenu>
<div>
<h4 className="text-sm">
{member.first_name} {member.last_name}
</h4>
<p className="text-xs text-brand-secondary">{member.email}</p>
</div>
</div>
))
: null}
</div>
)}
</section>
</div>
</WorkspaceAuthorizationLayout>
</>
<div className="flex items-center gap-2 text-xs">
{!member?.status && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
<p>Pending</p>
</div>
)}
{member?.status && !member?.accountCreated && (
<div className="mr-2 flex items-center justify-center rounded-full bg-blue-500/20 px-2 py-1 text-center text-xs text-blue-500">
<p>Account not created</p>
</div>
)}
<CustomSelect
label={ROLE[member.role as keyof typeof ROLE]}
value={member.role}
onChange={(value: any) => {
if (!workspaceSlug) return;
mutateMembers(
(prevData) =>
prevData?.map((m) =>
m.id === member.id ? { ...m, role: value } : m
),
false
);
workspaceService
.updateWorkspaceMember(workspaceSlug?.toString(), member.id, {
role: value,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message:
"An error occurred while updating member role. Please try again.",
});
});
}}
position="right"
disabled={
member.memberId === currentUser?.member.id ||
!member.status ||
(currentUser &&
currentUser.role !== 20 &&
currentUser.role < member.role)
}
>
{Object.keys(ROLE).map((key) => {
if (
currentUser &&
currentUser.role !== 20 &&
currentUser.role < parseInt(key)
)
return null;
return (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) {
setSelectedRemoveMember(member.id);
} else {
setSelectedInviteRemoveMember(member.id);
}
}}
>
Remove member
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
</div>
)}
</section>
</div>
</WorkspaceAuthorizationLayout>
);
};