[WEB-3964] refactor: permission layer (#7094)

* refactor: permission layer

* refactor: add original_role to project member serializer

* chore: minor fixes related to permission layer

* fix: strict type checking while checking user permissions
This commit is contained in:
Prateek Shourya
2025-05-30 19:57:07 +05:30
committed by GitHub
parent 322af8c436
commit 67cbe94d4a
64 changed files with 719 additions and 428 deletions

View File

@@ -149,10 +149,12 @@ class ProjectMemberAdminSerializer(BaseSerializer):
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
original_role = serializers.IntegerField(source='role', read_only=True)
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project", "created_at")
read_only_fields = ["created_at"]
fields = ("id", "role", "member", "project", "original_role", "created_at")
read_only_fields = ["original_role", "created_at"]
class ProjectMemberInviteSerializer(BaseSerializer):

View File

@@ -848,6 +848,7 @@
"live": "Živě",
"change_history": "Historie změn",
"coming_soon": "Již brzy",
"member": "Člen",
"members": "Členové",
"you": "Vy",
"upgrade_cta": {

View File

@@ -848,6 +848,7 @@
"live": "Live",
"change_history": "Änderungsverlauf",
"coming_soon": "Demnächst verfügbar",
"member": "Mitglied",
"members": "Mitglieder",
"you": "Sie",
"upgrade_cta": {

View File

@@ -690,6 +690,7 @@
"live": "Live",
"change_history": "Change History",
"coming_soon": "Coming soon",
"member": "Member",
"members": "Members",
"you": "You",
"upgrade_cta": {

View File

@@ -851,6 +851,7 @@
"live": "En vivo",
"change_history": "Historial de cambios",
"coming_soon": "Próximamente",
"member": "Miembro",
"members": "Miembros",
"you": "Tú",
"upgrade_cta": {

View File

@@ -849,6 +849,7 @@
"live": "En direct",
"change_history": "Historique des modifications",
"coming_soon": "À venir",
"member": "Membre",
"members": "Membres",
"you": "Vous",
"upgrade_cta": {

View File

@@ -848,6 +848,7 @@
"live": "Langsung",
"change_history": "Riwayat Perubahan",
"coming_soon": "Segera hadir",
"member": "Anggota",
"members": "Anggota",
"you": "Anda",
"upgrade_cta": {

View File

@@ -847,6 +847,7 @@
"live": "Live",
"change_history": "Cronologia modifiche",
"coming_soon": "Prossimamente",
"member": "Membro",
"members": "Membri",
"you": "Tu",
"upgrade_cta": {

View File

@@ -849,6 +849,7 @@
"live": "ライブ",
"change_history": "変更履歴",
"coming_soon": "近日公開",
"member": "メンバー",
"members": "メンバー",
"you": "あなた",
"upgrade_cta": {

View File

@@ -850,6 +850,7 @@
"live": "라이브",
"change_history": "변경 기록",
"coming_soon": "곧 출시",
"member": "멤버",
"members": "멤버",
"you": "나",
"upgrade_cta": {

View File

@@ -850,6 +850,7 @@
"live": "Na żywo",
"change_history": "Historia zmian",
"coming_soon": "Wkrótce",
"member": "Członek",
"members": "Członkowie",
"you": "Ty",
"upgrade_cta": {

View File

@@ -850,6 +850,7 @@
"live": "Ao vivo",
"change_history": "Histórico de alterações",
"coming_soon": "Em breve",
"member": "Membro",
"members": "Membros",
"you": "Você",
"upgrade_cta": {

View File

@@ -848,6 +848,7 @@
"live": "În direct",
"change_history": "Istoric modificări",
"coming_soon": "În curând",
"member": "Membru",
"members": "Membri",
"you": "Tu",
"upgrade_cta": {

View File

@@ -850,6 +850,7 @@
"live": "В прямом эфире",
"change_history": "История изменений",
"coming_soon": "Скоро",
"member": "Участник",
"members": "Участники",
"you": "Вы",
"upgrade_cta": {

View File

@@ -850,6 +850,7 @@
"live": "Živé",
"change_history": "História zmien",
"coming_soon": "Už čoskoro",
"member": "Člen",
"members": "Členovia",
"you": "Vy",
"upgrade_cta": {

View File

@@ -851,6 +851,7 @@
"live": "Canlı",
"change_history": "Değişiklik Geçmişi",
"coming_soon": "Çok Yakında",
"member": "Üye",
"members": "Üyeler",
"you": "Siz",
"upgrade_cta": {

View File

@@ -850,6 +850,7 @@
"live": "Наживо",
"change_history": "Історія змін",
"coming_soon": "Незабаром",
"member": "Учасник",
"members": "Учасники",
"you": "Ви",
"upgrade_cta": {

View File

@@ -849,6 +849,7 @@
"live": "Trực tiếp",
"change_history": "Lịch sử thay đổi",
"coming_soon": "Sắp ra mắt",
"member": "Thành viên",
"members": "Thành viên",
"you": "Bạn",
"upgrade_cta": {

View File

@@ -849,6 +849,7 @@
"live": "实时",
"change_history": "变更历史",
"coming_soon": "即将推出",
"member": "成员",
"members": "成员",
"you": "你",
"upgrade_cta": {

View File

@@ -850,6 +850,7 @@
"live": "即時",
"change_history": "變更歷史記錄",
"coming_soon": "即將推出",
"member": "成員",
"members": "成員",
"you": "您",
"upgrade_cta": {

View File

@@ -1,14 +1,5 @@
import { EUserProjectRoles } from "@plane/constants";
import type {
IProjectViewProps,
IUser,
IUserLite,
IUserMemberLite,
IWorkspace,
IWorkspaceLite,
TLogoProps,
TStateGroups,
} from "..";
import type { IUser, IUserLite, IWorkspace, TLogoProps, TStateGroups } from "..";
import { TUserPermissions } from "../enums";
export interface IPartialProject {
@@ -91,31 +82,20 @@ export interface IProjectMemberLite {
member_id: string;
}
export interface IProjectMember {
id: string;
member: IUserMemberLite;
project: IProjectLite;
workspace: IWorkspaceLite;
comment: string;
role: TUserPermissions;
preferences: ProjectPreferences;
view_props: IProjectViewProps;
default_props: IProjectViewProps;
created_at: Date;
updated_at: Date;
created_by: string;
updated_by: string;
}
export interface IProjectMembership {
id: string;
export type TProjectMembership = {
member: string;
role: TUserPermissions;
role: TUserPermissions | EUserProjectRoles;
created_at: string;
}
} & (
| {
id: string;
original_role: EUserProjectRoles;
}
| {
id: null;
original_role: null;
}
);
export interface IProjectBulkAddFormData {
members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[];

View File

@@ -1,4 +1,4 @@
import type { ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
import type { ICycle, TProjectMembership, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
import { EUserWorkspaceRoles } from "@plane/constants"; // TODO: check if importing this over here causes circular dependency
import { TUserPermissions } from "./enums";
@@ -93,7 +93,7 @@ export interface IWorkspaceMemberMe {
export interface ILastActiveWorkspaceDetails {
workspace_details: IWorkspace;
project_details?: IProjectMember[];
project_details?: TProjectMembership[];
}
export interface IWorkspaceDefaultSearchResult {

View File

@@ -8,6 +8,7 @@ export * from "./emoji";
export * from "./file";
export * from "./get-icon-for-link";
export * from "./issue";
export * from "./permission";
export * from "./state";
export * from "./string";
export * from "./subscription";

View File

@@ -0,0 +1,13 @@
import { EUserPermissions, EUserProjectRoles, EUserWorkspaceRoles } from "@plane/constants";
type TSupportedRole = EUserPermissions | EUserProjectRoles | EUserWorkspaceRoles;
/**
* @description Returns the highest role from an array of supported roles
* @param { TSupportedRole[] } roles
* @returns { TSupportedRole | undefined }
*/
export const getHighestRole = <T extends TSupportedRole>(roles: T[]): T | undefined => {
if (!roles || roles.length === 0) return undefined;
return roles.reduce((highest, current) => (current > highest ? current : highest));
};

View File

@@ -21,18 +21,17 @@ export interface IWorkspaceSettingLayout {
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
// store hooks
const { workspaceUserInfo } = useUserPermissions();
const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions();
// next hooks
const pathname = usePathname();
// derived values
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString());
const isAuthorized: boolean | string =
pathname &&
workspaceSlug &&
userWorkspaceRole &&
WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
let isAuthorized: boolean | string = false;
if (pathname && workspaceSlug && userWorkspaceRole) {
isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
}
return (
<>

View File

@@ -1,20 +1,32 @@
"use client";
import { observer } from "mobx-react";
// components
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
// hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
// plane web imports
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
const MembersSettingsPage = observer(() => {
// store
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const projectId = routerProjectId?.toString();
const workspaceSlug = routerWorkspaceSlug?.toString();
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
const isProjectMemberOrAdmin = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -31,8 +43,14 @@ const MembersSettingsPage = observer(() => {
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<section className={`w-full`}>
<ProjectSettingsMemberDefaults />
<ProjectMemberList />
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<div className="text-lg font-semibold">
{t(getProjectSettingsPageLabelI18nKey("members", "common.members"))}
</div>
</div>
<ProjectSettingsMemberDefaults projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectTeamspaceList projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectMemberList projectId={projectId} workspaceSlug={workspaceSlug} />
</section>
</SettingsContentWrapper>
);

View File

@@ -74,7 +74,6 @@ const OnboardingPage = observer(() => {
await finishUserOnboarding()
.then(() => {
captureEvent(USER_ONBOARDING_COMPLETED, {
// user_role: user.role,
email: user.email,
user_id: user.id,
status: "SUCCESS",

View File

@@ -1,34 +1,28 @@
import { useState } from "react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { IWorkspaceMember } from "@plane/types";
import { IWorkspaceMember, TProjectMembership } from "@plane/types";
// components
import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store";
export interface RowData {
export interface RowData extends Pick<TProjectMembership, "original_role"> {
member: IWorkspaceMember;
role: EUserPermissions;
}
export const useProjectColumns = () => {
type TUseProjectColumnsProps = {
projectId: string;
workspaceSlug: string;
};
export const useProjectColumns = (props: TUseProjectColumnsProps) => {
const { projectId, workspaceSlug } = props;
// states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
const { workspaceSlug, projectId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { allowPermissions, projectUserInfo } = useUserPermissions();
const currentProjectRole =
(projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()]?.role as unknown as EUserPermissions) ??
EUserPermissions.GUEST;
const getFormattedDate = (dateStr: string) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return date.toLocaleDateString("en-US", options);
};
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
// derived values
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
@@ -36,6 +30,15 @@ export const useProjectColumns = () => {
workspaceSlug.toString(),
projectId.toString()
);
const currentProjectRole =
getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug.toString(), projectId.toString()) ?? EUserPermissions.GUEST;
const getFormattedDate = (dateStr: string) => {
const date = new Date(dateStr);
const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" };
return date.toLocaleDateString("en-US", options);
};
const columns = [
{
@@ -76,5 +79,5 @@ export const useProjectColumns = () => {
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
},
];
return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal };
return { columns, removeMemberModal, setRemoveMemberModal };
};

View File

@@ -0,0 +1 @@
export * from "./teamspace-list";

View File

@@ -0,0 +1,6 @@
export type TProjectTeamspaceList = {
workspaceSlug: string;
projectId: string;
};
export const ProjectTeamspaceList: React.FC<TProjectTeamspaceList> = () => null;

View File

@@ -16,7 +16,7 @@ export const PROJECT_SETTINGS = {
},
members: {
key: "members",
i18n_label: "members",
i18n_label: "common.members",
href: `/members`,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/members/`,

View File

@@ -0,0 +1,7 @@
/**
* @description Get the i18n key for the project settings page label
* @param _settingsKey - The key of the project settings page
* @param defaultLabelKey - The default i18n key for the project settings page label
* @returns The i18n key for the project settings page label
*/
export const getProjectSettingsPageLabelI18nKey = (_settingsKey: string, defaultLabelKey: string) => defaultLabelKey;

View File

@@ -0,0 +1,43 @@
import { computedFn } from "mobx-utils";
// plane imports
import { EUserProjectRoles } from "@plane/constants";
// plane web imports
import { RootStore } from "@/plane-web/store/root.store";
// store
import { IMemberRootStore } from "@/store/member";
import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/base-project-member.store";
export type IProjectMemberStore = IBaseProjectMemberStore;
export class ProjectMemberStore extends BaseProjectMemberStore implements IProjectMemberStore {
constructor(_memberRoot: IMemberRootStore, rootStore: RootStore) {
super(_memberRoot, rootStore);
}
/**
* @description Returns the highest role from the project membership
* @param { string } userId
* @param { string } projectId
* @returns { EUserProjectRoles | undefined }
*/
getUserProjectRole = computedFn((userId: string, projectId: string): EUserProjectRoles | undefined =>
this.getRoleFromProjectMembership(userId, projectId)
);
/**
* @description Returns the role from the project membership
* @param projectId
* @param userId
* @param role
*/
getProjectMemberRoleForUpdate = (_projectId: string, _userId: string, role: EUserProjectRoles): EUserProjectRoles =>
role;
/**
* @description Processes the removal of a member from a project
* This method handles the cleanup of member data from the project member map
* @param projectId - The ID of the project to remove the member from
* @param userId - The ID of the user to remove from the project
*/
processMemberRemoval = (projectId: string, userId: string) => this.handleMemberRemoval(projectId, userId);
}

View File

@@ -0,0 +1,23 @@
import { computedFn } from "mobx-utils";
import { EUserPermissions } from "@plane/constants";
import { RootStore } from "@/plane-web/store/root.store";
import { BaseUserPermissionStore, IBaseUserPermissionStore } from "@/store/user/base-permissions.store";
export type IUserPermissionStore = IBaseUserPermissionStore;
export class UserPermissionStore extends BaseUserPermissionStore implements IUserPermissionStore {
constructor(store: RootStore) {
super(store);
}
/**
* @description Returns the project role from the workspace
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { EUserPermissions | undefined }
*/
getProjectRoleByWorkspaceSlugAndProjectId = computedFn(
(workspaceSlug: string, projectId: string): EUserPermissions | undefined =>
this.getProjectRole(workspaceSlug, projectId)
);
}

View File

@@ -2,23 +2,28 @@
import { useState } from "react";
import Image from "next/image";
import { useParams } from "next/navigation";
// hooks
import { ClipboardList } from "lucide-react";
// plane imports
import { Button } from "@plane/ui";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
// ui
// icons
// images
import JoinProjectImg from "@/public/auth/project-not-authorized.svg";
// assets
import Unauthorized from "@/public/auth/unauthorized.svg";
export const JoinProject: React.FC = () => {
type Props = {
projectId?: string;
isPrivateProject?: boolean;
};
export const JoinProject: React.FC<Props> = (props) => {
const { projectId, isPrivateProject = false } = props;
// states
const [isJoiningProject, setIsJoiningProject] = useState(false);
// store hooks
const { joinProject } = useUserPermissions();
const { fetchProjectDetails } = useProject();
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug } = useParams();
const handleJoin = () => {
if (!workspaceSlug || !projectId) return;
@@ -33,25 +38,31 @@ export const JoinProject: React.FC = () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
<div className="h-44 w-72">
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
<Image src={Unauthorized} height="176" width="288" alt="JoinProject" />
</div>
<h1 className="text-xl font-medium text-custom-text-100">You are not a member of this project</h1>
<h1 className="text-xl font-medium text-custom-text-100">
{!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`}
</h1>
<div className="w-full max-w-md text-base text-custom-text-200">
<p className="mx-auto w-full text-sm md:w-3/4">
You are not a member of this project, but you can join this project by clicking the button below.
{!isPrivateProject
? `Click the button below to join it.`
: `This is a private project. \n We can't tell you more about this project to protect confidentiality.`}
</p>
</div>
<div>
<Button
variant="primary"
prependIcon={<ClipboardList color="white" />}
loading={isJoiningProject}
onClick={handleJoin}
>
{isJoiningProject ? "Joining..." : "Click to join"}
</Button>
</div>
{!isPrivateProject && (
<div>
<Button
variant="primary"
prependIcon={<ClipboardList color="white" />}
loading={isJoiningProject}
onClick={handleJoin}
>
{isJoiningProject ? "Taking you in" : "Click to join"}
</Button>
</div>
)}
</div>
);
};

View File

@@ -38,7 +38,7 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
const { data: currentUser } = useUser();
const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox();
const inboxIssue = getIssueInboxByIssueId(inboxIssueId);
const { allowPermissions, projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions();
const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
// derived values
const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || "");
@@ -67,7 +67,7 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
inboxIssue?.issue.created_by === currentUser?.id;
const isGuest = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
const isGuest = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
const readOnly = !isOwner && isGuest;

View File

@@ -49,14 +49,14 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
issue: { getIssueById },
} = useIssueDetail();
const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectById } = useProject();
const { data: currentUser } = useUser();
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
const currentUserProjectRole = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isAdmin = (currentUserProjectRole ?? EUserPermissions.GUEST) === EUserPermissions.ADMIN;
const isGuest = (currentUserProjectRole ?? EUserPermissions.GUEST) === EUserPermissions.GUEST;
const currentUserProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isAdmin = currentUserProjectRole === EUserPermissions.ADMIN;
const isGuest = currentUserProjectRole === EUserPermissions.GUEST;
const isAssigned = issue?.assignee_ids && currentUser?.id ? issue?.assignee_ids.includes(currentUser?.id) : false;
const isWorklogButtonEnabled = !isIntakeIssue && !isGuest && (isAdmin || isAssigned);
// toggle filter

View File

@@ -40,7 +40,6 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
alt={t("project_cover_image_alt")}
/>
)}
<div className="absolute left-2.5 top-2.5">
<ProjectTemplateSelect handleModalClose={handleClose} />
</div>

View File

@@ -25,7 +25,7 @@ const ProjectCreateButtons: React.FC<Props> = (props) => {
return (
<div className="flex justify-end gap-2 py-4 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("cancel")}
{t("common.cancel")}
</Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={getIndex("submit")}>
{isSubmitting ? t("creating") : t("create_project")}

View File

@@ -1,27 +1,27 @@
"use client";
import { observer } from "mobx-react";
// plane imports
import { PROJECT_MEMBER_LEAVE } from "@plane/constants";
import { TOAST_TYPE, Table, setToast } from "@plane/ui";
// components
import { ConfirmProjectMemberRemove } from "@/components/project";
// constants
// hooks
import { useEventTracker, useMember, useUser, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { useProjectColumns } from "@/plane-web/components/projects/settings/useProjectColumns";
import { IProjectMemberDetails } from "@/store/member/project-member.store";
// store
import { IProjectMemberDetails } from "@/store/member/base-project-member.store";
type Props = {
memberDetails: (IProjectMemberDetails | null)[];
projectId: string;
workspaceSlug: string;
};
export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
const { memberDetails } = props;
const { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal } = useProjectColumns();
const { memberDetails, projectId, workspaceSlug } = props;
// router
const router = useAppRouter();
// store hooks
@@ -31,7 +31,11 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
project: { removeMemberFromProject },
} = useMember();
const { captureEvent } = useEventTracker();
// const { isMobile } = usePlatformOS();
// helper hooks
const { columns, removeMemberModal, setRemoveMemberModal } = useProjectColumns({
projectId,
workspaceSlug,
});
const handleRemove = async (memberId: string) => {
if (!workspaceSlug || !projectId || !memberId) return;

View File

@@ -2,22 +2,26 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search } from "lucide-react";
// hooks
// components
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// components
import { ProjectMemberListItem, SendProjectInvitationModal } from "@/components/project";
// ui
import { MembersSettingsLoader } from "@/components/ui";
// hooks
import { useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
import { SettingsHeading } from "../settings";
export const ProjectMemberList: React.FC = observer(() => {
// router
const { projectId } = useParams();
type TProjectMemberListProps = {
projectId: string;
workspaceSlug: string;
};
export const ProjectMemberList: React.FC<TProjectMemberListProps> = observer((props) => {
const { projectId, workspaceSlug } = props;
// states
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -30,17 +34,17 @@ export const ProjectMemberList: React.FC = observer(() => {
const { t } = useTranslation();
const searchedMembers = (projectMemberIds ?? []).filter((userId) => {
const searchedProjectMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return false;
if (!memberDetails?.member || !memberDetails.original_role) return false;
const fullName = `${memberDetails?.member.first_name} ${memberDetails?.member.last_name}`.toLowerCase();
const displayName = memberDetails?.member.display_name.toLowerCase();
return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase());
});
const memberDetails = searchedMembers?.map((memberId) =>
const memberDetails = searchedProjectMembers?.map((memberId) =>
projectId ? getProjectMemberDetails(memberId, projectId.toString()) : null
);
@@ -48,9 +52,14 @@ export const ProjectMemberList: React.FC = observer(() => {
return (
<>
<SendProjectInvitationModal isOpen={inviteModal} onClose={() => setInviteModal(false)} />
<SendProjectInvitationModal
isOpen={inviteModal}
onClose={() => setInviteModal(false)}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<SettingsHeading
title={t("members")}
title={t(getProjectSettingsPageLabelI18nKey("members", "common.members"))}
appendToRight={
<div className="flex gap-2">
<div className="ml-auto flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
@@ -82,9 +91,14 @@ export const ProjectMemberList: React.FC = observer(() => {
<MembersSettingsLoader />
) : (
<div className="divide-y divide-custom-border-100 overflow-scroll">
{searchedMembers.length !== 0 && <ProjectMemberListItem memberDetails={memberDetails ?? []} />}
{searchedMembers.length === 0 && (
{searchedProjectMembers.length !== 0 && (
<ProjectMemberListItem
memberDetails={memberDetails ?? []}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
)}
{searchedProjectMembers.length === 0 && (
<h4 className="text-sm mt-16 text-center text-custom-text-400">{t("no_matching_members")}</h4>
)}
</div>

View File

@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ban } from "lucide-react";
// plane ui
import { EUserPermissions } from "@plane/constants";
import { EUserProjectRoles } from "@plane/constants";
import { Avatar, CustomSearchSelect } from "@plane/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
@@ -32,7 +32,7 @@ export const MemberSelect: React.FC<Props> = observer((props) => {
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserPermissions.GUEST;
const isGuest = memberDetails.role === EUserProjectRoles.GUEST;
if (isGuest) return;
return {
@@ -59,7 +59,7 @@ export const MemberSelect: React.FC<Props> = observer((props) => {
<CustomSearchSelect
value={value}
label={
<div className="flex items-center gap-2 h-5">
<div className="flex items-center gap-2 h-3.5">
{selectedOption && (
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
)}
@@ -73,7 +73,7 @@ export const MemberSelect: React.FC<Props> = observer((props) => {
)}
</div>
}
buttonClassName="!px-3 !py-2"
buttonClassName="!px-3 !py-2 bg-custom-background-100"
options={
options &&
options && [

View File

@@ -166,7 +166,7 @@ export const ProjectMultiSelectModal: React.FC<Props> = observer((props) => {
</Combobox>
<div className="flex items-center justify-end gap-2 p-3 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
{t("cancel")}
</Button>
<Button
ref={moveButtonRef}
@@ -176,7 +176,7 @@ export const ProjectMultiSelectModal: React.FC<Props> = observer((props) => {
loading={isSubmitting}
disabled={!areSelectedProjectsChanged}
>
{isSubmitting ? "Confirming" : "Confirm"}
{isSubmitting ? t("confirming") : t("confirm")}
</Button>
</div>
</ModalCore>

View File

@@ -1,14 +1,13 @@
"use client";
import { useEffect } from "react";
import { useEffect, ReactNode } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IProject, IUserLite, IWorkspace } from "@plane/types";
// ui
import { Loader, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { MemberSelect } from "@/components/project";
@@ -17,35 +16,53 @@ import { PROJECT_MEMBERS } from "@/constants/fetch-keys";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store";
// types
const defaultValues: Partial<IProject> = {
project_lead: null,
default_assignee: null,
};
export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
// router
const { workspaceSlug, projectId } = useParams();
type TDefaultSettingItemProps = {
title: string;
description: string;
children: ReactNode;
};
const DefaultSettingItem: React.FC<TDefaultSettingItemProps> = ({ title, description, children }) => (
<div className="flex items-center justify-between gap-x-2">
<div className="flex flex-col gap-0.5">
<h4 className="text-sm font-medium">{title}</h4>
<p className="text-xs text-custom-text-300">{description}</p>
</div>
<div className="w-full max-w-48 sm:max-w-64">{children}</div>
</div>
);
type TProjectSettingsMemberDefaultsProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectSettingsMemberDefaults: React.FC<TProjectSettingsMemberDefaultsProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
// derived values
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString(),
workspaceSlug,
currentProjectDetails?.id
);
// form info
const { reset, control } = useForm<IProject>({ defaultValues });
// fetching user members
useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId.toString()) : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId) : null,
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
);
useEffect(() => {
@@ -71,7 +88,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
...formData,
});
await updateProject(workspaceSlug.toString(), projectId.toString(), {
await updateProject(workspaceSlug, projectId, {
default_assignee:
formData.default_assignee === "none"
? null
@@ -94,7 +111,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
const toggleGuestViewAllIssues = async (value: boolean) => {
if (!workspaceSlug || !projectId) return;
updateProject(workspaceSlug.toString(), projectId.toString(), {
updateProject(workspaceSlug, projectId, {
guest_view_all_features: value,
})
.then(() => {
@@ -110,82 +127,64 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
};
return (
<>
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium">{t("common.defaults")}</h3>
</div>
<div className="flex w-full flex-col gap-2 pb-4">
<div className="flex w-full items-center gap-4 py-4">
<div className="flex w-1/2 flex-col gap-2">
<h4 className="text-sm">{t("project_settings.members.project_lead")}</h4>
<div className="">
{currentProjectDetails ? (
<Controller
control={control}
name="project_lead"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ project_lead: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
<div className="flex w-1/2 flex-col gap-2">
<h4 className="text-sm">{t("project_settings.members.default_assignee")}</h4>
<div className="">
{currentProjectDetails ? (
<Controller
control={control}
name="default_assignee"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ default_assignee: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
</div>
</div>
{currentProjectDetails && (
<div className="relative pb-4 flex justify-between items-center gap-3">
<div className="space-y-1">
<h3 className="text-lg font-medium text-custom-text-100">
{t("project_settings.members.guest_super_permissions.title")}
</h3>
<p className="text-sm text-custom-text-200">
{t("project_settings.members.guest_super_permissions.sub_heading")}
</p>
</div>
<ToggleSwitch
value={!!currentProjectDetails?.guest_view_all_features}
onChange={() => toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)}
disabled={!isAdmin}
size="md"
<div className="flex flex-col gap-y-6 my-6">
<DefaultSettingItem title="Project Lead" description="Select the project lead for the project.">
{currentProjectDetails ? (
<Controller
control={control}
name="project_lead"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ project_lead: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
</div>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</DefaultSettingItem>
<DefaultSettingItem title="Default Assignee" description="Select the default assignee for the project.">
{currentProjectDetails ? (
<Controller
control={control}
name="default_assignee"
render={({ field: { value } }) => (
<MemberSelect
value={value}
onChange={(val: string) => {
submitChanges({ default_assignee: val });
}}
isDisabled={!isAdmin}
/>
)}
/>
) : (
<Loader className="h-9 w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</DefaultSettingItem>
{currentProjectDetails && (
<DefaultSettingItem
title="Guest access"
description="This will allow guests to have view access to all the project work items."
>
<div className="flex items-center justify-end">
<ToggleSwitch
value={!!currentProjectDetails?.guest_view_all_features}
onChange={() => toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)}
disabled={!isAdmin}
size="sm"
/>
</div>
</DefaultSettingItem>
)}
</>
</div>
);
});

View File

@@ -2,26 +2,24 @@
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import { ChevronDown, Plus, X } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { ROLE, PROJECT_MEMBER_ADDED, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Avatar, Button, CustomSelect, CustomSearchSelect, TOAST_TYPE, setToast } from "@plane/ui";
// constants
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
// plane-web constants
type Props = {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
projectId: string;
workspaceSlug: string;
};
type member = {
@@ -43,14 +41,14 @@ const defaultValues: FormValues = {
};
export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, onSuccess } = props;
// router
const { workspaceSlug, projectId } = useParams();
const { isOpen, onClose, onSuccess, projectId, workspaceSlug } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { captureEvent } = useEventTracker();
const { projectUserInfo } = useUserPermissions();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const {
project: { projectMemberIds, bulkAddMembersToProject },
project: { getProjectMemberDetails, bulkAddMembersToProject },
workspace: { workspaceMemberIds, getWorkspaceMemberDetails },
} = useMember();
// form info
@@ -62,19 +60,15 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
handleSubmit,
control,
} = useForm<FormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: "members",
});
const { t } = useTranslation();
const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role;
// derived values
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const uninvitedPeople = workspaceMemberIds?.filter((userId) => {
const isInvited = projectMemberIds?.find((u) => u === userId);
const projectMemberDetails = getProjectMemberDetails(userId, projectId);
const isInvited = projectMemberDetails?.member.id && projectMemberDetails?.original_role;
return !isInvited;
});
@@ -181,7 +175,6 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
currentMemberWorkspaceRole as EUserPermissions
);
return Object.fromEntries(
Object.entries(ROLE).filter(([key]) => !isGuestOROwner || [currentMemberWorkspaceRole].includes(parseInt(key)))
);

View File

@@ -1,23 +1,18 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { Controller, useForm } from "react-hook-form";
import { Trash2 } from "lucide-react";
import { CircleMinus } from "lucide-react";
import { Disclosure } from "@headlessui/react";
// plane imports
import { ROLE, EUserPermissions } from "@plane/constants";
// plane types
import { IUser, IWorkspaceMember } from "@plane/types";
// plane ui
import { CustomSelect, PopoverMenu, TOAST_TYPE, setToast } from "@plane/ui";
// constants
// helpers
import { getFileURL } from "@/helpers/file.helper";
import { ROLE, EUserPermissions, EUserProjectRoles } from "@plane/constants";
import { IUser, IWorkspaceMember, TProjectMembership } from "@plane/types";
import { CustomMenu, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// hooks
import { useMember, useUser, useUserPermissions } from "@/hooks/store";
export interface RowData {
export interface RowData extends Pick<TProjectMembership, "original_role"> {
member: IWorkspaceMember;
role: EUserPermissions;
}
type NameProps = {
@@ -44,11 +39,11 @@ export const NameColumn: React.FC<NameProps> = (props) => {
<Disclosure>
{({}) => (
<div className="relative group">
<div className="flex items-center gap-x-4 gap-y-2 w-72 justify-between">
<div className="flex items-center gap-2 w-72">
<div className="flex items-center gap-x-2 gap-y-2 flex-1">
{avatar_url && avatar_url.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-4 w-4 items-center justify-center rounded-full capitalize text-white">
<span className="relative flex size-4 items-center justify-center rounded-full capitalize text-white">
<img
src={getFileURL(avatar_url)}
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
@@ -58,30 +53,30 @@ export const NameColumn: React.FC<NameProps> = (props) => {
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${id}`}>
<span className="relative flex h-4 w-4 text-xs items-center justify-center rounded-full bg-gray-700 capitalize text-white">
<span className="relative flex size-4 items-center justify-center rounded-full bg-gray-700 capitalize text-white text-xs">
{(email ?? display_name ?? "?")[0]}
</span>
</Link>
)}
{first_name} {last_name}
</div>
{(isAdmin || id === currentUser?.id) && (
<PopoverMenu
data={[""]}
keyExtractor={(item) => item}
popoverClassName="justify-end"
buttonClassName="outline-none origin-center rotate-90 size-8 aspect-square flex-shrink-0 grid place-items-center opacity-0 group-hover:opacity-100 transition-opacity"
render={() => (
<CustomMenu
ellipsis
buttonClassName="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
optionsClassName="p-1.5"
placement="bottom-end"
>
<CustomMenu.MenuItem>
<div
className="flex items-center gap-x-3 cursor-pointer"
className="flex items-center gap-x-1 cursor-pointer text-red-600 font-medium"
onClick={() => setRemoveMemberModal(rowData)}
>
<Trash2 className="size-3.5 align-middle" />
<CircleMinus className="flex-shrink-0 size-3.5" />
{rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
</div>
)}
/>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
@@ -92,20 +87,20 @@ export const NameColumn: React.FC<NameProps> = (props) => {
export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) => {
const { rowData, projectId, workspaceSlug } = props;
// store hooks
const {
project: { updateMemberRole },
workspace: { getWorkspaceMemberDetails },
} = useMember();
const { data: currentUser } = useUser();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
// form info
const {
control,
formState: { errors },
} = useForm();
// store hooks
const {
project: { updateMember },
workspace: { getWorkspaceMemberDetails },
} = useMember();
const { data: currentUser } = useUser();
const { projectUserInfo } = useUserPermissions();
// derived values
const roleLabel = ROLE[rowData.original_role ?? EUserPermissions.GUEST];
const isCurrentUser = currentUser?.id === rowData.member.id;
const isRowDataWorkspaceAdmin = [EUserPermissions.ADMIN].includes(
Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST
@@ -115,8 +110,7 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
Number(getWorkspaceMemberDetails(currentUser.id)?.role) ?? EUserPermissions.GUEST
)
: false;
const currentProjectRole = projectUserInfo?.[workspaceSlug.toString()]?.[projectId.toString()]
?.role as unknown as EUserPermissions;
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isCurrentUserProjectAdmin = currentProjectRole
? ![EUserPermissions.MEMBER, EUserPermissions.GUEST].includes(Number(currentProjectRole) ?? EUserPermissions.GUEST)
@@ -148,27 +142,26 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
rules={{ required: "Role is required." }}
render={() => (
<CustomSelect
value={rowData.role?.toString()}
onChange={(value: EUserPermissions) => {
value={rowData.original_role}
onChange={async (value: EUserProjectRoles) => {
if (!workspaceSlug) return;
await updateMemberRole(workspaceSlug.toString(), projectId.toString(), rowData.member.id, value).catch(
(err) => {
console.log(err, "err");
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
updateMember(workspaceSlug.toString(), projectId.toString(), rowData.member.id, {
role: value as unknown as EUserPermissions, // Cast value to unknown first, then to EUserPermissions
}).catch((err) => {
console.log(err, "err");
const error = err.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToast({
type: TOAST_TYPE.ERROR,
title: "You cant change this role yet.",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
});
setToast({
type: TOAST_TYPE.ERROR,
title: "You cant change this role yet.",
message: errorString ?? "An error occurred while updating member role. Please try again.",
});
}
);
}}
label={
<div className="flex ">
<span>{ROLE[rowData.role]}</span>
<span>{roleLabel}</span>
</div>
}
buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`}
@@ -186,7 +179,7 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
/>
) : (
<div className="w-32 flex ">
<span>{ROLE[rowData.role]}</span>
<span>{roleLabel}</span>
</div>
)}
</>

View File

@@ -8,6 +8,7 @@ import { Loader } from "@plane/ui";
import { cn } from "@/helpers/common.helper";
import { useProject, useUserPermissions, useUserSettings } from "@/hooks/store";
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
export const NavItemChildren = observer((props: { projectId: string }) => {
const { projectId } = props;
@@ -65,7 +66,7 @@ export const NavItemChildren = observer((props: { projectId: string }) => {
"text-sm font-medium"
)}
>
{t(link.i18n_label)}
{t(getProjectSettingsPageLabelI18nKey(link.key, link.i18n_label))}
</div>
</Link>
)

View File

@@ -1,17 +1,16 @@
import range from "lodash/range";
export const MembersSettingsLoader = () => (
<div className="divide-y-[0.5px] divide-custom-border-100 animate-pulse">
{range(4).map((i) => (
<div key={i} className="group flex items-center justify-between px-3 py-4">
<div className="flex items-center gap-x-4 gap-y-2">
<span className="h-10 w-10 bg-custom-background-80 rounded-full" />
<div className="flex flex-col gap-1">
<span className="h-5 w-20 bg-custom-background-80 rounded" />
<span className="h-4 w-36 bg-custom-background-80 rounded" />
</div>
<div className="divide-y-[0.5px] divide-custom-border-100">
{range(3).map((i) => (
<div key={i} className="group grid grid-cols-5 items-center justify-evenly px-3 py-4">
<div className="flex col-span-2 items-center gap-x-2.5">
<span className="size-6 bg-custom-background-80 rounded-full" />
<span className="h-5 w-24 bg-custom-background-80 rounded" />
</div>
<span className="h-6 w-16 bg-custom-background-80 rounded" />
<span className="h-5 w-24 bg-custom-background-80 rounded" />
<span className="h-5 w-20 bg-custom-background-80 rounded" />
<span className="h-5 w-28 bg-custom-background-80 rounded" />
</div>
))}
</div>

View File

@@ -66,7 +66,9 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
title={
<div className="flex w-full items-center justify-between pt-4">
<div className="flex">
<h4 className="text-xl font-medium pt-2 pb-2">{t("workspace_settings.settings.members.pending_invites")}</h4>
<h4 className="text-xl font-medium pt-2 pb-2">
{t("workspace_settings.settings.members.pending_invites")}
</h4>
{searchedInvitationsIds && (
<CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" />
)}

View File

@@ -1,8 +1,8 @@
import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// types
import { IUserPermissionStore } from "@/store/user/permissions.store";
// plane web imports
import { IUserPermissionStore } from "@/plane-web/store/user/permission.store";
export const useUserPermissions = (): IUserPermissionStore => {
const context = useContext(StoreContext);

View File

@@ -46,7 +46,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { fetchUserProjectInfo, allowPermissions, projectUserInfo } = useUserPermissions();
const { fetchUserProjectInfo, allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { loader, getProjectById, fetchProjectDetails } = useProject();
const { fetchAllCycles } = useCycle();
const { fetchModulesSlim, fetchModules } = useModule();
@@ -64,7 +64,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// derived values
const projectExists = projectId ? getProjectById(projectId.toString()) : null;
const projectMemberInfo = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()];
const projectMemberInfo = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const hasPermissionToCurrentProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
EUserPermissionsLevel.PROJECT,
@@ -179,7 +179,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
projectId &&
hasPermissionToCurrentProject === false
)
return <JoinProject />;
return <JoinProject projectId={projectId} />;
// check if the project info is not found.
if (loader === "loaded" && projectId && !!hasPermissionToCurrentProject === false)

View File

@@ -25,10 +25,13 @@ const PostHogProvider: FC<IPosthogWrapper> = observer((props) => {
const { currentWorkspace } = useWorkspace();
const { instance } = useInstance();
const { workspaceSlug, projectId } = useParams();
const { workspaceInfoBySlug, projectUserInfo } = useUserPermissions();
const { getWorkspaceRoleByWorkspaceSlug, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role;
const currentWorkspaceRole = workspaceInfoBySlug(workspaceSlug?.toString())?.role;
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(
workspaceSlug?.toString(),
projectId?.toString()
);
const currentWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug?.toString());
const is_telemetry_enabled = instance?.is_telemetry_enabled || false;
useEffect(() => {

View File

@@ -12,10 +12,7 @@ export const sanitizeWorkItemQueries = (
// Get current project details and user id and role for the project
const currentProject = rootStore.projectRoot.project.getProjectById(projectId);
const currentUserId = rootStore.user.data?.id;
const currentUserRole = rootStore.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
workspaceSlug,
projectId
);
const currentUserRole = rootStore.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
// Only apply this restriction for guests when guest_view_all_features is disabled
if (

View File

@@ -1,5 +1,5 @@
// types
import type { IProjectBulkAddFormData, IProjectMember, IProjectMembership } from "@plane/types";
import type { IProjectBulkAddFormData, TProjectMembership } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
@@ -9,7 +9,7 @@ export class ProjectMemberService extends APIService {
super(API_BASE_URL);
}
async fetchProjectMembers(workspaceSlug: string, projectId: string): Promise<IProjectMembership[]> {
async fetchProjectMembers(workspaceSlug: string, projectId: string): Promise<TProjectMembership[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`)
.then((response) => response?.data)
.catch((error) => {
@@ -21,7 +21,7 @@ export class ProjectMemberService extends APIService {
workspaceSlug: string,
projectId: string,
data: IProjectBulkAddFormData
): Promise<IProjectMembership[]> {
): Promise<TProjectMembership[]> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`, data)
.then((response) => response?.data)
.catch((error) => {
@@ -29,7 +29,7 @@ export class ProjectMemberService extends APIService {
});
}
async projectMemberMe(workspaceSlug: string, projectId: string): Promise<IProjectMember> {
async projectMemberMe(workspaceSlug: string, projectId: string): Promise<TProjectMembership> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`)
.then((response) => response?.data)
.catch((error) => {
@@ -37,7 +37,7 @@ export class ProjectMemberService extends APIService {
});
}
async getProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise<IProjectMember> {
async getProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise<TProjectMembership> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`)
.then((response) => response?.data)
.catch((error) => {
@@ -49,8 +49,8 @@ export class ProjectMemberService extends APIService {
workspaceSlug: string,
projectId: string,
memberId: string,
data: Partial<IProjectMember>
): Promise<IProjectMember> {
data: Partial<TProjectMembership>
): Promise<TProjectMembership> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`, data)
.then((response) => response?.data)
.catch((error) => {

View File

@@ -1,36 +1,35 @@
import set from "lodash/set";
import sortBy from "lodash/sortBy";
import uniq from "lodash/uniq";
import unset from "lodash/unset";
import update from "lodash/update";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { EUserPermissions } from "@plane/constants";
import { IProjectBulkAddFormData, IProjectMember, IProjectMembership, IUserLite } from "@plane/types";
// plane-web constants
// plane imports
import { EUserPermissions, EUserProjectRoles } from "@plane/constants";
import { IProjectBulkAddFormData, TProjectMembership, IUserLite } from "@plane/types";
// plane web imports
import { RootStore } from "@/plane-web/store/root.store";
// services
import { ProjectMemberService } from "@/services/project";
// store
import { IRouterStore } from "@/store/router.store";
import { IUserStore } from "@/store/user";
// store
// local imports
import { IProjectStore } from "../project/project.store";
import { CoreRootStore } from "../root.store";
import { IMemberRootStore } from ".";
export interface IProjectMemberDetails {
id: string;
export interface IProjectMemberDetails extends Omit<TProjectMembership, "member"> {
member: IUserLite;
role: EUserPermissions;
}
export interface IProjectMemberStore {
export interface IBaseProjectMemberStore {
// observables
projectMemberFetchStatusMap: {
[projectId: string]: boolean;
};
projectMemberMap: {
[projectId: string]: Record<string, IProjectMembership>;
[projectId: string]: Record<string, TProjectMembership>;
};
// computed
projectMemberIds: string[] | null;
@@ -39,41 +38,45 @@ export interface IProjectMemberStore {
getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
getProjectMemberIds: (projectId: string, includeGuestUsers: boolean) => string[] | null;
// fetch actions
fetchProjectMembers: (workspaceSlug: string, projectId: string) => Promise<IProjectMembership[]>;
fetchProjectMembers: (
workspaceSlug: string,
projectId: string,
clearExistingMembers?: boolean
) => Promise<TProjectMembership[]>;
// bulk operation actions
bulkAddMembersToProject: (
workspaceSlug: string,
projectId: string,
data: IProjectBulkAddFormData
) => Promise<IProjectMembership[]>;
) => Promise<TProjectMembership[]>;
// crud actions
updateMember: (
updateMemberRole: (
workspaceSlug: string,
projectId: string,
userId: string,
data: { role: EUserPermissions }
) => Promise<IProjectMember>;
role: EUserProjectRoles
) => Promise<TProjectMembership>;
removeMemberFromProject: (workspaceSlug: string, projectId: string, userId: string) => Promise<void>;
}
export class ProjectMemberStore implements IProjectMemberStore {
export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore {
// observables
projectMemberFetchStatusMap: {
[projectId: string]: boolean;
} = {};
projectMemberMap: {
[projectId: string]: Record<string, IProjectMembership>;
[projectId: string]: Record<string, TProjectMembership>;
} = {};
// stores
routerStore: IRouterStore;
userStore: IUserStore;
memberRoot: IMemberRootStore;
projectRoot: IProjectStore;
rootStore: CoreRootStore;
rootStore: RootStore;
// services
projectMemberService;
constructor(_memberRoot: IMemberRootStore, _rootStore: CoreRootStore) {
constructor(_memberRoot: IMemberRootStore, _rootStore: RootStore) {
makeObservable(this, {
// observables
projectMemberMap: observable,
@@ -82,7 +85,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
// actions
fetchProjectMembers: action,
bulkAddMembersToProject: action,
updateMember: action,
updateMemberRole: action,
removeMemberFromProject: action,
});
@@ -103,6 +106,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
const projectId = this.routerStore.projectId;
if (!projectId) return null;
let members = Object.values(this.projectMemberMap?.[projectId] ?? {});
if (members.length === 0) return null;
members = sortBy(members, [
(m) => m.member !== this.userStore.data?.id,
(m) => this.memberRoot.memberMap?.[m.member]?.display_name.toLowerCase(),
@@ -117,20 +121,64 @@ export class ProjectMemberStore implements IProjectMemberStore {
*/
getProjectMemberFetchStatus = computedFn((projectId: string) => this.projectMemberFetchStatusMap?.[projectId]);
/**
* @description get the project memberships
* @param projectId
*/
protected getProjectMemberships = computedFn((projectId: string) =>
Object.values(this.projectMemberMap?.[projectId] ?? {})
);
/**
* @description get the project membership by user id
* @param userId
* @param projectId
*/
protected getProjectMembershipByUserId = computedFn(
(userId: string, projectId: string) => this.projectMemberMap?.[projectId]?.[userId]
);
/**
* @description get the role from the project membership
* @param userId
* @param projectId
*/
protected getRoleFromProjectMembership = computedFn(
(userId: string, projectId: string): EUserProjectRoles | undefined => {
const projectMembership = this.getProjectMembershipByUserId(userId, projectId);
if (!projectMembership) return undefined;
const projectMembershipRole = projectMembership.original_role ?? projectMembership.role;
return projectMembershipRole ? (projectMembershipRole as EUserProjectRoles) : undefined;
}
);
/**
* @description Returns the project membership role for a user
* @description This method is specifically used when adding new members to a project. For existing members,
* the role is fetched directly from the backend during member listing.
* @param { string } userId - The ID of the user
* @param { string } projectId - The ID of the project
* @returns { EUserProjectRoles | undefined } The user's role in the project, or undefined if not found
*/
abstract getUserProjectRole: (userId: string, projectId: string) => EUserProjectRoles | undefined;
/**
* @description get the details of a project member
* @param userId
* @param projectId
*/
getProjectMemberDetails = computedFn((userId: string, projectId: string) => {
const projectMember = this.projectMemberMap?.[projectId]?.[userId];
const projectMember = this.getProjectMembershipByUserId(userId, projectId);
if (!projectMember) return null;
const memberDetails: IProjectMemberDetails = {
id: projectMember.id,
role: projectMember.role,
original_role: projectMember.original_role,
member: {
...this.memberRoot?.memberMap?.[projectMember.member],
joining_date: projectMember.created_at,
},
created_at: projectMember.created_at,
};
return memberDetails;
});
@@ -141,7 +189,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
*/
getProjectMemberIds = computedFn((projectId: string, includeGuestUsers: boolean): string[] | null => {
if (!this.projectMemberMap?.[projectId]) return null;
let members = Object.values(this.projectMemberMap?.[projectId]);
let members = this.getProjectMemberships(projectId);
if (includeGuestUsers === false) {
members = members.filter((m) => m.role !== EUserPermissions.GUEST);
}
@@ -158,9 +206,12 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @param workspaceSlug
* @param projectId
*/
fetchProjectMembers = async (workspaceSlug: string, projectId: string) =>
fetchProjectMembers = async (workspaceSlug: string, projectId: string, clearExistingMembers: boolean = false) =>
await this.projectMemberService.fetchProjectMembers(workspaceSlug, projectId).then((response) => {
runInAction(() => {
if (clearExistingMembers) {
unset(this.projectMemberMap, [projectId]);
}
response.forEach((member) => {
set(this.projectMemberMap, [projectId, member.member], member);
});
@@ -174,13 +225,17 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @param workspaceSlug
* @param projectId
* @param data
* @returns Promise<IProjectMembership[]>
* @returns Promise<TProjectMembership[]>
*/
bulkAddMembersToProject = async (workspaceSlug: string, projectId: string, data: IProjectBulkAddFormData) =>
await this.projectMemberService.bulkAddMembersToProject(workspaceSlug, projectId, data).then((response) => {
runInAction(() => {
response.forEach((member) => {
set(this.projectMemberMap, [projectId, member.member], member);
set(this.projectMemberMap, [projectId, member.member], {
...member,
role: this.getUserProjectRole(member.member, projectId) ?? member.role,
original_role: member.role,
});
});
});
update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) =>
@@ -193,6 +248,18 @@ export class ProjectMemberStore implements IProjectMemberStore {
return response;
});
/**
* @description update the role of a member in a project
* @param projectId
* @param userId
* @param role
*/
abstract getProjectMemberRoleForUpdate: (
projectId: string,
userId: string,
role: EUserProjectRoles
) => EUserProjectRoles;
/**
* @description update the role of a member in a project
* @param workspaceSlug
@@ -200,40 +267,82 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @param userId
* @param data
*/
updateMember = async (workspaceSlug: string, projectId: string, userId: string, data: { role: EUserPermissions }) => {
updateMemberRole = async (workspaceSlug: string, projectId: string, userId: string, role: EUserProjectRoles) => {
const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails) throw new Error("Member not found");
if (!memberDetails || !memberDetails?.id) throw new Error("Member not found");
// original data to revert back in case of error
const originalProjectMemberData = this.projectMemberMap?.[projectId]?.[userId]?.role;
const isCurrentUser = this.rootStore.user.data?.id === userId;
const membershipBeforeUpdate = this.getProjectMembershipByUserId(userId, projectId);
const permissionBeforeUpdate = isCurrentUser
? this.rootStore.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId)
: undefined;
const updatedProjectRole = this.getProjectMemberRoleForUpdate(projectId, userId, role);
try {
runInAction(() => {
set(this.projectMemberMap, [projectId, userId, "role"], data.role);
if (isCurrentUser)
set(this.rootStore.user.permission.projectUserInfo, [workspaceSlug, projectId, "role"], data.role);
set(this.projectMemberMap, [projectId, userId, "original_role"], role);
set(this.projectMemberMap, [projectId, userId, "role"], updatedProjectRole);
if (isCurrentUser) {
set(
this.rootStore.user.permission.workspaceProjectsPermissions,
[workspaceSlug, projectId],
updatedProjectRole
);
}
set(this.rootStore.user.permission.projectUserInfo, [workspaceSlug, projectId, "role"], updatedProjectRole);
});
const response = await this.projectMemberService.updateProjectMember(
workspaceSlug,
projectId,
memberDetails?.id,
data
{
role,
}
);
return response;
} catch (error) {
// revert back to original members in case of error
runInAction(() => {
set(this.projectMemberMap, [projectId, userId, "role"], originalProjectMemberData);
if (isCurrentUser)
set(this.projectMemberMap, [projectId, userId, "original_role"], membershipBeforeUpdate?.original_role);
set(this.projectMemberMap, [projectId, userId, "role"], membershipBeforeUpdate?.role);
if (isCurrentUser) {
set(
this.rootStore.user.permission.workspaceProjectsPermissions,
[workspaceSlug, projectId],
membershipBeforeUpdate?.original_role
);
set(
this.rootStore.user.permission.projectUserInfo,
[workspaceSlug, projectId, "role"],
originalProjectMemberData
permissionBeforeUpdate
);
}
});
throw error;
}
};
/**
* @description Handles the removal of a member from a project
* @param projectId - The ID of the project to remove the member from
* @param userId - The ID of the user to remove from the project
*/
protected handleMemberRemoval = (projectId: string, userId: string) => {
unset(this.projectMemberMap, [projectId, userId]);
set(
this.projectRoot.projectMap,
[projectId, "members"],
this.projectRoot.projectMap?.[projectId]?.members?.filter((memberId) => memberId !== userId)
);
};
/**
* @description Processes the removal of a member from a project
* This abstract method handles the cleanup of member data from the project member map
* @param projectId - The ID of the project to remove the member from
* @param userId - The ID of the user to remove from the project
*/
abstract processMemberRemoval: (projectId: string, userId: string) => void;
/**
* @description remove a member from a project
* @param workspaceSlug
@@ -242,14 +351,11 @@ export class ProjectMemberStore implements IProjectMemberStore {
*/
removeMemberFromProject = async (workspaceSlug: string, projectId: string, userId: string) => {
const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails) throw new Error("Member not found");
if (!memberDetails || !memberDetails?.id) throw new Error("Member not found");
await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id).then(() => {
runInAction(() => {
delete this.projectMemberMap?.[projectId]?.[userId];
this.processMemberRemoval(projectId, userId);
});
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.filter(
(memberId) => memberId !== userId
);
});
};
}

View File

@@ -1,10 +1,11 @@
import { makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// type
// plane imports
import { IUserLite } from "@plane/types";
// store
import { CoreRootStore } from "../root.store";
import { IProjectMemberStore, ProjectMemberStore } from "./project-member.store";
// plane web imports
import { IProjectMemberStore, ProjectMemberStore } from "@/plane-web/store/member/project-member.store";
import { RootStore } from "@/plane-web/store/root.store";
// local imports
import { IWorkspaceMemberStore, WorkspaceMemberStore } from "./workspace-member.store";
export interface IMemberRootStore {
@@ -25,7 +26,7 @@ export class MemberRootStore implements IMemberRootStore {
workspace: IWorkspaceMemberStore;
project: IProjectMemberStore;
constructor(_rootStore: CoreRootStore) {
constructor(_rootStore: RootStore) {
makeObservable(this, {
// observables
memberMap: observable,

View File

@@ -115,7 +115,7 @@ export class ProjectPageStore implements IProjectPageStore {
*/
get canCurrentUserCreatePage() {
const { workspaceSlug, projectId } = this.store.router;
const currentUserProjectRole = this.store.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
const currentUserProjectRole = this.store.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);

View File

@@ -73,7 +73,7 @@ export class ProjectPage extends BasePage implements TProjectPage {
if (!workspaceSlug || !this.project_ids?.length) return;
let highestRole: EUserPermissions | undefined = undefined;
this.project_ids.map((projectId) => {
const currentUserProjectRole = this.rootStore.user.permission.projectPermissionsByWorkspaceSlugAndProjectId(
const currentUserProjectRole = this.rootStore.user.permission.getProjectRoleByWorkspaceSlugAndProjectId(
workspaceSlug?.toString() || "",
projectId?.toString() || ""
);

View File

@@ -71,11 +71,11 @@ export class CoreRootStore {
this.router = new RouterStore();
this.commandPalette = new CommandPaletteStore();
this.instance = new InstanceStore();
this.user = new UserStore(this);
this.user = new UserStore(this as unknown as RootStore);
this.theme = new ThemeStore();
this.workspaceRoot = new WorkspaceRootStore(this);
this.projectRoot = new ProjectRootStore(this);
this.memberRoot = new MemberRootStore(this);
this.memberRoot = new MemberRootStore(this as unknown as RootStore);
this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this);
@@ -106,10 +106,10 @@ export class CoreRootStore {
this.router = new RouterStore();
this.commandPalette = new CommandPaletteStore();
this.instance = new InstanceStore();
this.user = new UserStore(this);
this.user = new UserStore(this as unknown as RootStore);
this.workspaceRoot = new WorkspaceRootStore(this);
this.projectRoot = new ProjectRootStore(this);
this.memberRoot = new MemberRootStore(this);
this.memberRoot = new MemberRootStore(this as unknown as RootStore);
this.cycle = new CycleStore(this);
this.cycleFilter = new CycleFilterStore(this);
this.module = new ModulesStore(this);

View File

@@ -2,7 +2,7 @@ import set from "lodash/set";
import unset from "lodash/unset";
import { action, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
// plane imports
import {
EUserProjectRoles,
EUserWorkspaceRoles,
@@ -10,35 +10,32 @@ import {
EUserPermissionsLevel,
TUserPermissions,
TUserPermissionsLevel,
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
import { IProjectMember, IUserProjectsRole, IWorkspaceMemberMe } from "@plane/types";
// plane web types
// plane web services
import { TProjectMembership, IUserProjectsRole, IWorkspaceMemberMe } from "@plane/types";
// plane web imports
import { WorkspaceService } from "@/plane-web/services/workspace.service";
import { RootStore } from "@/plane-web/store/root.store";
// services
import projectMemberService from "@/services/project/project-member.service";
import userService from "@/services/user.service";
// store
import { CoreRootStore } from "@/store/root.store";
// derived services
const workspaceService = new WorkspaceService();
type ETempUserRole = TUserPermissions | EUserWorkspaceRoles | EUserProjectRoles; // TODO: Remove this once we have migrated user permissions to enums to plane constants package
export interface IUserPermissionStore {
export interface IBaseUserPermissionStore {
loader: boolean;
// observables
workspaceUserInfo: Record<string, IWorkspaceMemberMe>; // workspaceSlug -> IWorkspaceMemberMe
projectUserInfo: Record<string, Record<string, IProjectMember>>; // workspaceSlug -> projectId -> IProjectMember
projectUserInfo: Record<string, Record<string, TProjectMembership>>; // workspaceSlug -> projectId -> TProjectMembership
workspaceProjectsPermissions: Record<string, IUserProjectsRole>; // workspaceSlug -> IUserProjectsRole
// computed
// computed helpers
workspaceInfoBySlug: (workspaceSlug: string) => IWorkspaceMemberMe | undefined;
projectPermissionsByWorkspaceSlugAndProjectId: (
workspaceSlug: string,
projectId: string
) => TUserPermissions | undefined;
getWorkspaceRoleByWorkspaceSlug: (workspaceSlug: string) => TUserPermissions | EUserWorkspaceRoles | undefined;
getProjectRolesByWorkspaceSlug: (workspaceSlug: string) => IUserProjectsRole;
getProjectRoleByWorkspaceSlugAndProjectId: (workspaceSlug: string, projectId: string) => EUserPermissions | undefined;
allowPermissions: (
allowPermissions: ETempUserRole[],
level: TUserPermissionsLevel,
@@ -46,25 +43,29 @@ export interface IUserPermissionStore {
projectId?: string,
onPermissionAllowed?: () => boolean
) => boolean;
// action helpers
// actions
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe | undefined>;
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
leaveWorkspace: (workspaceSlug: string) => Promise<void>;
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<IProjectMember | undefined>;
fetchUserProjectPermissions: (workspaceSlug: string) => Promise<IUserProjectsRole | undefined>;
joinProject: (workspaceSlug: string, projectId: string) => Promise<void | undefined>;
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<TProjectMembership>;
fetchUserProjectPermissions: (workspaceSlug: string) => Promise<IUserProjectsRole>;
joinProject: (workspaceSlug: string, projectId: string) => Promise<void>;
leaveProject: (workspaceSlug: string, projectId: string) => Promise<void>;
hasPageAccess: (workspaceSlug: string, key: string) => boolean;
}
export class UserPermissionStore implements IUserPermissionStore {
/**
* @description This store is used to handle permission layer for the currently logged user.
* It manages workspace and project level permissions, roles and access control.
*/
export abstract class BaseUserPermissionStore implements IBaseUserPermissionStore {
loader: boolean = false;
// constants
workspaceUserInfo: Record<string, IWorkspaceMemberMe> = {};
projectUserInfo: Record<string, Record<string, IProjectMember>> = {};
projectUserInfo: Record<string, Record<string, TProjectMembership>> = {};
workspaceProjectsPermissions: Record<string, IUserProjectsRole> = {};
// observables
constructor(private store: CoreRootStore) {
constructor(protected store: RootStore) {
makeObservable(this, {
// observables
loader: observable.ref,
@@ -82,8 +83,6 @@ export class UserPermissionStore implements IUserPermissionStore {
});
}
// computed
// computed helpers
/**
* @description Returns the current workspace information
@@ -95,18 +94,69 @@ export class UserPermissionStore implements IUserPermissionStore {
return this.workspaceUserInfo[workspaceSlug] || undefined;
});
/**
* @description Returns the workspace role by slug
* @param { string } workspaceSlug
* @returns { TUserPermissions | EUserWorkspaceRoles | undefined }
*/
getWorkspaceRoleByWorkspaceSlug = computedFn(
(workspaceSlug: string): TUserPermissions | EUserWorkspaceRoles | undefined => {
if (!workspaceSlug) return undefined;
return this.workspaceUserInfo[workspaceSlug]?.role as TUserPermissions | EUserWorkspaceRoles | undefined;
}
);
/**
* @description Returns the project membership permission
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { EUserPermissions | undefined }
*/
protected getProjectRole = computedFn((workspaceSlug: string, projectId: string): EUserPermissions | undefined => {
if (!workspaceSlug || !projectId) return undefined;
return this.workspaceProjectsPermissions?.[workspaceSlug]?.[projectId] || undefined;
});
/**
* @description Returns the project permissions by workspace slug
* @param { string } workspaceSlug
* @returns { IUserProjectsRole }
*/
getProjectRolesByWorkspaceSlug = computedFn((workspaceSlug: string): IUserProjectsRole => {
const projectPermissions = this.workspaceProjectsPermissions[workspaceSlug] || {};
return Object.keys(projectPermissions).reduce((acc, projectId) => {
const projectRole = this.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
if (projectRole) {
acc[projectId] = projectRole;
}
return acc;
}, {} as IUserProjectsRole);
});
/**
* @description Returns the current project permissions
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { IUserProjectsRole | undefined }
* @returns { EUserPermissions | undefined }
*/
projectPermissionsByWorkspaceSlugAndProjectId = computedFn(
(workspaceSlug: string, projectId: string): TUserPermissions | undefined => {
if (!workspaceSlug || !projectId) return undefined;
return this.workspaceProjectsPermissions?.[workspaceSlug]?.[projectId] || undefined;
abstract getProjectRoleByWorkspaceSlugAndProjectId: (
workspaceSlug: string,
projectId: string
) => EUserPermissions | undefined;
/**
* @description Returns whether the user has the permission to access a page
* @param { string } page
* @returns { boolean }
*/
hasPageAccess = computedFn((workspaceSlug: string, key: string): boolean => {
if (!workspaceSlug || !key) return false;
const settings = WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.find((item) => item.key === key);
if (settings) {
return this.allowPermissions(settings.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug);
}
);
return false;
});
// action helpers
/**
@@ -132,19 +182,22 @@ export class UserPermissionStore implements IUserPermissionStore {
let currentUserRole: TUserPermissions | undefined = undefined;
if (level === EUserPermissionsLevel.WORKSPACE) {
const workspaceInfoBySlug = workspaceSlug && this.workspaceInfoBySlug(workspaceSlug);
if (workspaceInfoBySlug) {
currentUserRole = workspaceInfoBySlug?.role as unknown as EUserPermissions;
}
currentUserRole = (workspaceSlug && this.getWorkspaceRoleByWorkspaceSlug(workspaceSlug)) as
| EUserPermissions
| undefined;
}
if (level === EUserPermissionsLevel.PROJECT) {
currentUserRole = (workspaceSlug &&
projectId &&
this.projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId)) as EUserPermissions | undefined;
this.getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId)) as EUserPermissions | undefined;
}
if (currentUserRole && allowPermissions.includes(currentUserRole as TUserPermissions)) {
if (typeof currentUserRole === "string") {
currentUserRole = parseInt(currentUserRole);
}
if (currentUserRole && typeof currentUserRole === "number" && allowPermissions.includes(currentUserRole)) {
if (onPermissionAllowed) {
return onPermissionAllowed();
} else {
@@ -159,9 +212,9 @@ export class UserPermissionStore implements IUserPermissionStore {
/**
* @description Fetches the user's workspace information
* @param { string } workspaceSlug
* @returns { Promise<void | undefined> }
* @returns { Promise<IWorkspaceMemberMe | undefined> }
*/
fetchUserWorkspaceInfo = async (workspaceSlug: string): Promise<IWorkspaceMemberMe | undefined> => {
fetchUserWorkspaceInfo = async (workspaceSlug: string): Promise<IWorkspaceMemberMe> => {
try {
this.loader = true;
const response = await workspaceService.workspaceMemberMe(workspaceSlug);
@@ -202,9 +255,9 @@ export class UserPermissionStore implements IUserPermissionStore {
* @description Fetches the user's project information
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { Promise<void | undefined> }
* @returns { Promise<TProjectMembership | undefined> }
*/
fetchUserProjectInfo = async (workspaceSlug: string, projectId: string): Promise<IProjectMember | undefined> => {
fetchUserProjectInfo = async (workspaceSlug: string, projectId: string): Promise<TProjectMembership> => {
try {
const response = await projectMemberService.projectMemberMe(workspaceSlug, projectId);
if (response) {
@@ -223,9 +276,9 @@ export class UserPermissionStore implements IUserPermissionStore {
/**
* @description Fetches the user's project permissions
* @param { string } workspaceSlug
* @returns { Promise<void | undefined> }
* @returns { Promise<IUserProjectsRole | undefined> }
*/
fetchUserProjectPermissions = async (workspaceSlug: string): Promise<IUserProjectsRole | undefined> => {
fetchUserProjectPermissions = async (workspaceSlug: string): Promise<IUserProjectsRole> => {
try {
const response = await workspaceService.getWorkspaceUserProjectsRole(workspaceSlug);
runInAction(() => {
@@ -242,18 +295,17 @@ export class UserPermissionStore implements IUserPermissionStore {
* @description Joins a project
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { Promise<void | undefined> }
* @returns { Promise<void> }
*/
joinProject = async (workspaceSlug: string, projectId: string): Promise<void | undefined> => {
joinProject = async (workspaceSlug: string, projectId: string): Promise<void> => {
try {
const response = await userService.joinProject(workspaceSlug, [projectId]);
const projectMemberRole = this.workspaceInfoBySlug(workspaceSlug)?.role ?? EUserPermissions.MEMBER;
const projectMemberRole = this.getWorkspaceRoleByWorkspaceSlug(workspaceSlug) ?? EUserPermissions.MEMBER;
if (response) {
runInAction(() => {
set(this.workspaceProjectsPermissions, [workspaceSlug, projectId], projectMemberRole);
});
}
return response;
} catch (error) {
console.error("Error user joining the project", error);
throw error;
@@ -264,7 +316,7 @@ export class UserPermissionStore implements IUserPermissionStore {
* @description Leaves a project
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { Promise<void | undefined> }
* @returns { Promise<void> }
*/
leaveProject = async (workspaceSlug: string, projectId: string): Promise<void> => {
try {

View File

@@ -1,23 +1,24 @@
import cloneDeep from "lodash/cloneDeep";
import set from "lodash/set";
import { action, makeObservable, observable, runInAction, computed } from "mobx";
// types
// plane imports
import { EUserPermissions } from "@plane/constants";
import { IUser } from "@plane/types";
import { TUserPermissions } from "@plane/types/src/enums";
// constants
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// local
// local db
import { persistence } from "@/local-db/storage.sqlite";
// plane web imports
import { RootStore } from "@/plane-web/store/root.store";
import { IUserPermissionStore, UserPermissionStore } from "@/plane-web/store/user/permission.store";
// services
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
// stores
import { CoreRootStore } from "@/store/root.store";
import { IAccountStore } from "@/store/user/account.store";
import { ProfileStore, IUserProfileStore } from "@/store/user/profile.store";
import { IUserPermissionStore, UserPermissionStore } from "./permissions.store";
// local imports
import { IUserSettingsStore, UserSettingsStore } from "./settings.store";
type TUserErrorStatus = {
@@ -68,7 +69,7 @@ export class UserStore implements IUserStore {
userService: UserService;
authService: AuthService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
// stores
this.userProfile = new ProfileStore(store);
this.userSettings = new UserSettingsStore();
@@ -265,8 +266,7 @@ export class UserStore implements IUserStore {
fetchProjectsWithCreatePermissions = (): { [key: string]: TUserPermissions } => {
const { workspaceSlug } = this.store.router;
const allWorkspaceProjectRoles =
this.permission.workspaceProjectsPermissions && this.permission.workspaceProjectsPermissions[workspaceSlug || ""];
const allWorkspaceProjectRoles = this.permission.getProjectRolesByWorkspaceSlug(workspaceSlug || "");
const userPermissions =
(allWorkspaceProjectRoles &&

View File

@@ -0,0 +1 @@
export * from "ce/helpers/project-settings";

View File

@@ -0,0 +1 @@
export * from "ce/store/member/project-member.store";

View File

@@ -0,0 +1 @@
export * from "ce/store/user/permission.store";