mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 11:57:56 +01:00
[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:
@@ -148,11 +148,13 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
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):
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"live": "Živě",
|
||||
"change_history": "Historie změn",
|
||||
"coming_soon": "Již brzy",
|
||||
"member": "Člen",
|
||||
"members": "Členové",
|
||||
"you": "Vy",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"live": "Live",
|
||||
"change_history": "Änderungsverlauf",
|
||||
"coming_soon": "Demnächst verfügbar",
|
||||
"member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"you": "Sie",
|
||||
"upgrade_cta": {
|
||||
@@ -2459,4 +2460,4 @@
|
||||
"previously_edited_by": "Zuvor bearbeitet von",
|
||||
"edited_by": "Bearbeitet von"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,6 +690,7 @@
|
||||
"live": "Live",
|
||||
"change_history": "Change History",
|
||||
"coming_soon": "Coming soon",
|
||||
"member": "Member",
|
||||
"members": "Members",
|
||||
"you": "You",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -851,6 +851,7 @@
|
||||
"live": "En vivo",
|
||||
"change_history": "Historial de cambios",
|
||||
"coming_soon": "Próximamente",
|
||||
"member": "Miembro",
|
||||
"members": "Miembros",
|
||||
"you": "Tú",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -849,6 +849,7 @@
|
||||
"live": "En direct",
|
||||
"change_history": "Historique des modifications",
|
||||
"coming_soon": "À venir",
|
||||
"member": "Membre",
|
||||
"members": "Membres",
|
||||
"you": "Vous",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -848,6 +848,7 @@
|
||||
"live": "Langsung",
|
||||
"change_history": "Riwayat Perubahan",
|
||||
"coming_soon": "Segera hadir",
|
||||
"member": "Anggota",
|
||||
"members": "Anggota",
|
||||
"you": "Anda",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -847,6 +847,7 @@
|
||||
"live": "Live",
|
||||
"change_history": "Cronologia modifiche",
|
||||
"coming_soon": "Prossimamente",
|
||||
"member": "Membro",
|
||||
"members": "Membri",
|
||||
"you": "Tu",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -849,6 +849,7 @@
|
||||
"live": "ライブ",
|
||||
"change_history": "変更履歴",
|
||||
"coming_soon": "近日公開",
|
||||
"member": "メンバー",
|
||||
"members": "メンバー",
|
||||
"you": "あなた",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "라이브",
|
||||
"change_history": "변경 기록",
|
||||
"coming_soon": "곧 출시",
|
||||
"member": "멤버",
|
||||
"members": "멤버",
|
||||
"you": "나",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "В прямом эфире",
|
||||
"change_history": "История изменений",
|
||||
"coming_soon": "Скоро",
|
||||
"member": "Участник",
|
||||
"members": "Участники",
|
||||
"you": "Вы",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "Živé",
|
||||
"change_history": "História zmien",
|
||||
"coming_soon": "Už čoskoro",
|
||||
"member": "Člen",
|
||||
"members": "Členovia",
|
||||
"you": "Vy",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "Наживо",
|
||||
"change_history": "Історія змін",
|
||||
"coming_soon": "Незабаром",
|
||||
"member": "Учасник",
|
||||
"members": "Учасники",
|
||||
"you": "Ви",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -849,6 +849,7 @@
|
||||
"live": "实时",
|
||||
"change_history": "变更历史",
|
||||
"coming_soon": "即将推出",
|
||||
"member": "成员",
|
||||
"members": "成员",
|
||||
"you": "你",
|
||||
"upgrade_cta": {
|
||||
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "即時",
|
||||
"change_history": "變更歷史記錄",
|
||||
"coming_soon": "即將推出",
|
||||
"member": "成員",
|
||||
"members": "成員",
|
||||
"you": "您",
|
||||
"upgrade_cta": {
|
||||
|
||||
46
packages/types/src/project/projects.d.ts
vendored
46
packages/types/src/project/projects.d.ts
vendored
@@ -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 }[];
|
||||
|
||||
4
packages/types/src/workspace.d.ts
vendored
4
packages/types/src/workspace.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -8,9 +8,10 @@ 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";
|
||||
export * from "./theme";
|
||||
export * from "./work-item";
|
||||
export * from "./workspace";
|
||||
export * from "./workspace";
|
||||
13
packages/utils/src/permission.ts
Normal file
13
packages/utils/src/permission.ts
Normal 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));
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
1
web/ce/components/projects/teamspaces/index.ts
Normal file
1
web/ce/components/projects/teamspaces/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./teamspace-list";
|
||||
6
web/ce/components/projects/teamspaces/teamspace-list.tsx
Normal file
6
web/ce/components/projects/teamspaces/teamspace-list.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export type TProjectTeamspaceList = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ProjectTeamspaceList: React.FC<TProjectTeamspaceList> = () => null;
|
||||
@@ -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/`,
|
||||
|
||||
7
web/ce/helpers/project-settings.ts
Normal file
7
web/ce/helpers/project-settings.ts
Normal 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;
|
||||
43
web/ce/store/member/project-member.store.ts
Normal file
43
web/ce/store/member/project-member.store.ts
Normal 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);
|
||||
}
|
||||
23
web/ce/store/user/permission.store.ts
Normal file
23
web/ce/store/user/permission.store.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)))
|
||||
);
|
||||
|
||||
@@ -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 can’t change this role yet.",
|
||||
message: errorString ?? "An error occurred while updating member role. Please try again.",
|
||||
});
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You can’t 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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() || ""
|
||||
);
|
||||
|
||||
@@ -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() || ""
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
@@ -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 &&
|
||||
|
||||
1
web/ee/helpers/project-settings.ts
Normal file
1
web/ee/helpers/project-settings.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "ce/helpers/project-settings";
|
||||
1
web/ee/store/member/project-member.store.ts
Normal file
1
web/ee/store/member/project-member.store.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "ce/store/member/project-member.store";
|
||||
1
web/ee/store/user/permission.store.ts
Normal file
1
web/ee/store/user/permission.store.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "ce/store/user/permission.store";
|
||||
Reference in New Issue
Block a user