diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index ea3c4eb3d6f..c828195d2d1 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -1,4 +1,5 @@ # Third party imports +import pytz from rest_framework import serializers # Module imports @@ -18,6 +19,14 @@ class CycleSerializer(BaseSerializer): completed_estimates = serializers.FloatField(read_only=True) started_estimates = serializers.FloatField(read_only=True) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + project = self.context.get("project") + if project and project.timezone: + project_timezone = pytz.timezone(project.timezone) + self.fields["start_date"].timezone = project_timezone + self.fields["end_date"].timezone = project_timezone + def validate(self, data): if ( data.get("start_date", None) is not None diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 3665e3b0f20..e0f44f9845c 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -137,10 +137,12 @@ def get_queryset(self): ) def get(self, request, slug, project_id, pk=None): + project = Project.objects.get(workspace__slug=slug, pk=project_id) if pk: queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk) data = CycleSerializer( - queryset, fields=self.fields, expand=self.expand + queryset, fields=self.fields, + expand=self.expand, context={"project": project} ).data return Response(data, status=status.HTTP_200_OK) queryset = self.get_queryset().filter(archived_at__isnull=True) @@ -152,7 +154,8 @@ def get(self, request, slug, project_id, pk=None): start_date__lte=timezone.now(), end_date__gte=timezone.now() ) data = CycleSerializer( - queryset, many=True, fields=self.fields, expand=self.expand + queryset, many=True, fields=self.fields, + expand=self.expand, context={"project": project} ).data return Response(data, status=status.HTTP_200_OK) @@ -163,7 +166,8 @@ def get(self, request, slug, project_id, pk=None): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, many=True, fields=self.fields, + expand=self.expand, context={"project": project} ).data, ) @@ -174,7 +178,8 @@ def get(self, request, slug, project_id, pk=None): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, many=True, fields=self.fields, + expand=self.expand, context={"project": project} ).data, ) @@ -185,7 +190,8 @@ def get(self, request, slug, project_id, pk=None): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, many=True, fields=self.fields, + expand=self.expand, context={"project": project} ).data, ) @@ -198,14 +204,16 @@ def get(self, request, slug, project_id, pk=None): request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, many=True, fields=self.fields, + expand=self.expand, context={"project": project} ).data, ) return self.paginate( request=request, queryset=(queryset), on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand + cycles, many=True, fields=self.fields, + expand=self.expand, context={"project": project} ).data, ) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 84a161619b5..45b6f94d877 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -268,7 +268,7 @@ def list(self, request, slug, project_id): ) datetime_fields = ["start_date", "end_date"] data = user_timezone_converter( - data, datetime_fields, request.user.user_timezone + data, datetime_fields, project_timezone ) return Response(data, status=status.HTTP_200_OK) @@ -318,9 +318,13 @@ def create(self, request, slug, project_id): .first() ) + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone + datetime_fields = ["start_date", "end_date"] cycle = user_timezone_converter( - cycle, datetime_fields, request.user.user_timezone + cycle, datetime_fields, project_timezone ) # Send the model activity @@ -407,9 +411,13 @@ def partial_update(self, request, slug, project_id, pk): "created_by", ).first() + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone + datetime_fields = ["start_date", "end_date"] cycle = user_timezone_converter( - cycle, datetime_fields, request.user.user_timezone + cycle, datetime_fields, project_timezone ) # Send the model activity @@ -480,10 +488,11 @@ def retrieve(self, request, slug, project_id, pk): ) queryset = queryset.first() + # Fetch the project timezone + project = Project.objects.get(id=self.kwargs.get("project_id")) + project_timezone = project.timezone datetime_fields = ["start_date", "end_date"] - data = user_timezone_converter( - data, datetime_fields, request.user.user_timezone - ) + data = user_timezone_converter(data, datetime_fields, project_timezone) recent_visited_task.delay( slug=slug, diff --git a/apiserver/plane/app/views/project/member.py b/apiserver/plane/app/views/project/member.py index 55d2d4a58b6..7b910509c41 100644 --- a/apiserver/plane/app/views/project/member.py +++ b/apiserver/plane/app/views/project/member.py @@ -10,11 +10,7 @@ ProjectMemberRoleSerializer, ) -from plane.app.permissions import ( - ProjectMemberPermission, - ProjectLitePermission, - WorkspaceUserPermission, -) +from plane.app.permissions import WorkspaceUserPermission from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember from plane.bgtasks.project_add_user_email_task import project_add_user_email @@ -26,14 +22,6 @@ class ProjectMemberViewSet(BaseViewSet): serializer_class = ProjectMemberAdminSerializer model = ProjectMember - def get_permissions(self): - if self.action == "leave": - self.permission_classes = [ProjectLitePermission] - else: - self.permission_classes = [ProjectMemberPermission] - - return super(ProjectMemberViewSet, self).get_permissions() - search_fields = ["member__display_name", "member__first_name"] def get_queryset(self): @@ -187,12 +175,20 @@ def list(self, request, slug, project_id): ) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission([ROLE.ADMIN]) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get( pk=pk, workspace__slug=slug, project_id=project_id, is_active=True ) - if request.user.id == project_member.member_id: + + # Fetch the workspace role of the project member + workspace_role = WorkspaceMember.objects.get( + workspace__slug=slug, member=project_member.member, is_active=True + ).role + is_workspace_admin = workspace_role == ROLE.ADMIN.value + + # Check if the user is not editing their own role if they are not an admin + if request.user.id == project_member.member_id and not is_workspace_admin: return Response( {"error": "You cannot update your own role"}, status=status.HTTP_400_BAD_REQUEST, @@ -205,9 +201,6 @@ def partial_update(self, request, slug, project_id, pk): is_active=True, ) - workspace_role = WorkspaceMember.objects.get( - workspace__slug=slug, member=project_member.member, is_active=True - ).role if workspace_role in [5] and int( request.data.get("role", project_member.role) ) in [15, 20]: @@ -222,6 +215,7 @@ def partial_update(self, request, slug, project_id, pk): "role" in request.data and int(request.data.get("role", project_member.role)) > requested_project_member.role + and not is_workspace_admin ): return Response( {"error": "You cannot update a role that is higher than your own role"}, diff --git a/apiserver/plane/app/views/workspace/member.py b/apiserver/plane/app/views/workspace/member.py index 9541f99803d..5dde2f78cac 100644 --- a/apiserver/plane/app/views/workspace/member.py +++ b/apiserver/plane/app/views/workspace/member.py @@ -68,10 +68,11 @@ def partial_update(self, request, slug, pk): status=status.HTTP_400_BAD_REQUEST, ) - if workspace_member.role > int(request.data.get("role")): - _ = ProjectMember.objects.filter( + # If a user is moved to a guest role he can't have any other role in projects + if "role" in request.data and int(request.data.get("role")) == 5: + ProjectMember.objects.filter( workspace__slug=slug, member_id=workspace_member.member_id - ).update(role=int(request.data.get("role"))) + ).update(role=5) serializer = WorkSpaceMemberSerializer( workspace_member, data=request.data, partial=True diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index cddc1a56ce7..6bfdbbcf896 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -83,14 +83,14 @@ export const WORKSPACE_SETTINGS = { key: "general", i18n_label: "workspace_settings.settings.general.title", href: `/settings`, - access: [EUserWorkspaceRoles.ADMIN], + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, }, members: { key: "members", i18n_label: "workspace_settings.settings.members.title", href: `/settings/members`, - access: [EUserWorkspaceRoles.ADMIN], + access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, }, "billing-and-plans": { @@ -123,6 +123,10 @@ export const WORKSPACE_SETTINGS = { }, }; +export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries( + Object.entries(WORKSPACE_SETTINGS).map(([_, { href, access }]) => [href, access]) +); + export const WORKSPACE_SETTINGS_LINKS: { key: string; i18n_label: string; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx index 565e187545c..9deaef126ad 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx @@ -15,10 +15,12 @@ const MembersSettingsPage = observer(() => { const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; - const canPerformProjectMemberActions = allowPermissions( + const isProjectMemberOrAdmin = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT ); + const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin; if (workspaceUserInfo && !canPerformProjectMemberActions) { return ; diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx index 6dfe44ed66c..24f81d39d01 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx @@ -3,7 +3,8 @@ import { FC, ReactNode } from "react"; import { observer } from "mobx-react"; // components -import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useParams, usePathname } from "next/navigation"; +import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants"; import { NotAuthorizedView } from "@/components/auth-screens"; import { AppHeader } from "@/components/core"; // hooks @@ -21,17 +22,26 @@ export interface IWorkspaceSettingLayout { const WorkspaceSettingLayout: FC = observer((props) => { const { children } = props; - const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const { workspaceUserInfo } = useUserPermissions(); + const pathname = usePathname(); + const { workspaceSlug } = useParams(); // derived values - const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role; + const isAuthorized = + pathname && + workspaceSlug && + userWorkspaceRole && + WORKSPACE_SETTINGS_ACCESS[pathname.replace(`/${workspaceSlug}`, "").slice(0, -1)]?.includes( + userWorkspaceRole as EUserWorkspaceRoles + ); return ( <> } />
- {workspaceUserInfo && !isWorkspaceAdmin ? ( + {workspaceUserInfo && !isAuthorized ? ( ) : ( <> diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts index 50bff11151e..15869c186cd 100644 --- a/web/ce/constants/project/settings/tabs.ts +++ b/web/ce/constants/project/settings/tabs.ts @@ -18,7 +18,7 @@ export const PROJECT_SETTINGS = { key: "members", i18n_label: "members", href: `/settings/members`, - access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, Icon: SettingIcon, }, diff --git a/web/core/components/project/settings/member-columns.tsx b/web/core/components/project/settings/member-columns.tsx index f3b6ec956c7..d9af617eae0 100644 --- a/web/core/components/project/settings/member-columns.tsx +++ b/web/core/components/project/settings/member-columns.tsx @@ -13,7 +13,7 @@ import { CustomSelect, PopoverMenu, TOAST_TYPE, setToast } from "@plane/ui"; // helpers import { getFileURL } from "@/helpers/file.helper"; // hooks -import { useMember, useUser } from "@/hooks/store"; +import { useMember, useUser, useUserPermissions } from "@/hooks/store"; export interface RowData { member: IWorkspaceMember; @@ -91,7 +91,7 @@ export const NameColumn: React.FC = (props) => { }; export const AccountTypeColumn: React.FC = observer((props) => { - const { rowData, currentProjectRole, projectId, workspaceSlug } = props; + const { rowData, projectId, workspaceSlug } = props; // form info const { control, @@ -99,48 +99,56 @@ export const AccountTypeColumn: React.FC = observer((props) => } = useForm(); // store hooks const { - project: { updateMember, getProjectMemberDetails }, + project: { updateMember }, workspace: { getWorkspaceMemberDetails }, } = useMember(); const { data: currentUser } = useUser(); + const { projectUserInfo } = useUserPermissions(); // derived values const isCurrentUser = currentUser?.id === rowData.member.id; - const isProjectAdminOrGuest = [EUserPermissions.ADMIN, EUserPermissions.GUEST].includes(rowData.role); - const isWorkspaceMember = [EUserPermissions.MEMBER].includes( + const isRowDataWorkspaceAdmin = [EUserPermissions.ADMIN].includes( Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST ); - const isCurrentUserProjectMember = currentUser - ? getProjectMemberDetails(currentUser.id, projectId)?.role === EUserPermissions.MEMBER + const isCurrentUserWorkspaceAdmin = currentUser + ? [EUserPermissions.ADMIN].includes( + Number(getWorkspaceMemberDetails(currentUser.id)?.role) ?? EUserPermissions.GUEST + ) : false; - const isRoleNonEditable = - isCurrentUser || (isProjectAdminOrGuest && !isWorkspaceMember) || isCurrentUserProjectMember; + const currentProjectRole = projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()] + ?.role as unknown as EUserPermissions; + const isCurrentUserProjectAdmin = currentProjectRole + ? ![EUserPermissions.MEMBER, EUserPermissions.GUEST].includes(Number(currentProjectRole) ?? EUserPermissions.GUEST) + : false; + + // logic + // Workspace admin can change his own role + // Project admin can change any role except his own and workspace admin's role + const isRoleEditable = + (isCurrentUserWorkspaceAdmin && isCurrentUser) || + (isCurrentUserProjectAdmin && !isRowDataWorkspaceAdmin && !isCurrentUser); const checkCurrentOptionWorkspaceRole = (value: string) => { const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role as EUserPermissions | undefined; if (!value || !currentMemberWorkspaceRole) return ROLE; - const isGuestOROwner = [EUserPermissions.ADMIN, EUserPermissions.GUEST].includes(currentMemberWorkspaceRole); + const isGuest = [EUserPermissions.GUEST].includes(currentMemberWorkspaceRole); return Object.fromEntries( - Object.entries(ROLE).filter(([key]) => !isGuestOROwner || [currentMemberWorkspaceRole].includes(parseInt(key))) + Object.entries(ROLE).filter(([key]) => !isGuest || parseInt(key) === EUserPermissions.GUEST) ); }; return ( <> - {isRoleNonEditable ? ( -
- {ROLE[rowData.role]} -
- ) : ( + {isRoleEditable ? ( ( + render={() => ( { if (!workspaceSlug) return; @@ -168,17 +176,18 @@ export const AccountTypeColumn: React.FC = observer((props) => optionsClassName="w-full" input > - {Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => { - if (parseInt(key) > (currentProjectRole ?? EUserPermissions.GUEST)) return null; - return ( - - {label} - - ); - })} + {Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => ( + + {label} + + ))} )} /> + ) : ( +
+ {ROLE[rowData.role]} +
)} ); diff --git a/web/core/components/workspace/sidebar/dropdown-item.tsx b/web/core/components/workspace/sidebar/dropdown-item.tsx index 2259f95d202..9eef151d15f 100644 --- a/web/core/components/workspace/sidebar/dropdown-item.tsx +++ b/web/core/components/workspace/sidebar/dropdown-item.tsx @@ -86,8 +86,8 @@ const SidebarDropdownItem = observer((props: TProps) => {
{workspace.id === activeWorkspace?.id && ( <> - {workspace?.role === EUserPermissions.ADMIN && ( -
+
+ {[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && ( { {t("settings")} + )} + {[EUserPermissions.ADMIN].includes(workspace?.role) && ( { {t("project_settings.members.invite_members.title")} -
- )} + )} +
)} diff --git a/web/core/store/member/project-member.store.ts b/web/core/store/member/project-member.store.ts index 5c67f61b1b7..e97e5ab320d 100644 --- a/web/core/store/member/project-member.store.ts +++ b/web/core/store/member/project-member.store.ts @@ -69,6 +69,7 @@ export class ProjectMemberStore implements IProjectMemberStore { userStore: IUserStore; memberRoot: IMemberRootStore; projectRoot: IProjectStore; + rootStore: CoreRootStore; // services projectMemberService; @@ -86,6 +87,7 @@ export class ProjectMemberStore implements IProjectMemberStore { }); // root store + this.rootStore = _rootStore; this.routerStore = _rootStore.router; this.userStore = _rootStore.user; this.memberRoot = _memberRoot; @@ -199,10 +201,13 @@ export class ProjectMemberStore implements IProjectMemberStore { const memberDetails = this.getProjectMemberDetails(userId, projectId); if (!memberDetails) throw new Error("Member not found"); // original data to revert back in case of error - const originalProjectMemberData = this.projectMemberMap?.[projectId]?.[userId]; + const originalProjectMemberData = this.projectMemberMap?.[projectId]?.[userId]?.role; + const isCurrentUser = this.rootStore.user.data?.id === userId; try { runInAction(() => { set(this.projectMemberMap, [projectId, userId, "role"], data.role); + if (isCurrentUser) + set(this.rootStore.user.permission.projectUserInfo, [workspaceSlug, projectId, "role"], data.role); }); const response = await this.projectMemberService.updateProjectMember( workspaceSlug, @@ -214,7 +219,13 @@ export class ProjectMemberStore implements IProjectMemberStore { } catch (error) { // revert back to original members in case of error runInAction(() => { - set(this.projectMemberMap, [projectId, userId], originalProjectMemberData); + set(this.projectMemberMap, [projectId, userId, "role"], originalProjectMemberData); + if (isCurrentUser) + set( + this.rootStore.user.permission.projectUserInfo, + [workspaceSlug, projectId, "role"], + originalProjectMemberData + ); }); throw error; }