[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, ProjectMemberRoleSerializer,
) )
from plane.app.permissions import ( from plane.app.permissions import WorkspaceUserPermission
ProjectMemberPermission,
ProjectLitePermission,
WorkspaceUserPermission,
)
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.bgtasks.project_add_user_email_task import project_add_user_email
@ -26,14 +22,6 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer serializer_class = ProjectMemberAdminSerializer
model = ProjectMember 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"] search_fields = ["member__display_name", "member__first_name"]
def get_queryset(self): def get_queryset(self):
@ -187,12 +175,20 @@ class ProjectMemberViewSet(BaseViewSet):
) )
return Response(serializer.data, status=status.HTTP_200_OK) 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): def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get( project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, is_active=True 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( return Response(
{"error": "You cannot update your own role"}, {"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -205,9 +201,6 @@ class ProjectMemberViewSet(BaseViewSet):
is_active=True, 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( if workspace_role in [5] and int(
request.data.get("role", project_member.role) request.data.get("role", project_member.role)
) in [15, 20]: ) in [15, 20]:
@ -222,6 +215,7 @@ class ProjectMemberViewSet(BaseViewSet):
"role" in request.data "role" in request.data
and int(request.data.get("role", project_member.role)) and int(request.data.get("role", project_member.role))
> requested_project_member.role > requested_project_member.role
and not is_workspace_admin
): ):
return Response( return Response(
{"error": "You cannot update a role that is higher than your own role"}, {"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, status=status.HTTP_400_BAD_REQUEST,
) )
if workspace_member.role > int(request.data.get("role")): # If a user is moved to a guest role he can't have any other role in projects
_ = ProjectMember.objects.filter( if "role" in request.data and int(request.data.get("role")) == 5:
ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id workspace__slug=slug, member_id=workspace_member.member_id
).update(role=int(request.data.get("role"))) ).update(role=5)
serializer = WorkSpaceMemberSerializer( serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True workspace_member, data=request.data, partial=True

View file

@ -83,14 +83,14 @@ export const WORKSPACE_SETTINGS = {
key: "general", key: "general",
i18n_label: "workspace_settings.settings.general.title", i18n_label: "workspace_settings.settings.general.title",
href: `/settings`, href: `/settings`,
access: [EUserWorkspaceRoles.ADMIN], access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
}, },
members: { members: {
key: "members", key: "members",
i18n_label: "workspace_settings.settings.members.title", i18n_label: "workspace_settings.settings.members.title",
href: `/settings/members`, href: `/settings/members`,
access: [EUserWorkspaceRoles.ADMIN], access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
}, },
"billing-and-plans": { "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: { export const WORKSPACE_SETTINGS_LINKS: {
key: string; key: string;
i18n_label: string; i18n_label: string;

View file

@ -15,10 +15,12 @@ const MembersSettingsPage = observer(() => {
const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values // derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined; const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
const canPerformProjectMemberActions = allowPermissions( const isProjectMemberOrAdmin = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER], [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT EUserPermissionsLevel.PROJECT
); );
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
if (workspaceUserInfo && !canPerformProjectMemberActions) { if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />; return <NotAuthorizedView section="settings" isProjectView />;

View file

@ -3,7 +3,8 @@
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// components // 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 { NotAuthorizedView } from "@/components/auth-screens";
import { AppHeader } from "@/components/core"; import { AppHeader } from "@/components/core";
// hooks // hooks
@ -21,17 +22,26 @@ export interface IWorkspaceSettingLayout {
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => { const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props; const { children } = props;
const { workspaceUserInfo, allowPermissions } = useUserPermissions(); const { workspaceUserInfo } = useUserPermissions();
const pathname = usePathname();
const { workspaceSlug } = useParams();
// derived values // 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 ( return (
<> <>
<AppHeader header={<WorkspaceSettingHeader />} /> <AppHeader header={<WorkspaceSettingHeader />} />
<MobileWorkspaceSettingsTabs /> <MobileWorkspaceSettingsTabs />
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto"> <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" /> <NotAuthorizedView section="settings" />
) : ( ) : (
<> <>

View file

@ -18,7 +18,7 @@ export const PROJECT_SETTINGS = {
key: "members", key: "members",
i18n_label: "members", i18n_label: "members",
href: `/settings/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/`, highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
Icon: SettingIcon, Icon: SettingIcon,
}, },

View file

@ -13,7 +13,7 @@ import { CustomSelect, PopoverMenu, TOAST_TYPE, setToast } from "@plane/ui";
// helpers // helpers
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
import { useMember, useUser } from "@/hooks/store"; import { useMember, useUser, useUserPermissions } from "@/hooks/store";
export interface RowData { export interface RowData {
member: IWorkspaceMember; member: IWorkspaceMember;
@ -91,7 +91,7 @@ export const NameColumn: React.FC<NameProps> = (props) => {
}; };
export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) => { export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) => {
const { rowData, currentProjectRole, projectId, workspaceSlug } = props; const { rowData, projectId, workspaceSlug } = props;
// form info // form info
const { const {
control, control,
@ -99,48 +99,56 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
} = useForm(); } = useForm();
// store hooks // store hooks
const { const {
project: { updateMember, getProjectMemberDetails }, project: { updateMember },
workspace: { getWorkspaceMemberDetails }, workspace: { getWorkspaceMemberDetails },
} = useMember(); } = useMember();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { projectUserInfo } = useUserPermissions();
// derived values // derived values
const isCurrentUser = currentUser?.id === rowData.member.id; const isCurrentUser = currentUser?.id === rowData.member.id;
const isProjectAdminOrGuest = [EUserPermissions.ADMIN, EUserPermissions.GUEST].includes(rowData.role); const isRowDataWorkspaceAdmin = [EUserPermissions.ADMIN].includes(
const isWorkspaceMember = [EUserPermissions.MEMBER].includes(
Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST
); );
const isCurrentUserProjectMember = currentUser const isCurrentUserWorkspaceAdmin = currentUser
? getProjectMemberDetails(currentUser.id, projectId)?.role === EUserPermissions.MEMBER ? [EUserPermissions.ADMIN].includes(
Number(getWorkspaceMemberDetails(currentUser.id)?.role) ?? EUserPermissions.GUEST
)
: false; : false;
const isRoleNonEditable = const currentProjectRole = projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()]
isCurrentUser || (isProjectAdminOrGuest && !isWorkspaceMember) || isCurrentUserProjectMember; ?.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 checkCurrentOptionWorkspaceRole = (value: string) => {
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role as EUserPermissions | undefined; const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role as EUserPermissions | undefined;
if (!value || !currentMemberWorkspaceRole) return ROLE; if (!value || !currentMemberWorkspaceRole) return ROLE;
const isGuestOROwner = [EUserPermissions.ADMIN, EUserPermissions.GUEST].includes(currentMemberWorkspaceRole); const isGuest = [EUserPermissions.GUEST].includes(currentMemberWorkspaceRole);
return Object.fromEntries( return Object.fromEntries(
Object.entries(ROLE).filter(([key]) => !isGuestOROwner || [currentMemberWorkspaceRole].includes(parseInt(key))) Object.entries(ROLE).filter(([key]) => !isGuest || parseInt(key) === EUserPermissions.GUEST)
); );
}; };
return ( return (
<> <>
{isRoleNonEditable ? ( {isRoleEditable ? (
<div className="w-32 flex ">
<span>{ROLE[rowData.role]}</span>
</div>
) : (
<Controller <Controller
name="role" name="role"
control={control} control={control}
rules={{ required: "Role is required." }} rules={{ required: "Role is required." }}
render={({ field: { value } }) => ( render={() => (
<CustomSelect <CustomSelect
value={value} value={rowData.role?.toString()}
onChange={(value: EUserPermissions) => { onChange={(value: EUserPermissions) => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
@ -168,17 +176,18 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
optionsClassName="w-full" optionsClassName="w-full"
input input
> >
{Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => { {Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => (
if (parseInt(key) > (currentProjectRole ?? EUserPermissions.GUEST)) return null; <CustomSelect.Option key={key} value={key}>
return ( {label}
<CustomSelect.Option key={key} value={key}> </CustomSelect.Option>
{label} ))}
</CustomSelect.Option>
);
})}
</CustomSelect> </CustomSelect>
)} )}
/> />
) : (
<div className="w-32 flex ">
<span>{ROLE[rowData.role]}</span>
</div>
)} )}
</> </>
); );

View file

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

View file

@ -69,6 +69,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
userStore: IUserStore; userStore: IUserStore;
memberRoot: IMemberRootStore; memberRoot: IMemberRootStore;
projectRoot: IProjectStore; projectRoot: IProjectStore;
rootStore: CoreRootStore;
// services // services
projectMemberService; projectMemberService;
@ -86,6 +87,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
}); });
// root store // root store
this.rootStore = _rootStore;
this.routerStore = _rootStore.router; this.routerStore = _rootStore.router;
this.userStore = _rootStore.user; this.userStore = _rootStore.user;
this.memberRoot = _memberRoot; this.memberRoot = _memberRoot;
@ -199,10 +201,13 @@ export class ProjectMemberStore implements IProjectMemberStore {
const memberDetails = this.getProjectMemberDetails(userId, projectId); const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails) throw new Error("Member not found"); if (!memberDetails) throw new Error("Member not found");
// original data to revert back in case of error // 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 { try {
runInAction(() => { runInAction(() => {
set(this.projectMemberMap, [projectId, userId, "role"], data.role); 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( const response = await this.projectMemberService.updateProjectMember(
workspaceSlug, workspaceSlug,
@ -214,7 +219,13 @@ export class ProjectMemberStore implements IProjectMemberStore {
} catch (error) { } catch (error) {
// revert back to original members in case of error // revert back to original members in case of error
runInAction(() => { 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; throw error;
} }