From 67cbe94d4a51e224f7e66f0978ea908041f47ad0 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 30 May 2025 19:57:07 +0530 Subject: [PATCH] [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 --- apiserver/plane/app/serializers/project.py | 8 +- .../i18n/src/locales/cs/translations.json | 1 + .../i18n/src/locales/de/translations.json | 3 +- .../i18n/src/locales/en/translations.json | 1 + .../i18n/src/locales/es/translations.json | 1 + .../i18n/src/locales/fr/translations.json | 1 + .../i18n/src/locales/id/translations.json | 1 + .../i18n/src/locales/it/translations.json | 1 + .../i18n/src/locales/ja/translations.json | 1 + .../i18n/src/locales/ko/translations.json | 1 + .../i18n/src/locales/pl/translations.json | 1 + .../i18n/src/locales/pt-BR/translations.json | 1 + .../i18n/src/locales/ro/translations.json | 1 + .../i18n/src/locales/ru/translations.json | 1 + .../i18n/src/locales/sk/translations.json | 1 + .../i18n/src/locales/tr-TR/translations.json | 1 + .../i18n/src/locales/ua/translations.json | 1 + .../i18n/src/locales/vi-VN/translations.json | 1 + .../i18n/src/locales/zh-CN/translations.json | 1 + .../i18n/src/locales/zh-TW/translations.json | 1 + packages/types/src/project/projects.d.ts | 46 ++--- packages/types/src/workspace.d.ts | 4 +- packages/utils/src/index.ts | 3 +- packages/utils/src/permission.ts | 13 ++ .../settings/(workspace)/layout.tsx | 13 +- .../projects/[projectId]/members/page.tsx | 26 ++- web/app/(all)/onboarding/page.tsx | 1 - .../projects/settings/useProjectColumns.tsx | 45 +++-- .../components/projects/teamspaces/index.ts | 1 + .../projects/teamspaces/teamspace-list.tsx | 6 + web/ce/constants/project/settings/tabs.ts | 2 +- web/ce/helpers/project-settings.ts | 7 + web/ce/store/member/project-member.store.ts | 43 ++++ web/ce/store/user/permission.store.ts | 23 +++ .../auth-screens/project/join-project.tsx | 51 +++-- web/core/components/inbox/content/root.tsx | 4 +- .../issue-detail/issue-activity/root.tsx | 8 +- web/core/components/project/create/header.tsx | 1 - .../project/create/project-create-buttons.tsx | 2 +- .../components/project/member-list-item.tsx | 20 +- web/core/components/project/member-list.tsx | 44 ++-- web/core/components/project/member-select.tsx | 8 +- .../components/project/multi-select-modal.tsx | 4 +- .../project-settings-member-defaults.tsx | 179 +++++++++-------- .../project/send-project-invitation-modal.tsx | 29 +-- .../project/settings/member-columns.tsx | 97 +++++---- .../project/sidebar/nav-item-children.tsx | 3 +- .../components/ui/loader/settings/members.tsx | 19 +- .../workspace/settings/members-list.tsx | 4 +- web/core/hooks/store/user/user-permissions.ts | 4 +- .../layouts/auth-layout/project-wrapper.tsx | 6 +- web/core/lib/posthog-provider.tsx | 9 +- web/core/local-db/utils/query-sanitizer.ts.ts | 5 +- .../project/project-member.service.ts | 14 +- ....store.ts => base-project-member.store.ts} | 188 ++++++++++++++---- web/core/store/member/index.ts | 11 +- web/core/store/pages/project-page.store.ts | 2 +- web/core/store/pages/project-page.ts | 2 +- web/core/store/root.store.ts | 8 +- ...ons.store.ts => base-permissions.store.ts} | 144 +++++++++----- web/core/store/user/index.ts | 16 +- web/ee/helpers/project-settings.ts | 1 + web/ee/store/member/project-member.store.ts | 1 + web/ee/store/user/permission.store.ts | 1 + 64 files changed, 719 insertions(+), 428 deletions(-) create mode 100644 packages/utils/src/permission.ts create mode 100644 web/ce/components/projects/teamspaces/index.ts create mode 100644 web/ce/components/projects/teamspaces/teamspace-list.tsx create mode 100644 web/ce/helpers/project-settings.ts create mode 100644 web/ce/store/member/project-member.store.ts create mode 100644 web/ce/store/user/permission.store.ts rename web/core/store/member/{project-member.store.ts => base-project-member.store.ts} (53%) rename web/core/store/user/{permissions.store.ts => base-permissions.store.ts} (63%) create mode 100644 web/ee/helpers/project-settings.ts create mode 100644 web/ee/store/member/project-member.store.ts create mode 100644 web/ee/store/user/permission.store.ts diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 6226385589..8d521e8e83 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -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): diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 28109cd9d2..78a396051e 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -848,6 +848,7 @@ "live": "Živě", "change_history": "Historie změn", "coming_soon": "Již brzy", + "member": "Člen", "members": "Členové", "you": "Vy", "upgrade_cta": { diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 1cc3707287..1e9ba1e3d5 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -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" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 60e79121bc..196e1ce4ca 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -690,6 +690,7 @@ "live": "Live", "change_history": "Change History", "coming_soon": "Coming soon", + "member": "Member", "members": "Members", "you": "You", "upgrade_cta": { diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 94f1819a48..8fbeb87ad5 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -851,6 +851,7 @@ "live": "En vivo", "change_history": "Historial de cambios", "coming_soon": "Próximamente", + "member": "Miembro", "members": "Miembros", "you": "Tú", "upgrade_cta": { diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 4f356f1491..16f4a74e48 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -849,6 +849,7 @@ "live": "En direct", "change_history": "Historique des modifications", "coming_soon": "À venir", + "member": "Membre", "members": "Membres", "you": "Vous", "upgrade_cta": { diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index 20b683c65f..577086dad1 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -848,6 +848,7 @@ "live": "Langsung", "change_history": "Riwayat Perubahan", "coming_soon": "Segera hadir", + "member": "Anggota", "members": "Anggota", "you": "Anda", "upgrade_cta": { diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index 5534d885c1..20abc2d165 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -847,6 +847,7 @@ "live": "Live", "change_history": "Cronologia modifiche", "coming_soon": "Prossimamente", + "member": "Membro", "members": "Membri", "you": "Tu", "upgrade_cta": { diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index a6f36a65b5..cd27d8bde7 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -849,6 +849,7 @@ "live": "ライブ", "change_history": "変更履歴", "coming_soon": "近日公開", + "member": "メンバー", "members": "メンバー", "you": "あなた", "upgrade_cta": { diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index 2858d729cb..c7610b003a 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -850,6 +850,7 @@ "live": "라이브", "change_history": "변경 기록", "coming_soon": "곧 출시", + "member": "멤버", "members": "멤버", "you": "나", "upgrade_cta": { diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index d11005833f..06d9182de6 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -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": { diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index de630da974..da2bcc7bbf 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -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": { diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index f60a4881b5..04ae61e6b2 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -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": { diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 564716529d..e667818683 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -850,6 +850,7 @@ "live": "В прямом эфире", "change_history": "История изменений", "coming_soon": "Скоро", + "member": "Участник", "members": "Участники", "you": "Вы", "upgrade_cta": { diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 60f2c21ca8..019bbba869 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -850,6 +850,7 @@ "live": "Živé", "change_history": "História zmien", "coming_soon": "Už čoskoro", + "member": "Člen", "members": "Členovia", "you": "Vy", "upgrade_cta": { diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index cec11a9921..1be2b8b741 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -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": { diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index 2a82df68f5..0dd0161cd4 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -850,6 +850,7 @@ "live": "Наживо", "change_history": "Історія змін", "coming_soon": "Незабаром", + "member": "Учасник", "members": "Учасники", "you": "Ви", "upgrade_cta": { diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index 418d96ac43..3f9158b9d6 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -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": { diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 8f8ca2d26f..d2fa34d6ef 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -849,6 +849,7 @@ "live": "实时", "change_history": "变更历史", "coming_soon": "即将推出", + "member": "成员", "members": "成员", "you": "你", "upgrade_cta": { diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index 472ba631c1..6741ac4b42 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -850,6 +850,7 @@ "live": "即時", "change_history": "變更歷史記錄", "coming_soon": "即將推出", + "member": "成員", "members": "成員", "you": "您", "upgrade_cta": { diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index d83853bb6e..ae1897d307 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -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 }[]; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 4393a911f6..0b81e3b910 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -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 { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 12ae0df54c..30f06b8c4c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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"; \ No newline at end of file diff --git a/packages/utils/src/permission.ts b/packages/utils/src/permission.ts new file mode 100644 index 0000000000..7810500059 --- /dev/null +++ b/packages/utils/src/permission.ts @@ -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 = (roles: T[]): T | undefined => { + if (!roles || roles.length === 0) return undefined; + return roles.reduce((highest, current) => (current > highest ? current : highest)); +}; diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx index fb96779947..5b2af44b75 100644 --- a/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/layout.tsx @@ -21,18 +21,17 @@ export interface IWorkspaceSettingLayout { const WorkspaceSettingLayout: FC = 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 ( <> diff --git a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx index 06990217fe..458279b878 100644 --- a/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx +++ b/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx @@ -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(() => {
- - +
+
+ {t(getProjectSettingsPageLabelI18nKey("members", "common.members"))} +
+
+ + +
); diff --git a/web/app/(all)/onboarding/page.tsx b/web/app/(all)/onboarding/page.tsx index a26bef3a60..2211ad07db 100644 --- a/web/app/(all)/onboarding/page.tsx +++ b/web/app/(all)/onboarding/page.tsx @@ -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", diff --git a/web/ce/components/projects/settings/useProjectColumns.tsx b/web/ce/components/projects/settings/useProjectColumns.tsx index bd9e589ad1..a1ef7f3f2e 100644 --- a/web/ce/components/projects/settings/useProjectColumns.tsx +++ b/web/ce/components/projects/settings/useProjectColumns.tsx @@ -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 { 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(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) =>
{getFormattedDate(rowData?.member?.joining_date || "")}
, }, ]; - return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal }; + return { columns, removeMemberModal, setRemoveMemberModal }; }; diff --git a/web/ce/components/projects/teamspaces/index.ts b/web/ce/components/projects/teamspaces/index.ts new file mode 100644 index 0000000000..968205a9b1 --- /dev/null +++ b/web/ce/components/projects/teamspaces/index.ts @@ -0,0 +1 @@ +export * from "./teamspace-list"; diff --git a/web/ce/components/projects/teamspaces/teamspace-list.tsx b/web/ce/components/projects/teamspaces/teamspace-list.tsx new file mode 100644 index 0000000000..05d401c576 --- /dev/null +++ b/web/ce/components/projects/teamspaces/teamspace-list.tsx @@ -0,0 +1,6 @@ +export type TProjectTeamspaceList = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectTeamspaceList: React.FC = () => null; diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts index 9eaba26db1..5443c64245 100644 --- a/web/ce/constants/project/settings/tabs.ts +++ b/web/ce/constants/project/settings/tabs.ts @@ -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/`, diff --git a/web/ce/helpers/project-settings.ts b/web/ce/helpers/project-settings.ts new file mode 100644 index 0000000000..dbe06507af --- /dev/null +++ b/web/ce/helpers/project-settings.ts @@ -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; diff --git a/web/ce/store/member/project-member.store.ts b/web/ce/store/member/project-member.store.ts new file mode 100644 index 0000000000..1b90c9c117 --- /dev/null +++ b/web/ce/store/member/project-member.store.ts @@ -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); +} diff --git a/web/ce/store/user/permission.store.ts b/web/ce/store/user/permission.store.ts new file mode 100644 index 0000000000..00300cdf2e --- /dev/null +++ b/web/ce/store/user/permission.store.ts @@ -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) + ); +} diff --git a/web/core/components/auth-screens/project/join-project.tsx b/web/core/components/auth-screens/project/join-project.tsx index b815f8738e..58e65c471d 100644 --- a/web/core/components/auth-screens/project/join-project.tsx +++ b/web/core/components/auth-screens/project/join-project.tsx @@ -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) => { + 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 (
- JoinProject + JoinProject
-

You are not a member of this project

+

+ {!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`} +

- 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.`}

-
- -
+ {!isPrivateProject && ( +
+ +
+ )}
); }; diff --git a/web/core/components/inbox/content/root.tsx b/web/core/components/inbox/content/root.tsx index dc8d1f0181..0c7493fb75 100644 --- a/web/core/components/inbox/content/root.tsx +++ b/web/core/components/inbox/content/root.tsx @@ -38,7 +38,7 @@ export const InboxContentRoot: FC = 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 = 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; diff --git a/web/core/components/issues/issue-detail/issue-activity/root.tsx b/web/core/components/issues/issue-detail/issue-activity/root.tsx index 67f7351a96..463192d6c2 100644 --- a/web/core/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/root.tsx @@ -49,14 +49,14 @@ export const IssueActivity: FC = 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 diff --git a/web/core/components/project/create/header.tsx b/web/core/components/project/create/header.tsx index fa841151f2..6bc1e8e1ac 100644 --- a/web/core/components/project/create/header.tsx +++ b/web/core/components/project/create/header.tsx @@ -40,7 +40,6 @@ const ProjectCreateHeader: React.FC = (props) => { alt={t("project_cover_image_alt")} /> )} -
diff --git a/web/core/components/project/create/project-create-buttons.tsx b/web/core/components/project/create/project-create-buttons.tsx index 22ce787da4..98a6e80f9b 100644 --- a/web/core/components/project/create/project-create-buttons.tsx +++ b/web/core/components/project/create/project-create-buttons.tsx @@ -25,7 +25,7 @@ const ProjectCreateButtons: React.FC = (props) => { return (
diff --git a/web/core/components/project/project-settings-member-defaults.tsx b/web/core/components/project/project-settings-member-defaults.tsx index 2dfc0563ef..3905a38bb7 100644 --- a/web/core/components/project/project-settings-member-defaults.tsx +++ b/web/core/components/project/project-settings-member-defaults.tsx @@ -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 = { 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 = ({ title, description, children }) => ( +
+
+

{title}

+

{description}

+
+
{children}
+
+); + +type TProjectSettingsMemberDefaultsProps = { + workspaceSlug: string; + projectId: string; +}; + +export const ProjectSettingsMemberDefaults: React.FC = 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({ 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 ( - <> -
-

{t("common.defaults")}

-
- -
-
-
-

{t("project_settings.members.project_lead")}

-
- {currentProjectDetails ? ( - ( - { - submitChanges({ project_lead: val }); - }} - isDisabled={!isAdmin} - /> - )} - /> - ) : ( - - - - )} -
-
- -
-

{t("project_settings.members.default_assignee")}

-
- {currentProjectDetails ? ( - ( - { - submitChanges({ default_assignee: val }); - }} - isDisabled={!isAdmin} - /> - )} - /> - ) : ( - - - - )} -
-
-
-
- {currentProjectDetails && ( -
-
-

- {t("project_settings.members.guest_super_permissions.title")} -

-

- {t("project_settings.members.guest_super_permissions.sub_heading")} -

-
- toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)} - disabled={!isAdmin} - size="md" +
+ + {currentProjectDetails ? ( + ( + { + submitChanges({ project_lead: val }); + }} + isDisabled={!isAdmin} + /> + )} /> -
+ ) : ( + + + + )} + + + {currentProjectDetails ? ( + ( + { + submitChanges({ default_assignee: val }); + }} + isDisabled={!isAdmin} + /> + )} + /> + ) : ( + + + + )} + + {currentProjectDetails && ( + +
+ toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)} + disabled={!isAdmin} + size="sm" + /> +
+
)} - +
); }); diff --git a/web/core/components/project/send-project-invitation-modal.tsx b/web/core/components/project/send-project-invitation-modal.tsx index e7d0813c54..9b0a0882f5 100644 --- a/web/core/components/project/send-project-invitation-modal.tsx +++ b/web/core/components/project/send-project-invitation-modal.tsx @@ -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 = 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 = observer((props) => { handleSubmit, control, } = useForm(); - 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 = observer((props) => { currentMemberWorkspaceRole as EUserPermissions ); - return Object.fromEntries( Object.entries(ROLE).filter(([key]) => !isGuestOROwner || [currentMemberWorkspaceRole].includes(parseInt(key))) ); diff --git a/web/core/components/project/settings/member-columns.tsx b/web/core/components/project/settings/member-columns.tsx index 6ee3fb74bd..393a4012a6 100644 --- a/web/core/components/project/settings/member-columns.tsx +++ b/web/core/components/project/settings/member-columns.tsx @@ -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 { member: IWorkspaceMember; - role: EUserPermissions; } type NameProps = { @@ -44,11 +39,11 @@ export const NameColumn: React.FC = (props) => { {({}) => (
-
+
{avatar_url && avatar_url.trim() !== "" ? ( - + = (props) => { ) : ( - + {(email ?? display_name ?? "?")[0]} )} {first_name} {last_name}
- {(isAdmin || id === currentUser?.id) && ( - 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={() => ( + +
setRemoveMemberModal(rowData)} > - + {rowData.member?.id === currentUser?.id ? "Leave " : "Remove "}
- )} - /> +
+
)}
@@ -92,20 +87,20 @@ export const NameColumn: React.FC = (props) => { export const AccountTypeColumn: React.FC = 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 = 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 = observer((props) => rules={{ required: "Role is required." }} render={() => ( { + 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={
- {ROLE[rowData.role]} + {roleLabel}
} 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 = observer((props) => /> ) : (
- {ROLE[rowData.role]} + {roleLabel}
)} diff --git a/web/core/components/settings/project/sidebar/nav-item-children.tsx b/web/core/components/settings/project/sidebar/nav-item-children.tsx index 02b0d42c21..514394844a 100644 --- a/web/core/components/settings/project/sidebar/nav-item-children.tsx +++ b/web/core/components/settings/project/sidebar/nav-item-children.tsx @@ -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))}
) diff --git a/web/core/components/ui/loader/settings/members.tsx b/web/core/components/ui/loader/settings/members.tsx index 1a412f7612..226fec4f8d 100644 --- a/web/core/components/ui/loader/settings/members.tsx +++ b/web/core/components/ui/loader/settings/members.tsx @@ -1,17 +1,16 @@ import range from "lodash/range"; export const MembersSettingsLoader = () => ( -
- {range(4).map((i) => ( -
-
- -
- - -
+
+ {range(3).map((i) => ( +
+
+ +
- + + +
))}
diff --git a/web/core/components/workspace/settings/members-list.tsx b/web/core/components/workspace/settings/members-list.tsx index 61d22d0789..93c2786c16 100644 --- a/web/core/components/workspace/settings/members-list.tsx +++ b/web/core/components/workspace/settings/members-list.tsx @@ -66,7 +66,9 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }> title={
-

{t("workspace_settings.settings.members.pending_invites")}

+

+ {t("workspace_settings.settings.members.pending_invites")} +

{searchedInvitationsIds && ( )} diff --git a/web/core/hooks/store/user/user-permissions.ts b/web/core/hooks/store/user/user-permissions.ts index 555e476745..12be981355 100644 --- a/web/core/hooks/store/user/user-permissions.ts +++ b/web/core/hooks/store/user/user-permissions.ts @@ -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); diff --git a/web/core/layouts/auth-layout/project-wrapper.tsx b/web/core/layouts/auth-layout/project-wrapper.tsx index c356cb883b..325d3a4875 100644 --- a/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/web/core/layouts/auth-layout/project-wrapper.tsx @@ -46,7 +46,7 @@ export const ProjectAuthWrapper: FC = 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 = 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 = observer((props) => { projectId && hasPermissionToCurrentProject === false ) - return ; + return ; // check if the project info is not found. if (loader === "loaded" && projectId && !!hasPermissionToCurrentProject === false) diff --git a/web/core/lib/posthog-provider.tsx b/web/core/lib/posthog-provider.tsx index af67c22ea9..8799e827ac 100644 --- a/web/core/lib/posthog-provider.tsx +++ b/web/core/lib/posthog-provider.tsx @@ -25,10 +25,13 @@ const PostHogProvider: FC = 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(() => { diff --git a/web/core/local-db/utils/query-sanitizer.ts.ts b/web/core/local-db/utils/query-sanitizer.ts.ts index 39ee122b85..7a923d417d 100644 --- a/web/core/local-db/utils/query-sanitizer.ts.ts +++ b/web/core/local-db/utils/query-sanitizer.ts.ts @@ -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 ( diff --git a/web/core/services/project/project-member.service.ts b/web/core/services/project/project-member.service.ts index 2a60a7b3e9..6f07c57781 100644 --- a/web/core/services/project/project-member.service.ts +++ b/web/core/services/project/project-member.service.ts @@ -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 { + async fetchProjectMembers(workspaceSlug: string, projectId: string): Promise { 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 { + ): Promise { 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 { + async projectMemberMe(workspaceSlug: string, projectId: string): Promise { 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 { + async getProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise { 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 - ): Promise { + data: Partial + ): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`, data) .then((response) => response?.data) .catch((error) => { diff --git a/web/core/store/member/project-member.store.ts b/web/core/store/member/base-project-member.store.ts similarity index 53% rename from web/core/store/member/project-member.store.ts rename to web/core/store/member/base-project-member.store.ts index ad9b1252a7..c5999c4eb9 100644 --- a/web/core/store/member/project-member.store.ts +++ b/web/core/store/member/base-project-member.store.ts @@ -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 { member: IUserLite; - role: EUserPermissions; } -export interface IProjectMemberStore { +export interface IBaseProjectMemberStore { // observables projectMemberFetchStatusMap: { [projectId: string]: boolean; }; projectMemberMap: { - [projectId: string]: Record; + [projectId: string]: Record; }; // 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; + fetchProjectMembers: ( + workspaceSlug: string, + projectId: string, + clearExistingMembers?: boolean + ) => Promise; // bulk operation actions bulkAddMembersToProject: ( workspaceSlug: string, projectId: string, data: IProjectBulkAddFormData - ) => Promise; + ) => Promise; // crud actions - updateMember: ( + updateMemberRole: ( workspaceSlug: string, projectId: string, userId: string, - data: { role: EUserPermissions } - ) => Promise; + role: EUserProjectRoles + ) => Promise; removeMemberFromProject: (workspaceSlug: string, projectId: string, userId: string) => Promise; } -export class ProjectMemberStore implements IProjectMemberStore { +export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore { // observables projectMemberFetchStatusMap: { [projectId: string]: boolean; } = {}; projectMemberMap: { - [projectId: string]: Record; + [projectId: string]: Record; } = {}; // 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 + * @returns Promise */ 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 - ); }); }; } diff --git a/web/core/store/member/index.ts b/web/core/store/member/index.ts index 12ede5b17e..b8bae30e7b 100644 --- a/web/core/store/member/index.ts +++ b/web/core/store/member/index.ts @@ -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, diff --git a/web/core/store/pages/project-page.store.ts b/web/core/store/pages/project-page.store.ts index 21f323f10b..070021bdcb 100644 --- a/web/core/store/pages/project-page.store.ts +++ b/web/core/store/pages/project-page.store.ts @@ -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() || "" ); diff --git a/web/core/store/pages/project-page.ts b/web/core/store/pages/project-page.ts index 71f33926a1..60bcb38046 100644 --- a/web/core/store/pages/project-page.ts +++ b/web/core/store/pages/project-page.ts @@ -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() || "" ); diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index 2aef8d030c..b3e93afc9a 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -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); diff --git a/web/core/store/user/permissions.store.ts b/web/core/store/user/base-permissions.store.ts similarity index 63% rename from web/core/store/user/permissions.store.ts rename to web/core/store/user/base-permissions.store.ts index e636d39939..4d54ce140e 100644 --- a/web/core/store/user/permissions.store.ts +++ b/web/core/store/user/base-permissions.store.ts @@ -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; // workspaceSlug -> IWorkspaceMemberMe - projectUserInfo: Record>; // workspaceSlug -> projectId -> IProjectMember + projectUserInfo: Record>; // workspaceSlug -> projectId -> TProjectMembership workspaceProjectsPermissions: Record; // 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; + fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; leaveWorkspace: (workspaceSlug: string) => Promise; - fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise; - fetchUserProjectPermissions: (workspaceSlug: string) => Promise; - joinProject: (workspaceSlug: string, projectId: string) => Promise; + fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise; + fetchUserProjectPermissions: (workspaceSlug: string) => Promise; + joinProject: (workspaceSlug: string, projectId: string) => Promise; leaveProject: (workspaceSlug: string, projectId: string) => Promise; + 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 = {}; - projectUserInfo: Record> = {}; + projectUserInfo: Record> = {}; workspaceProjectsPermissions: Record = {}; // 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 } + * @returns { Promise } */ - fetchUserWorkspaceInfo = async (workspaceSlug: string): Promise => { + fetchUserWorkspaceInfo = async (workspaceSlug: string): Promise => { 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 } + * @returns { Promise } */ - fetchUserProjectInfo = async (workspaceSlug: string, projectId: string): Promise => { + fetchUserProjectInfo = async (workspaceSlug: string, projectId: string): Promise => { 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 } + * @returns { Promise } */ - fetchUserProjectPermissions = async (workspaceSlug: string): Promise => { + fetchUserProjectPermissions = async (workspaceSlug: string): Promise => { 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 } + * @returns { Promise } */ - joinProject = async (workspaceSlug: string, projectId: string): Promise => { + joinProject = async (workspaceSlug: string, projectId: string): Promise => { 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 } + * @returns { Promise } */ leaveProject = async (workspaceSlug: string, projectId: string): Promise => { try { diff --git a/web/core/store/user/index.ts b/web/core/store/user/index.ts index fb94c01597..2d4b71e645 100644 --- a/web/core/store/user/index.ts +++ b/web/core/store/user/index.ts @@ -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 && diff --git a/web/ee/helpers/project-settings.ts b/web/ee/helpers/project-settings.ts new file mode 100644 index 0000000000..ec6ec6b332 --- /dev/null +++ b/web/ee/helpers/project-settings.ts @@ -0,0 +1 @@ +export * from "ce/helpers/project-settings"; diff --git a/web/ee/store/member/project-member.store.ts b/web/ee/store/member/project-member.store.ts new file mode 100644 index 0000000000..5ce937d279 --- /dev/null +++ b/web/ee/store/member/project-member.store.ts @@ -0,0 +1 @@ +export * from "ce/store/member/project-member.store"; diff --git a/web/ee/store/user/permission.store.ts b/web/ee/store/user/permission.store.ts new file mode 100644 index 0000000000..8281689bde --- /dev/null +++ b/web/ee/store/user/permission.store.ts @@ -0,0 +1 @@ +export * from "ce/store/user/permission.store";