diff --git a/apiserver/plane/app/permissions/__init__.py b/apiserver/plane/app/permissions/__init__.py index 8e879350476..e453881441b 100644 --- a/apiserver/plane/app/permissions/__init__.py +++ b/apiserver/plane/app/permissions/__init__.py @@ -12,3 +12,4 @@ ProjectMemberPermission, ProjectLitePermission, ) +from .base import allow_permission, ROLE \ No newline at end of file diff --git a/apiserver/plane/app/permissions/base.py b/apiserver/plane/app/permissions/base.py new file mode 100644 index 00000000000..bb4f867a793 --- /dev/null +++ b/apiserver/plane/app/permissions/base.py @@ -0,0 +1,61 @@ +from plane.db.models import WorkspaceMember, ProjectMember +from functools import wraps +from rest_framework.response import Response +from rest_framework import status + +from enum import Enum + +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + VIEWER = 10 + GUEST = 5 + + +def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(instance, request, *args, **kwargs): + + # Check for creator if required + if creator and model: + obj = model.objects.filter( + id=kwargs["pk"], created_by=request.user + ).exists() + if obj: + return view_func(instance, request, *args, **kwargs) + + # Convert allowed_roles to their values if they are enum members + allowed_role_values = [ + role.value if isinstance(role, ROLE) else role + for role in allowed_roles + ] + + # Check role permissions + if level == "WORKSPACE": + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role__in=allowed_role_values, + is_active=True, + ).exists(): + return view_func(instance, request, *args, **kwargs) + else: + if ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + role__in=allowed_role_values, + is_active=True, + ).exists(): + return view_func(instance, request, *args, **kwargs) + + # Return permission denied if no conditions are met + return Response( + {"error": "You don't have the required permissions."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + return _wrapped_view + + return decorator diff --git a/apiserver/plane/app/urls/inbox.py b/apiserver/plane/app/urls/inbox.py index b6848244ba6..6508c001d3a 100644 --- a/apiserver/plane/app/urls/inbox.py +++ b/apiserver/plane/app/urls/inbox.py @@ -40,7 +40,7 @@ name="inbox-issue", ), path( - "workspaces//projects//inbox-issues//", + "workspaces//projects//inbox-issues//", InboxIssueViewSet.as_view( { "get": "retrieve", diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index 3d27641e3c4..b72935fc25d 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -7,22 +7,22 @@ from rest_framework import status from rest_framework.response import Response +# Module imports from plane.app.permissions import WorkSpaceAdminPermission from plane.app.serializers import AnalyticViewSerializer - -# Module imports from plane.app.views.base import BaseAPIView, BaseViewSet from plane.bgtasks.analytic_plot_export import analytic_export_task from plane.db.models import AnalyticView, Issue, Workspace from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters +from plane.app.permissions import allow_permission, ROLE class AnalyticsEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE" + ) def get(self, request, slug): x_axis = request.GET.get("x_axis", False) y_axis = request.GET.get("y_axis", False) @@ -201,10 +201,10 @@ def get_queryset(self): class SavedAnalyticEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE" + ) def get(self, request, slug, analytic_id): analytic_view = AnalyticView.objects.get( pk=analytic_id, workspace__slug=slug @@ -234,10 +234,10 @@ def get(self, request, slug, analytic_id): class ExportAnalyticsEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], level="WORKSPACE" + ) def post(self, request, slug): x_axis = request.data.get("x_axis", False) y_axis = request.data.get("y_axis", False) @@ -301,10 +301,10 @@ def post(self, request, slug): class DefaultAnalyticsEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], level="WORKSPACE" + ) def get(self, request, slug): filters = issue_filters(request.GET, "GET") base_issues = Issue.issue_objects.filter( @@ -380,12 +380,10 @@ def get(self, request, slug): .order_by("-count") ) - open_estimate_sum = open_issues_queryset.aggregate( - sum=Sum("point") - )["sum"] - total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[ + open_estimate_sum = open_issues_queryset.aggregate(sum=Sum("point"))[ "sum" ] + total_estimate_sum = base_issues.aggregate(sum=Sum("point"))["sum"] return Response( { diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py index 947f43a5f8d..5f7f1434738 100644 --- a/apiserver/plane/app/views/cycle/archive.py +++ b/apiserver/plane/app/views/cycle/archive.py @@ -24,7 +24,7 @@ # Third party imports from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import allow_permission, ROLE from plane.db.models import Cycle, UserFavorite, Issue, Label, User, Project from plane.utils.analytics_plot import burndown_plot @@ -34,10 +34,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] - def get_queryset(self): favorite_subquery = UserFavorite.objects.filter( user=self.request.user, @@ -292,6 +288,7 @@ def get_queryset(self): .distinct() ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def get(self, request, slug, project_id, pk=None): if pk is None: queryset = ( @@ -596,6 +593,7 @@ def get(self, request, slug, project_id, pk=None): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id, cycle_id): cycle = Cycle.objects.get( pk=cycle_id, project_id=project_id, workspace__slug=slug @@ -614,6 +612,7 @@ def post(self, request, slug, project_id, cycle_id): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def delete(self, request, slug, project_id, cycle_id): cycle = Cycle.objects.get( pk=cycle_id, project_id=project_id, workspace__slug=slug diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 8e1ed5f115e..55cb24b85b0 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -29,8 +29,7 @@ from rest_framework import status from rest_framework.response import Response from plane.app.permissions import ( - ProjectEntityPermission, - ProjectLitePermission, + allow_permission, ROLE ) from plane.app.serializers import ( CycleSerializer, @@ -60,15 +59,6 @@ class CycleViewSet(BaseViewSet): serializer_class = CycleSerializer model = Cycle webhook_event = "cycle" - permission_classes = [ - ProjectEntityPermission, - ] - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - owned_by=self.request.user, - ) def get_queryset(self): favorite_subquery = UserFavorite.objects.filter( @@ -325,6 +315,7 @@ def get_queryset(self): .distinct() ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset().filter(archived_at__isnull=True) cycle_view = request.GET.get("cycle_view", "all") @@ -611,6 +602,7 @@ def list(self, request, slug, project_id): ) return Response(data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): if ( request.data.get("start_date", None) is None @@ -684,6 +676,7 @@ def create(self, request, slug, project_id): status=status.HTTP_400_BAD_REQUEST, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): queryset = self.get_queryset().filter( workspace__slug=slug, project_id=project_id, pk=pk @@ -771,6 +764,7 @@ def partial_update(self, request, slug, project_id, pk): return Response(cycle, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def retrieve(self, request, slug, project_id, pk): queryset = ( self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) @@ -1039,6 +1033,7 @@ def retrieve(self, request, slug, project_id, pk): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN], creator=True, model=Cycle) def destroy(self, request, slug, project_id, pk): cycle = Cycle.objects.get( workspace__slug=slug, project_id=project_id, pk=pk @@ -1097,10 +1092,8 @@ def destroy(self, request, slug, project_id, pk): class CycleDateCheckEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): start_date = request.data.get("start_date", False) end_date = request.data.get("end_date", False) @@ -1144,6 +1137,7 @@ def get_queryset(self): .select_related("cycle", "cycle__owned_by") ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): _ = UserFavorite.objects.create( project_id=project_id, @@ -1153,6 +1147,7 @@ def create(self, request, slug, project_id): ) return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, cycle_id): cycle_favorite = UserFavorite.objects.get( project=project_id, @@ -1166,10 +1161,8 @@ def destroy(self, request, slug, project_id, cycle_id): class TransferCycleIssueEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id, cycle_id): new_cycle_id = request.data.get("new_cycle_id", False) @@ -1579,10 +1572,8 @@ def post(self, request, slug, project_id, cycle_id): class CycleUserPropertiesEndpoint(BaseAPIView): - permission_classes = [ - ProjectLitePermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def patch(self, request, slug, project_id, cycle_id): cycle_properties = CycleUserProperties.objects.get( user=request.user, @@ -1605,6 +1596,7 @@ def patch(self, request, slug, project_id, cycle_id): serializer = CycleUserPropertiesSerializer(cycle_properties) return Response(serializer.data, status=status.HTTP_201_CREATED) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def get(self, request, slug, project_id, cycle_id): cycle_properties, _ = CycleUserProperties.objects.get_or_create( user=request.user, diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index b45c2ecc654..e8e9230c025 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -3,12 +3,7 @@ # Django imports from django.core import serializers -from django.db.models import ( - F, - Func, - OuterRef, - Q, -) +from django.db.models import F, Func, OuterRef, Q from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -17,10 +12,6 @@ from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ( - ProjectEntityPermission, -) - # Module imports from .. import BaseViewSet from plane.app.serializers import ( @@ -45,6 +36,7 @@ GroupedOffsetPaginator, SubGroupedOffsetPaginator, ) +from plane.app.permissions import allow_permission, ROLE class CycleIssueViewSet(BaseViewSet): @@ -54,10 +46,6 @@ class CycleIssueViewSet(BaseViewSet): webhook_event = "cycle_issue" bulk = True - permission_classes = [ - ProjectEntityPermission, - ] - filterset_fields = [ "issue__labels__id", "issue__assignees__id", @@ -92,6 +80,7 @@ def get_queryset(self): ) @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def list(self, request, slug, project_id, cycle_id): order_by_param = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") @@ -238,6 +227,7 @@ def list(self, request, slug, project_id, cycle_id): ), ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) @@ -333,6 +323,7 @@ def create(self, request, slug, project_id, cycle_id): ) return Response({"message": "success"}, status=status.HTTP_201_CREATED) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.filter( issue_id=issue_id, diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index fe9eb50ecb8..5dc1cf1b6e7 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -43,6 +43,7 @@ ProjectMember, User, Widget, + WorkspaceMember, ) from plane.utils.issue_filters import issue_filters @@ -51,36 +52,61 @@ def dashboard_overview_stats(self, request, slug): - assigned_issues = Issue.issue_objects.filter( - project__project_projectmember__is_active=True, - project__project_projectmember__member=request.user, + extra_filters = {} + if WorkspaceMember.objects.filter( workspace__slug=slug, - assignees__in=[request.user], - ).count() + member=request.user, + role=5, + is_active=True, + ).exists(): + extra_filters = {"created_by": request.user} - pending_issues_count = Issue.issue_objects.filter( - ~Q(state__group__in=["completed", "cancelled"]), - target_date__lt=timezone.now().date(), - project__project_projectmember__is_active=True, - project__project_projectmember__member=request.user, - workspace__slug=slug, - assignees__in=[request.user], - ).count() + assigned_issues = ( + Issue.issue_objects.filter( + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ) + .filter(**extra_filters) + .count() + ) - created_issues_count = Issue.issue_objects.filter( - workspace__slug=slug, - project__project_projectmember__is_active=True, - project__project_projectmember__member=request.user, - created_by_id=request.user.id, - ).count() + pending_issues_count = ( + Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + target_date__lt=timezone.now().date(), + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + workspace__slug=slug, + assignees__in=[request.user], + ) + .filter(**extra_filters) + .count() + ) - completed_issues_count = Issue.issue_objects.filter( - workspace__slug=slug, - project__project_projectmember__is_active=True, - project__project_projectmember__member=request.user, - assignees__in=[request.user], - state__group="completed", - ).count() + created_issues_count = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + created_by_id=request.user.id, + ) + .filter(**extra_filters) + .count() + ) + + completed_issues_count = ( + Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__is_active=True, + project__project_projectmember__member=request.user, + assignees__in=[request.user], + state__group="completed", + ) + .filter(**extra_filters) + .count() + ) return Response( { @@ -166,6 +192,14 @@ def dashboard_assigned_issues(self, request, slug): ) ) + if WorkspaceMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=5, + is_active=True, + ).exists(): + assigned_issues = assigned_issues.filter(created_by=request.user) + # Priority Ordering priority_order = ["urgent", "high", "medium", "low", "none"] assigned_issues = assigned_issues.annotate( @@ -409,6 +443,16 @@ def dashboard_created_issues(self, request, slug): def dashboard_issues_by_state_groups(self, request, slug): filters = issue_filters(request.query_params, "GET") state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + extra_filters = {} + + if WorkspaceMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=5, + is_active=True, + ).exists(): + extra_filters = {"created_by": request.user} + issues_by_state_groups = ( Issue.issue_objects.filter( workspace__slug=slug, @@ -416,7 +460,7 @@ def dashboard_issues_by_state_groups(self, request, slug): project__project_projectmember__member=request.user, assignees__in=[request.user], ) - .filter(**filters) + .filter(**filters, **extra_filters) .values("state__group") .annotate(count=Count("id")) ) @@ -439,6 +483,15 @@ def dashboard_issues_by_state_groups(self, request, slug): def dashboard_issues_by_priority(self, request, slug): filters = issue_filters(request.query_params, "GET") priority_order = ["urgent", "high", "medium", "low", "none"] + extra_filters = {} + + if WorkspaceMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=5, + is_active=True, + ).exists(): + extra_filters = {"created_by": request.user} issues_by_priority = ( Issue.issue_objects.filter( @@ -447,7 +500,7 @@ def dashboard_issues_by_priority(self, request, slug): project__project_projectmember__member=request.user, assignees__in=[request.user], ) - .filter(**filters) + .filter(**filters, **extra_filters) .values("priority") .annotate(count=Count("id")) ) diff --git a/apiserver/plane/app/views/estimate/base.py b/apiserver/plane/app/views/estimate/base.py index d70d4b8693d..cd80c62999a 100644 --- a/apiserver/plane/app/views/estimate/base.py +++ b/apiserver/plane/app/views/estimate/base.py @@ -7,7 +7,11 @@ # Module imports from ..base import BaseViewSet, BaseAPIView -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import ( + ProjectEntityPermission, + allow_permission, + ROLE, +) from plane.db.models import Project, Estimate, EstimatePoint, Issue from plane.app.serializers import ( EstimateSerializer, @@ -23,10 +27,8 @@ def generate_random_name(length=10): class ProjectEstimatePointEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def get(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) if project.estimate_id is not None: @@ -189,10 +191,8 @@ def destroy(self, request, slug, project_id, estimate_id): class EstimatePointEndpoint(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id, estimate_id): # TODO: add a key validation if the same key already exists if not request.data.get("key") or not request.data.get("value"): @@ -211,6 +211,7 @@ def create(self, request, slug, project_id, estimate_id): serializer = EstimatePointSerializer(estimate_point).data return Response(serializer, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update( self, request, slug, project_id, estimate_id, estimate_point_id ): @@ -231,6 +232,7 @@ def partial_update( serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy( self, request, slug, project_id, estimate_id, estimate_point_id ): diff --git a/apiserver/plane/app/views/exporter/base.py b/apiserver/plane/app/views/exporter/base.py index 5649581dc8c..50f9870d097 100644 --- a/apiserver/plane/app/views/exporter/base.py +++ b/apiserver/plane/app/views/exporter/base.py @@ -2,7 +2,7 @@ from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import WorkSpaceAdminPermission +from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ExporterHistorySerializer from plane.bgtasks.export_task import issue_export_task from plane.db.models import ExporterHistory, Project, Workspace @@ -12,12 +12,10 @@ class ExportIssuesEndpoint(BaseAPIView): - permission_classes = [ - WorkSpaceAdminPermission, - ] model = ExporterHistory serializer_class = ExporterHistorySerializer + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def post(self, request, slug): # Get the workspace workspace = Workspace.objects.get(slug=slug) @@ -64,6 +62,7 @@ def post(self, request, slug): status=status.HTTP_400_BAD_REQUEST, ) + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def get(self, request, slug): exporter_history = ExporterHistory.objects.filter( workspace__slug=slug, diff --git a/apiserver/plane/app/views/external/base.py b/apiserver/plane/app/views/external/base.py index d9a66b85068..6ae3f37ba8e 100644 --- a/apiserver/plane/app/views/external/base.py +++ b/apiserver/plane/app/views/external/base.py @@ -11,7 +11,7 @@ # Module imports from ..base import BaseAPIView -from plane.app.permissions import ProjectEntityPermission, WorkspaceEntityPermission +from plane.app.permissions import allow_permission, ROLE from plane.db.models import Workspace, Project from plane.app.serializers import ( ProjectLiteSerializer, @@ -21,10 +21,8 @@ class GPTIntegrationEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( [ @@ -84,10 +82,10 @@ def post(self, request, slug, project_id): class WorkspaceGPTIntegrationEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def post(self, request, slug): OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( [ diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index b66dcbde23d..b99c44f1d94 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -16,7 +16,9 @@ # Module imports from ..base import BaseViewSet -from plane.app.permissions import ProjectBasePermission, ProjectLitePermission +from plane.app.permissions import ( + allow_permission, ROLE +) from plane.db.models import ( Inbox, InboxIssue, @@ -39,9 +41,6 @@ class InboxViewSet(BaseViewSet): - permission_classes = [ - ProjectBasePermission, - ] serializer_class = InboxSerializer model = Inbox @@ -63,6 +62,7 @@ def get_queryset(self): .select_related("workspace", "project") ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def list(self, request, slug, project_id): inbox = self.get_queryset().first() return Response( @@ -70,9 +70,11 @@ def list(self, request, slug, project_id): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def perform_create(self, serializer): serializer.save(project_id=self.kwargs.get("project_id")) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, pk): inbox = Inbox.objects.filter( workspace__slug=slug, project_id=project_id, pk=pk @@ -88,9 +90,6 @@ def destroy(self, request, slug, project_id, pk): class InboxIssueViewSet(BaseViewSet): - permission_classes = [ - ProjectLitePermission, - ] serializer_class = InboxIssueSerializer model = InboxIssue @@ -168,6 +167,7 @@ def get_queryset(self): ) ).distinct() + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def list(self, request, slug, project_id): inbox_id = Inbox.objects.filter( workspace__slug=slug, project_id=project_id @@ -201,6 +201,14 @@ def list(self, request, slug, project_id): if inbox_status: inbox_issue = inbox_issue.filter(status__in=inbox_status) + if ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists(): + inbox_issue = inbox_issue.filter(created_by=request.user) return self.paginate( request=request, queryset=(inbox_issue), @@ -210,6 +218,7 @@ def list(self, request, slug, project_id): ).data, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def create(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): return Response( @@ -312,12 +321,13 @@ def create(self, request, slug, project_id): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - def partial_update(self, request, slug, project_id, issue_id): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def partial_update(self, request, slug, project_id, pk): inbox_id = Inbox.objects.filter( workspace__slug=slug, project_id=project_id ).first() inbox_issue = InboxIssue.objects.get( - issue_id=issue_id, + issue_id=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id, @@ -458,7 +468,7 @@ def partial_update(self, request, slug, project_id, issue_id): request.data, cls=DjangoJSONEncoder ), actor_id=str(request.user.id), - issue_id=str(issue_id), + issue_id=str(pk), project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), @@ -493,7 +503,7 @@ def partial_update(self, request, slug, project_id, issue_id): ) .get( inbox_id=inbox_id.id, - issue_id=issue_id, + issue_id=pk, project_id=project_id, ) ) @@ -506,7 +516,12 @@ def partial_update(self, request, slug, project_id, issue_id): serializer = InboxIssueDetailSerializer(inbox_issue).data return Response(serializer, status=status.HTTP_200_OK) - def retrieve(self, request, slug, project_id, issue_id): + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], + creator=True, + model=Issue, + ) + def retrieve(self, request, slug, project_id, pk): inbox_id = Inbox.objects.filter( workspace__slug=slug, project_id=project_id ).first() @@ -534,9 +549,7 @@ def retrieve(self, request, slug, project_id, issue_id): Value([], output_field=ArrayField(UUIDField())), ), ) - .get( - inbox_id=inbox_id.id, issue_id=issue_id, project_id=project_id - ) + .get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id) ) issue = InboxIssueDetailSerializer(inbox_issue).data return Response( @@ -544,12 +557,13 @@ def retrieve(self, request, slug, project_id, issue_id): status=status.HTTP_200_OK, ) - def destroy(self, request, slug, project_id, issue_id): + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue) + def destroy(self, request, slug, project_id, pk): inbox_id = Inbox.objects.filter( workspace__slug=slug, project_id=project_id ).first() inbox_issue = InboxIssue.objects.get( - issue_id=issue_id, + issue_id=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id, @@ -559,21 +573,8 @@ def destroy(self, request, slug, project_id, issue_id): if inbox_issue.status in [-2, -1, 0, 2]: # Delete the issue also issue = Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk=issue_id + workspace__slug=slug, project_id=project_id, pk=pk ).first() - if issue.created_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the issue"}, - status=status.HTTP_403_FORBIDDEN, - ) issue.delete() inbox_issue.delete() diff --git a/apiserver/plane/app/views/issue/activity.py b/apiserver/plane/app/views/issue/activity.py index 6815b254ed7..0475cb9ec9b 100644 --- a/apiserver/plane/app/views/issue/activity.py +++ b/apiserver/plane/app/views/issue/activity.py @@ -19,7 +19,7 @@ IssueActivitySerializer, IssueCommentSerializer, ) -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import ProjectEntityPermission, allow_permission, ROLE from plane.db.models import ( IssueActivity, IssueComment, @@ -33,6 +33,7 @@ class IssueActivityEndpoint(BaseAPIView): ] @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER]) def get(self, request, slug, project_id, issue_id): filters = {} if request.GET.get("created_at__gt", None) is not None: diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 90258d32518..53dec689818 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -46,15 +46,13 @@ GroupedOffsetPaginator, SubGroupedOffsetPaginator, ) +from plane.app.permissions import allow_permission, ROLE # Module imports from .. import BaseViewSet, BaseAPIView class IssueArchiveViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] serializer_class = IssueFlatSerializer model = Issue @@ -98,6 +96,7 @@ def get_queryset(self): ) @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") @@ -213,6 +212,7 @@ def list(self, request, slug, project_id): ), ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def retrieve(self, request, slug, project_id, pk=None): issue = ( self.get_queryset() @@ -256,6 +256,7 @@ def retrieve(self, request, slug, project_id, pk=None): serializer = IssueDetailSerializer(issue, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def archive(self, request, slug, project_id, pk=None): issue = Issue.issue_objects.get( workspace__slug=slug, @@ -294,6 +295,7 @@ def archive(self, request, slug, project_id, pk=None): {"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def unarchive(self, request, slug, project_id, pk=None): issue = Issue.objects.get( workspace__slug=slug, @@ -325,6 +327,7 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): ProjectEntityPermission, ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): issue_ids = request.data.get("issue_ids", []) diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index c642294c865..1e955f18b2b 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -13,19 +13,16 @@ # Module imports from .. import BaseAPIView from plane.app.serializers import IssueAttachmentSerializer -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import IssueAttachment, ProjectMember +from plane.db.models import IssueAttachment from plane.bgtasks.issue_activities_task import issue_activity class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer - permission_classes = [ - ProjectEntityPermission, - ] model = IssueAttachment parser_classes = (MultiPartParser, FormParser) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def post(self, request, slug, project_id, issue_id): serializer = IssueAttachmentSerializer(data=request.data) if serializer.is_valid(): @@ -47,21 +44,9 @@ def post(self, request, slug, project_id, issue_id): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment) def delete(self, request, slug, project_id, issue_id, pk): issue_attachment = IssueAttachment.objects.get(pk=pk) - if issue_attachment.created_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the attachment"}, - status=status.HTTP_403_FORBIDDEN, - ) issue_attachment.asset.delete(save=False) issue_attachment.delete() issue_activity.delay( @@ -78,6 +63,7 @@ def delete(self, request, slug, project_id, issue_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER]) def get(self, request, slug, project_id, issue_id): issue_attachments = IssueAttachment.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index a1db50b443a..6a4f3ce2b74 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -25,10 +25,7 @@ from rest_framework.response import Response # Module imports -from plane.app.permissions import ( - ProjectEntityPermission, - ProjectLitePermission, -) +from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( IssueCreateSerializer, IssueDetailSerializer, @@ -60,14 +57,10 @@ from .. import BaseAPIView, BaseViewSet from plane.utils.user_timezone_converter import user_timezone_converter -# Module imports - class IssueListEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def get(self, request, slug, project_id): issue_ids = request.GET.get("issues", False) @@ -184,9 +177,6 @@ def get_serializer_class(self): model = Issue webhook_event = "issue" - permission_classes = [ - ProjectEntityPermission, - ] search_fields = [ "name", @@ -232,6 +222,7 @@ def get_queryset(self): ).distinct() @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") @@ -256,6 +247,15 @@ def list(self, request, slug, project_id): sub_group_by=sub_group_by, ) + if ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists(): + issue_queryset = issue_queryset.filter(created_by=request.user) + if group_by: if sub_group_by: if group_by == sub_group_by: @@ -337,6 +337,7 @@ def list(self, request, slug, project_id): ), ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -411,6 +412,9 @@ def create(self, request, slug, project_id): return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER], creator=True, model=Issue + ) def retrieve(self, request, slug, project_id, pk=None): issue = ( self.get_queryset() @@ -483,6 +487,7 @@ def retrieve(self, request, slug, project_id, pk=None): serializer = IssueDetailSerializer(issue, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk=None): issue = ( self.get_queryset() @@ -548,23 +553,11 @@ def partial_update(self, request, slug, project_id, pk=None): return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission([ROLE.ADMIN], creator=True, model=Issue) def destroy(self, request, slug, project_id, pk=None): issue = Issue.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) - if issue.created_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the issue"}, - status=status.HTTP_403_FORBIDDEN, - ) issue.delete() issue_activity.delay( @@ -582,10 +575,8 @@ def destroy(self, request, slug, project_id, pk=None): class IssueUserDisplayPropertyEndpoint(BaseAPIView): - permission_classes = [ - ProjectLitePermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER]) def patch(self, request, slug, project_id): issue_property = IssueUserProperty.objects.get( user=request.user, @@ -605,6 +596,7 @@ def patch(self, request, slug, project_id): serializer = IssueUserPropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_201_CREATED) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER]) def get(self, request, slug, project_id): issue_property, _ = IssueUserProperty.objects.get_or_create( user=request.user, project_id=project_id @@ -614,22 +606,9 @@ def get(self, request, slug, project_id): class BulkDeleteIssuesEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN]) def delete(self, request, slug, project_id): - if ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role__in=[15, 10, 5], - project_id=project_id, - is_active=True, - ).exists(): - return Response( - {"error": "Only admin can perform this action"}, - status=status.HTTP_403_FORBIDDEN, - ) issue_ids = request.data.get("issue_ids", []) diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index 8a8f3d6ada0..418ec2682a6 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -16,7 +16,7 @@ IssueCommentSerializer, CommentReactionSerializer, ) -from plane.app.permissions import ProjectLitePermission +from plane.app.permissions import ProjectLitePermission, allow_permission, ROLE from plane.db.models import ( IssueComment, ProjectMember, @@ -29,9 +29,6 @@ class IssueCommentViewSet(BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment webhook_event = "issue_comment" - permission_classes = [ - ProjectLitePermission, - ] filterset_fields = [ "issue__id", @@ -66,6 +63,7 @@ def get_queryset(self): .distinct() ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER]) def create(self, request, slug, project_id, issue_id): serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): @@ -90,6 +88,11 @@ def create(self, request, slug, project_id, issue_id): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], + creator=True, + model=IssueComment, + ) def partial_update(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( workspace__slug=slug, @@ -121,6 +124,9 @@ def partial_update(self, request, slug, project_id, issue_id, pk): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission( + allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment + ) def destroy(self, request, slug, project_id, issue_id, pk): issue_comment = IssueComment.objects.get( workspace__slug=slug, diff --git a/apiserver/plane/app/views/issue/label.py b/apiserver/plane/app/views/issue/label.py index c5dc35809e9..95ee2a8471b 100644 --- a/apiserver/plane/app/views/issue/label.py +++ b/apiserver/plane/app/views/issue/label.py @@ -11,9 +11,7 @@ # Module imports from .. import BaseViewSet, BaseAPIView from plane.app.serializers import LabelSerializer -from plane.app.permissions import ( - ProjectMemberPermission, -) +from plane.app.permissions import allow_permission, ProjectBasePermission, ROLE from plane.db.models import ( Project, Label, @@ -25,7 +23,7 @@ class LabelViewSet(BaseViewSet): serializer_class = LabelSerializer model = Label permission_classes = [ - ProjectMemberPermission, + ProjectBasePermission, ] def get_queryset(self): @@ -45,6 +43,7 @@ def get_queryset(self): @invalidate_cache( path="/api/workspaces/:slug/labels/", url_params=True, user=False ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): try: serializer = LabelSerializer(data=request.data) @@ -67,17 +66,20 @@ def create(self, request, slug, project_id): @invalidate_cache( path="/api/workspaces/:slug/labels/", url_params=True, user=False ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, *args, **kwargs): return super().partial_update(request, *args, **kwargs) @invalidate_cache( path="/api/workspaces/:slug/labels/", url_params=True, user=False ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) class BulkCreateIssueLabelsEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN]) def post(self, request, slug, project_id): label_data = request.data.get("label_data", []) project = Project.objects.get(pk=project_id) diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 330170f09e1..6f19de6c2a4 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -30,8 +30,10 @@ # Module imports from plane.app.permissions import ( ProjectEntityPermission, - ProjectLitePermission, + allow_permission, + ROLE, ) + from plane.app.serializers import ( ModuleDetailSerializer, ModuleLinkSerializer, @@ -48,7 +50,6 @@ ModuleLink, ModuleUserProperties, Project, - ProjectMember, ) from plane.utils.analytics_plot import burndown_plot from plane.utils.user_timezone_converter import user_timezone_converter @@ -58,9 +59,6 @@ class ModuleViewSet(BaseViewSet): model = Module - permission_classes = [ - ProjectEntityPermission, - ] webhook_event = "module" def get_serializer_class(self): @@ -318,6 +316,8 @@ def get_queryset(self): .order_by("-is_favorite", "-created_at") ) + allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) + def create(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) serializer = ModuleWriteSerializer( @@ -380,6 +380,8 @@ def create(self, request, slug, project_id): return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) + def list(self, request, slug, project_id): queryset = self.get_queryset().filter(archived_at__isnull=True) if self.fields: @@ -427,6 +429,8 @@ def list(self, request, slug, project_id): ) return Response(modules, status=status.HTTP_200_OK) + allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) + def retrieve(self, request, slug, project_id, pk): queryset = ( self.get_queryset() @@ -671,6 +675,7 @@ def retrieve(self, request, slug, project_id, pk): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): module = self.get_queryset().filter(pk=pk) @@ -740,25 +745,12 @@ def partial_update(self, request, slug, project_id, pk): return Response(module, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission([ROLE.ADMIN], creator=True, model=Module) def destroy(self, request, slug, project_id, pk): module = Module.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) - if module.created_by_id != request.user.id and ( - not ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or creator can delete the module"}, - status=status.HTTP_403_FORBIDDEN, - ) - module_issues = list( ModuleIssue.objects.filter(module_id=pk).values_list( "issue", flat=True @@ -859,10 +851,8 @@ def destroy(self, request, slug, project_id, module_id): class ModuleUserPropertiesEndpoint(BaseAPIView): - permission_classes = [ - ProjectLitePermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def patch(self, request, slug, project_id, module_id): module_properties = ModuleUserProperties.objects.get( user=request.user, @@ -885,6 +875,7 @@ def patch(self, request, slug, project_id, module_id): serializer = ModuleUserPropertiesSerializer(module_properties) return Response(serializer.data, status=status.HTTP_201_CREATED) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def get(self, request, slug, project_id, module_id): module_properties, _ = ModuleUserProperties.objects.get_or_create( user=request.user, diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index 8e70a5bd6d9..0f800431d86 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -17,9 +17,7 @@ from rest_framework import status from rest_framework.response import Response -from plane.app.permissions import ( - ProjectEntityPermission, -) +from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( ModuleIssueSerializer, ) @@ -58,10 +56,6 @@ class ModuleIssueViewSet(BaseViewSet): "issue__assignees__id", ] - permission_classes = [ - ProjectEntityPermission, - ] - def get_queryset(self): return ( Issue.issue_objects.filter( @@ -97,6 +91,7 @@ def get_queryset(self): ).distinct() @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def list(self, request, slug, project_id, module_id): filters = issue_filters(request.query_params, "GET") issue_queryset = self.get_queryset().filter(**filters) @@ -204,6 +199,7 @@ def list(self, request, slug, project_id, module_id): ), ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) # create multiple issues inside a module def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) @@ -245,6 +241,7 @@ def create_module_issues(self, request, slug, project_id, module_id): ] return Response({"message": "success"}, status=status.HTTP_201_CREATED) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) # add multiple module inside an issue and remove multiple modules from an issue def create_issue_modules(self, request, slug, project_id, issue_id): modules = request.data.get("modules", []) @@ -307,6 +304,7 @@ def create_issue_modules(self, request, slug, project_id, issue_id): return Response({"message": "success"}, status=status.HTTP_201_CREATED) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.filter( workspace__slug=slug, diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py index 6cae9d02a72..9d664c6c42e 100644 --- a/apiserver/plane/app/views/notification/base.py +++ b/apiserver/plane/app/views/notification/base.py @@ -19,6 +19,7 @@ WorkspaceMember, ) from plane.utils.paginator import BasePaginator +from plane.app.permissions import allow_permission, ROLE # Module imports from ..base import BaseAPIView, BaseViewSet @@ -39,6 +40,10 @@ def get_queryset(self): .select_related("workspace", "project," "triggered_by", "receiver") ) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], + level="WORKSPACE", + ) def list(self, request, slug): # Get query parameters snoozed = request.GET.get("snoozed", "false") @@ -168,6 +173,10 @@ def list(self, request, slug): serializer = NotificationSerializer(notifications, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], + level="WORKSPACE", + ) def partial_update(self, request, slug, pk): notification = Notification.objects.get( workspace__slug=slug, pk=pk, receiver=request.user @@ -185,6 +194,9 @@ def partial_update(self, request, slug, pk): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def mark_read(self, request, slug, pk): notification = Notification.objects.get( receiver=request.user, workspace__slug=slug, pk=pk @@ -194,6 +206,9 @@ def mark_read(self, request, slug, pk): serializer = NotificationSerializer(notification) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def mark_unread(self, request, slug, pk): notification = Notification.objects.get( receiver=request.user, workspace__slug=slug, pk=pk @@ -203,6 +218,9 @@ def mark_unread(self, request, slug, pk): serializer = NotificationSerializer(notification) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def archive(self, request, slug, pk): notification = Notification.objects.get( receiver=request.user, workspace__slug=slug, pk=pk @@ -212,6 +230,9 @@ def archive(self, request, slug, pk): serializer = NotificationSerializer(notification) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def unarchive(self, request, slug, pk): notification = Notification.objects.get( receiver=request.user, workspace__slug=slug, pk=pk @@ -223,6 +244,11 @@ def unarchive(self, request, slug, pk): class UnreadNotificationEndpoint(BaseAPIView): + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], + level="WORKSPACE", + ) def get(self, request, slug): # Watching Issues Count unread_notifications_count = ( @@ -260,6 +286,10 @@ def get(self, request, slug): class MarkAllReadNotificationViewSet(BaseViewSet): + + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def create(self, request, slug): snoozed = request.data.get("snoozed", False) archived = request.data.get("archived", False) @@ -343,6 +373,9 @@ class UserNotificationPreferenceEndpoint(BaseAPIView): serializer_class = UserNotificationPreferenceSerializer # request the object + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) def get(self, request): user_notification_preference = UserNotificationPreference.objects.get( user=request.user @@ -353,6 +386,9 @@ def get(self, request): return Response(serializer.data, status=status.HTTP_200_OK) # update the object + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) def patch(self, request): user_notification_preference = UserNotificationPreference.objects.get( user=request.user diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 44c2c21408f..cda44b227b9 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -19,7 +19,7 @@ from rest_framework.response import Response -from plane.app.permissions import ProjectEntityPermission +from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( PageLogSerializer, PageSerializer, @@ -60,9 +60,6 @@ def unarchive_archive_page_and_descendants(page_id, archived_at): class PageViewSet(BaseViewSet): serializer_class = PageSerializer model = Page - permission_classes = [ - ProjectEntityPermission, - ] search_fields = [ "name", ] @@ -122,6 +119,7 @@ def get_queryset(self): .distinct() ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): serializer = PageSerializer( data=request.data, @@ -143,6 +141,7 @@ def create(self, request, slug, project_id): 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]) def partial_update(self, request, slug, project_id, pk): try: page = Page.objects.get( @@ -208,6 +207,7 @@ def partial_update(self, request, slug, project_id, pk): status=status.HTTP_400_BAD_REQUEST, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def retrieve(self, request, slug, project_id, pk=None): page = self.get_queryset().filter(pk=pk).first() if page is None: @@ -226,6 +226,7 @@ def retrieve(self, request, slug, project_id, pk=None): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def lock(self, request, slug, project_id, pk): page = Page.objects.filter( pk=pk, workspace__slug=slug, projects__id=project_id @@ -235,6 +236,7 @@ def lock(self, request, slug, project_id, pk): page.save() return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def unlock(self, request, slug, project_id, pk): page = Page.objects.filter( pk=pk, workspace__slug=slug, projects__id=project_id @@ -245,6 +247,7 @@ def unlock(self, request, slug, project_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def access(self, request, slug, project_id, pk): access = request.data.get("access", 0) page = Page.objects.filter( @@ -267,11 +270,13 @@ def access(self, request, slug, project_id, pk): page.save() return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def list(self, request, slug, project_id): queryset = self.get_queryset() pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def archive(self, request, slug, project_id, pk): page = Page.objects.get( pk=pk, workspace__slug=slug, projects__id=project_id @@ -299,6 +304,7 @@ def archive(self, request, slug, project_id, pk): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def unarchive(self, request, slug, project_id, pk): page = Page.objects.get( pk=pk, workspace__slug=slug, projects__id=project_id @@ -328,6 +334,7 @@ def unarchive(self, request, slug, project_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN], creator=True, model=Page) def destroy(self, request, slug, project_id, pk): page = Page.objects.get( pk=pk, workspace__slug=slug, projects__id=project_id @@ -370,12 +377,10 @@ def destroy(self, request, slug, project_id, pk): class PageFavoriteViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] model = UserFavorite + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id, pk): _ = UserFavorite.objects.create( project_id=project_id, @@ -385,6 +390,7 @@ def create(self, request, slug, project_id, pk): ) return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, pk): page_favorite = UserFavorite.objects.get( project=project_id, @@ -398,9 +404,6 @@ def destroy(self, request, slug, project_id, pk): class PageLogEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] serializer_class = PageLogSerializer model = PageLog @@ -440,9 +443,6 @@ def delete(self, request, slug, project_id, page_id, transaction): class SubPagesEndpoint(BaseAPIView): - permission_classes = [ - ProjectEntityPermission, - ] @method_decorator(gzip_page) def get(self, request, slug, project_id, page_id): @@ -461,10 +461,8 @@ def get(self, request, slug, project_id, page_id): class PagesDescriptionViewSet(BaseViewSet): - permission_classes = [ - ProjectEntityPermission, - ] + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER]) def retrieve(self, request, slug, project_id, pk): page = ( Page.objects.filter( @@ -489,6 +487,7 @@ def stream_data(): ) return response + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): page = ( Page.objects.filter( diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 22181dacff9..2aec4b3bba2 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -31,8 +31,9 @@ ) from plane.app.permissions import ( - ProjectBasePermission, ProjectMemberPermission, + allow_permission, + ROLE, ) from plane.db.models import ( UserFavorite, @@ -47,6 +48,7 @@ ProjectMember, State, Workspace, + WorkspaceMember, ) from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity @@ -57,10 +59,6 @@ class ProjectViewSet(BaseViewSet): model = Project webhook_event = "project" - permission_classes = [ - ProjectBasePermission, - ] - def get_queryset(self): sort_order = ProjectMember.objects.filter( member=self.request.user, @@ -155,6 +153,10 @@ def get_queryset(self): .distinct() ) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], + level="WORKSPACE", + ) def list(self, request, slug): fields = [ field @@ -173,11 +175,27 @@ def list(self, request, slug): projects, many=True ).data, ) + + if WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role__in=[5, 10], + ).exists(): + projects = projects.filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + projects = ProjectListSerializer( projects, many=True, fields=fields if fields else None ).data return Response(projects, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], + level="WORKSPACE", + ) def retrieve(self, request, slug, pk): project = ( self.get_queryset() @@ -249,6 +267,7 @@ def retrieve(self, request, slug, pk): serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def create(self, request, slug): try: workspace = Workspace.objects.get(slug=slug) @@ -378,6 +397,7 @@ def create(self, request, slug): status=status.HTTP_410_GONE, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def partial_update(self, request, slug, pk=None): try: workspace = Workspace.objects.get(slug=slug) @@ -459,10 +479,7 @@ def partial_update(self, request, slug, pk=None): class ProjectArchiveUnarchiveEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = timezone.now() @@ -472,6 +489,7 @@ def post(self, request, slug, project_id): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def delete(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = None @@ -480,10 +498,7 @@ def delete(self, request, slug, project_id): class ProjectIdentifierEndpoint(BaseAPIView): - permission_classes = [ - ProjectBasePermission, - ] - + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): name = request.GET.get("name", "").strip().upper() @@ -502,6 +517,7 @@ def get(self, request, slug): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def delete(self, request, slug): name = request.data.get("name", "").strip().upper() diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index 8c6852480b7..460e35e2ec4 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -23,17 +23,16 @@ Workspace, TeamMember, IssueUserProperty, + WorkspaceMember, ) from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.utils.host import base_host +from plane.app.permissions.base import allow_permission, ROLE class ProjectMemberViewSet(BaseViewSet): serializer_class = ProjectMemberAdminSerializer model = ProjectMember - permission_classes = [ - ProjectMemberPermission, - ] def get_permissions(self): if self.action == "leave": @@ -65,6 +64,7 @@ def get_queryset(self): .select_related("workspace", "workspace__owner") ) + @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): # Get the list of members to be added to the project and their roles i.e. the user_id and the role members = request.data.get("members", []) @@ -88,6 +88,23 @@ def create(self, request, slug, project_id): member.get("member_id"): member.get("role") for member in members } + # check the workspace role of the new user + for member in member_roles: + workspace_member_role = WorkspaceMember.objects.get( + workspace__slug=slug, + member=member, + is_active=True, + ).role + if workspace_member_role in [5, 10] and member_roles.get( + member + ) in [15, 20]: + return Response( + { + "error": "You cannot add a user with role higher than the workspace role" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Update roles in the members array based on the member_roles dictionary and set is_active to True for project_member in ProjectMember.objects.filter( project_id=project_id, @@ -172,6 +189,7 @@ def create(self, request, slug, project_id): # Return the serialized data return Response(serializer.data, status=status.HTTP_201_CREATED) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def list(self, request, slug, project_id): # Get the list of project members for the project project_members = ProjectMember.objects.filter( @@ -186,6 +204,7 @@ def list(self, request, slug, project_id): ) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN]) def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( pk=pk, @@ -205,6 +224,22 @@ def partial_update(self, request, slug, project_id, pk): member=request.user, is_active=True, ) + + workspace_role = WorkspaceMember.objects.get( + workspace__slug=slug, + member=project_member.member, + is_active=True, + ).role + if workspace_role in [5, 10] and int( + request.data.get("role", project_member.role) + ) in [15, 20]: + return Response( + { + "error": "You cannot add a user with role higher than the workspace role" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if ( "role" in request.data and int(request.data.get("role", project_member.role)) @@ -226,6 +261,7 @@ def partial_update(self, request, slug, project_id, pk): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( workspace__slug=slug, @@ -262,6 +298,7 @@ def destroy(self, request, slug, project_id, pk): project_member.save() return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]) def leave(self, request, slug, project_id): project_member = ProjectMember.objects.get( workspace__slug=slug, diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py index 6c0535af23a..3bb65418217 100644 --- a/apiserver/plane/app/views/search/issue.py +++ b/apiserver/plane/app/views/search/issue.py @@ -9,9 +9,7 @@ # Module imports from .base import BaseAPIView -from plane.db.models import ( - Issue, -) +from plane.db.models import Issue, ProjectMember from plane.utils.issue_search import search_issues @@ -75,6 +73,16 @@ def get(self, request, slug, project_id): if target_date == "none": issues = issues.filter(target_date__isnull=True) + + if ProjectMember.objects.filter( + project_id=project_id, + member=self.request.user, + is_active=True, + role=5 + ).exists(): + issues = issues.filter( + created_by=self.request.user + ) return Response( issues.values( diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 7a913095131..96b4242a9c5 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -20,8 +20,8 @@ from rest_framework.response import Response from plane.app.permissions import ( - ProjectEntityPermission, - WorkspaceEntityPermission, + allow_permission, + ROLE, ) from plane.app.serializers import ( IssueViewSerializer, @@ -58,9 +58,6 @@ class WorkspaceViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer model = IssueView - permission_classes = [ - WorkspaceEntityPermission, - ] def perform_create(self, serializer): workspace = Workspace.objects.get(slug=self.kwargs.get("slug")) @@ -78,6 +75,32 @@ def get_queryset(self): .distinct() ) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], + level="WORKSPACE", + ) + def list(self, request, slug): + queryset = self.get_queryset() + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + if WorkspaceMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=5, + is_active=True, + ).exists(): + queryset = queryset.filter(owned_by=request.user) + views = IssueViewSerializer( + queryset, many=True, fields=fields if fields else None + ).data + return Response(views, status=status.HTTP_200_OK) + + @allow_permission( + allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView + ) def partial_update(self, request, slug, pk): with transaction.atomic(): workspace_view = IssueView.objects.select_for_update().get( @@ -111,6 +134,12 @@ def partial_update(self, request, slug, pk): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) + @allow_permission( + allowed_roles=[ROLE.ADMIN], + level="WORKSPACE", + creator=True, + model=IssueView, + ) def destroy(self, request, slug, pk): workspace_view = IssueView.objects.get( pk=pk, @@ -157,10 +186,6 @@ def destroy(self, request, slug, pk): class WorkspaceViewIssuesViewSet(BaseViewSet): - permission_classes = [ - WorkspaceEntityPermission, - ] - def get_queryset(self): return ( Issue.issue_objects.annotate( @@ -232,6 +257,10 @@ def get_queryset(self): ) @method_decorator(gzip_page) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST], + level="WORKSPACE", + ) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") @@ -242,6 +271,16 @@ def list(self, request, slug): .annotate(cycle_id=F("issue_cycle__cycle_id")) ) + if WorkspaceMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=5, + is_active=True, + ).exists(): + issue_queryset = issue_queryset.filter( + created_by=request.user, + ) + # Issue queryset issue_queryset, order_by_param = order_issue_queryset( issue_queryset=issue_queryset, @@ -348,9 +387,6 @@ def list(self, request, slug): class IssueViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer model = IssueView - permission_classes = [ - ProjectEntityPermission, - ] def perform_create(self, serializer): serializer.save( @@ -384,8 +420,20 @@ def get_queryset(self): .distinct() ) + allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST] + ) + def list(self, request, slug, project_id): queryset = self.get_queryset() + if ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists(): + queryset = queryset.filter(owned_by=request.user) fields = [ field for field in request.GET.get("fields", "").split(",") @@ -396,6 +444,8 @@ def list(self, request, slug, project_id): ).data return Response(views, status=status.HTTP_200_OK) + allow_permission(allowed_roles=[], creator=True, model=IssueView) + def partial_update(self, request, slug, project_id, pk): with transaction.atomic(): issue_view = IssueView.objects.select_for_update().get( @@ -428,6 +478,8 @@ def partial_update(self, request, slug, project_id, pk): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) + allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView) + def destroy(self, request, slug, project_id, pk): project_view = IssueView.objects.get( pk=pk, @@ -472,6 +524,8 @@ def get_queryset(self): .select_related("view") ) + allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def create(self, request, slug, project_id): _ = UserFavorite.objects.create( user=request.user, @@ -481,6 +535,8 @@ def create(self, request, slug, project_id): ) return Response(status=status.HTTP_204_NO_CONTENT) + allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def destroy(self, request, slug, project_id, view_id): view_favorite = UserFavorite.objects.get( project=project_id, diff --git a/apiserver/plane/app/views/webhook/base.py b/apiserver/plane/app/views/webhook/base.py index 9586722a0cc..5581b6aa306 100644 --- a/apiserver/plane/app/views/webhook/base.py +++ b/apiserver/plane/app/views/webhook/base.py @@ -9,15 +9,13 @@ from plane.db.models import Webhook, WebhookLog, Workspace from plane.db.models.webhook import generate_token from ..base import BaseAPIView -from plane.app.permissions import WorkspaceOwnerPermission +from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import WebhookSerializer, WebhookLogSerializer class WebhookEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceOwnerPermission, - ] + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) try: @@ -40,6 +38,7 @@ def post(self, request, slug): ) raise IntegrityError + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def get(self, request, slug, pk=None): if pk is None: webhooks = Webhook.objects.filter(workspace__slug=slug) @@ -79,6 +78,7 @@ def get(self, request, slug, pk=None): ) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def patch(self, request, slug, pk): webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) serializer = WebhookSerializer( @@ -104,6 +104,7 @@ def patch(self, request, slug, pk): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def delete(self, request, slug, pk): webhook = Webhook.objects.get(pk=pk, workspace__slug=slug) webhook.delete() @@ -111,10 +112,8 @@ def delete(self, request, slug, pk): class WebhookSecretRegenerateEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceOwnerPermission, - ] + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def post(self, request, slug, pk): webhook = Webhook.objects.get(workspace__slug=slug, pk=pk) webhook.secret_key = generate_token() @@ -124,10 +123,8 @@ def post(self, request, slug, pk): class WebhookLogsEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceOwnerPermission, - ] + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def get(self, request, slug, webhook_id): webhook_logs = WebhookLog.objects.filter( workspace__slug=slug, webhook_id=webhook_id diff --git a/apiserver/plane/app/views/workspace/favorite.py b/apiserver/plane/app/views/workspace/favorite.py index d4fe6a622fd..204dbfc3c2c 100644 --- a/apiserver/plane/app/views/workspace/favorite.py +++ b/apiserver/plane/app/views/workspace/favorite.py @@ -9,14 +9,14 @@ from plane.app.views.base import BaseAPIView from plane.db.models import UserFavorite, Workspace from plane.app.serializers import UserFavoriteSerializer -from plane.app.permissions import WorkspaceEntityPermission +from plane.app.permissions import allow_permission, ROLE class WorkspaceFavoriteEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def get(self, request, slug): # the second filter is to check if the user is a member of the project favorites = UserFavorite.objects.filter( @@ -34,6 +34,9 @@ def get(self, request, slug): serializer = UserFavoriteSerializer(favorites, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) serializer = UserFavoriteSerializer(data=request.data) @@ -46,6 +49,9 @@ def post(self, request, slug): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def patch(self, request, slug, favorite_id): favorite = UserFavorite.objects.get( user=request.user, workspace__slug=slug, pk=favorite_id @@ -58,6 +64,9 @@ def patch(self, request, slug, favorite_id): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def delete(self, request, slug, favorite_id): favorite = UserFavorite.objects.get( user=request.user, workspace__slug=slug, pk=favorite_id @@ -67,10 +76,10 @@ def delete(self, request, slug, favorite_id): class WorkspaceFavoriteGroupEndpoint(BaseAPIView): - permission_classes = [ - WorkspaceEntityPermission, - ] + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" + ) def get(self, request, slug, favorite_id): favorites = UserFavorite.objects.filter( user=request.user, diff --git a/apiserver/plane/app/views/workspace/label.py b/apiserver/plane/app/views/workspace/label.py index 328f3f8c132..0ea9ea24ab6 100644 --- a/apiserver/plane/app/views/workspace/label.py +++ b/apiserver/plane/app/views/workspace/label.py @@ -13,7 +13,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView): permission_classes = [ WorkspaceViewerPermission, ] - + @cache_response(60 * 60 * 2) def get(self, request, slug): labels = Label.objects.filter( diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index b3ac8980f85..4171e1f332d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -6,7 +6,10 @@ import { useParams, useSearchParams } from "next/navigation"; import { TPageNavigationTabs } from "@plane/types"; // components import { PageHead } from "@/components/core"; +import { EmptyState } from "@/components/empty-state"; import { PagesListRoot, PagesListView } from "@/components/pages"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; // hooks import { useProject } from "@/hooks/store"; @@ -16,7 +19,7 @@ const ProjectPagesPage = observer(() => { const type = searchParams.get("type"); const { workspaceSlug, projectId } = useParams(); // store hooks - const { getProjectById } = useProject(); + const { getProjectById, currentProjectDetails } = useProject(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Pages` : undefined; @@ -29,6 +32,17 @@ const ProjectPagesPage = observer(() => { }; if (!workspaceSlug || !projectId) return <>; + + // No access to cycle + if (currentProjectDetails?.page_view === false) + return ( +
+ +
+ ); return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx index 1676b25aa1c..bcf6258dd56 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/automations/page.tsx @@ -7,10 +7,9 @@ import { IProject } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; import { PageHead } from "@/components/core"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks import { useProject, useUser } from "@/hooks/store"; @@ -19,6 +18,7 @@ const AutomationSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); // store hooks const { + canPerformProjectAdminActions, membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails: projectDetails, updateProject } = useProject(); @@ -36,13 +36,16 @@ const AutomationSettingsPage = observer(() => { }; // derived values - const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined; + if (currentProjectRole && !canPerformProjectAdminActions) { + return ; + } + return ( <> -
+

Automations

diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx index f292bd6d995..84b01b86ab9 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/estimates/page.tsx @@ -3,30 +3,40 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EstimateRoot } from "@/components/estimates"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks import { useUser, useProject } from "@/hooks/store"; const EstimatesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); const { + canPerformProjectAdminActions, membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); // derived values - const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined; if (!workspaceSlug || !projectId) return <>; + + if (currentProjectRole && !canPerformProjectAdminActions) { + return ; + } + return ( <> -
- +
+
); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx index f09ae985b11..8aba7c88aea 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/features/page.tsx @@ -2,12 +2,10 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import useSWR from "swr"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectFeaturesList } from "@/components/project"; -// constants -import { EUserProjectRoles } from "@/constants/project"; // hooks import { useProject, useUser } from "@/hooks/store"; @@ -15,28 +13,27 @@ const FeaturesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); // store const { - membership: { fetchUserProjectInfo }, + canPerformProjectAdminActions, + membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); - // fetch the project details - const { data: memberDetails } = useSWR( - workspaceSlug && projectId ? `PROJECT_MEMBERS_ME_${workspaceSlug}_${projectId}` : null, - workspaceSlug && projectId ? () => fetchUserProjectInfo(workspaceSlug.toString(), projectId.toString()) : null - ); // derived values - const isAdmin = memberDetails?.role === EUserProjectRoles.ADMIN; const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined; if (!workspaceSlug || !projectId) return null; + if (currentProjectRole && !canPerformProjectAdminActions) { + return ; + } + return ( <> -
+
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx index be9e3781a3d..8cb142b751f 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx @@ -24,7 +24,7 @@ export const ProjectSettingHeader: FC = observer(() => { } = useUser(); const { currentProjectDetails, loader } = useProject(); - if (currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER) return null; + const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST; return (
@@ -70,14 +70,17 @@ export const ProjectSettingHeader: FC = observer(() => { placement="bottom-start" closeOnSelect > - {PROJECT_SETTINGS_LINKS.map((item) => ( - router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} - > - {item.label} - - ))} + {PROJECT_SETTINGS_LINKS.map( + (item) => + projectMemberInfo >= item.access && ( + router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)} + > + {item.label} + + ) + )}
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx index 192d7147f70..0e88b9c5d5e 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/labels/page.tsx @@ -5,13 +5,19 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectSettingsLabelList } from "@/components/labels"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useUser } from "@/hooks/store"; const LabelsSettingsPage = observer(() => { + // store hooks const { currentProjectDetails } = useProject(); + const { + canPerformProjectMemberActions, + membership: { currentProjectRole }, + } = useUser(); const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined; const scrollableContainerRef = useRef(null); @@ -29,6 +35,10 @@ const LabelsSettingsPage = observer(() => { ); }, [scrollableContainerRef?.current]); + if (currentProjectRole && !canPerformProjectMemberActions) { + return ; + } + return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx index 8553d86aa51..743c14d1030 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/layout.tsx @@ -1,18 +1,8 @@ "use client"; import { FC, ReactNode } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -// ui -import { Button, LayersIcon } from "@plane/ui"; // components -import { NotAuthorizedView } from "@/components/auth-screens"; import { AppHeader, ContentWrapper } from "@/components/core"; -// constants -import { EUserProjectRoles } from "@/constants/project"; -// hooks -import { useUser } from "@/hooks/store"; // local components import { ProjectSettingHeader } from "./header"; import { ProjectSettingsSidebar } from "./sidebar"; @@ -21,33 +11,8 @@ export interface IProjectSettingLayout { children: ReactNode; } -const ProjectSettingLayout: FC = observer((props) => { +const ProjectSettingLayout: FC = (props) => { const { children } = props; - // router - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); - - const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER; - - if (restrictViewSettings) { - return ( - - - - } - /> - ); - } - return ( <> } /> @@ -63,6 +28,6 @@ const ProjectSettingLayout: FC = observer((props) => { ); -}); +}; export default ProjectSettingLayout; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx index af1c82e12aa..28c65f680fd 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/members/page.tsx @@ -2,17 +2,26 @@ import { observer } from "mobx-react"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project"; // hooks -import { useProject } from "@/hooks/store"; +import { useProject, useUser } from "@/hooks/store"; const MembersSettingsPage = observer(() => { // store const { currentProjectDetails } = useProject(); + const { + canPerformProjectViewerActions, + membership: { currentProjectRole }, + } = useUser(); // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; + if (currentProjectRole && !canPerformProjectViewerActions) { + return ; + } + return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx index 0b9a94f82e0..3b60e152533 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; // ui @@ -12,7 +13,7 @@ import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; // hooks import { useUser } from "@/hooks/store"; -export const ProjectSettingsSidebar = () => { +export const ProjectSettingsSidebar = observer(() => { const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); // mobx store @@ -60,4 +61,4 @@ export const ProjectSettingsSidebar = () => { ); -}; +}); diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx index 4132662bf02..b03310a0917 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/states/page.tsx @@ -3,18 +3,27 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { ProjectStateRoot } from "@/components/project-states"; // hook -import { useProject } from "@/hooks/store"; +import { useProject, useUser } from "@/hooks/store"; const StatesSettingsPage = observer(() => { const { workspaceSlug, projectId } = useParams(); // store const { currentProjectDetails } = useProject(); + const { + canPerformProjectMemberActions, + membership: { currentProjectRole }, + } = useUser(); // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined; + if (currentProjectRole && !canPerformProjectMemberActions) { + return ; + } + return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx index 26d5fd873c1..27f84d94efd 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx @@ -12,21 +12,17 @@ import { BreadcrumbLink, Logo } from "@/components/common"; import { ViewListHeader } from "@/components/views"; import { ViewAppliedFiltersList } from "@/components/views/applied-filters"; // constants -import { EUserProjectRoles } from "@/constants/project"; import { EViewAccess } from "@/constants/views"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks -import { useCommandPalette, useProject, useProjectView, useUser } from "@/hooks/store"; +import { useCommandPalette, useProject, useProjectView } from "@/hooks/store"; export const ProjectViewsHeader = observer(() => { // router const { workspaceSlug } = useParams(); // store hooks const { toggleCreateViewModal } = useCommandPalette(); - const { - membership: { currentProjectRole }, - } = useUser(); const { currentProjectDetails, loader } = useProject(); const { filters, updateFilters, clearAllFilters } = useProjectView(); @@ -49,9 +45,6 @@ export const ProjectViewsHeader = observer(() => { const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0; - const canUserCreateView = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - return ( <>
@@ -83,13 +76,11 @@ export const ProjectViewsHeader = observer(() => {
- {canUserCreateView && ( -
- -
- )} +
+ +
{isFiltersApplied && ( diff --git a/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx index 906fee32845..bfc583b7859 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/api-tokens/page.tsx @@ -8,13 +8,13 @@ import useSWR from "swr"; import { Button } from "@plane/ui"; // component import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token"; +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; import { APITokenSettingsLoader } from "@/components/ui"; // constants import { EmptyStateType } from "@/constants/empty-state"; import { API_TOKENS_LIST } from "@/constants/fetch-keys"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; // store hooks import { useUser, useWorkspace } from "@/hooks/store"; // services @@ -29,27 +29,22 @@ const ApiTokensPage = observer(() => { const { workspaceSlug } = useParams(); // store hooks const { + canPerformWorkspaceAdminActions, membership: { currentWorkspaceRole }, } = useUser(); const { currentWorkspace } = useWorkspace(); - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; - - const { data: tokens } = useSWR(workspaceSlug && isAdmin ? API_TOKENS_LIST(workspaceSlug.toString()) : null, () => - workspaceSlug && isAdmin ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null + const { data: tokens } = useSWR( + workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null, + () => + workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null ); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - API Tokens` : undefined; - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); + if (currentWorkspaceRole && !canPerformWorkspaceAdminActions) { + return ; + } if (!tokens) { return ; @@ -92,4 +87,4 @@ const ApiTokensPage = observer(() => { ); }); -export default ApiTokensPage; \ No newline at end of file +export default ApiTokensPage; diff --git a/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx index 3e3c586c473..0158c3c98e1 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/billing/page.tsx @@ -2,9 +2,8 @@ import { observer } from "mobx-react"; // component +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; -// constants -import { EUserWorkspaceRoles } from "@/constants/workspace"; // hooks import { useUser, useWorkspace } from "@/hooks/store"; // plane web components @@ -13,22 +12,16 @@ import { BillingRoot } from "@/plane-web/components/workspace"; const BillingSettingsPage = observer(() => { // store hooks const { + canPerformWorkspaceAdminActions, membership: { currentWorkspaceRole }, } = useUser(); const { currentWorkspace } = useWorkspace(); // derived values - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined; - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); + if (currentWorkspaceRole && !canPerformWorkspaceAdminActions) { + return ; + } return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx index 59fd4d2c754..85b8cd64424 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/exports/page.tsx @@ -2,39 +2,39 @@ import { observer } from "mobx-react"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import ExportGuide from "@/components/exporter/guide"; -// constants -import { EUserWorkspaceRoles } from "@/constants/workspace"; +// helpers +import { cn } from "@/helpers/common.helper"; // hooks import { useUser, useWorkspace } from "@/hooks/store"; const ExportsPage = observer(() => { // store hooks const { + canPerformWorkspaceViewerActions, + canPerformWorkspaceMemberActions, membership: { currentWorkspaceRole }, } = useUser(); const { currentWorkspace } = useWorkspace(); // derived values - const hasPageAccess = - currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Exports` : undefined; - if (!hasPageAccess) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); + // if user is not authorized to view this page + if (currentWorkspaceRole && !canPerformWorkspaceViewerActions) { + return ; + } return ( <> -
+

Exports

@@ -44,4 +44,4 @@ const ExportsPage = observer(() => { ); }); -export default ExportsPage; \ No newline at end of file +export default ExportsPage; diff --git a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx index 52b2256398f..0d48bef73f3 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx @@ -9,12 +9,13 @@ import { IWorkspaceBulkInviteFormData } from "@plane/types"; // ui import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace"; // constants import { MEMBER_INVITED } from "@/constants/event-tracker"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers +import { cn } from "@/helpers/common.helper"; import { getUserRole } from "@/helpers/user.helper"; // hooks import { useEventTracker, useMember, useUser, useWorkspace } from "@/hooks/store"; @@ -28,6 +29,9 @@ const WorkspaceMembersSettingsPage = observer(() => { // store hooks const { captureEvent } = useEventTracker(); const { + canPerformWorkspaceAdminActions, + canPerformWorkspaceViewerActions, + canPerformWorkspaceMemberActions, membership: { currentWorkspaceRole }, } = useUser(); const { @@ -79,9 +83,13 @@ const WorkspaceMembersSettingsPage = observer(() => { }; // derived values - const isAdmin = currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN].includes(currentWorkspaceRole); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined; + // if user is not authorized to view this page + if (currentWorkspaceRole && !canPerformWorkspaceViewerActions) { + return ; + } + return ( <> @@ -90,7 +98,11 @@ const WorkspaceMembersSettingsPage = observer(() => { onClose={() => setInviteModal(false)} onSubmit={handleWorkspaceInvite} /> -
+

Members

@@ -103,13 +115,13 @@ const WorkspaceMembersSettingsPage = observer(() => { onChange={(e) => setSearchQuery(e.target.value)} />
- {isAdmin && ( + {canPerformWorkspaceAdminActions && ( )}
- +
); diff --git a/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx index 695f1f16b28..a887c414406 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/webhooks/page.tsx @@ -7,6 +7,7 @@ import useSWR from "swr"; // ui import { Button } from "@plane/ui"; // components +import { NotAuthorizedView } from "@/components/auth-screens"; import { PageHead } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; import { WebhookSettingsLoader } from "@/components/ui"; @@ -23,16 +24,15 @@ const WebhooksListPage = observer(() => { const { workspaceSlug } = useParams(); // mobx store const { + canPerformWorkspaceAdminActions, membership: { currentWorkspaceRole }, } = useUser(); const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook(); const { currentWorkspace } = useWorkspace(); - const isAdmin = currentWorkspaceRole === 20; - useSWR( - workspaceSlug && isAdmin ? `WEBHOOKS_LIST_${workspaceSlug}` : null, - workspaceSlug && isAdmin ? () => fetchWebhooks(workspaceSlug.toString()) : null + workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null, + workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null ); const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhooks` : undefined; @@ -42,15 +42,9 @@ const WebhooksListPage = observer(() => { if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey(); }, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]); - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); + if (currentWorkspaceRole && !canPerformWorkspaceAdminActions) { + return ; + } if (!webhooks) return ; @@ -95,4 +89,4 @@ const WebhooksListPage = observer(() => { ); }); -export default WebhooksListPage; \ No newline at end of file +export default WebhooksListPage; diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/sidebar.tsx index be09caa9b40..338a8488b10 100644 --- a/web/app/[workspaceSlug]/(projects)/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/sidebar.tsx @@ -13,7 +13,7 @@ import { import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; import { cn } from "@/helpers/common.helper"; // hooks -import { useAppTheme } from "@/hooks/store"; +import { useAppTheme, useUser } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; // plane web components import useSize from "@/hooks/use-window-size"; @@ -23,6 +23,7 @@ export interface IAppSidebar {} export const AppSidebar: FC = observer(() => { // store hooks + const { canPerformWorkspaceMemberActions } = useUser(); const { toggleSidebar, sidebarCollapsed } = useAppTheme(); const windowSize = useSize(); // refs @@ -85,7 +86,7 @@ export const AppSidebar: FC = observer(() => { "opacity-0": !sidebarCollapsed, })} /> - + {canPerformWorkspaceMemberActions && }
diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx index 686e4f7b607..82e11b20836 100644 --- a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -14,11 +14,10 @@ import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/com import { CreateUpdateWorkspaceViewModal } from "@/components/workspace"; // constants import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; // hooks -import { useLabel, useMember, useUser, useIssues, useGlobalView } from "@/hooks/store"; +import { useLabel, useMember, useIssues, useGlobalView } from "@/hooks/store"; export const GlobalIssuesHeader = observer(() => { // states @@ -30,9 +29,6 @@ export const GlobalIssuesHeader = observer(() => { issuesFilter: { filters, updateFilters }, } = useIssues(EIssuesStoreType.GLOBAL); const { getViewDetailsById } = useGlobalView(); - const { - membership: { currentWorkspaceRole }, - } = useUser(); const { workspaceLabels } = useLabel(); const { workspace: { workspaceMemberIds }, @@ -97,8 +93,6 @@ export const GlobalIssuesHeader = observer(() => { [workspaceSlug, updateFilters, globalViewId] ); - const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; - const isLocked = viewDetails?.is_locked; return ( @@ -142,11 +136,10 @@ export const GlobalIssuesHeader = observer(() => { )} - {isAuthorizedUser && ( - - )} + +
diff --git a/web/ce/constants/workspace.ts b/web/ce/constants/workspace.ts index b89ced416db..109a7a4d147 100644 --- a/web/ce/constants/workspace.ts +++ b/web/ce/constants/workspace.ts @@ -17,7 +17,7 @@ export const WORKSPACE_SETTINGS = { key: "members", label: "Members", href: `/settings/members`, - access: EUserWorkspaceRoles.GUEST, + access: EUserWorkspaceRoles.VIEWER, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, Icon: SettingIcon, }, @@ -33,7 +33,7 @@ export const WORKSPACE_SETTINGS = { key: "export", label: "Exports", href: `/settings/exports`, - access: EUserWorkspaceRoles.MEMBER, + access: EUserWorkspaceRoles.VIEWER, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`, Icon: SettingIcon, }, diff --git a/web/core/components/auth-screens/not-authorized-view.tsx b/web/core/components/auth-screens/not-authorized-view.tsx index f8f101dd30b..fe344f468f8 100644 --- a/web/core/components/auth-screens/not-authorized-view.tsx +++ b/web/core/components/auth-screens/not-authorized-view.tsx @@ -1,62 +1,33 @@ import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -// hooks -import { useUser } from "@/hooks/store"; // layouts import DefaultLayout from "@/layouts/default-layout"; // images import ProjectNotAuthorizedImg from "@/public/auth/project-not-authorized.svg"; +import Unauthorized from "@/public/auth/unauthorized.svg"; import WorkspaceNotAuthorizedImg from "@/public/auth/workspace-not-authorized.svg"; type Props = { actionButton?: React.ReactNode; - type: "project" | "workspace"; + section?: "settings" | "general"; + isProjectView?: boolean; }; export const NotAuthorizedView: React.FC = observer((props) => { - const { actionButton, type } = props; - // router - const searchParams = useSearchParams(); - const next_path = searchParams.get("next_path"); - // hooks - const { data: currentUser } = useUser(); + const { actionButton, section = "general", isProjectView = false } = props; + + // assets + const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg; + const asset = section === "settings" ? settingAsset : Unauthorized; return (
- ProjectSettingImg + ProjectSettingImg

Oops! You are not authorized to view this page

- -
- {currentUser ? ( -

- You have signed in as {currentUser.email}.
- - Sign in - {" "} - with different account that has access to this page. -

- ) : ( -

- You need to{" "} - - Sign in - {" "} - with an account that has access to this page. -

- )} -
- {actionButton}
diff --git a/web/core/components/command-palette/command-palette.tsx b/web/core/components/command-palette/command-palette.tsx index 7427fabee83..62f04914500 100644 --- a/web/core/components/command-palette/command-palette.tsx +++ b/web/core/components/command-palette/command-palette.tsx @@ -43,8 +43,8 @@ export const CommandPalette: FC = observer(() => { const { platform } = usePlatformOS(); const { data: currentUser, - canPerformProjectCreateActions, - canPerformWorkspaceCreateActions, + canPerformProjectMemberActions, + canPerformWorkspaceMemberActions, canPerformAnyCreateAction, canPerformProjectAdminActions, } = useUser(); @@ -103,15 +103,15 @@ export const CommandPalette: FC = observer(() => { // auth const performProjectCreateActions = useCallback( (showToast: boolean = true) => { - if (!canPerformProjectCreateActions && showToast) + if (!canPerformProjectMemberActions && showToast) setToast({ type: TOAST_TYPE.ERROR, title: "You don't have permission to perform this action.", }); - return canPerformProjectCreateActions; + return canPerformProjectMemberActions; }, - [canPerformProjectCreateActions] + [canPerformProjectMemberActions] ); const performProjectBulkDeleteActions = useCallback( @@ -129,14 +129,14 @@ export const CommandPalette: FC = observer(() => { const performWorkspaceCreateActions = useCallback( (showToast: boolean = true) => { - if (!canPerformWorkspaceCreateActions && showToast) + if (!canPerformWorkspaceMemberActions && showToast) setToast({ type: TOAST_TYPE.ERROR, title: "You don't have permission to perform this action.", }); - return canPerformWorkspaceCreateActions; + return canPerformWorkspaceMemberActions; }, - [canPerformWorkspaceCreateActions] + [canPerformWorkspaceMemberActions] ); const performAnyProjectCreateActions = useCallback( diff --git a/web/core/components/pages/list/block-item-action.tsx b/web/core/components/pages/list/block-item-action.tsx index 83f5e3941cc..a5b3b5866cd 100644 --- a/web/core/components/pages/list/block-item-action.tsx +++ b/web/core/components/pages/list/block-item-action.tsx @@ -7,10 +7,12 @@ import { Earth, Info, Lock, Minus } from "lucide-react"; import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components import { PageQuickActions } from "@/components/pages/dropdowns"; +// constants +import { EUserProjectRoles } from "@/constants/project"; // helpers import { renderFormattedDate } from "@/helpers/date-time.helper"; // hooks -import { useMember, usePage } from "@/hooks/store"; +import { useMember, usePage, useProject } from "@/hooks/store"; type Props = { workspaceSlug: string; @@ -25,10 +27,15 @@ export const BlockItemAction: FC = observer((props) => { // store hooks const page = usePage(pageId); const { getUserDetails } = useMember(); + const { getProjectById } = useProject(); + // derived values const { access, created_at, is_favorite, owned_by, addToFavorites, removePageFromFavorites } = page; // derived values + const project = getProjectById(projectId); + const isViewerOrGuest = + project?.member_role && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(project.member_role); const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined; // handlers @@ -74,14 +81,16 @@ export const BlockItemAction: FC = observer((props) => { {/* favorite/unfavorite */} - { - e.preventDefault(); - e.stopPropagation(); - handleFavorites(); - }} - selected={is_favorite} - /> + {!isViewerOrGuest && ( + { + e.preventDefault(); + e.stopPropagation(); + handleFavorites(); + }} + selected={is_favorite} + /> + )} {/* quick actions dropdown */} = observer((props) => { // form info const { formState: { errors, isSubmitting }, + watch, + setValue, reset, handleSubmit, control, @@ -167,6 +169,19 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { }[] | undefined; + const checkCurrentOptionWorkspaceRole = (value: string) => { + const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role; + if (!value || !currentMemberWorkspaceRole) return ROLE; + + const isGuestOrViewer = [EUserWorkspaceRoles.GUEST, EUserWorkspaceRoles.VIEWER].includes( + currentMemberWorkspaceRole + ); + + return Object.fromEntries( + Object.entries(ROLE).filter(([key]) => !isGuestOrViewer || [5, 10].includes(parseInt(key))) + ); + }; + return ( @@ -237,6 +252,14 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { } onChange={(val: string) => { onChange(val); + // Update the role to the workspace role when member ID changes + const workspaceMemberDetails = getWorkspaceMemberDetails(val); + const workspaceRole = workspaceMemberDetails?.role ?? 5; + const newValue = ROLE[workspaceRole].toUpperCase(); + setValue( + `members.${index}.role`, + EUserProjectRoles[newValue as keyof typeof EUserProjectRoles] + ); }} options={options} optionsClassName="w-full" @@ -271,7 +294,9 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { input optionsClassName="w-full" > - {Object.entries(ROLE).map(([key, label]) => { + {Object.entries( + checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`)) + ).map(([key, label]) => { if (parseInt(key) > (currentProjectRole ?? EUserProjectRoles.GUEST)) return null; return ( diff --git a/web/core/components/project/settings/member-columns.tsx b/web/core/components/project/settings/member-columns.tsx index 0c4184e0eae..51f510d306c 100644 --- a/web/core/components/project/settings/member-columns.tsx +++ b/web/core/components/project/settings/member-columns.tsx @@ -91,6 +91,7 @@ export const AccountTypeColumn: React.FC = observer((props) => // store hooks const { project: { updateMember }, + workspace: { getWorkspaceMemberDetails }, } = useMember(); const { data: currentUser } = useUser(); @@ -99,6 +100,19 @@ export const AccountTypeColumn: React.FC = observer((props) => const isAdminRole = currentProjectRole === EUserProjectRoles.ADMIN; const isRoleNonEditable = isCurrentUser || !isAdminRole; + const checkCurrentOptionWorkspaceRole = (value: string) => { + const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role; + if (!value || !currentMemberWorkspaceRole) return ROLE; + + const isGuestOrViewer = [EUserWorkspaceRoles.GUEST, EUserWorkspaceRoles.VIEWER].includes( + currentMemberWorkspaceRole + ); + + return Object.fromEntries( + Object.entries(ROLE).filter(([key]) => !isGuestOrViewer || [5, 10].includes(parseInt(key))) + ); + }; + return ( <> {isRoleNonEditable ? ( @@ -140,11 +154,14 @@ export const AccountTypeColumn: React.FC = observer((props) => optionsClassName="w-full" input > - {Object.keys(ROLE).map((item) => ( - - {ROLE[item as unknown as keyof typeof ROLE]} - - ))} + {Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => { + if (parseInt(key) > (currentProjectRole ?? EUserProjectRoles.GUEST)) return null; + return ( + + {label} + + ); + })} )} /> diff --git a/web/core/components/workspace/sidebar/projects-list-item.tsx b/web/core/components/workspace/sidebar/projects-list-item.tsx index a9fdb002a29..5feada1a8f4 100644 --- a/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -45,7 +45,7 @@ import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; // hooks -import { useAppTheme, useEventTracker, useProject } from "@/hooks/store"; +import { useAppTheme, useEventTracker, useProject, useUser } from "@/hooks/store"; import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; import { usePlatformOS } from "@/hooks/use-platform-os"; // constants @@ -70,31 +70,37 @@ const navigation = (workspaceSlug: string, projectId: string) => [ name: "Issues", href: `/${workspaceSlug}/projects/${projectId}/issues`, Icon: LayersIcon, + access: EUserProjectRoles.GUEST, }, { name: "Cycles", href: `/${workspaceSlug}/projects/${projectId}/cycles`, Icon: ContrastIcon, + access: EUserProjectRoles.VIEWER, }, { name: "Modules", href: `/${workspaceSlug}/projects/${projectId}/modules`, Icon: DiceIcon, + access: EUserProjectRoles.VIEWER, }, { name: "Views", href: `/${workspaceSlug}/projects/${projectId}/views`, Icon: Layers, + access: EUserProjectRoles.GUEST, }, { name: "Pages", href: `/${workspaceSlug}/projects/${projectId}/pages`, Icon: FileText, + access: EUserProjectRoles.VIEWER, }, { name: "Intake", href: `/${workspaceSlug}/projects/${projectId}/inbox`, Icon: Intake, + access: EUserProjectRoles.GUEST, }, ]; @@ -106,6 +112,9 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { const { setTrackElement } = useEventTracker(); const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); const { isMobile } = usePlatformOS(); + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); // states const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); const [publishModalOpen, setPublishModal] = useState(false); @@ -378,16 +387,20 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { customButtonClassName="grid place-items-center" placement="bottom-start" > - - - - {project.is_favorite ? "Remove from favorites" : "Add to favorites"} - - + {!isViewerOrGuest && ( + + + + {project.is_favorite ? "Remove from favorites" : "Add to favorites"} + + + )} {/* publish project settings */} {isAdmin && ( @@ -400,14 +413,16 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { )} - - -
- - Draft issues -
- -
+ {!isViewerOrGuest && ( + + +
+ + Draft issues +
+ +
+ )} @@ -482,31 +497,37 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { (item.name === "Intake" && !project.inbox_view) ) return; - + const currentRole = currentWorkspaceAllProjectsRole + ? currentWorkspaceAllProjectsRole[projectId] + : undefined; return ( - - - + {currentRole >= item.access && ( + -
- - {!isSidebarCollapsed && {item.name}} -
-
- -
+ + +
+ + {!isSidebarCollapsed && {item.name}} +
+
+ + + )} + ); })} diff --git a/web/core/components/workspace/sidebar/projects-list.tsx b/web/core/components/workspace/sidebar/projects-list.tsx index 568f9844396..c9943a54205 100644 --- a/web/core/components/workspace/sidebar/projects-list.tsx +++ b/web/core/components/workspace/sidebar/projects-list.tsx @@ -263,7 +263,6 @@ export const SidebarProjectsList: FC = observer(() => { toggleCreateProjectModal(true); }} > - {!isCollapsed && "Add project"} )} diff --git a/web/core/constants/dashboard.ts b/web/core/constants/dashboard.ts index a723a40ba6f..9775570721c 100644 --- a/web/core/constants/dashboard.ts +++ b/web/core/constants/dashboard.ts @@ -279,7 +279,7 @@ export const SIDEBAR_WORKSPACE_MENU_ITEMS: { key: "active-cycles", label: "Cycles", href: `/active-cycles`, - access: EUserWorkspaceRoles.GUEST, + access: EUserWorkspaceRoles.MEMBER, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/active-cycles/`, Icon: ContrastIcon, }, @@ -317,7 +317,7 @@ export const SIDEBAR_USER_MENU_ITEMS: { key: "your-work", label: "Your work", href: "/profile", - access: EUserWorkspaceRoles.GUEST, + access: EUserWorkspaceRoles.MEMBER, highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) => options?.userId ? pathname.includes(`${baseUrl}/profile/${options?.userId}`) : false, Icon: UserActivityIcon, diff --git a/web/core/constants/empty-state.ts b/web/core/constants/empty-state.ts index 87f74362d83..984f6f96d7f 100644 --- a/web/core/constants/empty-state.ts +++ b/web/core/constants/empty-state.ts @@ -490,7 +490,7 @@ const emptyStateDetails = { }, }, accessType: "project", - access: EUserProjectRoles.MEMBER, + access: EUserProjectRoles.GUEST, }, // project pages [EmptyStateType.PROJECT_PAGE]: { diff --git a/web/core/constants/project.ts b/web/core/constants/project.ts index 0737e77d0d3..2152a3407c0 100644 --- a/web/core/constants/project.ts +++ b/web/core/constants/project.ts @@ -79,7 +79,7 @@ export const PROJECT_SETTINGS_LINKS: { key: "general", label: "General", href: `/settings`, - access: EUserProjectRoles.MEMBER, + access: EUserProjectRoles.GUEST, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, Icon: SettingIcon, }, @@ -87,7 +87,7 @@ export const PROJECT_SETTINGS_LINKS: { key: "members", label: "Members", href: `/settings/members`, - access: EUserProjectRoles.MEMBER, + access: EUserProjectRoles.VIEWER, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, Icon: SettingIcon, }, diff --git a/web/core/hooks/use-favorite-item-details.tsx b/web/core/hooks/use-favorite-item-details.tsx index 8bf17b8a785..9513d3e5e7a 100644 --- a/web/core/hooks/use-favorite-item-details.tsx +++ b/web/core/hooks/use-favorite-item-details.tsx @@ -6,7 +6,7 @@ import { import { useProject, usePage, useProjectView, useCycle, useModule } from "@/hooks/store"; export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => { - const favoriteItemId = favorite.entity_data.id; + const favoriteItemId = favorite?.entity_data?.id; const favoriteItemLogoProps = favorite?.entity_data?.logo_props; const favoriteItemName = favorite?.entity_data.name || favorite?.name; const favoriteItemEntityType = favorite?.entity_type; diff --git a/web/core/layouts/auth-layout/workspace-wrapper.tsx b/web/core/layouts/auth-layout/workspace-wrapper.tsx index 37be07fcecc..361c983a7dd 100644 --- a/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -30,7 +30,7 @@ export const WorkspaceAuthWrapper: FC = observer((props) // next themes const { resolvedTheme } = useTheme(); // store hooks - const { membership, signOut, data: currentUser } = useUser(); + const { membership, signOut, data: currentUser, canPerformWorkspaceMemberActions } = useUser(); const { fetchProjects } = useProject(); const { fetchFavorite } = useFavorite(); const { @@ -72,8 +72,12 @@ export const WorkspaceAuthWrapper: FC = observer((props) ); // fetch workspace favorite useSWR( - workspaceSlug && currentWorkspace ? `WORKSPACE_FAVORITE_${workspaceSlug}` : null, - workspaceSlug && currentWorkspace ? () => fetchFavorite(workspaceSlug.toString()) : null, + workspaceSlug && currentWorkspace && canPerformWorkspaceMemberActions + ? `WORKSPACE_FAVORITE_${workspaceSlug}` + : null, + workspaceSlug && currentWorkspace && canPerformWorkspaceMemberActions + ? () => fetchFavorite(workspaceSlug.toString()) + : null, { revalidateIfStale: false, revalidateOnFocus: false } ); diff --git a/web/core/store/user/index.ts b/web/core/store/user/index.ts index d087811a76b..56eee659aa4 100644 --- a/web/core/store/user/index.ts +++ b/web/core/store/user/index.ts @@ -42,9 +42,18 @@ export interface IUserStore { reset: () => void; signOut: () => Promise; // computed - canPerformProjectCreateActions: boolean; + + // workspace level + canPerformWorkspaceAdminActions: boolean; + canPerformWorkspaceMemberActions: boolean; + canPerformWorkspaceViewerActions: boolean; + canPerformWorkspaceGuestActions: boolean; + + // project level canPerformProjectAdminActions: boolean; - canPerformWorkspaceCreateActions: boolean; + canPerformProjectMemberActions: boolean; + canPerformProjectViewerActions: boolean; + canPerformProjectGuestActions: boolean; canPerformAnyCreateAction: boolean; projectsWithCreatePermissions: { [projectId: string]: number } | null; } @@ -92,9 +101,16 @@ export class UserStore implements IUserStore { reset: action, signOut: action, // computed - canPerformProjectCreateActions: computed, + canPerformWorkspaceAdminActions: computed, + canPerformWorkspaceMemberActions: computed, + canPerformWorkspaceViewerActions: computed, + canPerformWorkspaceGuestActions: computed, + canPerformProjectAdminActions: computed, - canPerformWorkspaceCreateActions: computed, + canPerformProjectMemberActions: computed, + canPerformProjectViewerActions: computed, + canPerformProjectGuestActions: computed, + canPerformAnyCreateAction: computed, projectsWithCreatePermissions: computed, }); @@ -273,15 +289,40 @@ export class UserStore implements IUserStore { } /** - * @description tells if user has project create actions permissions + * @description returns true if user has workspace admin actions permissions * @returns {boolean} */ - get canPerformProjectCreateActions() { - return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.MEMBER; + get canPerformWorkspaceAdminActions() { + return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; + } + + /** + * @description returns true if user has workspace member actions permissions + * @returns {boolean} + */ + get canPerformWorkspaceMemberActions() { + return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; } /** - * @description tells if user has project admin actions permissions + * @description returns true if user has workspace viewer actions permissions + * @returns {boolean} + */ + + get canPerformWorkspaceViewerActions() { + return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.VIEWER; + } + + /** + * @description returns true if user has workspace guest actions permissions + * @returns {boolean} + */ + get canPerformWorkspaceGuestActions() { + return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.GUEST; + } + + /** + * @description returns true if user has project admin actions permissions * @returns {boolean} */ get canPerformProjectAdminActions() { @@ -289,10 +330,27 @@ export class UserStore implements IUserStore { } /** - * @description tells if user has workspace create actions permissions + * @description returns true if user has project member actions permissions * @returns {boolean} */ - get canPerformWorkspaceCreateActions() { - return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + get canPerformProjectMemberActions() { + return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.MEMBER; + } + + /** + * @description returns true if user has project viewer actions permissions + * @returns {boolean} + */ + + get canPerformProjectViewerActions() { + return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.VIEWER; + } + + /** + * @description returns true if user has project guest actions permissions + * @returns {boolean} + */ + get canPerformProjectGuestActions() { + return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.GUEST; } } diff --git a/web/public/auth/unauthorized.svg b/web/public/auth/unauthorized.svg new file mode 100644 index 00000000000..2bad48a570f --- /dev/null +++ b/web/public/auth/unauthorized.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error unauthorized + \ No newline at end of file