[WEB-6201] refactor: merge user permission, membership and subscription related stores to core (#5794)

This commit is contained in:
Prateek Shourya
2026-02-10 17:38:55 +05:30
committed by GitHub
parent ce04ba6eae
commit 3a4fdfe1d7
22 changed files with 187 additions and 522 deletions
@@ -1,57 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { computedFn } from "mobx-utils";
import type { EUserProjectRoles } from "@plane/types";
// plane imports
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
// store
import type { IMemberRootStore } from "@/store/member";
import type { IBaseProjectMemberStore } from "@/store/member/project/base-project-member.store";
import { BaseProjectMemberStore } from "@/store/member/project/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);
}
@@ -1,26 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
// store
import type { IBaseWorkspaceMemberStore } from "@/store/member/workspace/workspace-member.store";
import { BaseWorkspaceMemberStore } from "@/store/member/workspace/workspace-member.store";
import type { RootStore } from "@/plane-web/store/root.store";
import type { IMemberRootStore } from "@/store/member";
export type IWorkspaceMemberStore = IBaseWorkspaceMemberStore;
export class WorkspaceMemberStore extends BaseWorkspaceMemberStore implements IWorkspaceMemberStore {
constructor(_memberRoot: IMemberRootStore, _rootStore: RootStore) {
super(_memberRoot, _rootStore);
}
}
@@ -1,41 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { computedFn } from "mobx-utils";
import type { EUserPermissions } from "@plane/constants";
import type { RootStore } from "@/plane-web/store/root.store";
import { BaseUserPermissionStore } from "@/store/user/base-permissions.store";
import type { 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)
);
fetchWorkspaceLevelProjectEntities = (workspaceSlug: string, projectId: string): void => {
void this.store.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId);
};
}
@@ -22,7 +22,7 @@ import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// store
import type { IProjectMemberDetails } from "@/store/member/project/base-project-member.store";
import type { IProjectMemberDetails } from "@/store/member/project/membership.store";
// local imports
import { useProjectMemberColumns } from "./useProjectMemberColumns";
@@ -15,7 +15,7 @@ import { useContext } from "react";
// mobx store
import { StoreContext } from "@/lib/store-context";
// plane web imports
import type { IUserPermissionStore } from "@/plane-web/store/user/permission.store";
import type { IUserPermissionStore } from "@/store/user/permissions.store";
export const useUserPermissions = (): IUserPermissionStore => {
const context = useContext(StoreContext);
+5 -5
View File
@@ -15,11 +15,11 @@ import { makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import type { IUserLite } from "@plane/types";
// plane web imports
import type { IProjectMemberStore } from "@/plane-web/store/member/project-member.store";
import { ProjectMemberStore } from "@/plane-web/store/member/project-member.store";
import type { IWorkspaceMemberStore } from "@/plane-web/store/member/workspace-member.store";
import { WorkspaceMemberStore } from "@/plane-web/store/member/workspace-member.store";
// store
import type { IProjectMemberStore } from "./project/membership.store";
import { ProjectMemberStore } from "./project/membership.store";
import type { IWorkspaceMemberStore } from "./workspace/membership.store";
import { WorkspaceMemberStore } from "./workspace/membership.store";
import type { RootStore } from "@/plane-web/store/root.store";
export interface IMemberRootStore {
@@ -15,14 +15,15 @@ import { uniq, unset, set, update, sortBy } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { EUserPermissions } from "@plane/constants";
import { E_FEATURE_FLAGS, EUserPermissions } from "@plane/constants";
import { EUserProjectRoles } from "@plane/types";
import type {
EUserProjectRoles,
IProjectBulkAddFormData,
IProjectUserPropertiesResponse,
IUserLite,
TProjectMembership,
} from "@plane/types";
import { getHighestRole } from "@plane/utils";
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
// services
@@ -34,14 +35,14 @@ import type { IUserStore } from "@/store/user";
// local imports
import type { IMemberRootStore } from "../index";
import { sortProjectMembers } from "../utils";
import type { IProjectMemberFiltersStore } from "./project-member-filters.store";
import { ProjectMemberFiltersStore } from "./project-member-filters.store";
import type { IProjectMemberFiltersStore } from "./filters.store";
import { ProjectMemberFiltersStore } from "./filters.store";
export interface IProjectMemberDetails extends Omit<TProjectMembership, "member"> {
member: IUserLite;
}
export interface IBaseProjectMemberStore {
export interface IProjectMemberStore {
// observables
projectMemberFetchStatusMap: {
[projectId: string]: boolean;
@@ -91,7 +92,7 @@ export interface IBaseProjectMemberStore {
removeMemberFromProject: (workspaceSlug: string, projectId: string, userId: string) => Promise<void>;
}
export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore {
export class ProjectMemberStore implements IProjectMemberStore {
// observables
projectMemberFetchStatusMap: {
[projectId: string]: boolean;
@@ -211,7 +212,19 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
* @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;
getUserProjectRole = computedFn((userId: string, projectId: string): EUserProjectRoles | undefined => {
// Get the roles from the project and teamspace membership
const projectRoleFromProjectMembership = this.getRoleFromProjectMembership(userId, projectId);
const projectRoleFromTeamspaceMembership = this.getProjectRoleFromTeamspaceMembership(userId, projectId);
// Filter out undefined roles and get the highest role
const roles = [projectRoleFromProjectMembership, projectRoleFromTeamspaceMembership].filter(
(role): role is EUserProjectRoles => role !== undefined
);
// Return the highest role
return getHighestRole(roles);
});
/**
* @description get the details of a project member
@@ -331,22 +344,26 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.concat(
data.members.map((m) => m.member_id)
);
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
return response;
});
}
/**
* @description update the role of a member in a project
* @description Returns the highest role from the project and teamspace membership
* @param projectId
* @param userId
* @param role
*/
abstract getProjectMemberRoleForUpdate: (
projectId: string,
userId: string,
role: EUserProjectRoles
) => EUserProjectRoles;
getProjectMemberRoleForUpdate = (projectId: string, userId: string, role: EUserProjectRoles): EUserProjectRoles => {
const projectRoleFromTeamspaceMembership = this.getProjectRoleFromTeamspaceMembership(userId, projectId);
const availableRoles = [projectRoleFromTeamspaceMembership, role].filter(
(role): role is EUserProjectRoles => role !== undefined
);
return getHighestRole(availableRoles) ?? role;
};
/**
* @description update the role of a member in a project
@@ -386,6 +403,7 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
role,
}
);
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
return response;
} catch (error) {
// revert back to original members in case of error
@@ -429,7 +447,28 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
* @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 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) => {
// Get the role from the teamspace membership
const projectRoleFromTeamspaceMembership = this.getProjectRoleFromTeamspaceMembership(userId, projectId);
if (projectRoleFromTeamspaceMembership) {
// If the user is a member of the teamspace, update user membership detail
update(this.projectMemberMap, [projectId, userId], (prev: TProjectMembership) => ({
...prev,
id: null,
original_role: null,
role: projectRoleFromTeamspaceMembership,
}));
} else {
this.handleMemberRemoval(projectId, userId);
}
};
/**
* @description remove a member from a project
@@ -444,6 +483,7 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
runInAction(() => {
this.processMemberRemoval(projectId, userId);
});
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
}
/**
@@ -502,4 +542,43 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
throw error;
}
};
// private helpers
/**
* @description Returns the project role from the teamspace membership
* @param { string } userId
* @param { string } projectId
* @returns { EUserProjectRoles | undefined }
*/
private getProjectRoleFromTeamspaceMembership = computedFn(
(userId: string, projectId: string): EUserProjectRoles | undefined => {
// Find all the teamspaces linked to the project
const projectTeamspaceIds = this.rootStore.teamspaceRoot.teamspaces.getProjectTeamspaceIds(projectId);
if (!projectTeamspaceIds || projectTeamspaceIds.length === 0) return undefined;
// Check if the user is a member of any of the teamspaces
const isUserMemberOfTeamspace = projectTeamspaceIds.some((teamspaceId) =>
this.rootStore.teamspaceRoot.teamspaces.isUserMemberOfTeamspace(userId, teamspaceId)
);
if (!isUserMemberOfTeamspace) return undefined;
// Return MEMBER if the user is a member of any of the teamspaces
return EUserProjectRoles.MEMBER;
}
);
/**
* @description Mutate project members activity
* @param workspaceSlug
* @param projectId
*/
private mutateProjectMembersActivity = async (workspaceSlug: string, projectId: string) => {
const isProjectMembersActivityEnabled = this.rootStore.featureFlags.getFeatureFlag(
workspaceSlug,
E_FEATURE_FLAGS.PROJECT_MEMBER_ACTIVITY,
false
);
if (isProjectMembersActivityEnabled) {
await this.rootStore.projectMembersActivityStore.fetchProjectMembersActivity(workspaceSlug, projectId);
}
};
}
@@ -15,6 +15,7 @@ import { set, sortBy } from "lodash-es";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// types
import { E_FEATURE_FLAGS } from "@plane/constants";
import type { EUserPermissions } from "@plane/constants";
import type {
IWorkspaceBulkInviteFormData,
@@ -32,9 +33,9 @@ import { WorkspaceService } from "@/services/workspace.service";
import type { IRouterStore } from "@/store/router.store";
import type { IUserStore } from "@/store/user";
// store
import type { IMemberRootStore } from "../index.ts";
import type { IWorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
import { WorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
import type { IMemberRootStore } from "../index";
import type { IWorkspaceMemberFiltersStore } from "./filters.store";
import { WorkspaceMemberFiltersStore } from "./filters.store";
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
export interface IWorkspaceMembership {
@@ -44,7 +45,7 @@ export interface IWorkspaceMembership {
is_active?: boolean;
}
export interface IBaseWorkspaceMemberStore {
export interface IWorkspaceMemberStore {
// observables
workspaceMemberMap: Record<string, Record<string, IWorkspaceMembership>>;
workspaceMemberInvitations: Record<string, IWorkspaceMemberInvitation[]>;
@@ -91,9 +92,11 @@ export interface IBaseWorkspaceMemberStore {
checklist: Partial<Record<TGettingStartedChecklistKeys, boolean>>
) => Promise<IWorkspaceMemberMe>;
updateChecklistIfNotDoneAlready: (workspaceSlug: string, key: TGettingStartedChecklistKeys) => Promise<void>;
// mutation helpers
mutateWorkspaceMembersActivity: (workspaceSlug: string) => Promise<void>;
}
export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberStore {
export class WorkspaceMemberStore implements IWorkspaceMemberStore {
// observables
workspaceMemberMap: {
[workspaceSlug: string]: Record<string, IWorkspaceMembership>;
@@ -300,6 +303,7 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
set(this.workspaceMemberMap, [workspaceSlug, userId, "role"], data.role);
});
await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberDetails.id, data);
void this.mutateWorkspaceMembersActivity(workspaceSlug);
} catch (error) {
// revert back to original members in case of error
runInAction(() => {
@@ -321,6 +325,7 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
runInAction(() => {
set(this.workspaceMemberMap, [workspaceSlug, userId, "is_active"], false);
});
void this.mutateWorkspaceMembersActivity(workspaceSlug);
}
/**
* @description fetch all the member invitations of a workspace
@@ -344,6 +349,7 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
await this.fetchWorkspaceMemberInvitations(workspaceSlug);
// Auto-complete getting started checklist
void this.updateChecklistIfNotDoneAlready(workspaceSlug, "team_members_invited");
void this.mutateWorkspaceMembersActivity(workspaceSlug);
}
/**
@@ -389,6 +395,7 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
(inv) => inv.id !== invitationId
);
});
void this.mutateWorkspaceMembersActivity(workspaceSlug);
}
isUserSuspended = computedFn((userId: string, workspaceSlug: string) => {
@@ -404,18 +411,15 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
*/
getGettingStartedChecklistByWorkspaceSlug = computedFn(
(workspaceSlug: string): Partial<Record<TGettingStartedChecklistKeys, boolean>> | undefined => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const checklist = this.rootStore.user.permission.workspaceUserInfo[workspaceSlug]?.getting_started_checklist;
if (!checklist) return undefined;
// Filter out null values to match return type
const filtered: Partial<Record<TGettingStartedChecklistKeys, boolean>> = {};
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
(Object.keys(checklist) as TGettingStartedChecklistKeys[]).forEach((key) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const value = checklist[key];
if (value !== null) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
filtered[key] = value;
}
});
@@ -439,12 +443,10 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
errorMessage: string
): Promise<IWorkspaceMemberMe> => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const existingData = this.rootStore.user.permission.workspaceUserInfo[workspaceSlug]?.[fieldKey];
const filteredExisting: Record<string, boolean> = {};
if (existingData && typeof existingData === "object") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
Object.entries(existingData).forEach(([key, value]) => {
if (value !== null && typeof value === "boolean") {
filteredExisting[key] = value;
@@ -452,7 +454,6 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
});
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
const response = await this.workspaceService.updateMemberOnboarding(workspaceSlug, {
[fieldKey]: {
...filteredExisting,
@@ -462,12 +463,10 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
if (response) {
runInAction(() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const currentFieldData = this.rootStore.user.permission.workspaceUserInfo[workspaceSlug]?.[fieldKey];
const mergedData: Record<string, boolean> = {};
if (currentFieldData && typeof currentFieldData === "object") {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
Object.entries(currentFieldData).forEach(([key, value]) => {
if (value !== null && typeof value === "boolean") {
mergedData[key] = value;
@@ -485,7 +484,6 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
});
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response;
} catch (error) {
console.error(errorMessage, error);
@@ -543,4 +541,20 @@ export abstract class BaseWorkspaceMemberStore implements IBaseWorkspaceMemberSt
await this.updateChecklist(workspaceSlug, { [key]: true });
}
};
/**
* Mutate workspace members activity
* @param workspaceSlug
*/
mutateWorkspaceMembersActivity = async (workspaceSlug: string) => {
const isMembersActivityEnabled = this.rootStore.featureFlags.getFeatureFlag(
workspaceSlug,
E_FEATURE_FLAGS.WORKSPACE_MEMBER_ACTIVITY,
false
);
if (isMembersActivityEnabled) {
await this.rootStore.workspaceMembersActivityStore.fetchWorkspaceMembersActivity(workspaceSlug);
}
};
}
+14
View File
@@ -177,6 +177,11 @@ import type { IEpicAnalyticStore } from "./work-items/epic/analytic.store";
import { EpicAnalytics } from "./work-items/epic/analytic.store";
import type { IEpicBaseStore } from "./work-items/epic/base.store";
import { EpicBaseStore } from "./work-items/epic/base.store";
// subscriptions
import type { ISelfHostedSubscriptionStore } from "./subscription/self-hosted-subscription.store";
import { SelfHostedSubscriptionStore } from "./subscription/self-hosted-subscription.store";
import type { IWorkspaceSubscriptionStore } from "./subscription/subscription.store";
import { WorkspaceSubscriptionStore } from "./subscription/subscription.store";
enableStaticRendering(typeof window === "undefined");
@@ -252,6 +257,9 @@ export class CoreRootStore {
// epics
epicAnalytics: IEpicAnalyticStore;
epicBaseStore: IEpicBaseStore;
// subscriptions
workspaceSubscription: IWorkspaceSubscriptionStore;
selfHostedSubscription: ISelfHostedSubscriptionStore;
constructor() {
this.router = new RouterStore();
@@ -325,6 +333,9 @@ export class CoreRootStore {
// epics
this.epicAnalytics = new EpicAnalytics(this as unknown as RootStore);
this.epicBaseStore = new EpicBaseStore(this as unknown as RootStore);
// subscriptions
this.workspaceSubscription = new WorkspaceSubscriptionStore(this as unknown as RootStore);
this.selfHostedSubscription = new SelfHostedSubscriptionStore(this as unknown as RootStore);
}
resetOnSignOut() {
@@ -401,5 +412,8 @@ export class CoreRootStore {
// epics
this.epicAnalytics = new EpicAnalytics(this as unknown as RootStore);
this.epicBaseStore = new EpicBaseStore(this as unknown as RootStore);
// subscriptions
this.workspaceSubscription = new WorkspaceSubscriptionStore(this as unknown as RootStore);
this.selfHostedSubscription = new SelfHostedSubscriptionStore(this as unknown as RootStore);
}
}
+2 -2
View File
@@ -18,12 +18,12 @@ import { EUserPermissions, API_BASE_URL } from "@plane/constants";
import type { IUser, TUserPermissions } from "@plane/types";
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
import type { IUserPermissionStore } from "@/plane-web/store/user/permission.store";
import { UserPermissionStore } from "@/plane-web/store/user/permission.store";
// services
import { AuthService } from "@/services/auth.service";
import { UserService } from "@/services/user.service";
// stores
import type { IUserPermissionStore } from "@/store/user/permissions.store";
import { UserPermissionStore } from "@/store/user/permissions.store";
import type { IAccountStore } from "@/store/user/account.store";
import type { IUserProfileStore } from "@/store/user/profile.store";
import { ProfileStore } from "@/store/user/profile.store";
@@ -23,6 +23,7 @@ import {
} from "@plane/constants";
import type { EUserProjectRoles, IUserProjectsRole, IWorkspaceMemberMe, TProjectMembership } from "@plane/types";
import { EUserWorkspaceRoles } from "@plane/types";
import { getHighestRole } from "@plane/utils";
// plane web imports
import { WorkspaceService } from "@/services/workspace.service";
import type { RootStore } from "@/plane-web/store/root.store";
@@ -35,7 +36,7 @@ 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 IBaseUserPermissionStore {
export interface IUserPermissionStore {
loader: boolean;
// observables
workspaceUserInfo: Record<string, IWorkspaceMemberMe>; // workspaceSlug -> IWorkspaceMemberMe
@@ -71,7 +72,7 @@ export interface IBaseUserPermissionStore {
* @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 {
export class UserPermissionStore implements IUserPermissionStore {
loader: boolean = false;
// constants
workspaceUserInfo: Record<string, IWorkspaceMemberMe> = {};
@@ -152,16 +153,27 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor
});
/**
* @description Returns the current project permissions
* @description Returns the highest role from the project and teamspace membership
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { EUserPermissions | undefined }
*/
abstract getProjectRoleByWorkspaceSlugAndProjectId: (
workspaceSlug: string,
projectId?: string
) => EUserPermissions | undefined;
getProjectRoleByWorkspaceSlugAndProjectId = computedFn(
(workspaceSlug: string, projectId?: string): EUserPermissions | undefined => {
// Early return for invalid inputs
if (!workspaceSlug || !projectId) return undefined;
// Get permissions from the project and teamspace membership
const projectRole = this.getProjectRole(workspaceSlug, projectId);
const projectRoleFromTeamspace = this.getProjectRoleFromTeamspace(projectId);
// Filter out undefined roles and get the highest role
const roles = [projectRole, projectRoleFromTeamspace].filter(
(role): role is EUserPermissions => role !== undefined
);
return getHighestRole(roles);
}
);
/**
* @description Fetches project-level entities that are not automatically loaded by the project wrapper.
* This is used when joining a project to ensure all necessary workspace-level project data is available.
@@ -169,7 +181,12 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor
* @param { string } projectId
* @returns { Promise<void> }
*/
abstract fetchWorkspaceLevelProjectEntities: (workspaceSlug: string, projectId: string) => void;
fetchWorkspaceLevelProjectEntities = (workspaceSlug: string, projectId: string): void => {
void Promise.all([
this.store.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId),
this.store.issueTypes.fetchAll(workspaceSlug, projectId),
]);
};
/**
* @description Returns whether the user has the permission to access a page
@@ -362,4 +379,17 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor
throw error;
}
};
// private helpers
/**
* @description Returns the project membership permission from the teamspace
* @description If project is linked to any teamspace, then the permission is MEMBER
* @param { string } projectId
* @returns { EUserPermissions | undefined }
*/
private getProjectRoleFromTeamspace = computedFn((projectId: string): EUserPermissions | undefined => {
const projectTeamspaceIds = this.store.teamspaceRoot.teamspaces.getProjectTeamspaceIds(projectId);
if (!projectTeamspaceIds || projectTeamspaceIds.length === 0) return undefined;
return EUserPermissions.MEMBER;
});
}
+1 -1
View File
@@ -33,7 +33,7 @@ import { TeamViewIssues, TeamViewIssuesFilter } from "@/store/work-items/team-vi
import type { IWorkspaceIssues } from "@/store/work-items/workspace/issue.store";
import { WorkspaceIssues } from "@/store/work-items/workspace/issue.store";
import type { RootStore } from "@/plane-web/store/root.store";
import type { IWorkspaceMembership } from "@/store/member/workspace/workspace-member.store";
import type { IWorkspaceMembership } from "@/store/member/workspace/membership.store";
// issues data store
import type { IArchivedIssuesFilter, IArchivedIssues } from "./archived";
import { ArchivedIssuesFilter, ArchivedIssues } from "./archived";
@@ -15,7 +15,7 @@ import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// plane web stores
import type { ISelfHostedSubscriptionStore } from "@/plane-web/store/subscription/self-hosted-subscription.store";
import type { ISelfHostedSubscriptionStore } from "@/store/subscription/self-hosted-subscription.store";
export const useSelfHostedSubscription = (): ISelfHostedSubscriptionStore => {
const context = useContext(StoreContext);
@@ -15,7 +15,7 @@ import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
// plane web stores
import type { IWorkspaceSubscriptionStore } from "@/plane-web/store/subscription/subscription.store";
import type { IWorkspaceSubscriptionStore } from "@/store/subscription/subscription.store";
export const useWorkspaceSubscription = (): IWorkspaceSubscriptionStore => {
const context = useContext(StoreContext);
@@ -1,176 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { update } from "lodash-es";
import { computedFn } from "mobx-utils";
import { EUserProjectRoles } from "@plane/types";
import type { IProjectBulkAddFormData, TProjectMembership } from "@plane/types";
// plane imports
import { getHighestRole } from "@plane/utils";
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
// store
import type { IMemberRootStore } from "@/store/member";
import type { IBaseProjectMemberStore } from "@/store/member/project/base-project-member.store";
import { BaseProjectMemberStore } from "@/store/member/project/base-project-member.store";
import { E_FEATURE_FLAGS } from "@plane/constants";
export type IProjectMemberStore = IBaseProjectMemberStore;
export class ProjectMemberStore extends BaseProjectMemberStore implements IProjectMemberStore {
constructor(_memberRoot: IMemberRootStore, rootStore: RootStore) {
super(_memberRoot, rootStore);
}
/**
* @description Returns the project role from the teamspace membership
* @param { string } userId
* @param { string } projectId
* @returns { EUserProjectRoles | undefined }
*/
private getProjectRoleFromTeamspaceMembership = computedFn(
(userId: string, projectId: string): EUserProjectRoles | undefined => {
// Find all the teamspaces linked to the project
const projectTeamspaceIds = this.rootStore.teamspaceRoot.teamspaces.getProjectTeamspaceIds(projectId);
if (!projectTeamspaceIds || projectTeamspaceIds.length === 0) return undefined;
// Check if the user is a member of any of the teamspaces
const isUserMemberOfTeamspace = projectTeamspaceIds.some((teamspaceId) =>
this.rootStore.teamspaceRoot.teamspaces.isUserMemberOfTeamspace(userId, teamspaceId)
);
if (!isUserMemberOfTeamspace) return undefined;
// Return MEMBER if the user is a member of any of the teamspaces
return EUserProjectRoles.MEMBER;
}
);
/**
* @description Returns the highest role from the project and teamspace membership
* @param { string } userId
* @param { string } projectId
* @returns { EUserProjectRoles | undefined }
*/
getUserProjectRole = computedFn((userId: string, projectId: string): EUserProjectRoles | undefined => {
// Get the roles from the project and teamspace membership
const projectRoleFromProjectMembership = this.getRoleFromProjectMembership(userId, projectId);
const projectRoleFromTeamspaceMembership = this.getProjectRoleFromTeamspaceMembership(userId, projectId);
// Filter out undefined roles and get the highest role
const roles = [projectRoleFromProjectMembership, projectRoleFromTeamspaceMembership].filter(
(role): role is EUserProjectRoles => role !== undefined
);
// Return the highest role
return getHighestRole(roles);
});
/**
* @description Returns the highest role from the project and teamspace membership
* @param projectId
* @param userId
* @param role
*/
getProjectMemberRoleForUpdate = (projectId: string, userId: string, role: EUserProjectRoles): EUserProjectRoles => {
const projectRoleFromTeamspaceMembership = this.getProjectRoleFromTeamspaceMembership(userId, projectId);
const availableRoles = [projectRoleFromTeamspaceMembership, role].filter(
(role): role is EUserProjectRoles => role !== undefined
);
return getHighestRole(availableRoles) ?? 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) => {
// Get the role from the teamspace membership
const projectRoleFromTeamspaceMembership = this.getProjectRoleFromTeamspaceMembership(userId, projectId);
if (projectRoleFromTeamspaceMembership) {
// If the user is a member of the teamspace, update user membership detail
update(this.projectMemberMap, [projectId, userId], (prev: TProjectMembership) => ({
...prev,
id: null,
original_role: null,
role: projectRoleFromTeamspaceMembership,
}));
} else {
this.handleMemberRemoval(projectId, userId);
}
};
/**
* @description Mutate project members activity
* @param workspaceSlug
* @param projectId
*/
mutateProjectMembersActivity = async (workspaceSlug: string, projectId: string) => {
const isProjectMembersActivityEnabled = this.rootStore.featureFlags.getFeatureFlag(
workspaceSlug,
E_FEATURE_FLAGS.PROJECT_MEMBER_ACTIVITY,
false
);
if (isProjectMembersActivityEnabled) {
await this.rootStore.projectMembersActivityStore.fetchProjectMembersActivity(workspaceSlug, projectId);
}
};
/**
* @description bulk add members to a project
* @param workspaceSlug
* @param projectId
* @param data
* @returns Promise<TProjectMembership[]>
*/
override bulkAddMembersToProject = async (
workspaceSlug: string,
projectId: string,
data: IProjectBulkAddFormData
) => {
const response = await super.bulkAddMembersToProject(workspaceSlug, projectId, data);
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
return response;
};
/**
* @description update the role of a member in a project
* @param workspaceSlug
* @param projectId
* @param userId
* @param data
*/
override updateMemberRole = async (
workspaceSlug: string,
projectId: string,
userId: string,
role: EUserProjectRoles
) => {
const response = await super.updateMemberRole(workspaceSlug, projectId, userId, role);
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
return response;
};
/**
* @description remove a member from a project
* @param workspaceSlug
* @param projectId
* @param userId
*/
override removeMemberFromProject = async (workspaceSlug: string, projectId: string, userId: string) => {
await super.removeMemberFromProject(workspaceSlug, projectId, userId);
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
};
}
@@ -1,90 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
// store
import type { IBaseWorkspaceMemberStore } from "@/store/member/workspace/workspace-member.store";
import { BaseWorkspaceMemberStore } from "@/store/member/workspace/workspace-member.store";
import type { RootStore } from "@/plane-web/store/root.store";
import type { IMemberRootStore } from "@/store/member";
// types
import type { EUserPermissions } from "@plane/constants";
import { E_FEATURE_FLAGS } from "@plane/constants";
import type { IWorkspaceBulkInviteFormData } from "@plane/types";
export type IWorkspaceMemberStore = IBaseWorkspaceMemberStore & {
mutateWorkspaceMembersActivity: (workspaceSlug: string) => Promise<void>;
};
export class WorkspaceMemberStore extends BaseWorkspaceMemberStore implements IWorkspaceMemberStore {
constructor(_memberRoot: IMemberRootStore, _rootStore: RootStore) {
super(_memberRoot, _rootStore);
}
// actions
/**
* Mutate workspace members activity
* @param workspaceSlug
*/
mutateWorkspaceMembersActivity = async (workspaceSlug: string) => {
const isMembersActivityEnabled = this.rootStore.featureFlags.getFeatureFlag(
workspaceSlug,
E_FEATURE_FLAGS.WORKSPACE_MEMBER_ACTIVITY,
false
);
if (isMembersActivityEnabled) {
await this.rootStore.workspaceMembersActivityStore.fetchWorkspaceMembersActivity(workspaceSlug);
}
};
/**
* @description update the role of a workspace member
* @param workspaceSlug
* @param userId
* @param data
*/
override updateMember = async (workspaceSlug: string, userId: string, data: { role: EUserPermissions }) => {
await super.updateMember(workspaceSlug, userId, data);
void this.mutateWorkspaceMembersActivity(workspaceSlug);
};
/**
* @description remove a member from workspace
* @param workspaceSlug
* @param userId
*/
override removeMemberFromWorkspace = async (workspaceSlug: string, userId: string) => {
await super.removeMemberFromWorkspace(workspaceSlug, userId);
void this.mutateWorkspaceMembersActivity(workspaceSlug);
};
/**
* @description bulk invite members to a workspace
* @param workspaceSlug
* @param data
*/
override inviteMembersToWorkspace = async (workspaceSlug: string, data: IWorkspaceBulkInviteFormData) => {
await super.inviteMembersToWorkspace(workspaceSlug, data);
void this.mutateWorkspaceMembersActivity(workspaceSlug);
};
/**
* @description delete a member invitation
* @param workspaceSlug
* @param memberId
*/
override deleteMemberInvitation = async (workspaceSlug: string, invitationId: string) => {
await super.deleteMemberInvitation(workspaceSlug, invitationId);
void this.mutateWorkspaceMembersActivity(workspaceSlug);
};
}
-10
View File
@@ -20,10 +20,6 @@ import type { IPublishPageStore } from "@/plane-web/store/pages/publish-page.sto
import { PublishPageStore } from "@/plane-web/store/pages/publish-page.store";
import type { IWorkspacePageStore } from "@/plane-web/store/pages/workspace-page.store";
import { WorkspacePageStore } from "@/plane-web/store/pages/workspace-page.store";
import type { ISelfHostedSubscriptionStore } from "@/plane-web/store/subscription/self-hosted-subscription.store";
import { SelfHostedSubscriptionStore } from "@/plane-web/store/subscription/self-hosted-subscription.store";
import type { IWorkspaceSubscriptionStore } from "@/plane-web/store/subscription/subscription.store";
import { WorkspaceSubscriptionStore } from "@/plane-web/store/subscription/subscription.store";
import { TimeLineStore } from "@/plane-web/store/timeline";
import type { IProjectFilterStore, IWorkspaceProjectStatesStore } from "@/plane-web/store/workspace-project-states";
import { ProjectFilterStore, WorkspaceProjectStatesStore } from "@/plane-web/store/workspace-project-states";
@@ -51,8 +47,6 @@ export class RootStore extends CoreRootStore {
theme: IThemeStore;
workspacePages: IWorkspacePageStore;
publishPage: IPublishPageStore;
workspaceSubscription: IWorkspaceSubscriptionStore;
selfHostedSubscription: ISelfHostedSubscriptionStore;
workspaceProjectStates: IWorkspaceProjectStatesStore;
projectFilter: IProjectFilterStore;
cycle: ICycleStore;
@@ -69,8 +63,6 @@ export class RootStore extends CoreRootStore {
this.theme = new ThemeStore();
this.workspacePages = new WorkspacePageStore(this);
this.publishPage = new PublishPageStore(this);
this.workspaceSubscription = new WorkspaceSubscriptionStore(this);
this.selfHostedSubscription = new SelfHostedSubscriptionStore(this);
this.workspaceProjectStates = new WorkspaceProjectStatesStore(this);
this.projectFilter = new ProjectFilterStore(this);
this.cycle = new CycleStore(this);
@@ -90,8 +82,6 @@ export class RootStore extends CoreRootStore {
this.theme = new ThemeStore();
this.workspacePages = new WorkspacePageStore(this);
this.publishPage = new PublishPageStore(this);
this.workspaceSubscription = new WorkspaceSubscriptionStore(this);
this.selfHostedSubscription = new SelfHostedSubscriptionStore(this);
this.workspaceProjectStates = new WorkspaceProjectStatesStore(this);
this.projectFilter = new ProjectFilterStore(this);
this.cycle = new CycleStore(this);
@@ -1,72 +0,0 @@
/**
* SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
* SPDX-License-Identifier: LicenseRef-Plane-Commercial
*
* Licensed under the Plane Commercial License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* https://plane.so/legals/eula
*
* DO NOT remove or modify this notice.
* NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
*/
import { computedFn } from "mobx-utils";
// plane imports
import { EUserPermissions } from "@plane/constants";
import { getHighestRole } from "@plane/utils";
// plane web imports
import type { RootStore } from "@/plane-web/store/root.store";
// store
import type { IBaseUserPermissionStore } from "@/store/user/base-permissions.store";
import { BaseUserPermissionStore } 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 membership permission from the teamspace
* @description If project is linked to any teamspace, then the permission is MEMBER
* @param { string } projectId
* @returns { EUserPermissions | undefined }
*/
private getProjectRoleFromTeamspace = computedFn((projectId: string): EUserPermissions | undefined => {
const projectTeamspaceIds = this.store.teamspaceRoot.teamspaces.getProjectTeamspaceIds(projectId);
if (!projectTeamspaceIds || projectTeamspaceIds.length === 0) return undefined;
return EUserPermissions.MEMBER;
});
/**
* @description Returns the highest role from the project and teamspace membership
* @param { string } workspaceSlug
* @param { string } projectId
* @returns { EUserPermissions | undefined }
*/
getProjectRoleByWorkspaceSlugAndProjectId = computedFn(
(workspaceSlug: string, projectId?: string): EUserPermissions | undefined => {
// Early return for invalid inputs
if (!workspaceSlug || !projectId) return undefined;
// Get permissions from the project and teamspace membership
const projectRole = this.getProjectRole(workspaceSlug, projectId);
const projectRoleFromTeamspace = this.getProjectRoleFromTeamspace(projectId);
// Filter out undefined roles and get the highest role
const roles = [projectRole, projectRoleFromTeamspace].filter(
(role): role is EUserPermissions => role !== undefined
);
return getHighestRole(roles);
}
);
fetchWorkspaceLevelProjectEntities = (workspaceSlug: string, projectId: string): void => {
void Promise.all([
this.store.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId),
this.store.issueTypes.fetchAll(workspaceSlug, projectId),
]);
};
}