[WEB-3551] fix: role improvements (#6763)

* Return Cycle start and end dates in project's timezone

* fix: role improvements

* chore: role updates

* chore: update role endpoint to update workspace admin permissions

* fix: conditions

* chore: update member role for workspace members

* chore: update workspace permission role

* fix: currentAdmin permissions

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Akshita Goyal 2025-03-24 12:52:57 +05:30 committed by GitHub
parent cbe248591e
commit 4032aa62c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 94 additions and 61 deletions

View file

@ -10,11 +10,7 @@ from plane.app.serializers import (
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 @@ class ProjectMemberViewSet(BaseViewSet):
)
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 @@ class ProjectMemberViewSet(BaseViewSet):
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 @@ class ProjectMemberViewSet(BaseViewSet):
"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"},

View file

@ -68,10 +68,11 @@ class WorkSpaceMemberViewSet(BaseViewSet):
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

View file

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

View file

@ -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 <NotAuthorizedView section="settings" isProjectView />;

View file

@ -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<IWorkspaceSettingLayout> = 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 (
<>
<AppHeader header={<WorkspaceSettingHeader />} />
<MobileWorkspaceSettingsTabs />
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
{workspaceUserInfo && !isWorkspaceAdmin ? (
{workspaceUserInfo && !isAuthorized ? (
<NotAuthorizedView section="settings" />
) : (
<>

View file

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

View file

@ -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<NameProps> = (props) => {
};
export const AccountTypeColumn: React.FC<AccountTypeProps> = 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<AccountTypeProps> = 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 ? (
<div className="w-32 flex ">
<span>{ROLE[rowData.role]}</span>
</div>
) : (
{isRoleEditable ? (
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value } }) => (
render={() => (
<CustomSelect
value={value}
value={rowData.role?.toString()}
onChange={(value: EUserPermissions) => {
if (!workspaceSlug) return;
@ -168,17 +176,18 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
optionsClassName="w-full"
input
>
{Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => {
if (parseInt(key) > (currentProjectRole ?? EUserPermissions.GUEST)) return null;
return (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
);
})}
{Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => (
<CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
) : (
<div className="w-32 flex ">
<span>{ROLE[rowData.role]}</span>
</div>
)}
</>
);

View file

@ -86,8 +86,8 @@ const SidebarDropdownItem = observer((props: TProps) => {
</div>
{workspace.id === activeWorkspace?.id && (
<>
{workspace?.role === EUserPermissions.ADMIN && (
<div className="mt-2 mb-1 flex gap-2">
<div className="mt-2 mb-1 flex gap-2">
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings`}
onClick={handleClose}
@ -96,6 +96,8 @@ const SidebarDropdownItem = observer((props: TProps) => {
<Settings className="h-4 w-4 my-auto" />
<span className="text-sm font-medium my-auto">{t("settings")}</span>
</Link>
)}
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings/members`}
onClick={handleClose}
@ -106,8 +108,8 @@ const SidebarDropdownItem = observer((props: TProps) => {
{t("project_settings.members.invite_members.title")}
</span>
</Link>
</div>
)}
)}
</div>
</>
)}
</Menu.Item>

View file

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