Skip to content

Commit

Permalink
Merge pull request #1343 from vivid-planet/feature/user-permissions
Browse files Browse the repository at this point in the history
[Feature] User permissions
  • Loading branch information
fraxachun authored Dec 19, 2023
2 parents e4b6025 + 112b159 commit 2eeb647
Show file tree
Hide file tree
Showing 86 changed files with 1,103 additions and 1,044 deletions.
5 changes: 5 additions & 0 deletions .changeset/dirty-trees-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@comet/cms-admin": minor
---

Add `MasterMenu` and `MasterMenuRoutes` components which both take a single data structure to define menu and routes.
16 changes: 16 additions & 0 deletions .changeset/sixty-cobras-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@comet/cms-api": major
---

Replace ContentScopeModule with UserPermissionsModule

Breaking changes:

- ContentScope-Module has been removed
- canAccessScope has been moved to AccessControlService and renamed to isAllowedContentScope
- role- and rights-fields has been removed from CurrentUser-Object
- AllowForRole-decorator has been removed
- Rename decorator SubjectEntity to AffectedEntity
- Add RequiredPermission-decorator and make it mandatory when using UserPermissionsModule

Upgrade-Guide: tbd
258 changes: 69 additions & 189 deletions demo/admin/src/App.tsx

Large diffs are not rendered by default.

20 changes: 3 additions & 17 deletions demo/admin/src/common/ContentScopeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { gql, useQuery } from "@apollo/client";
import { Loading } from "@comet/admin";
import { Domain as DomainIcon } from "@comet/admin-icons";
import {
ContentScopeConfigProps,
Expand All @@ -11,12 +9,11 @@ import {
useContentScope as useContentScopeLibrary,
UseContentScopeApi,
useContentScopeConfig as useContentScopeConfigLibrary,
useCurrentUser,
useSitesConfig,
} from "@comet/cms-admin";
import React from "react";

import { GQLCurrentUserScopeQuery } from "./ContentScopeProvider.generated";

type Domain = "main" | "secondary" | string;
type Language = "en" | string;
export interface ContentScope {
Expand Down Expand Up @@ -52,22 +49,11 @@ export function useContentScopeConfig(p: ContentScopeConfigProps): void {
return useContentScopeConfigLibrary(p);
}

const currentUserQuery = gql`
query CurrentUserScope {
currentUser {
role
domains
}
}
`;

const ContentScopeProvider: React.FC<Pick<ContentScopeProviderProps, "children">> = ({ children }) => {
const sitesConfig = useSitesConfig();
const { loading, data } = useQuery<GQLCurrentUserScopeQuery>(currentUserQuery);

if (loading || !data) return <Loading behavior="fillPageHeight" />;
const user = useCurrentUser();

const allowedUserDomains = data.currentUser.domains;
const allowedUserDomains = user.contentScopes.map((scope) => scope.domain);

const allowedSiteConfigs = Object.fromEntries(
Object.entries(sitesConfig.configs).filter(([siteKey, siteConfig]) => allowedUserDomains.includes(siteKey)),
Expand Down
280 changes: 194 additions & 86 deletions demo/admin/src/common/MasterMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,95 +1,203 @@
import { Menu, MenuCollapsibleItem, MenuContext, MenuItemRouterLink, useWindowSize } from "@comet/admin";
import { Assets, Dashboard, Data, PageTree, Snips, Wrench } from "@comet/admin-icons";
import { Assets, Dashboard as DashboardIcon, Data, PageTree, Snips, Wrench } from "@comet/admin-icons";
import {
createRedirectsPage,
CronJobsPage,
DamPage,
MasterMenu as CometMasterMenu,
MasterMenuData,
PagesPage,
PublisherPage,
UserPermissionsPage,
} from "@comet/cms-admin";
import Dashboard from "@src/dashboard/Dashboard";
import { GQLPageTreeNodeCategory } from "@src/graphql.generated";
import { Link } from "@src/links/Link";
import { NewsLinkBlock } from "@src/news/blocks/NewsLinkBlock";
import { NewsPage } from "@src/news/generated/NewsPage";
import MainMenu from "@src/pages/mainMenu/MainMenu";
import { Page } from "@src/pages/Page";
import { categoryToUrlParam, pageTreeCategories, urlParamToCategory } from "@src/pageTree/pageTreeCategories";
import { PredefinedPage } from "@src/predefinedPage/PredefinedPage";
import ProductCategoriesPage from "@src/products/categories/ProductCategoriesPage";
import { ProductsPage } from "@src/products/generated/ProductsPage";
import ProductsHandmadePage from "@src/products/ProductsPage";
import ProductTagsPage from "@src/products/tags/ProductTagsPage";
import * as React from "react";
import { useIntl } from "react-intl";
import { useRouteMatch } from "react-router";
import { FormattedMessage } from "react-intl";
import { Redirect, RouteComponentProps } from "react-router-dom";

const permanentMenuMinWidth = 1024;
import { ComponentDemo } from "./ComponentDemo";
import { ContentScopeIndicator } from "./ContentScopeIndicator";
import { EditPageNode } from "./EditPageNode";

const MasterMenu: React.FC = () => {
const { open, toggleOpen } = React.useContext(MenuContext);
const windowSize = useWindowSize();
const intl = useIntl();
const match = useRouteMatch();
export const pageTreeDocumentTypes = {
Page,
Link,
PredefinedPage,
};

const useTemporaryMenu: boolean = windowSize.width < permanentMenuMinWidth;
const RedirectsPage = createRedirectsPage({ customTargets: { news: NewsLinkBlock }, scopeParts: ["domain"] });

// Open menu when changing to permanent variant and close when changing to temporary variant.
React.useEffect(() => {
if ((useTemporaryMenu && open) || (!useTemporaryMenu && !open)) {
toggleOpen();
}
// useEffect dependencies must only include `location`, because the function should only be called once after changing the location.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location]);
export const masterMenuData: MasterMenuData = [
{
primary: <FormattedMessage id="menu.dashboard" defaultMessage="Dashboard" />,
icon: <DashboardIcon />,
route: {
path: "/dashboard",
component: Dashboard,
},
},
{
primary: <FormattedMessage id="menu.pageTree" defaultMessage="Page tree" />,
icon: <PageTree />,
submenu: pageTreeCategories.map((category) => ({
primary: category.label,
to: `/pages/pagetree/${categoryToUrlParam(category.category as GQLPageTreeNodeCategory)}`,
})),
route: {
path: "/pages/pagetree/:category",
render: ({ match }: RouteComponentProps<{ category: string }>) => {
const category = urlParamToCategory(match.params.category);

return (
<Menu variant={useTemporaryMenu ? "temporary" : "permanent"}>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.dashboard", defaultMessage: "Dashboard" })}
icon={<Dashboard />}
to={`${match.url}/dashboard`}
/>
<MenuCollapsibleItem primary={intl.formatMessage({ id: "menu.pageTree", defaultMessage: "Page tree" })} icon={<PageTree />}>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.pageTree.mainNavigation", defaultMessage: "Main navigation" })}
to={`${match.url}/pages/pagetree/main-navigation`}
/>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.pageTree.topMenu", defaultMessage: "Top menu" })}
to={`${match.url}/pages/pagetree/top-menu`}
/>
</MenuCollapsibleItem>
if (category === undefined) {
return <Redirect to={`${match.url}/dashboard`} />;
}

<MenuCollapsibleItem primary={intl.formatMessage({ id: "menu.structuredContent", defaultMessage: "Structured Content" })} icon={<Data />}>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.news", defaultMessage: "News" })}
to={`${match.url}/structured-content/news`}
/>
</MenuCollapsibleItem>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.dam", defaultMessage: "Assets" })}
icon={<Assets />}
to={`${match.url}/assets`}
/>
<MenuCollapsibleItem primary={intl.formatMessage({ id: "menu.projectSnips", defaultMessage: "Project snips" })} icon={<Snips />}>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.mainMenu", defaultMessage: "Main menu" })}
to={`${match.url}/project-snips/main-menu`}
/>
</MenuCollapsibleItem>
<MenuCollapsibleItem primary={intl.formatMessage({ id: "menu.system", defaultMessage: "System" })} icon={<Wrench />}>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.publisher", defaultMessage: "Publisher" })}
to={`${match.url}/system/publisher`}
/>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.cronJobs", defaultMessage: "Cron Jobs" })}
to={`${match.url}/system/cron-jobs`}
/>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.redirects", defaultMessage: "Redirects" })}
to={`${match.url}/system/redirects`}
/>
</MenuCollapsibleItem>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.componentDemo", defaultMessage: "Component demo" })}
to={`${match.url}/component-demo`}
icon={<Snips />}
/>
<MenuCollapsibleItem primary="Products" icon={<Snips />}>
<MenuItemRouterLink primary="Products" to={`${match.url}/products`} icon={<Snips />} />
<MenuItemRouterLink primary="Categories" to={`${match.url}/product-categories`} icon={<Snips />} />
<MenuItemRouterLink primary="Tags" to={`${match.url}/product-tags`} icon={<Snips />} />
<MenuItemRouterLink primary="Products Handmade" to={`${match.url}/products-handmade`} icon={<Snips />} />
</MenuCollapsibleItem>
<MenuItemRouterLink
primary={intl.formatMessage({ id: "menu.userPermissions", defaultMessage: "User Permissions" })}
to={`${match.url}/user-permissions`}
icon={<Snips />}
/>
</Menu>
);
};
return (
<PagesPage
path={`/pages/pagetree/${match.params.category}`}
allCategories={pageTreeCategories}
documentTypes={pageTreeDocumentTypes}
editPageNode={EditPageNode}
category={category}
renderContentScopeIndicator={(scope) => <ContentScopeIndicator scope={scope} variant="toolbar" />}
/>
);
},
},
requiredPermission: "pageTree",
},
{
primary: <FormattedMessage id="menu.structuredContent" defaultMessage="Structured Content" />,
icon: <Data />,
submenu: [
{
primary: <FormattedMessage id="menu.news" defaultMessage="News" />,
route: {
path: "/structured-content/news",
component: NewsPage,
},
},
],
requiredPermission: "news",
},
{
primary: <FormattedMessage id="menu.dam" defaultMessage="Assets" />,
icon: <Assets />,
route: {
path: "/assets",
render: () => <DamPage renderContentScopeIndicator={(scope) => <ContentScopeIndicator scope={scope} domainOnly variant="toolbar" />} />,
},
requiredPermission: "dam",
},
{
primary: <FormattedMessage id="menu.projectSnips" defaultMessage="Project snips" />,
icon: <Snips />,
submenu: [
{
primary: <FormattedMessage id="menu.mainMenu" defaultMessage="Main menu" />,
route: {
path: "/project-snips/main-menu",
component: MainMenu,
},
},
],
requiredPermission: "pageTree",
},
{
primary: <FormattedMessage id="menu.system" defaultMessage="System" />,
icon: <Wrench />,
submenu: [
{
primary: <FormattedMessage id="menu.publisher" defaultMessage="Publisher" />,
route: {
path: "/system/publisher",
component: PublisherPage,
},
requiredPermission: "builds",
},
{
primary: <FormattedMessage id="menu.cronJobs" defaultMessage="Cron Jobs" />,
route: {
path: "/system/cron-jobs",
component: CronJobsPage,
},
requiredPermission: "cronJobs",
},
{
primary: <FormattedMessage id="menu.redirects" defaultMessage="Redirects" />,
route: {
path: "/system/redirects",
render: () => <RedirectsPage redirectPathAfterChange="/system/redirects" />,
},
requiredPermission: "pageTree",
},
],
},
{
primary: <FormattedMessage id="menu.componentDemo" defaultMessage="Component demo" />,
icon: <Snips />,
route: {
path: "/component-demo",
component: ComponentDemo,
},
requiredPermission: "pageTree",
},
{
primary: <FormattedMessage id="menu.products" defaultMessage="Products" />,
icon: <Snips />,
submenu: [
{
primary: <FormattedMessage id="menu.products" defaultMessage="Products" />,
route: {
path: "/products",
component: ProductsPage,
},
},
{
primary: <FormattedMessage id="menu.productCategories" defaultMessage="Categories" />,
route: {
path: "/product-categories",
component: ProductCategoriesPage,
},
},
{
primary: <FormattedMessage id="menu.productTags" defaultMessage="Tags" />,
route: {
path: "/product-tags",
component: ProductTagsPage,
},
},
{
primary: <FormattedMessage id="menu.productsHandmade" defaultMessage="Products Handmade" />,
route: {
path: "/products-handmade",
component: ProductsHandmadePage,
},
},
],
requiredPermission: "products",
},
{
primary: <FormattedMessage id="menu.userPermissions" defaultMessage="User Permissions" />,
icon: <Snips />,
route: {
path: "/user-permissions",
component: UserPermissionsPage,
},
requiredPermission: "userPermissions",
},
];

const MasterMenu = () => <CometMasterMenu menu={masterMenuData} />;
export default MasterMenu;
17 changes: 9 additions & 8 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ type CurrentUserPermission {
permission: String!
}

type CurrentUser {
id: String!
name: String!
email: String!
language: String!
contentScopes: [JSONObject!]!
permissions: [CurrentUserPermission!]!
}

type User {
id: String!
name: String!
Expand Down Expand Up @@ -260,14 +269,6 @@ type PredefinedPage implements DocumentInterface {
type: String
}

type CurrentUser {
name: String!
email: String!
language: String!
role: String!
domains: [String!]!
}

type DamScope {
domain: String!
}
Expand Down
Loading

0 comments on commit 2eeb647

Please sign in to comment.