[WEB-2126] chore: guest and viewer role permission (#5347)
* chore: user store code refactor * chore: general unauthorized screen asset added * chore: workspace setting sidebar options updated for guest and viewer * chore: NotAuthorizedView component code updated * chore: project setting layout code refactor * chore: workspace setting members and exports page permission validation added * chore: workspace members and exports settings page improvement * chore: project invite modal updated * chore: workspace setting unauthorized access empty state * chore: workspace setting unauthorized access empty state * chore: project settings sidebar permission updated * fix: project settings user role permission updated * chore: app sidebar role permission validation updated * chore: app sidebar role permission validation * chore: disabled page empty state validation * chore: app sidebar add project improvement * chore: guest role changes * fix: user favorite * chore: changed pages permission * chore: guest role changes * fix: app sidebar project item permission * fix: project setting empty state flicker * fix: workspace setting empty state flicker * chore: granted notification permission to viewer * chore: project invite and edit validation updated * chore: favorite validation added for guest and viewer role * chore: create view validation updated * chore: views permission changes * chore: create view empty state validation updated * chore: created ENUM for permissions --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
This commit is contained in:
parent
d60e988ca1
commit
0a1c656865
62 changed files with 957 additions and 590 deletions
|
|
@ -6,7 +6,10 @@ import { useParams, useSearchParams } from "next/navigation";
|
|||
import { TPageNavigationTabs } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PagesListRoot, PagesListView } from "@/components/pages";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
|
|
@ -16,7 +19,7 @@ const ProjectPagesPage = observer(() => {
|
|||
const type = searchParams.get("type");
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Pages` : undefined;
|
||||
|
|
@ -29,6 +32,17 @@ const ProjectPagesPage = observer(() => {
|
|||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to cycle
|
||||
if (currentProjectDetails?.page_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_PAGE}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ import { IProject } from "@plane/types";
|
|||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
|
||||
import { PageHead } from "@/components/core";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
|
|
@ -19,6 +18,7 @@ const AutomationSettingsPage = observer(() => {
|
|||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
canPerformProjectAdminActions,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails: projectDetails, updateProject } = useProject();
|
||||
|
|
@ -36,13 +36,16 @@ const AutomationSettingsPage = observer(() => {
|
|||
};
|
||||
|
||||
// derived values
|
||||
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
|
||||
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
|
||||
|
||||
if (currentProjectRole && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Automations</h3>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,30 +3,40 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EstimateRoot } from "@/components/estimates";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useUser, useProject } from "@/hooks/store";
|
||||
|
||||
const EstimatesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const {
|
||||
canPerformProjectAdminActions,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
// derived values
|
||||
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (currentProjectRole && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "pointer-events-none opacity-60"}`}>
|
||||
<EstimateRoot workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} isAdmin={isAdmin} />
|
||||
<div
|
||||
className={`w-full overflow-y-auto py-8 pr-9 ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
|
||||
>
|
||||
<EstimateRoot
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
isAdmin={canPerformProjectAdminActions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectFeaturesList } from "@/components/project";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
|
|
@ -15,28 +13,27 @@ const FeaturesSettingsPage = observer(() => {
|
|||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const {
|
||||
membership: { fetchUserProjectInfo },
|
||||
canPerformProjectAdminActions,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// fetch the project details
|
||||
const { data: memberDetails } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_MEMBERS_ME_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchUserProjectInfo(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// derived values
|
||||
const isAdmin = memberDetails?.role === EUserProjectRoles.ADMIN;
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
|
||||
if (currentProjectRole && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<ProjectFeaturesList
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isAdmin={isAdmin}
|
||||
isAdmin={canPerformProjectAdminActions}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const ProjectSettingHeader: FC = observer(() => {
|
|||
} = useUser();
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
|
||||
if (currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER) return null;
|
||||
const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||
|
|
@ -70,14 +70,17 @@ export const ProjectSettingHeader: FC = observer(() => {
|
|||
placement="bottom-start"
|
||||
closeOnSelect
|
||||
>
|
||||
{PROJECT_SETTINGS_LINKS.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)}
|
||||
>
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(item) =>
|
||||
projectMemberInfo >= item.access && (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)}
|
||||
>
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,19 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
|||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectSettingsLabelList } from "@/components/labels";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const LabelsSettingsPage = observer(() => {
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
canPerformProjectMemberActions,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -29,6 +35,10 @@ const LabelsSettingsPage = observer(() => {
|
|||
);
|
||||
}, [scrollableContainerRef?.current]);
|
||||
|
||||
if (currentProjectRole && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { Button, LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// local components
|
||||
import { ProjectSettingHeader } from "./header";
|
||||
import { ProjectSettingsSidebar } from "./sidebar";
|
||||
|
|
@ -21,33 +11,8 @@ export interface IProjectSettingLayout {
|
|||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ProjectSettingLayout: FC<IProjectSettingLayout> = observer((props) => {
|
||||
const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER;
|
||||
|
||||
if (restrictViewSettings) {
|
||||
return (
|
||||
<NotAuthorizedView
|
||||
type="project"
|
||||
actionButton={
|
||||
//TODO: Create a new component called Button Link to handle such scenarios
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`}>
|
||||
<Button variant="primary" size="md" prependIcon={<LayersIcon />}>
|
||||
Go to issues
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectSettingHeader />} />
|
||||
|
|
@ -63,6 +28,6 @@ const ProjectSettingLayout: FC<IProjectSettingLayout> = observer((props) => {
|
|||
</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default ProjectSettingLayout;
|
||||
|
|
|
|||
|
|
@ -2,17 +2,26 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const MembersSettingsPage = observer(() => {
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
canPerformProjectViewerActions,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
|
||||
|
||||
if (currentProjectRole && !canPerformProjectViewerActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// ui
|
||||
|
|
@ -12,7 +13,7 @@ import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project";
|
|||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
|
||||
export const ProjectSettingsSidebar = () => {
|
||||
export const ProjectSettingsSidebar = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
|
|
@ -60,4 +61,4 @@ export const ProjectSettingsSidebar = () => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,18 +3,27 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectStateRoot } from "@/components/project-states";
|
||||
// hook
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const StatesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
canPerformProjectMemberActions,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
|
||||
|
||||
if (currentProjectRole && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
|
|
|||
|
|
@ -12,21 +12,17 @@ import { BreadcrumbLink, Logo } from "@/components/common";
|
|||
import { ViewListHeader } from "@/components/views";
|
||||
import { ViewAppliedFiltersList } from "@/components/views/applied-filters";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { EViewAccess } from "@/constants/views";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useProject, useProjectView, useUser } from "@/hooks/store";
|
||||
import { useCommandPalette, useProject, useProjectView } from "@/hooks/store";
|
||||
|
||||
export const ProjectViewsHeader = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleCreateViewModal } = useCommandPalette();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { filters, updateFilters, clearAllFilters } = useProjectView();
|
||||
|
||||
|
|
@ -49,9 +45,6 @@ export const ProjectViewsHeader = observer(() => {
|
|||
|
||||
const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0;
|
||||
|
||||
const canUserCreateView =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||
|
|
@ -83,13 +76,11 @@ export const ProjectViewsHeader = observer(() => {
|
|||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<ViewListHeader />
|
||||
{canUserCreateView && (
|
||||
<div>
|
||||
<Button variant="primary" size="sm" onClick={() => toggleCreateViewModal(true)}>
|
||||
Add view
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button variant="primary" size="sm" onClick={() => toggleCreateViewModal(true)}>
|
||||
Add view
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isFiltersApplied && (
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import useSWR from "swr";
|
|||
import { Button } from "@plane/ui";
|
||||
// component
|
||||
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { APITokenSettingsLoader } from "@/components/ui";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// store hooks
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
// services
|
||||
|
|
@ -29,27 +29,22 @@ const ApiTokensPage = observer(() => {
|
|||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
canPerformWorkspaceAdminActions,
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
|
||||
const { data: tokens } = useSWR(workspaceSlug && isAdmin ? API_TOKENS_LIST(workspaceSlug.toString()) : null, () =>
|
||||
workspaceSlug && isAdmin ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
|
||||
const { data: tokens } = useSWR(
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null,
|
||||
() =>
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - API Tokens` : undefined;
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (currentWorkspaceRole && !canPerformWorkspaceAdminActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
|
|
@ -92,4 +87,4 @@ const ApiTokensPage = observer(() => {
|
|||
);
|
||||
});
|
||||
|
||||
export default ApiTokensPage;
|
||||
export default ApiTokensPage;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
// component
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// hooks
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
// plane web components
|
||||
|
|
@ -13,22 +12,16 @@ import { BillingRoot } from "@/plane-web/components/workspace";
|
|||
const BillingSettingsPage = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
canPerformWorkspaceAdminActions,
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined;
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (currentWorkspaceRole && !canPerformWorkspaceAdminActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -2,39 +2,39 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import ExportGuide from "@/components/exporter/guide";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
|
||||
const ExportsPage = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
canPerformWorkspaceViewerActions,
|
||||
canPerformWorkspaceMemberActions,
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
// derived values
|
||||
const hasPageAccess =
|
||||
currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentWorkspaceRole);
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Exports` : undefined;
|
||||
|
||||
if (!hasPageAccess)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
// if user is not authorized to view this page
|
||||
if (currentWorkspaceRole && !canPerformWorkspaceViewerActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full overflow-y-auto md:pr-9 pr-4">
|
||||
<div
|
||||
className={cn("w-full overflow-y-auto md:pr-9 pr-4", {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Exports</h3>
|
||||
</div>
|
||||
|
|
@ -44,4 +44,4 @@ const ExportsPage = observer(() => {
|
|||
);
|
||||
});
|
||||
|
||||
export default ExportsPage;
|
||||
export default ExportsPage;
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
|||
// ui
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace";
|
||||
// constants
|
||||
import { MEMBER_INVITED } from "@/constants/event-tracker";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getUserRole } from "@/helpers/user.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useMember, useUser, useWorkspace } from "@/hooks/store";
|
||||
|
|
@ -28,6 +29,9 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
|||
// store hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
canPerformWorkspaceAdminActions,
|
||||
canPerformWorkspaceViewerActions,
|
||||
canPerformWorkspaceMemberActions,
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const {
|
||||
|
|
@ -79,9 +83,13 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
|||
};
|
||||
|
||||
// derived values
|
||||
const isAdmin = currentWorkspaceRole && [EUserWorkspaceRoles.ADMIN].includes(currentWorkspaceRole);
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined;
|
||||
|
||||
// if user is not authorized to view this page
|
||||
if (currentWorkspaceRole && !canPerformWorkspaceViewerActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
|
@ -90,7 +98,11 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
|||
onClose={() => setInviteModal(false)}
|
||||
onSubmit={handleWorkspaceInvite}
|
||||
/>
|
||||
<section className="w-full overflow-y-auto md:pr-9 pr-4">
|
||||
<section
|
||||
className={cn("w-full overflow-y-auto md:pr-9 pr-4", {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||
<h4 className="text-xl font-medium">Members</h4>
|
||||
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
|
||||
|
|
@ -103,13 +115,13 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
|||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{canPerformWorkspaceAdminActions && (
|
||||
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
|
||||
Add member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={isAdmin ?? false} />
|
||||
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import useSWR from "swr";
|
|||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { WebhookSettingsLoader } from "@/components/ui";
|
||||
|
|
@ -23,16 +24,15 @@ const WebhooksListPage = observer(() => {
|
|||
const { workspaceSlug } = useParams();
|
||||
// mobx store
|
||||
const {
|
||||
canPerformWorkspaceAdminActions,
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { fetchWebhooks, webhooks, clearSecretKey, webhookSecretKey, createWebhook } = useWebhook();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const isAdmin = currentWorkspaceRole === 20;
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && isAdmin ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
|
||||
workspaceSlug && isAdmin ? () => fetchWebhooks(workspaceSlug.toString()) : null
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? () => fetchWebhooks(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Webhooks` : undefined;
|
||||
|
|
@ -42,15 +42,9 @@ const WebhooksListPage = observer(() => {
|
|||
if (!showCreateWebhookModal && webhookSecretKey) clearSecretKey();
|
||||
}, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]);
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (currentWorkspaceRole && !canPerformWorkspaceAdminActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
if (!webhooks) return <WebhookSettingsLoader />;
|
||||
|
||||
|
|
@ -95,4 +89,4 @@ const WebhooksListPage = observer(() => {
|
|||
);
|
||||
});
|
||||
|
||||
export default WebhooksListPage;
|
||||
export default WebhooksListPage;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
import { useAppTheme, useUser } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
// plane web components
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
|
|
@ -23,6 +23,7 @@ export interface IAppSidebar {}
|
|||
|
||||
export const AppSidebar: FC<IAppSidebar> = observer(() => {
|
||||
// store hooks
|
||||
const { canPerformWorkspaceMemberActions } = useUser();
|
||||
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
|
||||
const windowSize = useSize();
|
||||
// refs
|
||||
|
|
@ -85,7 +86,7 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
|
|||
"opacity-0": !sidebarCollapsed,
|
||||
})}
|
||||
/>
|
||||
<SidebarFavoritesMenu />
|
||||
{canPerformWorkspaceMemberActions && <SidebarFavoritesMenu />}
|
||||
|
||||
<SidebarProjectsList />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,10 @@ import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/com
|
|||
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useLabel, useMember, useUser, useIssues, useGlobalView } from "@/hooks/store";
|
||||
import { useLabel, useMember, useIssues, useGlobalView } from "@/hooks/store";
|
||||
|
||||
export const GlobalIssuesHeader = observer(() => {
|
||||
// states
|
||||
|
|
@ -30,9 +29,6 @@ export const GlobalIssuesHeader = observer(() => {
|
|||
issuesFilter: { filters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { getViewDetailsById } = useGlobalView();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { workspaceLabels } = useLabel();
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
|
|
@ -97,8 +93,6 @@ export const GlobalIssuesHeader = observer(() => {
|
|||
[workspaceSlug, updateFilters, globalViewId]
|
||||
);
|
||||
|
||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
const isLocked = viewDetails?.is_locked;
|
||||
|
||||
return (
|
||||
|
|
@ -142,11 +136,10 @@ export const GlobalIssuesHeader = observer(() => {
|
|||
</FiltersDropdown>
|
||||
</>
|
||||
)}
|
||||
{isAuthorizedUser && (
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateViewModal(true)}>
|
||||
Add view
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateViewModal(true)}>
|
||||
Add view
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const WORKSPACE_SETTINGS = {
|
|||
key: "members",
|
||||
label: "Members",
|
||||
href: `/settings/members`,
|
||||
access: EUserWorkspaceRoles.GUEST,
|
||||
access: EUserWorkspaceRoles.VIEWER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
|
|
@ -33,7 +33,7 @@ export const WORKSPACE_SETTINGS = {
|
|||
key: "export",
|
||||
label: "Exports",
|
||||
href: `/settings/exports`,
|
||||
access: EUserWorkspaceRoles.MEMBER,
|
||||
access: EUserWorkspaceRoles.VIEWER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,62 +1,33 @@
|
|||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// layouts
|
||||
import DefaultLayout from "@/layouts/default-layout";
|
||||
// images
|
||||
import ProjectNotAuthorizedImg from "@/public/auth/project-not-authorized.svg";
|
||||
import Unauthorized from "@/public/auth/unauthorized.svg";
|
||||
import WorkspaceNotAuthorizedImg from "@/public/auth/workspace-not-authorized.svg";
|
||||
|
||||
type Props = {
|
||||
actionButton?: React.ReactNode;
|
||||
type: "project" | "workspace";
|
||||
section?: "settings" | "general";
|
||||
isProjectView?: boolean;
|
||||
};
|
||||
|
||||
export const NotAuthorizedView: React.FC<Props> = observer((props) => {
|
||||
const { actionButton, type } = props;
|
||||
// router
|
||||
const searchParams = useSearchParams();
|
||||
const next_path = searchParams.get("next_path");
|
||||
// hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { actionButton, section = "general", isProjectView = false } = props;
|
||||
|
||||
// assets
|
||||
const settingAsset = isProjectView ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg;
|
||||
const asset = section === "settings" ? settingAsset : Unauthorized;
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<Image
|
||||
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
|
||||
height="176"
|
||||
width="288"
|
||||
alt="ProjectSettingImg"
|
||||
/>
|
||||
<Image src={asset} height="176" width="288" alt="ProjectSettingImg" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-custom-text-100">Oops! You are not authorized to view this page</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-custom-text-200">
|
||||
{currentUser ? (
|
||||
<p>
|
||||
You have signed in as {currentUser.email}. <br />
|
||||
<Link href={`/?next_path=${next_path}`}>
|
||||
<span className="font-medium text-custom-text-100">Sign in</span>
|
||||
</Link>{" "}
|
||||
with different account that has access to this page.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
You need to{" "}
|
||||
<Link href={`/?next_path=${next_path}`}>
|
||||
<span className="font-medium text-custom-text-100">Sign in</span>
|
||||
</Link>{" "}
|
||||
with an account that has access to this page.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionButton}
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export const CommandPalette: FC = observer(() => {
|
|||
const { platform } = usePlatformOS();
|
||||
const {
|
||||
data: currentUser,
|
||||
canPerformProjectCreateActions,
|
||||
canPerformWorkspaceCreateActions,
|
||||
canPerformProjectMemberActions,
|
||||
canPerformWorkspaceMemberActions,
|
||||
canPerformAnyCreateAction,
|
||||
canPerformProjectAdminActions,
|
||||
} = useUser();
|
||||
|
|
@ -103,15 +103,15 @@ export const CommandPalette: FC = observer(() => {
|
|||
// auth
|
||||
const performProjectCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformProjectCreateActions && showToast)
|
||||
if (!canPerformProjectMemberActions && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
|
||||
return canPerformProjectCreateActions;
|
||||
return canPerformProjectMemberActions;
|
||||
},
|
||||
[canPerformProjectCreateActions]
|
||||
[canPerformProjectMemberActions]
|
||||
);
|
||||
|
||||
const performProjectBulkDeleteActions = useCallback(
|
||||
|
|
@ -129,14 +129,14 @@ export const CommandPalette: FC = observer(() => {
|
|||
|
||||
const performWorkspaceCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
if (!canPerformWorkspaceCreateActions && showToast)
|
||||
if (!canPerformWorkspaceMemberActions && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "You don't have permission to perform this action.",
|
||||
});
|
||||
return canPerformWorkspaceCreateActions;
|
||||
return canPerformWorkspaceMemberActions;
|
||||
},
|
||||
[canPerformWorkspaceCreateActions]
|
||||
[canPerformWorkspaceMemberActions]
|
||||
);
|
||||
|
||||
const performAnyProjectCreateActions = useCallback(
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import { Earth, Info, Lock, Minus } from "lucide-react";
|
|||
import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageQuickActions } from "@/components/pages/dropdowns";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useMember, usePage } from "@/hooks/store";
|
||||
import { useMember, usePage, useProject } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -25,10 +27,15 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
|||
// store hooks
|
||||
const page = usePage(pageId);
|
||||
const { getUserDetails } = useMember();
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
// derived values
|
||||
const { access, created_at, is_favorite, owned_by, addToFavorites, removePageFromFavorites } = page;
|
||||
|
||||
// derived values
|
||||
const project = getProjectById(projectId);
|
||||
const isViewerOrGuest =
|
||||
project?.member_role && [EUserProjectRoles.VIEWER, EUserProjectRoles.GUEST].includes(project.member_role);
|
||||
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
|
||||
|
||||
// handlers
|
||||
|
|
@ -74,14 +81,16 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
|||
</Tooltip>
|
||||
|
||||
{/* favorite/unfavorite */}
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleFavorites();
|
||||
}}
|
||||
selected={is_favorite}
|
||||
/>
|
||||
{!isViewerOrGuest && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleFavorites();
|
||||
}}
|
||||
selected={is_favorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* quick actions dropdown */}
|
||||
<PageQuickActions
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Avatar, Button, CustomSelect, CustomSearchSelect, TOAST_TYPE, setToast
|
|||
// helpers
|
||||
import { PROJECT_MEMBER_ADDED } from "@/constants/event-tracker";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { ROLE } from "@/constants/workspace";
|
||||
import { EUserWorkspaceRoles, ROLE } from "@/constants/workspace";
|
||||
import { useEventTracker, useMember, useUser } from "@/hooks/store";
|
||||
// constants
|
||||
|
||||
|
|
@ -56,6 +56,8 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
|||
// form info
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
handleSubmit,
|
||||
control,
|
||||
|
|
@ -167,6 +169,19 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
|||
}[]
|
||||
| undefined;
|
||||
|
||||
const checkCurrentOptionWorkspaceRole = (value: string) => {
|
||||
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role;
|
||||
if (!value || !currentMemberWorkspaceRole) return ROLE;
|
||||
|
||||
const isGuestOrViewer = [EUserWorkspaceRoles.GUEST, EUserWorkspaceRoles.VIEWER].includes(
|
||||
currentMemberWorkspaceRole
|
||||
);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(ROLE).filter(([key]) => !isGuestOrViewer || [5, 10].includes(parseInt(key)))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
|
|
@ -237,6 +252,14 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
|||
}
|
||||
onChange={(val: string) => {
|
||||
onChange(val);
|
||||
// Update the role to the workspace role when member ID changes
|
||||
const workspaceMemberDetails = getWorkspaceMemberDetails(val);
|
||||
const workspaceRole = workspaceMemberDetails?.role ?? 5;
|
||||
const newValue = ROLE[workspaceRole].toUpperCase();
|
||||
setValue(
|
||||
`members.${index}.role`,
|
||||
EUserProjectRoles[newValue as keyof typeof EUserProjectRoles]
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
optionsClassName="w-full"
|
||||
|
|
@ -271,7 +294,9 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
|||
input
|
||||
optionsClassName="w-full"
|
||||
>
|
||||
{Object.entries(ROLE).map(([key, label]) => {
|
||||
{Object.entries(
|
||||
checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`))
|
||||
).map(([key, label]) => {
|
||||
if (parseInt(key) > (currentProjectRole ?? EUserProjectRoles.GUEST)) return null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
|
|||
// store hooks
|
||||
const {
|
||||
project: { updateMember },
|
||||
workspace: { getWorkspaceMemberDetails },
|
||||
} = useMember();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
|
|
@ -99,6 +100,19 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
|
|||
const isAdminRole = currentProjectRole === EUserProjectRoles.ADMIN;
|
||||
const isRoleNonEditable = isCurrentUser || !isAdminRole;
|
||||
|
||||
const checkCurrentOptionWorkspaceRole = (value: string) => {
|
||||
const currentMemberWorkspaceRole = getWorkspaceMemberDetails(value)?.role;
|
||||
if (!value || !currentMemberWorkspaceRole) return ROLE;
|
||||
|
||||
const isGuestOrViewer = [EUserWorkspaceRoles.GUEST, EUserWorkspaceRoles.VIEWER].includes(
|
||||
currentMemberWorkspaceRole
|
||||
);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(ROLE).filter(([key]) => !isGuestOrViewer || [5, 10].includes(parseInt(key)))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRoleNonEditable ? (
|
||||
|
|
@ -140,11 +154,14 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
|
|||
optionsClassName="w-full"
|
||||
input
|
||||
>
|
||||
{Object.keys(ROLE).map((item) => (
|
||||
<CustomSelect.Option key={item} value={item as unknown as EUserProjectRoles}>
|
||||
{ROLE[item as unknown as keyof typeof ROLE]}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
{Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => {
|
||||
if (parseInt(key) > (currentProjectRole ?? EUserProjectRoles.GUEST)) return null;
|
||||
return (
|
||||
<CustomSelect.Option key={key} value={key}>
|
||||
{label}
|
||||
</CustomSelect.Option>
|
||||
);
|
||||
})}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import { EUserProjectRoles } from "@/constants/project";
|
|||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme, useEventTracker, useProject } from "@/hooks/store";
|
||||
import { useAppTheme, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// constants
|
||||
|
|
@ -70,31 +70,37 @@ const navigation = (workspaceSlug: string, projectId: string) => [
|
|||
name: "Issues",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/issues`,
|
||||
Icon: LayersIcon,
|
||||
access: EUserProjectRoles.GUEST,
|
||||
},
|
||||
{
|
||||
name: "Cycles",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
|
||||
Icon: ContrastIcon,
|
||||
access: EUserProjectRoles.VIEWER,
|
||||
},
|
||||
{
|
||||
name: "Modules",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/modules`,
|
||||
Icon: DiceIcon,
|
||||
access: EUserProjectRoles.VIEWER,
|
||||
},
|
||||
{
|
||||
name: "Views",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/views`,
|
||||
Icon: Layers,
|
||||
access: EUserProjectRoles.GUEST,
|
||||
},
|
||||
{
|
||||
name: "Pages",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/pages`,
|
||||
Icon: FileText,
|
||||
access: EUserProjectRoles.VIEWER,
|
||||
},
|
||||
{
|
||||
name: "Intake",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/inbox`,
|
||||
Icon: Intake,
|
||||
access: EUserProjectRoles.GUEST,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -106,6 +112,9 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
|
|||
const { setTrackElement } = useEventTracker();
|
||||
const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const {
|
||||
membership: { currentWorkspaceAllProjectsRole },
|
||||
} = useUser();
|
||||
// states
|
||||
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
|
||||
const [publishModalOpen, setPublishModal] = useState(false);
|
||||
|
|
@ -378,16 +387,20 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
|
|||
customButtonClassName="grid place-items-center"
|
||||
placement="bottom-start"
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Star
|
||||
className={cn("h-3.5 w-3.5 ", {
|
||||
"fill-yellow-500 stroke-yellow-500": project.is_favorite,
|
||||
})}
|
||||
/>
|
||||
<span>{project.is_favorite ? "Remove from favorites" : "Add to favorites"}</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{!isViewerOrGuest && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Star
|
||||
className={cn("h-3.5 w-3.5 ", {
|
||||
"fill-yellow-500 stroke-yellow-500": project.is_favorite,
|
||||
})}
|
||||
/>
|
||||
<span>{project.is_favorite ? "Remove from favorites" : "Add to favorites"}</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{/* publish project settings */}
|
||||
{isAdmin && (
|
||||
|
|
@ -400,14 +413,16 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
|
|||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem>
|
||||
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
|
||||
<span>Draft issues</span>
|
||||
</div>
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
{!isViewerOrGuest && (
|
||||
<CustomMenu.MenuItem>
|
||||
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
|
||||
<span>Draft issues</span>
|
||||
</div>
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
|
|
@ -482,31 +497,37 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
|
|||
(item.name === "Intake" && !project.inbox_view)
|
||||
)
|
||||
return;
|
||||
|
||||
const currentRole = currentWorkspaceAllProjectsRole
|
||||
? currentWorkspaceAllProjectsRole[projectId]
|
||||
: undefined;
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.name}
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${project?.name}: ${item.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!isSidebarCollapsed}
|
||||
>
|
||||
<Link key={item.name} href={item.href} onClick={handleProjectClick}>
|
||||
<SidebarNavItem
|
||||
<>
|
||||
{currentRole >= item.access && (
|
||||
<Tooltip
|
||||
key={item.name}
|
||||
className={`pl-[18px] ${isSidebarCollapsed ? "p-0 size-7 justify-center mx-auto" : ""}`}
|
||||
isActive={pathname.includes(item.href)}
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${project?.name}: ${item.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!isSidebarCollapsed}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
<item.Icon
|
||||
className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`}
|
||||
/>
|
||||
{!isSidebarCollapsed && <span className="text-xs font-medium">{item.name}</span>}
|
||||
</div>
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<Link key={item.name} href={item.href} onClick={handleProjectClick}>
|
||||
<SidebarNavItem
|
||||
key={item.name}
|
||||
className={`pl-[18px] ${isSidebarCollapsed ? "p-0 size-7 justify-center mx-auto" : ""}`}
|
||||
isActive={pathname.includes(item.href)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 py-[1px]">
|
||||
<item.Icon
|
||||
className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`}
|
||||
/>
|
||||
{!isSidebarCollapsed && <span className="text-xs font-medium">{item.name}</span>}
|
||||
</div>
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Disclosure.Panel>
|
||||
|
|
|
|||
|
|
@ -263,7 +263,6 @@ export const SidebarProjectsList: FC = observer(() => {
|
|||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="flex-shrink-0 size-4" />
|
||||
{!isCollapsed && "Add project"}
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ export const SIDEBAR_WORKSPACE_MENU_ITEMS: {
|
|||
key: "active-cycles",
|
||||
label: "Cycles",
|
||||
href: `/active-cycles`,
|
||||
access: EUserWorkspaceRoles.GUEST,
|
||||
access: EUserWorkspaceRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/active-cycles/`,
|
||||
Icon: ContrastIcon,
|
||||
},
|
||||
|
|
@ -317,7 +317,7 @@ export const SIDEBAR_USER_MENU_ITEMS: {
|
|||
key: "your-work",
|
||||
label: "Your work",
|
||||
href: "/profile",
|
||||
access: EUserWorkspaceRoles.GUEST,
|
||||
access: EUserWorkspaceRoles.MEMBER,
|
||||
highlight: (pathname: string, baseUrl: string, options?: TLinkOptions) =>
|
||||
options?.userId ? pathname.includes(`${baseUrl}/profile/${options?.userId}`) : false,
|
||||
Icon: UserActivityIcon,
|
||||
|
|
|
|||
|
|
@ -490,7 +490,7 @@ const emptyStateDetails = {
|
|||
},
|
||||
},
|
||||
accessType: "project",
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
access: EUserProjectRoles.GUEST,
|
||||
},
|
||||
// project pages
|
||||
[EmptyStateType.PROJECT_PAGE]: {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const PROJECT_SETTINGS_LINKS: {
|
|||
key: "general",
|
||||
label: "General",
|
||||
href: `/settings`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
access: EUserProjectRoles.GUEST,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
|
|
@ -87,7 +87,7 @@ export const PROJECT_SETTINGS_LINKS: {
|
|||
key: "members",
|
||||
label: "Members",
|
||||
href: `/settings/members`,
|
||||
access: EUserProjectRoles.MEMBER,
|
||||
access: EUserProjectRoles.VIEWER,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
||||
Icon: SettingIcon,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
import { useProject, usePage, useProjectView, useCycle, useModule } from "@/hooks/store";
|
||||
|
||||
export const useFavoriteItemDetails = (workspaceSlug: string, favorite: IFavorite) => {
|
||||
const favoriteItemId = favorite.entity_data.id;
|
||||
const favoriteItemId = favorite?.entity_data?.id;
|
||||
const favoriteItemLogoProps = favorite?.entity_data?.logo_props;
|
||||
const favoriteItemName = favorite?.entity_data.name || favorite?.name;
|
||||
const favoriteItemEntityType = favorite?.entity_type;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
|||
// next themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { membership, signOut, data: currentUser } = useUser();
|
||||
const { membership, signOut, data: currentUser, canPerformWorkspaceMemberActions } = useUser();
|
||||
const { fetchProjects } = useProject();
|
||||
const { fetchFavorite } = useFavorite();
|
||||
const {
|
||||
|
|
@ -72,8 +72,12 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
|
|||
);
|
||||
// fetch workspace favorite
|
||||
useSWR(
|
||||
workspaceSlug && currentWorkspace ? `WORKSPACE_FAVORITE_${workspaceSlug}` : null,
|
||||
workspaceSlug && currentWorkspace ? () => fetchFavorite(workspaceSlug.toString()) : null,
|
||||
workspaceSlug && currentWorkspace && canPerformWorkspaceMemberActions
|
||||
? `WORKSPACE_FAVORITE_${workspaceSlug}`
|
||||
: null,
|
||||
workspaceSlug && currentWorkspace && canPerformWorkspaceMemberActions
|
||||
? () => fetchFavorite(workspaceSlug.toString())
|
||||
: null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -42,9 +42,18 @@ export interface IUserStore {
|
|||
reset: () => void;
|
||||
signOut: () => Promise<void>;
|
||||
// computed
|
||||
canPerformProjectCreateActions: boolean;
|
||||
|
||||
// workspace level
|
||||
canPerformWorkspaceAdminActions: boolean;
|
||||
canPerformWorkspaceMemberActions: boolean;
|
||||
canPerformWorkspaceViewerActions: boolean;
|
||||
canPerformWorkspaceGuestActions: boolean;
|
||||
|
||||
// project level
|
||||
canPerformProjectAdminActions: boolean;
|
||||
canPerformWorkspaceCreateActions: boolean;
|
||||
canPerformProjectMemberActions: boolean;
|
||||
canPerformProjectViewerActions: boolean;
|
||||
canPerformProjectGuestActions: boolean;
|
||||
canPerformAnyCreateAction: boolean;
|
||||
projectsWithCreatePermissions: { [projectId: string]: number } | null;
|
||||
}
|
||||
|
|
@ -92,9 +101,16 @@ export class UserStore implements IUserStore {
|
|||
reset: action,
|
||||
signOut: action,
|
||||
// computed
|
||||
canPerformProjectCreateActions: computed,
|
||||
canPerformWorkspaceAdminActions: computed,
|
||||
canPerformWorkspaceMemberActions: computed,
|
||||
canPerformWorkspaceViewerActions: computed,
|
||||
canPerformWorkspaceGuestActions: computed,
|
||||
|
||||
canPerformProjectAdminActions: computed,
|
||||
canPerformWorkspaceCreateActions: computed,
|
||||
canPerformProjectMemberActions: computed,
|
||||
canPerformProjectViewerActions: computed,
|
||||
canPerformProjectGuestActions: computed,
|
||||
|
||||
canPerformAnyCreateAction: computed,
|
||||
projectsWithCreatePermissions: computed,
|
||||
});
|
||||
|
|
@ -273,15 +289,40 @@ export class UserStore implements IUserStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* @description tells if user has project create actions permissions
|
||||
* @description returns true if user has workspace admin actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canPerformProjectCreateActions() {
|
||||
return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
get canPerformWorkspaceAdminActions() {
|
||||
return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description tells if user has project admin actions permissions
|
||||
* @description returns true if user has workspace member actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canPerformWorkspaceMemberActions() {
|
||||
return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if user has workspace viewer actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
get canPerformWorkspaceViewerActions() {
|
||||
return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.VIEWER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if user has workspace guest actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canPerformWorkspaceGuestActions() {
|
||||
return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.GUEST;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if user has project admin actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canPerformProjectAdminActions() {
|
||||
|
|
@ -289,10 +330,27 @@ export class UserStore implements IUserStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* @description tells if user has workspace create actions permissions
|
||||
* @description returns true if user has project member actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canPerformWorkspaceCreateActions() {
|
||||
return !!this.membership.currentWorkspaceRole && this.membership.currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
get canPerformProjectMemberActions() {
|
||||
return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if user has project viewer actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
get canPerformProjectViewerActions() {
|
||||
return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.VIEWER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if user has project guest actions permissions
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get canPerformProjectGuestActions() {
|
||||
return !!this.membership.currentProjectRole && this.membership.currentProjectRole >= EUserProjectRoles.GUEST;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
web/public/auth/unauthorized.svg
Normal file
44
web/public/auth/unauthorized.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 23 KiB |
Loading…
Add table
Add a link
Reference in a new issue