[WEB-2460] fix: role permission validation (#5615)

* fix: workspace menu quick action

* fix: guest role upgrade flow validation

* fix: create issue validation

* fix: create issue validation

* fix: cmd k permission validation

* fix: subscription validation

* fix: create label permission validation

* fix: build error

* chore: guest can comment in their created issues

* chore: changed the queryset

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Anmol Singh Bhatia
2024-09-16 18:56:28 +05:30
committed by GitHub
parent 2e816656e5
commit 45da70cf6a
9 changed files with 132 additions and 76 deletions

View File

@@ -21,6 +21,8 @@ from plane.db.models import (
IssueComment,
ProjectMember,
CommentReaction,
Project,
Issue,
)
from plane.bgtasks.issue_activities_task import issue_activity
@@ -67,9 +69,27 @@ class IssueCommentViewSet(BaseViewSet):
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def create(self, request, slug, project_id, issue_id):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to comment on the issue"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -94,7 +114,7 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
allowed_roles=[ROLE.ADMIN],
creator=True,
model=IssueComment,
)
@@ -182,6 +202,7 @@ class CommentReactionViewSet(BaseViewSet):
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def create(self, request, slug, project_id, comment_id):
@@ -210,6 +231,7 @@ class CommentReactionViewSet(BaseViewSet):
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):

View File

@@ -12,7 +12,7 @@ from rest_framework import status
# Module imports
from .. import BaseViewSet
from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity
@@ -20,9 +20,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
@@ -40,6 +37,7 @@ class IssueReactionViewSet(BaseViewSet):
.distinct()
)
@allow_permission(ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST)
def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
@@ -62,6 +60,7 @@ class IssueReactionViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,

View File

@@ -31,7 +31,7 @@ import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// helpers
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -41,6 +41,7 @@ import { IssueIdentifier } from "@/plane-web/components/issues";
import { WorkspaceService } from "@/plane-web/services";
// services
import { IssueService } from "@/services/issue";
import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions";
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
@@ -71,6 +72,7 @@ export const CommandModal: React.FC = observer(() => {
const [pages, setPages] = useState<string[]>([]);
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { setTrackElement } = useEventTracker();
// router
@@ -84,6 +86,11 @@ export const CommandModal: React.FC = observer(() => {
const { baseTabIndex } = getTabIndex(undefined, isMobile);
const canPerformWorkspaceActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// TODO: update this to mobx store
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
@@ -314,8 +321,7 @@ export const CommandModal: React.FC = observer(() => {
</Command.Item>
</Command.Group>
)}
{workspaceSlug && (
{workspaceSlug && canPerformWorkspaceActions && (
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
@@ -335,23 +341,26 @@ export const CommandModal: React.FC = observer(() => {
)}
{/* project actions */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Search settings...
</div>
</Command.Item>
</Command.Group>
{projectId && canPerformAnyCreateAction && (
<CommandPaletteProjectActions closePalette={closePalette} />
)}
{canPerformWorkspaceAction && (
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Search settings...
</div>
</Command.Item>
</Command.Group>
)}
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">

View File

@@ -6,10 +6,11 @@ import { IIssueLabel, TIssue } from "@plane/types";
// components
import { TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail, useLabel, useProjectInbox } from "@/hooks/store";
import { useIssueDetail, useLabel, useProjectInbox, useUserPermissions } from "@/hooks/store";
// ui
// types
import { LabelList, LabelCreate, IssueLabelSelectRoot } from "./";
import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions";
export type TIssueLabel = {
workspaceSlug: string;
@@ -34,7 +35,9 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
issue: { getIssueById },
} = useIssueDetail();
const { getIssueInboxByIssueId } = useProjectInbox();
const { allowPermissions } = useUserPermissions();
const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const issue = isInboxIssue ? getIssueInboxByIssueId(issueId)?.issue : getIssueById(issueId);
const labelOperations: TLabelOperations = useMemo(
@@ -99,7 +102,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
/>
)}
{!disabled && (
{!disabled && canCreateLabel && (
<LabelCreate
workspaceSlug={workspaceSlug}
projectId={projectId}

View File

@@ -7,7 +7,8 @@ import { Bell, BellOff } from "lucide-react";
// UI
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { useIssueDetail, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export type TIssueSubscription = {
workspaceSlug: string;
@@ -25,8 +26,16 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
} = useIssueDetail();
// state
const [loading, setLoading] = useState(false);
// hooks
const { allowPermissions } = useUserPermissions();
const isSubscribed = getSubscriptionByIssueId(issueId);
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const handleSubscription = async () => {
setLoading(true);
@@ -64,6 +73,7 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
variant="outline-primary"
className="hover:!bg-custom-primary-100/20"
onClick={handleSubscription}
disabled={!isEditable}
>
{loading ? (
<span>

View File

@@ -98,7 +98,8 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
// derived values
const isCurrentUser = currentUser?.id === rowData.member.id;
const isAdminOrGuest = [EUserPermissions.ADMIN, EUserPermissions.GUEST].includes(rowData.role);
const isRoleNonEditable = isCurrentUser || isAdminOrGuest;
const userWorkspaceRole = getWorkspaceMemberDetails(rowData.member.id)?.role;
const isRoleNonEditable = isCurrentUser || (isAdminOrGuest && userWorkspaceRole !== EUserPermissions.MEMBER);
const checkCurrentOptionWorkspaceRole = (value: string) => {
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role as EUserPermissions | undefined;

View File

@@ -10,8 +10,9 @@ import { CreateUpdateIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useCommandPalette, useEventTracker, useProject } from "@/hooks/store";
import { useAppTheme, useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const SidebarQuickActions = observer(() => {
// states
@@ -28,11 +29,16 @@ export const SidebarQuickActions = observer(() => {
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { setTrackElement } = useEventTracker();
const { joinedProjectIds } = useProject();
const { allowPermissions } = useUserPermissions();
// local storage
const { storedValue, setValue } = useLocalStorage<Record<string, Partial<TIssue>>>("draftedIssue", {});
// derived values
const disabled = joinedProjectIds.length === 0;
const workspaceDraftIssue = workspaceSlug ? storedValue?.[workspaceSlug] ?? undefined : undefined;
const canCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const disabled = joinedProjectIds.length === 0 || !canCreateIssue;
const workspaceDraftIssue = workspaceSlug ? (storedValue?.[workspaceSlug] ?? undefined) : undefined;
const handleMouseEnter = () => {
// if enter before time out clear the timeout

View File

@@ -23,7 +23,7 @@ import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { UpgradeBadge } from "@/plane-web/components/workspace";
import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const SidebarWorkspaceMenu = observer(() => {
// state
@@ -43,6 +43,7 @@ export const SidebarWorkspaceMenu = observer(() => {
const { setValue: toggleWorkspaceMenu, storedValue } = useLocalStorage<boolean>("is_workspace_menu_open", true);
// derived values
const isWorkspaceMenuOpen = !!storedValue;
const isAdmin = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE);
const handleLinkClick = (itemKey: string) => {
if (window.innerWidth < 768) {
@@ -67,11 +68,14 @@ export const SidebarWorkspaceMenu = observer(() => {
return (
<Disclosure as="div" defaultOpen>
{!sidebarCollapsed && (
<div className={
cn("flex px-2 bg-custom-sidebar-background-100 group/workspace-button hover:bg-custom-sidebar-background-90 rounded", {
"mt-2.5": !sidebarCollapsed,
})
}>
<div
className={cn(
"flex px-2 bg-custom-sidebar-background-100 group/workspace-button hover:bg-custom-sidebar-background-90 rounded",
{
"mt-2.5": !sidebarCollapsed,
}
)}
>
{" "}
<Disclosure.Button
as="button"
@@ -80,45 +84,47 @@ export const SidebarWorkspaceMenu = observer(() => {
>
<span>WORKSPACE</span>
</Disclosure.Button>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded my-auto"
onClick={() => {
setIsMenuActive(!isMenuActive);
}}
>
<MoreHorizontal className="size-4" />
</span>
}
className={cn(
"h-full flex items-center opacity-0 z-20 pointer-events-none flex-shrink-0 group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto my-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
{isAdmin && (
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded my-auto"
onClick={() => {
setIsMenuActive(!isMenuActive);
}}
>
<MoreHorizontal className="size-4" />
</span>
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/archives`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span>
</div>
</Link>
</CustomMenu.MenuItem>
className={cn(
"h-full flex items-center opacity-0 z-20 pointer-events-none flex-shrink-0 group-hover/workspace-button:opacity-100 group-hover/workspace-button:pointer-events-auto my-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
>
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/archives`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span>
</div>
</Link>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/settings`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Settings</span>
</div>
</Link>
</CustomMenu.MenuItem>
</CustomMenu>
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/settings`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Settings</span>
</div>
</Link>
</CustomMenu.MenuItem>
</CustomMenu>
)}
<Disclosure.Button
as="button"
className="sticky top-0 z-10 group/workspace-button px-0.5 py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90 rounded text-xs font-semibold"

View File

@@ -48,7 +48,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// router
const { workspaceSlug, projectId } = useParams();
const projectMemberInfo = projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()];
const projectMemberInfo = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()];
// fetching project details
useSWR(