diff --git a/.changeset/dirty-trees-burn.md b/.changeset/dirty-trees-burn.md
new file mode 100644
index 0000000000..71ae41adf8
--- /dev/null
+++ b/.changeset/dirty-trees-burn.md
@@ -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.
diff --git a/.changeset/sixty-cobras-brake.md b/.changeset/sixty-cobras-brake.md
new file mode 100644
index 0000000000..bb9ee035ea
--- /dev/null
+++ b/.changeset/sixty-cobras-brake.md
@@ -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
diff --git a/demo/admin/src/App.tsx b/demo/admin/src/App.tsx
index b9337384a3..057e1c87b8 100644
--- a/demo/admin/src/App.tsx
+++ b/demo/admin/src/App.tsx
@@ -6,52 +6,35 @@ import "material-design-icons/iconfont/material-icons.css";
import "typeface-open-sans";
import { ApolloProvider } from "@apollo/client";
-import { ErrorDialogHandler, MasterLayout, MuiThemeProvider, RouterBrowserRouter, RouteWithErrorBoundary, SnackbarProvider } from "@comet/admin";
+import { ErrorDialogHandler, MasterLayout, MuiThemeProvider, RouterBrowserRouter, SnackbarProvider } from "@comet/admin";
import {
CmsBlockContextProvider,
createHttpClient,
- createRedirectsPage,
- CronJobsPage,
+ CurrentUserProvider,
DamConfigProvider,
- DamPage,
LocaleProvider,
- PagesPage,
- PublisherPage,
+ MasterMenuRoutes,
SiteConfig,
SitePreview,
SitesConfigProvider,
- UserPermissionsPage,
} from "@comet/cms-admin";
import { css, Global } from "@emotion/react";
import { createApolloClient } from "@src/common/apollo/createApolloClient";
import ContentScopeProvider, { ContentScope } from "@src/common/ContentScopeProvider";
-import { additionalPageTreeNodeFieldsFragment, EditPageNode } from "@src/common/EditPageNode";
-import MasterHeader from "@src/common/MasterHeader";
-import MasterMenu from "@src/common/MasterMenu";
+import { additionalPageTreeNodeFieldsFragment } from "@src/common/EditPageNode";
import { createConfig } from "@src/config";
-import Dashboard from "@src/dashboard/Dashboard";
-import { pageTreeCategories, urlParamToCategory } from "@src/pageTree/pageTreeCategories";
-import { PredefinedPage } from "@src/predefinedPage/PredefinedPage";
+import { pageTreeCategories } from "@src/pageTree/pageTreeCategories";
import theme from "@src/theme";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import * as ReactDOM from "react-dom";
import { IntlProvider } from "react-intl";
-import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
+import { Route, Switch } from "react-router-dom";
-import { ComponentDemo } from "./common/ComponentDemo";
-import { ContentScopeIndicator } from "./common/ContentScopeIndicator";
+import MasterHeader from "./common/MasterHeader";
+import MasterMenu, { masterMenuData, pageTreeDocumentTypes } from "./common/MasterMenu";
import { getMessages } from "./lang";
-import { Link } from "./links/Link";
-import { NewsLinkBlock } from "./news/blocks/NewsLinkBlock";
-import { NewsPage } from "./news/generated/NewsPage";
-import MainMenu from "./pages/mainMenu/MainMenu";
-import { Page } from "./pages/Page";
-import ProductCategoriesPage from "./products/categories/ProductCategoriesPage";
-import { ProductsPage } from "./products/generated/ProductsPage";
-import ProductsHandmadePage from "./products/ProductsPage";
-import ProductTagsPage from "./products/tags/ProductTagsPage";
const GlobalStyle = () => (
, baseEl);
@@ -83,162 +58,67 @@ class App extends React.Component {
public render(): JSX.Element {
return (
- , scope: ContentScope) => configs[scope.domain],
- }}
- >
-
-
- scope.domain}>
-
-
-
-
-
-
-
-
- {({ match }) => (
-
- {/* @TODO: add preview to contentScope once site is capable of contentScope */}
- }
- />
- (
-
-
-
-
- ) => {
- const category = urlParamToCategory(params.category);
-
- if (category === undefined) {
- return ;
- }
-
- return (
- (
-
- )}
- />
- );
- }}
- />
-
- (
- (
-
- )}
- />
- )}
- />
-
-
-
-
- (
-
- )}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
- />
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
+
+ , scope: ContentScope) => configs[scope.domain],
+ }}
+ >
+
+
+ scope.domain}>
+
+
+
+
+
+
+
+
+ {({ match }) => (
+
+ {/* @TODO: add preview to contentScope once site is capable of contentScope */}
+ }
+ />
+ (
+
+
+
+ )}
+ />
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/demo/admin/src/common/ContentScopeProvider.tsx b/demo/admin/src/common/ContentScopeProvider.tsx
index b66c29b4ba..65e4a5bd89 100644
--- a/demo/admin/src/common/ContentScopeProvider.tsx
+++ b/demo/admin/src/common/ContentScopeProvider.tsx
@@ -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,
@@ -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 {
@@ -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> = ({ children }) => {
const sitesConfig = useSitesConfig();
- const { loading, data } = useQuery(currentUserQuery);
-
- if (loading || !data) return ;
+ 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)),
diff --git a/demo/admin/src/common/MasterMenu.tsx b/demo/admin/src/common/MasterMenu.tsx
index a588e2d180..d0578b60a2 100644
--- a/demo/admin/src/common/MasterMenu.tsx
+++ b/demo/admin/src/common/MasterMenu.tsx
@@ -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: ,
+ icon: ,
+ route: {
+ path: "/dashboard",
+ component: Dashboard,
+ },
+ },
+ {
+ primary: ,
+ icon: ,
+ 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 (
-
- );
-};
+ return (
+ }
+ />
+ );
+ },
+ },
+ requiredPermission: "pageTree",
+ },
+ {
+ primary: ,
+ icon: ,
+ submenu: [
+ {
+ primary: ,
+ route: {
+ path: "/structured-content/news",
+ component: NewsPage,
+ },
+ },
+ ],
+ requiredPermission: "news",
+ },
+ {
+ primary: ,
+ icon: ,
+ route: {
+ path: "/assets",
+ render: () => } />,
+ },
+ requiredPermission: "dam",
+ },
+ {
+ primary: ,
+ icon: ,
+ submenu: [
+ {
+ primary: ,
+ route: {
+ path: "/project-snips/main-menu",
+ component: MainMenu,
+ },
+ },
+ ],
+ requiredPermission: "pageTree",
+ },
+ {
+ primary: ,
+ icon: ,
+ submenu: [
+ {
+ primary: ,
+ route: {
+ path: "/system/publisher",
+ component: PublisherPage,
+ },
+ requiredPermission: "builds",
+ },
+ {
+ primary: ,
+ route: {
+ path: "/system/cron-jobs",
+ component: CronJobsPage,
+ },
+ requiredPermission: "cronJobs",
+ },
+ {
+ primary: ,
+ route: {
+ path: "/system/redirects",
+ render: () => ,
+ },
+ requiredPermission: "pageTree",
+ },
+ ],
+ },
+ {
+ primary: ,
+ icon: ,
+ route: {
+ path: "/component-demo",
+ component: ComponentDemo,
+ },
+ requiredPermission: "pageTree",
+ },
+ {
+ primary: ,
+ icon: ,
+ submenu: [
+ {
+ primary: ,
+ route: {
+ path: "/products",
+ component: ProductsPage,
+ },
+ },
+ {
+ primary: ,
+ route: {
+ path: "/product-categories",
+ component: ProductCategoriesPage,
+ },
+ },
+ {
+ primary: ,
+ route: {
+ path: "/product-tags",
+ component: ProductTagsPage,
+ },
+ },
+ {
+ primary: ,
+ route: {
+ path: "/products-handmade",
+ component: ProductsHandmadePage,
+ },
+ },
+ ],
+ requiredPermission: "products",
+ },
+ {
+ primary: ,
+ icon: ,
+ route: {
+ path: "/user-permissions",
+ component: UserPermissionsPage,
+ },
+ requiredPermission: "userPermissions",
+ },
+];
+const MasterMenu = () => ;
export default MasterMenu;
diff --git a/demo/api/schema.gql b/demo/api/schema.gql
index 6bc4e7f58d..81e0ab4d65 100644
--- a/demo/api/schema.gql
+++ b/demo/api/schema.gql
@@ -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!
@@ -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!
}
diff --git a/demo/api/src/app.module.ts b/demo/api/src/app.module.ts
index 54c982a409..33a911e6fc 100644
--- a/demo/api/src/app.module.ts
+++ b/demo/api/src/app.module.ts
@@ -5,10 +5,7 @@ import {
BlocksModule,
BlocksTransformerMiddlewareFactory,
BuildsModule,
- ContentScope,
- ContentScopeModule,
CronJobsModule,
- CurrentUserInterface,
DamModule,
DependenciesModule,
FilesService,
@@ -31,6 +28,7 @@ import { PagesModule } from "@src/pages/pages.module";
import { PredefinedPage } from "@src/predefined-page/entities/predefined-page.entity";
import { Request } from "express";
+import { AccessControlService } from "./auth/access-control.service";
import { AuthModule } from "./auth/auth.module";
import { UserService } from "./auth/user.service";
import { DamScope } from "./dam/dto/dam-scope";
@@ -78,14 +76,8 @@ export class AppModule {
inject: [BLOCKS_MODULE_TRANSFORMER_DEPENDENCIES],
}),
AuthModule,
- ContentScopeModule.forRoot({
- canAccessScope(requestScope: ContentScope, user: CurrentUserInterface) {
- if (!user.domains) return true; //all domains
- return user.domains.includes(requestScope.domain);
- },
- }),
UserPermissionsModule.forRootAsync({
- useFactory: (userService: UserService) => ({
+ useFactory: (userService: UserService, accessControlService: AccessControlService) => ({
availablePermissions: ["news", "products"],
availableContentScopes: [
{ domain: "main", language: "de" },
@@ -93,8 +85,9 @@ export class AppModule {
{ domain: "secondary", language: "en" },
],
userService,
+ accessControlService,
}),
- inject: [UserService],
+ inject: [UserService, AccessControlService],
imports: [AuthModule],
}),
BlocksModule.forRoot({
diff --git a/demo/api/src/auth/access-control.service.ts b/demo/api/src/auth/access-control.service.ts
new file mode 100644
index 0000000000..8ffffe8e7a
--- /dev/null
+++ b/demo/api/src/auth/access-control.service.ts
@@ -0,0 +1,20 @@
+import { AbstractAccessControlService, ContentScopesForUser, PermissionsForUser, User, UserPermissions } from "@comet/cms-api";
+import { Injectable } from "@nestjs/common";
+
+@Injectable()
+export class AccessControlService extends AbstractAccessControlService {
+ getPermissionsForUser(user: User): PermissionsForUser {
+ if (user.email.endsWith("@comet-dxp.com")) {
+ return UserPermissions.allPermissions;
+ } else {
+ return [{ permission: "news" }];
+ }
+ }
+ getContentScopesForUser(user: User): ContentScopesForUser {
+ if (user.email.endsWith("@comet-dxp.com")) {
+ return UserPermissions.allContentScopes;
+ } else {
+ return [{ domain: "main", language: "en" }];
+ }
+ }
+}
diff --git a/demo/api/src/auth/auth.module.ts b/demo/api/src/auth/auth.module.ts
index ed849aead8..03e8a30aab 100644
--- a/demo/api/src/auth/auth.module.ts
+++ b/demo/api/src/auth/auth.module.ts
@@ -1,21 +1,15 @@
-import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy } from "@comet/cms-api";
+import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy, CurrentUser } from "@comet/cms-api";
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
-import { CurrentUser } from "./current-user";
+import { AccessControlService } from "./access-control.service";
+import { staticUsers } from "./static-users";
import { UserService } from "./user.service";
@Module({
providers: [
createStaticAuthedUserStrategy({
- staticAuthedUser: {
- id: "1",
- name: "Test Admin",
- email: "demo@comet-dxp.com",
- language: "en",
- role: "admin",
- domains: ["main", "secondary"],
- },
+ staticAuthedUser: staticUsers[0].id,
}),
createAuthResolver({
currentUser: CurrentUser,
@@ -25,7 +19,8 @@ import { UserService } from "./user.service";
useClass: createCometAuthGuard(["static-authed-user"]),
},
UserService,
+ AccessControlService,
],
- exports: [UserService],
+ exports: [UserService, AccessControlService],
})
export class AuthModule {}
diff --git a/demo/api/src/auth/current-user.ts b/demo/api/src/auth/current-user.ts
deleted file mode 100644
index 75bae1703b..0000000000
--- a/demo/api/src/auth/current-user.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { CurrentUserInterface } from "@comet/cms-api";
-import { Field, ObjectType } from "@nestjs/graphql";
-
-declare module "@comet/cms-api" {
- interface CurrentUserInterface {
- domains: Array<"main" | "secondary">;
- }
-}
-
-@ObjectType()
-export class CurrentUser implements CurrentUserInterface {
- id: string;
- @Field()
- name: string;
-
- @Field()
- email: string;
-
- @Field()
- language: string;
-
- @Field()
- role: string;
-
- @Field(() => [String])
- domains: Array<"main" | "secondary">;
-}
diff --git a/demo/api/src/auth/static-users.ts b/demo/api/src/auth/static-users.ts
new file mode 100644
index 0000000000..8e998c8ce7
--- /dev/null
+++ b/demo/api/src/auth/static-users.ts
@@ -0,0 +1,16 @@
+import { User } from "@comet/cms-api";
+
+export const staticUsers: User[] = [
+ {
+ id: "1",
+ name: "Admin",
+ email: "demo@comet-dxp.com",
+ language: "en",
+ },
+ {
+ id: "2",
+ name: "Non-Admin",
+ email: "test@test.com",
+ language: "en",
+ },
+];
diff --git a/demo/api/src/auth/user.service.ts b/demo/api/src/auth/user.service.ts
index 86e6e4d9af..42197fb897 100644
--- a/demo/api/src/auth/user.service.ts
+++ b/demo/api/src/auth/user.service.ts
@@ -1,23 +1,10 @@
-import { ContentScopesForUser, FindUsersArgs, PermissionsForUser, User, UserPermissions, UserPermissionsUserService, Users } from "@comet/cms-api";
+import { FindUsersArgs, User, UserPermissionsUserServiceInterface, Users } from "@comet/cms-api";
import { Injectable } from "@nestjs/common";
-const staticUsers: User[] = [
- {
- id: "1",
- name: "Admin",
- email: "demo@comet-dxp.com",
- language: "en",
- },
- {
- id: "2",
- name: "Non-Admin",
- email: "test@test.com",
- language: "en",
- },
-];
+import { staticUsers } from "./static-users";
@Injectable()
-export class UserService implements UserPermissionsUserService {
+export class UserService implements UserPermissionsUserServiceInterface {
getUser(id: string): User {
const index = parseInt(id) - 1;
if (staticUsers[index]) return staticUsers[index];
@@ -28,18 +15,4 @@ export class UserService implements UserPermissionsUserService {
const users = staticUsers.filter((user) => !search || user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search));
return [users, users.length];
}
- getPermissionsForUser(user: User): PermissionsForUser {
- if (user.email.endsWith("@comet-dxp.com")) {
- return UserPermissions.allPermissions;
- } else {
- return [{ permission: "news" }];
- }
- }
- getContentScopesForUser(user: User): ContentScopesForUser {
- if (user.email.endsWith("@comet-dxp.com")) {
- return UserPermissions.allContentScopes;
- } else {
- return [{ domain: "main", language: "en" }];
- }
- }
}
diff --git a/demo/api/src/footer/entities/footer.entity.ts b/demo/api/src/footer/entities/footer.entity.ts
index 2d01becd0c..099e5eb16e 100644
--- a/demo/api/src/footer/entities/footer.entity.ts
+++ b/demo/api/src/footer/entities/footer.entity.ts
@@ -12,7 +12,7 @@ import { FooterContentScope } from "./footer-content-scope.entity";
implements: () => [DocumentInterface],
})
@RootBlockEntity()
-@CrudSingleGenerator({ targetDirectory: `${__dirname}/../generated/` })
+@CrudSingleGenerator({ targetDirectory: `${__dirname}/../generated/`, requiredPermission: ["pageTree"] })
export class Footer extends BaseEntity