[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:
parent
cbe248591e
commit
4032aa62c5
9 changed files with 94 additions and 61 deletions
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue