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

* refactor: permission layer

* refactor: add original_role to project member serializer

* chore: minor fixes related to permission layer

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

View File

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

View File

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

View File

@@ -848,6 +848,7 @@
"live": "Live", "live": "Live",
"change_history": "Änderungsverlauf", "change_history": "Änderungsverlauf",
"coming_soon": "Demnächst verfügbar", "coming_soon": "Demnächst verfügbar",
"member": "Mitglied",
"members": "Mitglieder", "members": "Mitglieder",
"you": "Sie", "you": "Sie",
"upgrade_cta": { "upgrade_cta": {
@@ -2459,4 +2460,4 @@
"previously_edited_by": "Zuvor bearbeitet von", "previously_edited_by": "Zuvor bearbeitet von",
"edited_by": "Bearbeitet von" "edited_by": "Bearbeitet von"
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,5 @@
import { EUserProjectRoles } from "@plane/constants"; import { EUserProjectRoles } from "@plane/constants";
import type { import type { IUser, IUserLite, IWorkspace, TLogoProps, TStateGroups } from "..";
IProjectViewProps,
IUser,
IUserLite,
IUserMemberLite,
IWorkspace,
IWorkspaceLite,
TLogoProps,
TStateGroups,
} from "..";
import { TUserPermissions } from "../enums"; import { TUserPermissions } from "../enums";
export interface IPartialProject { export interface IPartialProject {
@@ -91,31 +82,20 @@ export interface IProjectMemberLite {
member_id: string; member_id: string;
} }
export interface IProjectMember { export type TProjectMembership = {
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;
member: string; member: string;
role: TUserPermissions; role: TUserPermissions | EUserProjectRoles;
created_at: string; created_at: string;
} } & (
| {
id: string;
original_role: EUserProjectRoles;
}
| {
id: null;
original_role: null;
}
);
export interface IProjectBulkAddFormData { export interface IProjectBulkAddFormData {
members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[]; members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[];

View File

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

View File

@@ -8,9 +8,10 @@ export * from "./emoji";
export * from "./file"; export * from "./file";
export * from "./get-icon-for-link"; export * from "./get-icon-for-link";
export * from "./issue"; export * from "./issue";
export * from "./permission";
export * from "./state"; export * from "./state";
export * from "./string"; export * from "./string";
export * from "./subscription"; export * from "./subscription";
export * from "./theme"; export * from "./theme";
export * from "./work-item"; export * from "./work-item";
export * from "./workspace"; export * from "./workspace";

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,28 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; // plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; 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"; import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns";
// hooks
import { useUser, useUserPermissions } from "@/hooks/store"; import { useUser, useUserPermissions } from "@/hooks/store";
export interface RowData { export interface RowData extends Pick<TProjectMembership, "original_role"> {
member: IWorkspaceMember; member: IWorkspaceMember;
role: EUserPermissions;
} }
export const useProjectColumns = () => { type TUseProjectColumnsProps = {
projectId: string;
workspaceSlug: string;
};
export const useProjectColumns = (props: TUseProjectColumnsProps) => {
const { projectId, workspaceSlug } = props;
// states // states
const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null); const [removeMemberModal, setRemoveMemberModal] = useState<RowData | null>(null);
// store hooks
const { workspaceSlug, projectId } = useParams();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { allowPermissions, projectUserInfo } = useUserPermissions(); const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = 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);
};
// derived values // derived values
const isAdmin = allowPermissions( const isAdmin = allowPermissions(
[EUserPermissions.ADMIN], [EUserPermissions.ADMIN],
@@ -36,6 +30,15 @@ export const useProjectColumns = () => {
workspaceSlug.toString(), workspaceSlug.toString(),
projectId.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 = [ const columns = [
{ {
@@ -76,5 +79,5 @@ export const useProjectColumns = () => {
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>, tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
}, },
]; ];
return { columns, workspaceSlug, projectId, removeMemberModal, setRemoveMemberModal }; return { columns, removeMemberModal, setRemoveMemberModal };
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,23 +2,28 @@
import { useState } from "react"; import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// hooks
import { ClipboardList } from "lucide-react"; import { ClipboardList } from "lucide-react";
// plane imports
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// hooks
import { useProject, useUserPermissions } from "@/hooks/store"; import { useProject, useUserPermissions } from "@/hooks/store";
// ui // assets
// icons import Unauthorized from "@/public/auth/unauthorized.svg";
// images
import JoinProjectImg from "@/public/auth/project-not-authorized.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 // states
const [isJoiningProject, setIsJoiningProject] = useState(false); const [isJoiningProject, setIsJoiningProject] = useState(false);
// store hooks // store hooks
const { joinProject } = useUserPermissions(); const { joinProject } = useUserPermissions();
const { fetchProjectDetails } = useProject(); const { fetchProjectDetails } = useProject();
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug } = useParams();
const handleJoin = () => { const handleJoin = () => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@@ -33,25 +38,31 @@ export const JoinProject: React.FC = () => {
return ( 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="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"> <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> </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"> <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"> <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> </p>
</div> </div>
<div> {!isPrivateProject && (
<Button <div>
variant="primary" <Button
prependIcon={<ClipboardList color="white" />} variant="primary"
loading={isJoiningProject} prependIcon={<ClipboardList color="white" />}
onClick={handleJoin} loading={isJoiningProject}
> onClick={handleJoin}
{isJoiningProject ? "Joining..." : "Click to join"} >
</Button> {isJoiningProject ? "Taking you in" : "Click to join"}
</div> </Button>
</div>
)}
</div> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,7 +66,9 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
title={ title={
<div className="flex w-full items-center justify-between pt-4"> <div className="flex w-full items-center justify-between pt-4">
<div className="flex"> <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 && ( {searchedInvitationsIds && (
<CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" /> <CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" />
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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