fix: workspace settings pages authorization (#2915)

* fix: workspace settings pages authorization

* chore: user cannot add a member with a higher role than theirs

* chore: update workspace general settings auth
This commit is contained in:
Aaryan Khandelwal 2023-11-28 17:05:42 +05:30 committed by sriram veeraghanta
parent 03387848fe
commit eb366887d7
15 changed files with 317 additions and 240 deletions

View File

@ -25,7 +25,6 @@ import {
IViewIssuesFilterStore, IViewIssuesFilterStore,
IViewIssuesStore, IViewIssuesStore,
} from "store/issues"; } from "store/issues";
import { EUserWorkspaceRoles } from "layouts/settings-layout/workspace/sidebar";
import { TUnGroupedIssues } from "store/issues/types"; import { TUnGroupedIssues } from "store/issues/types";
interface IBaseGanttRoot { interface IBaseGanttRoot {
@ -46,6 +45,10 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
const { projectDetails } = useProjectDetails(); const { projectDetails } = useProjectDetails();
const {
user: { currentProjectRole },
} = useMobxStore();
const appliedDisplayFilters = issueFiltersStore.issueFilters?.displayFilters; const appliedDisplayFilters = issueFiltersStore.issueFilters?.displayFilters;
const issuesResponse = issueStore.getIssues; const issuesResponse = issueStore.getIssues;
@ -69,7 +72,7 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
); );
}; };
const isAllowed = (projectDetails?.member_role || 0) >= EUserWorkspaceRoles.MEMBER; const isAllowed = currentProjectRole && currentProjectRole >= 15;
return ( return (
<> <>

View File

@ -1,10 +1,12 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { Plus, X } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui // ui
import { Button, CustomSelect, Input } from "@plane/ui"; import { Button, CustomSelect, Input } from "@plane/ui";
// icons
import { Plus, X } from "lucide-react";
// types // types
import { IWorkspaceBulkInviteFormData, TUserWorkspaceRole } from "types"; import { IWorkspaceBulkInviteFormData, TUserWorkspaceRole } from "types";
// constants // constants
@ -34,9 +36,12 @@ const defaultValues: FormValues = {
], ],
}; };
export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => { export const SendWorkspaceInvitationModal: React.FC<Props> = observer((props) => {
const { isOpen, onClose, onSubmit } = props; const { isOpen, onClose, onSubmit } = props;
// mobx store
const {
user: { currentWorkspaceRole },
} = useMobxStore();
// form info // form info
const { const {
control, control,
@ -59,30 +64,6 @@ export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
}, 350); }, 350);
}; };
// const onSubmit = async (formData: FormValues) => {
// if (!workspaceSlug) return;
// return workspaceService
// .inviteWorkspace(workspaceSlug, formData, user)
// .then(async () => {
// if (onSuccess) await onSuccess();
// handleClose();
// setToastAlert({
// type: "success",
// title: "Success!",
// message: "Invitations sent successfully.",
// });
// })
// .catch((err) =>
// setToastAlert({
// type: "error",
// title: "Error!",
// message: `${err.error ?? "Something went wrong. Please try again."}`,
// })
// );
// };
const appendField = () => { const appendField = () => {
append({ email: "", role: 15 }); append({ email: "", role: 15 });
}; };
@ -181,11 +162,14 @@ export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
width="w-full" width="w-full"
input input
> >
{Object.entries(ROLE).map(([key, value]) => ( {Object.entries(ROLE).map(([key, value]) => {
<CustomSelect.Option key={key} value={parseInt(key)}> if (currentWorkspaceRole && currentWorkspaceRole >= parseInt(key))
{value} return (
</CustomSelect.Option> <CustomSelect.Option key={key} value={parseInt(key)}>
))} {value}
</CustomSelect.Option>
);
})}
</CustomSelect> </CustomSelect>
)} )}
/> />
@ -230,4 +214,4 @@ export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
}; });

View File

@ -1,7 +1,9 @@
import { useState, FC } from "react"; import { useState, FC } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr"; import { mutate } from "swr";
import { ChevronDown, Dot, XCircle } from "lucide-react";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
@ -10,12 +12,10 @@ import useToast from "hooks/use-toast";
import { ConfirmWorkspaceMemberRemove } from "components/workspace"; import { ConfirmWorkspaceMemberRemove } from "components/workspace";
// ui // ui
import { CustomSelect, Tooltip } from "@plane/ui"; import { CustomSelect, Tooltip } from "@plane/ui";
// icons
import { ChevronDown, Dot, XCircle } from "lucide-react";
// types // types
import { TUserWorkspaceRole } from "types"; import { TUserWorkspaceRole } from "types";
// constants // constants
import { ROLE } from "constants/workspace"; import { EUserWorkspaceRoles, ROLE } from "constants/workspace";
type Props = { type Props = {
member: { member: {
@ -33,7 +33,7 @@ type Props = {
}; };
}; };
export const WorkspaceMembersListItem: FC<Props> = (props) => { export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
const { member } = props; const { member } = props;
// router // router
const router = useRouter(); const router = useRouter();
@ -43,7 +43,6 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
workspaceMember: { removeMember, updateMember, deleteWorkspaceInvitation }, workspaceMember: { removeMember, updateMember, deleteWorkspaceInvitation },
user: { currentWorkspaceMemberInfo, currentWorkspaceRole, currentUser, currentUserSettings, leaveWorkspace }, user: { currentWorkspaceMemberInfo, currentWorkspaceRole, currentUser, currentUserSettings, leaveWorkspace },
} = useMobxStore(); } = useMobxStore();
const isAdmin = currentWorkspaceRole === 20;
// states // states
const [removeMemberModal, setRemoveMemberModal] = useState(false); const [removeMemberModal, setRemoveMemberModal] = useState(false);
// hooks // hooks
@ -53,10 +52,7 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
if (!workspaceSlug || !currentUserSettings) return; if (!workspaceSlug || !currentUserSettings) return;
await leaveWorkspace(workspaceSlug.toString()) await leaveWorkspace(workspaceSlug.toString())
.then(() => { .then(() => router.push("/profile"))
if (currentUserSettings.workspace?.invites > 0) router.push("/invitations");
else router.push("/create-workspace");
})
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
@ -114,6 +110,20 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
} else await handleRemoveInvitation(); } else await handleRemoveInvitation();
}; };
// is the member current logged in user
const isCurrentUser = member.memberId === currentWorkspaceMemberInfo?.member;
// is the current logged in user admin
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
// role change access-
// 1. user cannot change their own role
// 2. only admin or member can change role
// 3. user cannot change role of higher role
const hasRoleChangeAccess =
currentWorkspaceRole &&
!isCurrentUser &&
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole) &&
member.role <= currentWorkspaceRole;
if (!currentWorkspaceMemberInfo) return null; if (!currentWorkspaceMemberInfo) return null;
return ( return (
@ -180,12 +190,12 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
<div className="flex item-center gap-1 px-2 py-0.5 rounded"> <div className="flex item-center gap-1 px-2 py-0.5 rounded">
<span <span
className={`flex items-center text-xs font-medium rounded ${ className={`flex items-center text-xs font-medium rounded ${
member.memberId !== currentWorkspaceMemberInfo.member ? "" : "text-custom-sidebar-text-400" hasRoleChangeAccess ? "" : "text-custom-sidebar-text-400"
}`} }`}
> >
{ROLE[member.role as keyof typeof ROLE]} {ROLE[member.role as keyof typeof ROLE]}
</span> </span>
{member.memberId !== currentWorkspaceMemberInfo.member && ( {hasRoleChangeAccess && (
<span className="grid place-items-center"> <span className="grid place-items-center">
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</span> </span>
@ -206,11 +216,7 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
}); });
}); });
}} }}
disabled={ disabled={!hasRoleChangeAccess}
member.memberId === currentWorkspaceMemberInfo.member ||
!member.status ||
Boolean(currentWorkspaceRole && currentWorkspaceRole !== 20 && currentWorkspaceRole < member.role)
}
placement="bottom-end" placement="bottom-end"
> >
{Object.keys(ROLE).map((key) => { {Object.keys(ROLE).map((key) => {
@ -224,23 +230,24 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
); );
})} })}
</CustomSelect> </CustomSelect>
{isAdmin && ( <Tooltip
<Tooltip tooltipContent={isCurrentUser ? "Leave workspace" : "Remove member"}
tooltipContent={ disabled={!isAdmin && !isCurrentUser}
member.memberId === currentWorkspaceMemberInfo.member ? "Leave workspace" : "Remove member" >
<button
type="button"
onClick={() => setRemoveMemberModal(true)}
className={
isAdmin || isCurrentUser
? "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
: "opacity-0 pointer-events-none"
} }
> >
<button <XCircle className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={2} />
type="button" </button>
onClick={() => setRemoveMemberModal(true)} </Tooltip>
className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
>
<XCircle className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={2} />
</button>
</Tooltip>
)}
</div> </div>
</div> </div>
</> </>
); );
}; });

View File

@ -9,18 +9,14 @@ import { WorkspaceMembersListItem } from "components/workspace";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer(({ searchQuery }) => { export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer((props) => {
const { searchQuery } = props;
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// store // store
const { const {
workspaceMember: { workspaceMember: { workspaceMembersWithInvitations, fetchWorkspaceMemberInvitations },
workspaceMembers,
workspaceMembersWithInvitations,
workspaceMemberInvitations,
fetchWorkspaceMemberInvitations,
},
user: { currentWorkspaceMemberInfo },
} = useMobxStore(); } = useMobxStore();
// fetching workspace invitations // fetching workspace invitations
useSWR( useSWR(
@ -36,12 +32,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer(({ sea
return `${email}${displayName}${fullName}`.includes(searchQuery.toLowerCase()); return `${email}${displayName}${fullName}`.includes(searchQuery.toLowerCase());
}); });
if ( if (!workspaceMembersWithInvitations)
!workspaceMembers ||
!workspaceMemberInvitations ||
!workspaceMembersWithInvitations ||
!currentWorkspaceMemberInfo
)
return ( return (
<Loader className="space-y-5"> <Loader className="space-y-5">
<Loader.Item height="40px" /> <Loader.Item height="40px" />

View File

@ -14,12 +14,12 @@ import { DeleteWorkspaceModal } from "components/workspace";
import { WorkspaceImageUploadModal } from "components/core"; import { WorkspaceImageUploadModal } from "components/core";
// ui // ui
import { Button, CustomSelect, Input, Spinner } from "@plane/ui"; import { Button, CustomSelect, Input, Spinner } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
import { IWorkspace } from "types"; import { IWorkspace } from "types";
// constants // constants
import { ORGANIZATION_SIZE } from "constants/workspace"; import { EUserWorkspaceRoles, ORGANIZATION_SIZE } from "constants/workspace";
import { trackEvent } from "helpers/event-tracker.helper";
import { copyUrlToClipboard } from "helpers/string.helper";
const defaultValues: Partial<IWorkspace> = { const defaultValues: Partial<IWorkspace> = {
name: "", name: "",
@ -40,9 +40,9 @@ export const WorkspaceDetails: FC = observer(() => {
const { const {
workspace: { currentWorkspace, updateWorkspace }, workspace: { currentWorkspace, updateWorkspace },
user: { currentWorkspaceRole }, user: { currentWorkspaceRole },
trackEvent: { postHogEventTracker } trackEvent: { postHogEventTracker },
} = useMobxStore(); } = useMobxStore();
const isAdmin = currentWorkspaceRole === 20;
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -67,28 +67,22 @@ export const WorkspaceDetails: FC = observer(() => {
await updateWorkspace(currentWorkspace.slug, payload) await updateWorkspace(currentWorkspace.slug, payload)
.then((res) => { .then((res) => {
postHogEventTracker( postHogEventTracker("WORKSPACE_UPDATE", {
'WORKSPACE_UPDATE', ...res,
{ state: "SUCCESS",
...res, });
state: "SUCCESS"
}
)
setToastAlert({ setToastAlert({
title: "Success", title: "Success",
type: "success", type: "success",
message: "Workspace updated successfully", message: "Workspace updated successfully",
}); });
}).catch((err) => { })
postHogEventTracker( .catch((err) => {
'WORKSPACE_UPDATE', postHogEventTracker("WORKSPACE_UPDATE", {
{ state: "FAILED",
state: "FAILED" });
} console.error(err);
); });
console.error(err)
}
);
}; };
const handleRemoveLogo = () => { const handleRemoveLogo = () => {
@ -136,6 +130,8 @@ export const WorkspaceDetails: FC = observer(() => {
if (currentWorkspace) reset({ ...currentWorkspace }); if (currentWorkspace) reset({ ...currentWorkspace });
}, [currentWorkspace, reset]); }, [currentWorkspace, reset]);
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
if (!currentWorkspace) if (!currentWorkspace)
return ( return (
<div className="grid place-items-center h-full w-full px-4 sm:px-0"> <div className="grid place-items-center h-full w-full px-4 sm:px-0">
@ -192,11 +188,10 @@ export const WorkspaceDetails: FC = observer(() => {
<button type="button" onClick={handleCopyUrl} className="text-sm tracking-tight">{`${ <button type="button" onClick={handleCopyUrl} className="text-sm tracking-tight">{`${
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "") typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
}/${currentWorkspace.slug}`}</button> }/${currentWorkspace.slug}`}</button>
<div className="flex item-center gap-2.5"> {isAdmin && (
<button <button
className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium" className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"
onClick={() => setIsImageUploadModalOpen(true)} onClick={() => setIsImageUploadModalOpen(true)}
disabled={!isAdmin}
> >
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? ( {watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<> <>
@ -207,14 +202,14 @@ export const WorkspaceDetails: FC = observer(() => {
"Upload logo" "Upload logo"
)} )}
</button> </button>
</div> )}
</div> </div>
</div> </div>
<div className="flex flex-col gap-8 my-10"> <div className="flex flex-col gap-8 my-10">
<div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full"> <div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 "> <div className="flex flex-col gap-1">
<h4 className="text-sm">Workspace Name</h4> <h4 className="text-sm">Workspace name</h4>
<Controller <Controller
control={control} control={control}
name="name" name="name"
@ -243,7 +238,7 @@ export const WorkspaceDetails: FC = observer(() => {
</div> </div>
<div className="flex flex-col gap-1 "> <div className="flex flex-col gap-1 ">
<h4 className="text-sm">Company Size</h4> <h4 className="text-sm">Company size</h4>
<Controller <Controller
name="organization_size" name="organization_size"
control={control} control={control}
@ -277,9 +272,10 @@ export const WorkspaceDetails: FC = observer(() => {
id="url" id="url"
name="url" name="url"
type="url" type="url"
value={`${typeof window !== "undefined" && value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "") window.location.origin.replace("http://", "").replace("https://", "")
}/${currentWorkspace.slug}`} }/${currentWorkspace.slug}`}
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.url)} hasError={Boolean(errors.url)}
@ -291,11 +287,13 @@ export const WorkspaceDetails: FC = observer(() => {
</div> </div>
</div> </div>
<div className="flex items-center justify-between py-2"> {isAdmin && (
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isAdmin}> <div className="flex items-center justify-between py-2">
{isSubmitting ? "Updating..." : "Update Workspace"} <Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
</Button> {isSubmitting ? "Updating..." : "Update Workspace"}
</div> </Button>
</div>
)}
</div> </div>
{isAdmin && ( {isAdmin && (
<Disclosure as="div" className="border-t border-custom-border-100"> <Disclosure as="div" className="border-t border-custom-border-100">

View File

@ -4,8 +4,16 @@ import JiraLogo from "public/services/jira.svg";
import CSVLogo from "public/services/csv.svg"; import CSVLogo from "public/services/csv.svg";
import ExcelLogo from "public/services/excel.svg"; import ExcelLogo from "public/services/excel.svg";
import JSONLogo from "public/services/json.svg"; import JSONLogo from "public/services/json.svg";
// types
import { TStaticViewTypes } from "types"; import { TStaticViewTypes } from "types";
export enum EUserWorkspaceRoles {
GUEST = 5,
VIEWER = 10,
MEMBER = 15,
ADMIN = 20,
}
export const ROLE = { export const ROLE = {
5: "Guest", 5: "Guest",
10: "Viewer", 10: "Viewer",
@ -105,3 +113,50 @@ export const RESTRICTED_URLS = [
"spaces", "spaces",
"workspace-invitations", "workspace-invitations",
]; ];
export const WORKSPACE_SETTINGS_LINKS: {
label: string;
href: string;
access: EUserWorkspaceRoles;
}[] = [
{
label: "General",
href: `/settings`,
access: EUserWorkspaceRoles.GUEST,
},
{
label: "Members",
href: `/settings/members`,
access: EUserWorkspaceRoles.GUEST,
},
{
label: "Billing and plans",
href: `/settings/billing`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "Integrations",
href: `/settings/integrations`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "Imports",
href: `/settings/imports`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "Exports",
href: `/settings/exports`,
access: EUserWorkspaceRoles.MEMBER,
},
{
label: "Webhooks",
href: `/settings/webhooks`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "API tokens",
href: `/settings/api-tokens`,
access: EUserWorkspaceRoles.ADMIN,
},
];

View File

@ -1,82 +1,35 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import { RootStore } from "store/root"; // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// constants
export enum EUserWorkspaceRoles { import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace";
GUEST = 5,
MEMBER = 15,
ADMIN = 20,
}
export const WorkspaceSettingsSidebar = () => { export const WorkspaceSettingsSidebar = () => {
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { user: userStore }: RootStore = useMobxStore(); // mobx store
const {
user: { currentWorkspaceRole },
} = useMobxStore();
const workspaceMemberInfo = userStore.currentWorkspaceRole || EUserWorkspaceRoles.GUEST; const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
const workspaceLinks: Array<{
label: string;
href: string;
access: EUserWorkspaceRoles;
}> = [
{
label: "General",
href: `/${workspaceSlug}/settings`,
access: EUserWorkspaceRoles.GUEST,
},
{
label: "Members",
href: `/${workspaceSlug}/settings/members`,
access: EUserWorkspaceRoles.GUEST,
},
{
label: "Billing and plans",
href: `/${workspaceSlug}/settings/billing`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "Integrations",
href: `/${workspaceSlug}/settings/integrations`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "Imports",
href: `/${workspaceSlug}/settings/imports`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "Exports",
href: `/${workspaceSlug}/settings/exports`,
access: EUserWorkspaceRoles.MEMBER,
},
{
label: "Webhooks",
href: `/${workspaceSlug}/settings/webhooks`,
access: EUserWorkspaceRoles.ADMIN,
},
{
label: "API tokens",
href: `/${workspaceSlug}/settings/api-tokens`,
access: EUserWorkspaceRoles.ADMIN,
},
];
return ( return (
<div className="flex flex-col gap-6 w-80 px-5"> <div className="flex flex-col gap-6 w-80 px-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span> <span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
<div className="flex flex-col gap-1 w-full"> <div className="flex flex-col gap-1 w-full">
{workspaceLinks.map( {WORKSPACE_SETTINGS_LINKS.map(
(link) => (link) =>
workspaceMemberInfo >= link.access && ( workspaceMemberInfo >= link.access && (
<Link key={link.href} href={link.href}> <Link key={link.href} href={`/${workspaceSlug}/${link.href}`}>
<a> <a>
<div <div
className={`px-4 py-2 text-sm font-medium rounded-md ${ className={`px-4 py-2 text-sm font-medium rounded-md ${
router.pathname.split("/")?.[3] === link.href.split("/")?.[3] router.pathname.split("/")?.[3] === link.href.split("/")?.[2]
? "bg-custom-primary-100/10 text-custom-primary-100" ? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`} }`}

View File

@ -1,6 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WorkspaceSettingLayout } from "layouts/settings-layout";
@ -15,8 +18,7 @@ import { APITokenService } from "services/api_token.service";
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
// constants // constants
import { API_TOKENS_LIST } from "constants/fetch-keys"; import { API_TOKENS_LIST } from "constants/fetch-keys";
import { observer } from "mobx-react-lite"; import { EUserWorkspaceRoles } from "constants/workspace";
import { useMobxStore } from "lib/mobx/store-provider";
const apiTokenService = new APITokenService(); const apiTokenService = new APITokenService();
@ -31,7 +33,7 @@ const ApiTokensPage: NextPageWithLayout = observer(() => {
user: { currentWorkspaceRole }, user: { currentWorkspaceRole },
} = useMobxStore(); } = useMobxStore();
const isAdmin = currentWorkspaceRole === 20; const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
const { data: tokens } = useSWR(workspaceSlug && isAdmin ? API_TOKENS_LIST(workspaceSlug.toString()) : null, () => const { data: tokens } = useSWR(workspaceSlug && isAdmin ? API_TOKENS_LIST(workspaceSlug.toString()) : null, () =>
workspaceSlug && isAdmin ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null workspaceSlug && isAdmin ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null

View File

@ -1,4 +1,6 @@
import { ReactElement } from "react"; import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WorkspaceSettingLayout } from "layouts/settings-layout";
@ -8,27 +10,44 @@ import { WorkspaceSettingHeader } from "components/headers";
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// types // types
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
const BillingSettingsPage: NextPageWithLayout = () => ( const BillingSettingsPage: NextPageWithLayout = observer(() => {
<section className="pr-9 py-8 w-full overflow-y-auto"> const {
<div> user: { currentWorkspaceRole },
<div className="flex items-center py-3.5 border-b border-custom-border-100"> } = useMobxStore();
<h3 className="text-xl font-medium">Billing & Plans</h3>
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
if (!isAdmin)
return (
<div className="h-full w-full flex justify-center mt-10 p-4">
<p className="text-custom-text-300 text-sm">You are not authorized to access this page.</p>
</div> </div>
</div> );
<div className="px-4 py-6">
return (
<section className="pr-9 py-8 w-full overflow-y-auto">
<div> <div>
<h4 className="text-md mb-1 leading-6">Current plan</h4> <div className="flex items-center py-3.5 border-b border-custom-border-100">
<p className="mb-3 text-sm text-custom-text-200">You are currently using the free plan</p> <h3 className="text-xl font-medium">Billing & Plans</h3>
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer"> </div>
<Button variant="neutral-primary">View Plans</Button>
</a>
</div> </div>
</div> <div className="px-4 py-6">
</section> <div>
); <h4 className="text-md mb-1 leading-6">Current plan</h4>
<p className="mb-3 text-sm text-custom-text-200">You are currently using the free plan</p>
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer">
<Button variant="neutral-primary">View Plans</Button>
</a>
</div>
</div>
</section>
);
});
BillingSettingsPage.getLayout = function getLayout(page: ReactElement) { BillingSettingsPage.getLayout = function getLayout(page: React.ReactElement) {
return ( return (
<AppLayout header={<WorkspaceSettingHeader title="Billing & Plans Settings" />}> <AppLayout header={<WorkspaceSettingHeader title="Billing & Plans Settings" />}>
<WorkspaceSettingLayout>{page}</WorkspaceSettingLayout> <WorkspaceSettingLayout>{page}</WorkspaceSettingLayout>

View File

@ -1,4 +1,6 @@
import { ReactElement } from "react"; import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// layout // layout
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WorkspaceSettingLayout } from "layouts/settings-layout";
@ -7,17 +9,35 @@ import { WorkspaceSettingHeader } from "components/headers";
import ExportGuide from "components/exporter/guide"; import ExportGuide from "components/exporter/guide";
// types // types
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
const ExportsPage: NextPageWithLayout = () => ( const ExportsPage: NextPageWithLayout = observer(() => {
<div className="pr-9 py-8 w-full overflow-y-auto"> const {
<div className="flex items-center py-3.5 border-b border-custom-border-100"> user: { currentWorkspaceRole },
<h3 className="text-xl font-medium">Exports</h3> } = useMobxStore();
const hasPageAccess =
currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole);
if (!hasPageAccess)
return (
<div className="h-full w-full flex justify-center mt-10 p-4">
<p className="text-custom-text-300 text-sm">You are not authorized to access this page.</p>
</div>
);
return (
<div className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-100">
<h3 className="text-xl font-medium">Exports</h3>
</div>
<ExportGuide />
</div> </div>
<ExportGuide /> );
</div> });
);
ExportsPage.getLayout = function getLayout(page: ReactElement) { ExportsPage.getLayout = function getLayout(page: React.ReactElement) {
return ( return (
<AppLayout header={<WorkspaceSettingHeader title="Export Settings" />}> <AppLayout header={<WorkspaceSettingHeader title="Export Settings" />}>
<WorkspaceSettingLayout>{page}</WorkspaceSettingLayout> <WorkspaceSettingLayout>{page}</WorkspaceSettingLayout>

View File

@ -1,4 +1,6 @@
import { ReactElement } from "react"; import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// layouts // layouts
import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WorkspaceSettingLayout } from "layouts/settings-layout";
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
@ -7,17 +9,34 @@ import IntegrationGuide from "components/integration/guide";
import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingHeader } from "components/headers";
// types // types
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
const ImportsPage: NextPageWithLayout = () => ( const ImportsPage: NextPageWithLayout = observer(() => {
<section className="pr-9 py-8 w-full overflow-y-auto"> const {
<div className="flex items-center py-3.5 border-b border-custom-border-100"> user: { currentWorkspaceRole },
<h3 className="text-xl font-medium">Imports</h3> } = useMobxStore();
</div>
<IntegrationGuide />
</section>
);
ImportsPage.getLayout = function getLayout(page: ReactElement) { const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
if (!isAdmin)
return (
<div className="h-full w-full flex justify-center mt-10 p-4">
<p className="text-custom-text-300 text-sm">You are not authorized to access this page.</p>
</div>
);
return (
<section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center py-3.5 border-b border-custom-border-100">
<h3 className="text-xl font-medium">Imports</h3>
</div>
<IntegrationGuide />
</section>
);
});
ImportsPage.getLayout = function getLayout(page: React.ReactElement) {
return ( return (
<AppLayout header={<WorkspaceSettingHeader title="Import Settings" />}> <AppLayout header={<WorkspaceSettingHeader title="Import Settings" />}>
<WorkspaceSettingLayout>{page}</WorkspaceSettingLayout> <WorkspaceSettingLayout>{page}</WorkspaceSettingLayout>

View File

@ -1,6 +1,9 @@
import { ReactElement } from "react"; import { ReactElement } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services // services
import { IntegrationService } from "services/integrations"; import { IntegrationService } from "services/integrations";
// layouts // layouts
@ -16,16 +19,31 @@ import { Loader } from "@plane/ui";
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
// fetch-keys // fetch-keys
import { APP_INTEGRATIONS } from "constants/fetch-keys"; import { APP_INTEGRATIONS } from "constants/fetch-keys";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
// services
const integrationService = new IntegrationService(); const integrationService = new IntegrationService();
const WorkspaceIntegrationsPage: NextPageWithLayout = () => { const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// mobx store
const {
user: { currentWorkspaceRole },
} = useMobxStore();
const { data: appIntegrations } = useSWR(workspaceSlug ? APP_INTEGRATIONS : null, () => const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
workspaceSlug ? integrationService.getAppIntegrationsList() : null
if (!isAdmin)
return (
<div className="h-full w-full flex justify-center mt-10 p-4">
<p className="text-custom-text-300 text-sm">You are not authorized to access this page.</p>
</div>
);
const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () =>
workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null
); );
return ( return (
@ -43,7 +61,7 @@ const WorkspaceIntegrationsPage: NextPageWithLayout = () => {
</div> </div>
</section> </section>
); );
}; });
WorkspaceIntegrationsPage.getLayout = function getLayout(page: ReactElement) { WorkspaceIntegrationsPage.getLayout = function getLayout(page: ReactElement) {
return ( return (

View File

@ -1,9 +1,11 @@
import { useState, ReactElement } from "react"; import { useState, ReactElement } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/settings-layout"; import { WorkspaceSettingLayout } from "layouts/settings-layout";
@ -12,21 +14,20 @@ import { WorkspaceSettingHeader } from "components/headers";
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "components/workspace"; import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "components/workspace";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// icons
import { Search } from "lucide-react";
// helpers
import { trackEvent } from "helpers/event-tracker.helper";
// types // types
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
import { IWorkspaceBulkInviteFormData } from "types"; import { IWorkspaceBulkInviteFormData } from "types";
// constants
import { EUserWorkspaceRoles } from "constants/workspace";
const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// store // store
const { const {
user: { currentWorkspaceRole },
workspaceMember: { inviteMembersToWorkspace }, workspaceMember: { inviteMembersToWorkspace },
trackEvent: { postHogEventTracker, setTrackElement } trackEvent: { postHogEventTracker, setTrackElement },
} = useMobxStore(); } = useMobxStore();
// states // states
const [inviteModal, setInviteModal] = useState(false); const [inviteModal, setInviteModal] = useState(false);
@ -57,15 +58,16 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => {
}); });
}; };
const hasAddMemberPermission =
currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole);
return ( return (
<> <>
{workspaceSlug && ( <SendWorkspaceInvitationModal
<SendWorkspaceInvitationModal isOpen={inviteModal}
isOpen={inviteModal} onClose={() => setInviteModal(false)}
onClose={() => setInviteModal(false)} onSubmit={handleWorkspaceInvite}
onSubmit={handleWorkspaceInvite} />
/>
)}
<section className="pr-9 py-8 w-full overflow-y-auto"> <section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-100"> <div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-100">
<h4 className="text-xl font-medium">Members</h4> <h4 className="text-xl font-medium">Members</h4>
@ -79,13 +81,18 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
<Button variant="primary" size="sm" onClick={() => { {hasAddMemberPermission && (
setTrackElement("WORKSPACE_SETTINGS_MEMBERS_PAGE_HEADER"); <Button
setInviteModal(true) variant="primary"
} size="sm"
}> onClick={() => {
Add Member setTrackElement("WORKSPACE_SETTINGS_MEMBERS_PAGE_HEADER");
</Button> setInviteModal(true);
}}
>
Add member
</Button>
)}
</div> </div>
<WorkspaceMembersList searchQuery={searchQuery} /> <WorkspaceMembersList searchQuery={searchQuery} />
</section> </section>

View File

@ -88,7 +88,8 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
* computed value provides the members information including the invitations. * computed value provides the members information including the invitations.
*/ */
get workspaceMembersWithInvitations() { get workspaceMembersWithInvitations() {
if (!this.workspaceMembers || !this.workspaceMemberInvitations) return null; if (!this.workspaceMembers) return null;
return [ return [
...(this.workspaceMemberInvitations?.map((item) => ({ ...(this.workspaceMemberInvitations?.map((item) => ({
id: item.id, id: item.id,

View File

@ -1,4 +1,4 @@
import type { IProjectMember, IUser, IUserLite, IUserMemberLite, IWorkspaceViewProps } from "types"; import type { IProjectMember, IUser, IUserLite, IWorkspaceViewProps } from "types";
export type TUserWorkspaceRole = 5 | 10 | 15 | 20; export type TUserWorkspaceRole = 5 | 10 | 15 | 20;