From b35bb8d15ef0d5cfde0ecab4f1ad630a7e9e5356 Mon Sep 17 00:00:00 2001 From: David Schwarz <118435139+VP-DS@users.noreply.github.com> Date: Thu, 11 Jan 2024 08:15:41 +0100 Subject: [PATCH 01/32] Add basis for content translation (#1482) Wrap a component with a `ContentTranslationServiceProvider` to add support for content translation to all underlying `FinalFormInput` inputs. --- .changeset/rare-shiny-bulbasaur.md | 31 ++++++++++++++++ .../admin/admin/src/form/FinalFormInput.tsx | 37 ++++++++++++++----- packages/admin/admin/src/index.ts | 2 + .../ContentTranslationServiceContext.tsx | 13 +++++++ .../ContentTranslationServiceProvider.tsx | 7 ++++ .../useContentTranslationService.tsx | 7 ++++ 6 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 .changeset/rare-shiny-bulbasaur.md create mode 100644 packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx create mode 100644 packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx create mode 100644 packages/admin/admin/src/translator/useContentTranslationService.tsx diff --git a/.changeset/rare-shiny-bulbasaur.md b/.changeset/rare-shiny-bulbasaur.md new file mode 100644 index 0000000000..f52ea6a08b --- /dev/null +++ b/.changeset/rare-shiny-bulbasaur.md @@ -0,0 +1,31 @@ +--- +"@comet/admin": minor +--- + +Add basis for content translation + +Wrap a component with a `ContentTranslationServiceProvider` to add support for content translation to all underlying `FinalFormInput` inputs. + +```tsx + { + return yourTranslationFnc(text); + }} +> + ... + +``` + +You can disable translation for a specific `FinalFormInput` by using the `disableContentTranslation` prop. + +```diff +} ++ disableContentTranslation +/> +``` diff --git a/packages/admin/admin/src/form/FinalFormInput.tsx b/packages/admin/admin/src/form/FinalFormInput.tsx index 45c70df4d0..5bd12f1a88 100644 --- a/packages/admin/admin/src/form/FinalFormInput.tsx +++ b/packages/admin/admin/src/form/FinalFormInput.tsx @@ -1,28 +1,47 @@ -import { InputBase, InputBaseProps } from "@mui/material"; +import { Translate } from "@comet/admin-icons"; +import { Button, InputBase, InputBaseProps, Tooltip } from "@mui/material"; import * as React from "react"; import { FieldRenderProps } from "react-final-form"; +import { FormattedMessage } from "react-intl"; import { ClearInputAdornment } from "../common/ClearInputAdornment"; +import { useContentTranslationService } from "../translator/useContentTranslationService"; export type FinalFormInputProps = InputBaseProps & FieldRenderProps & { clearable?: boolean; + disableContentTranslation?: boolean; }; -export function FinalFormInput({ meta, input, innerRef, endAdornment, clearable, ...props }: FinalFormInputProps): React.ReactElement { +export function FinalFormInput({ + meta, + input, + innerRef, + endAdornment, + clearable, + disableContentTranslation, + ...props +}: FinalFormInputProps): React.ReactElement { + const { enabled, translate } = useContentTranslationService(); + return ( + <> + {clearable && ( input.onChange("")} /> - {endAdornment} - - ) : ( - endAdornment - ) + )} + {enabled && !disableContentTranslation && ( + }> + + + )} + {endAdornment} + } /> ); diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index d8e838b870..f225f73dce 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -198,3 +198,5 @@ export { RouterTabsClassKey } from "./tabs/RouterTabs.styles"; export { Tab, Tabs, TabsProps } from "./tabs/Tabs"; export { TabsClassKey } from "./tabs/Tabs.styles"; export { TabScrollButton, TabScrollButtonClassKey, TabScrollButtonProps } from "./tabs/TabScrollButton"; +export { ContentTranslationServiceProvider } from "./translator/ContentTranslationServiceProvider"; +export { useContentTranslationService } from "./translator/useContentTranslationService"; diff --git a/packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx b/packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx new file mode 100644 index 0000000000..5dc204a997 --- /dev/null +++ b/packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +export interface ContentTranslationServiceContext { + enabled: boolean; + translate: (text: string) => Promise; +} + +export const ContentTranslationServiceContext = React.createContext({ + enabled: false, + translate: function (text: string): Promise { + throw new Error("This is a dummy function for the translation feature that should never be called!"); + }, +}); diff --git a/packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx b/packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx new file mode 100644 index 0000000000..a218a9c7bb --- /dev/null +++ b/packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx @@ -0,0 +1,7 @@ +import * as React from "react"; + +import { ContentTranslationServiceContext } from "./ContentTranslationServiceContext"; + +export const ContentTranslationServiceProvider: React.FunctionComponent = ({ children, enabled, translate }) => { + return {children}; +}; diff --git a/packages/admin/admin/src/translator/useContentTranslationService.tsx b/packages/admin/admin/src/translator/useContentTranslationService.tsx new file mode 100644 index 0000000000..55b862d3be --- /dev/null +++ b/packages/admin/admin/src/translator/useContentTranslationService.tsx @@ -0,0 +1,7 @@ +import * as React from "react"; + +import { ContentTranslationServiceContext } from "./ContentTranslationServiceContext"; + +export function useContentTranslationService(): ContentTranslationServiceContext { + return React.useContext(ContentTranslationServiceContext); +} From 6c277440d80f30cbdd624c0a5ba960bf2806e68d Mon Sep 17 00:00:00 2001 From: David Schwarz <118435139+VP-DS@users.noreply.github.com> Date: Thu, 11 Jan 2024 08:15:41 +0100 Subject: [PATCH 02/32] Add basis for content translation (#1482) Wrap a component with a `ContentTranslationServiceProvider` to add support for content translation to all underlying `FinalFormInput` inputs. --- .changeset/rare-shiny-bulbasaur.md | 31 ++++++++++++++++ .../admin/admin/src/form/FinalFormInput.tsx | 37 ++++++++++++++----- packages/admin/admin/src/index.ts | 2 + .../ContentTranslationServiceContext.tsx | 13 +++++++ .../ContentTranslationServiceProvider.tsx | 7 ++++ .../useContentTranslationService.tsx | 7 ++++ 6 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 .changeset/rare-shiny-bulbasaur.md create mode 100644 packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx create mode 100644 packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx create mode 100644 packages/admin/admin/src/translator/useContentTranslationService.tsx diff --git a/.changeset/rare-shiny-bulbasaur.md b/.changeset/rare-shiny-bulbasaur.md new file mode 100644 index 0000000000..f52ea6a08b --- /dev/null +++ b/.changeset/rare-shiny-bulbasaur.md @@ -0,0 +1,31 @@ +--- +"@comet/admin": minor +--- + +Add basis for content translation + +Wrap a component with a `ContentTranslationServiceProvider` to add support for content translation to all underlying `FinalFormInput` inputs. + +```tsx + { + return yourTranslationFnc(text); + }} +> + ... + +``` + +You can disable translation for a specific `FinalFormInput` by using the `disableContentTranslation` prop. + +```diff +} ++ disableContentTranslation +/> +``` diff --git a/packages/admin/admin/src/form/FinalFormInput.tsx b/packages/admin/admin/src/form/FinalFormInput.tsx index 45c70df4d0..5bd12f1a88 100644 --- a/packages/admin/admin/src/form/FinalFormInput.tsx +++ b/packages/admin/admin/src/form/FinalFormInput.tsx @@ -1,28 +1,47 @@ -import { InputBase, InputBaseProps } from "@mui/material"; +import { Translate } from "@comet/admin-icons"; +import { Button, InputBase, InputBaseProps, Tooltip } from "@mui/material"; import * as React from "react"; import { FieldRenderProps } from "react-final-form"; +import { FormattedMessage } from "react-intl"; import { ClearInputAdornment } from "../common/ClearInputAdornment"; +import { useContentTranslationService } from "../translator/useContentTranslationService"; export type FinalFormInputProps = InputBaseProps & FieldRenderProps & { clearable?: boolean; + disableContentTranslation?: boolean; }; -export function FinalFormInput({ meta, input, innerRef, endAdornment, clearable, ...props }: FinalFormInputProps): React.ReactElement { +export function FinalFormInput({ + meta, + input, + innerRef, + endAdornment, + clearable, + disableContentTranslation, + ...props +}: FinalFormInputProps): React.ReactElement { + const { enabled, translate } = useContentTranslationService(); + return ( + <> + {clearable && ( input.onChange("")} /> - {endAdornment} - - ) : ( - endAdornment - ) + )} + {enabled && !disableContentTranslation && ( + }> + + + )} + {endAdornment} + } /> ); diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 46358bf73f..928869b265 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -198,3 +198,5 @@ export { RouterTabsClassKey } from "./tabs/RouterTabs.styles"; export { Tab, Tabs, TabsProps } from "./tabs/Tabs"; export { TabsClassKey } from "./tabs/Tabs.styles"; export { TabScrollButton, TabScrollButtonClassKey, TabScrollButtonProps } from "./tabs/TabScrollButton"; +export { ContentTranslationServiceProvider } from "./translator/ContentTranslationServiceProvider"; +export { useContentTranslationService } from "./translator/useContentTranslationService"; diff --git a/packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx b/packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx new file mode 100644 index 0000000000..5dc204a997 --- /dev/null +++ b/packages/admin/admin/src/translator/ContentTranslationServiceContext.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +export interface ContentTranslationServiceContext { + enabled: boolean; + translate: (text: string) => Promise; +} + +export const ContentTranslationServiceContext = React.createContext({ + enabled: false, + translate: function (text: string): Promise { + throw new Error("This is a dummy function for the translation feature that should never be called!"); + }, +}); diff --git a/packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx b/packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx new file mode 100644 index 0000000000..a218a9c7bb --- /dev/null +++ b/packages/admin/admin/src/translator/ContentTranslationServiceProvider.tsx @@ -0,0 +1,7 @@ +import * as React from "react"; + +import { ContentTranslationServiceContext } from "./ContentTranslationServiceContext"; + +export const ContentTranslationServiceProvider: React.FunctionComponent = ({ children, enabled, translate }) => { + return {children}; +}; diff --git a/packages/admin/admin/src/translator/useContentTranslationService.tsx b/packages/admin/admin/src/translator/useContentTranslationService.tsx new file mode 100644 index 0000000000..55b862d3be --- /dev/null +++ b/packages/admin/admin/src/translator/useContentTranslationService.tsx @@ -0,0 +1,7 @@ +import * as React from "react"; + +import { ContentTranslationServiceContext } from "./ContentTranslationServiceContext"; + +export function useContentTranslationService(): ContentTranslationServiceContext { + return React.useContext(ContentTranslationServiceContext); +} From 8e158f8d346fed499d2dae8e1023dd398a43eb81 Mon Sep 17 00:00:00 2001 From: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> Date: Thu, 8 Feb 2024 12:57:53 +0100 Subject: [PATCH 03/32] Add missing `@RequiredPermission()` decorator to `FileLicensesResolver` (#1670) --- .changeset/slow-lies-tan.md | 5 +++++ packages/api/cms-api/src/dam/files/file-licenses.resolver.ts | 2 ++ 2 files changed, 7 insertions(+) create mode 100644 .changeset/slow-lies-tan.md diff --git a/.changeset/slow-lies-tan.md b/.changeset/slow-lies-tan.md new file mode 100644 index 0000000000..d96750ea7a --- /dev/null +++ b/.changeset/slow-lies-tan.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-api": patch +--- + +Add missing `@RequiredPermission()` decorator to `FileLicensesResolver` diff --git a/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts b/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts index 7b7721f5a7..390170a777 100644 --- a/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts +++ b/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts @@ -1,9 +1,11 @@ import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; import { add, differenceInCalendarDays, isAfter, isBefore } from "date-fns"; +import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; import { License } from "./entities/license.embeddable"; @Resolver(() => License) +@RequiredPermission(["dam"]) export class FileLicensesResolver { // if durationTo = '2023-02-27T00:00:00.000Z' then the license is still valid on 27.03.2023 // and expires at '2023-02-28T00:00:00.000Z' From 4994fbf13317a3313a32c9a6f7e4115cf35b3e4c Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Thu, 8 Feb 2024 15:27:20 +0100 Subject: [PATCH 04/32] Remove manual lint step from v6 migration guide (#1664) The manual step isn't necessary anymore after https://github.com/vivid-planet/comet-upgrade/pull/9 --------- Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> --- .../docs/migration/migration-from-v5-to-v6.md | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/docs/docs/migration/migration-from-v5-to-v6.md b/docs/docs/migration/migration-from-v5-to-v6.md index 079f067095..9805c4394e 100644 --- a/docs/docs/migration/migration-from-v5-to-v6.md +++ b/docs/docs/migration/migration-from-v5-to-v6.md @@ -6,7 +6,7 @@ sidebar_position: 1 # Migrating from v5 to v6 First, execute `npx @comet/upgrade@latest v6` in the root of your project. -It automatically installs the new versions of all `@comet` libraries and handles some of the necessary renames. +It automatically installs the new versions of all `@comet` libraries, runs an ESLint autofix and handles some of the necessary renames.
@@ -269,20 +269,3 @@ This was removed because it was often unwanted and overridden. The icons `Betrieb`, `LogischeFilter`, `Pool`, `Pool2`, `Vignette1`, `Vignette2`, `StateGreen`, `StateGreenRing`, `StateOrange`, `StateOrangeRing`, `StateRed` and `StateRedRing` were removed. If you used any of these icons in your app, you must add them to your project. You can download them [here](https://github.com/vivid-planet/comet/tree/76e50aa86fd69b1df79825967c6c5c50e2cb6df7/packages/admin/admin-icons/icons/deprecated). - -## ESLint - -**Both new rules are auto-fixable.** All errors can be fixed by executing `npm run lint:eslint -- --fix` in `/api`, `/admin` and `/site`. - -### @comet/no-other-module-relative-import - -The `@comet/no-other-module-relative-import` rule is now enabled by default. It enforces absolute imports when importing from other modules. - -```diff -- import { AThingInModuleA } from "../moduleA/AThingInModuleA" -+ import { AThingInModuleA } from "@src/moduleA/AThingInModuleA" -``` - -### import/newline-after-import - -The `import/newline-after-import` rule is now enabled by default. It enforces adding a blank line between imports and code. From f668cba1f45fa75faf1a1e3eb03e972f893816bb Mon Sep 17 00:00:00 2001 From: David Schwarz Date: Fri, 9 Feb 2024 10:37:10 +0100 Subject: [PATCH 05/32] Set default generatorOptions for create, update and delete if undefined (generate-crud.ts: generateCrud function) --- packages/api/cms-api/src/generator/generate-crud.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/api/cms-api/src/generator/generate-crud.ts b/packages/api/cms-api/src/generator/generate-crud.ts index 0df4b39d95..39e1c7b268 100644 --- a/packages/api/cms-api/src/generator/generate-crud.ts +++ b/packages/api/cms-api/src/generator/generate-crud.ts @@ -912,10 +912,13 @@ function generateResolver({ generatorOptions, metadata }: { generatorOptions: Cr } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function generateCrud(generatorOptions: CrudGeneratorOptions, metadata: EntityMetadata): Promise { - generatorOptions.update = generatorOptions.update ?? true; - generatorOptions.create = generatorOptions.create ?? true; - generatorOptions.delete = generatorOptions.delete ?? true; +export async function generateCrud(generatorOptionsParam: CrudGeneratorOptions, metadata: EntityMetadata): Promise { + const generatorOptions = { + ...generatorOptionsParam, + create: generatorOptionsParam.create ?? true, + update: generatorOptionsParam.update ?? true, + delete: generatorOptionsParam.delete ?? true, + }; const generatedFiles: GeneratedFile[] = []; From 9d47b5b88fe3631a01a676bd01c496e74133b7a4 Mon Sep 17 00:00:00 2001 From: Johannes Munker <56400587+jomunker@users.noreply.github.com> Date: Mon, 12 Feb 2024 12:26:37 +0100 Subject: [PATCH 06/32] Add download functionality to DAM folders (#1230) This PR adds the functionality to download folders recursively in the DAM as ZIP. --------- Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> Co-authored-by: Thomas Dax --- .gitignore | 1 + .../admin/src/rowActions/RowActionsItem.tsx | 22 +++++--- .../src/rowActions/RowActionsListItem.tsx | 20 ++++--- .../src/dam/DataGrid/DamContextMenu.tsx | 12 +++++ packages/api/cms-api/package.json | 1 + packages/api/cms-api/src/dam/dam.module.ts | 3 +- .../src/dam/files/folders.controller.ts | 34 ++++++++++++ .../cms-api/src/dam/files/folders.service.ts | 54 +++++++++++++++++++ pnpm-lock.yaml | 3 ++ 9 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 packages/api/cms-api/src/dam/files/folders.controller.ts diff --git a/.gitignore b/.gitignore index 1fee62d94c..1096bee992 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ lang/ .pnp.* junit.xml .env.local +**/.idea diff --git a/packages/admin/admin/src/rowActions/RowActionsItem.tsx b/packages/admin/admin/src/rowActions/RowActionsItem.tsx index d742757462..858153a441 100644 --- a/packages/admin/admin/src/rowActions/RowActionsItem.tsx +++ b/packages/admin/admin/src/rowActions/RowActionsItem.tsx @@ -10,14 +10,24 @@ export interface CommonRowActionItemProps { onClick?: React.MouseEventHandler; } -export type RowActionsItemPropsComponentsProps = RowActionsIconItemComponentsProps & RowActionsListItemComponentsProps; +export type RowActionsItemPropsComponentsProps = RowActionsIconItemComponentsProps & + RowActionsListItemComponentsProps; -export interface RowActionsItemProps extends Omit, Omit { - componentsProps?: RowActionsItemPropsComponentsProps; +export interface RowActionsItemProps + extends Omit, + Omit, "componentsProps"> { + componentsProps?: RowActionsItemPropsComponentsProps; children?: React.ReactNode; } -export const RowActionsItem = ({ icon, children, disabled, onClick, componentsProps, ...restListItemProps }: RowActionsItemProps) => { +export function RowActionsItem({ + icon, + children, + disabled, + onClick, + componentsProps, + ...restListItemProps +}: RowActionsItemProps): React.ReactElement> { const { level, closeAllMenus } = React.useContext(RowActionsMenuContext); if (level === 1) { @@ -33,7 +43,7 @@ export const RowActionsItem = ({ icon, children, disabled, onClick, componentsPr } return ( - icon={icon} disabled={disabled} onClick={(event) => { @@ -50,4 +60,4 @@ export const RowActionsItem = ({ icon, children, disabled, onClick, componentsPr {children} ); -}; +} diff --git a/packages/admin/admin/src/rowActions/RowActionsListItem.tsx b/packages/admin/admin/src/rowActions/RowActionsListItem.tsx index 4e37e0827c..9a144f1484 100644 --- a/packages/admin/admin/src/rowActions/RowActionsListItem.tsx +++ b/packages/admin/admin/src/rowActions/RowActionsListItem.tsx @@ -4,22 +4,26 @@ import * as React from "react"; import { CommonRowActionItemProps } from "./RowActionsItem"; -export type RowActionsListItemComponentsProps = React.PropsWithChildren<{ +export type RowActionsListItemComponentsProps = React.PropsWithChildren<{ listItemIcon?: Partial; listItemText?: Partial; - menuItem?: Partial; + menuItem?: Partial & { component: MenuItemComponent }>; }>; -export interface RowActionsListItemProps extends CommonRowActionItemProps { +export interface RowActionsListItemProps extends CommonRowActionItemProps { textSecondary?: React.ReactNode; endIcon?: React.ReactNode; - componentsProps?: RowActionsListItemComponentsProps; + componentsProps?: RowActionsListItemComponentsProps; children?: React.ReactNode; } -export const RowActionsListItem = React.forwardRef(function RowActionsListItem(props, ref) { +const RowActionsListItemNoRef = ( + props: RowActionsListItemProps, + ref: React.ForwardedRef, +) => { const { icon, children, textSecondary, endIcon, componentsProps = {}, ...restMenuItemProps } = props; const { listItemIcon: listItemIconProps, listItemText: listItemTextProps, menuItem: menuItemProps } = componentsProps; + return ( {icon !== undefined && {icon}} @@ -27,7 +31,11 @@ export const RowActionsListItem = React.forwardRef{endIcon}} ); -}); +}; + +export const RowActionsListItem = React.forwardRef(RowActionsListItemNoRef) as ( + props: RowActionsListItemProps & { ref?: React.ForwardedRef }, +) => React.ReactElement; const EndIcon = styled("div")(({ theme }) => ({ marginLeft: theme.spacing(2), diff --git a/packages/admin/cms-admin/src/dam/DataGrid/DamContextMenu.tsx b/packages/admin/cms-admin/src/dam/DataGrid/DamContextMenu.tsx index 6ff0ac3042..c752fbbcb3 100644 --- a/packages/admin/cms-admin/src/dam/DataGrid/DamContextMenu.tsx +++ b/packages/admin/cms-admin/src/dam/DataGrid/DamContextMenu.tsx @@ -6,6 +6,7 @@ import { saveAs } from "file-saver"; import * as React from "react"; import { FormattedMessage } from "react-intl"; +import { useCmsBlockContext } from "../../blocks/useCmsBlockContext"; import { UnknownError } from "../../common/errors/errorMessages"; import { GQLDamFile, GQLDamFolder } from "../../graphql.generated"; import { ConfirmDeleteDialog } from "../FileActions/ConfirmDeleteDialog"; @@ -30,6 +31,7 @@ const FolderInnerMenu = ({ folder, openMoveDialog }: FolderInnerMenuProps): Reac const editDialogApi = useEditDialogApi(); const errorDialog = useErrorDialog(); const apolloClient = useApolloClient(); + const context = useCmsBlockContext(); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); @@ -56,6 +58,8 @@ const FolderInnerMenu = ({ folder, openMoveDialog }: FolderInnerMenuProps): Reac } }; + const downloadUrl = `${context.damConfig.apiUrl}/dam/folders/${folder.id}/zip`; + return ( <> @@ -68,6 +72,14 @@ const FolderInnerMenu = ({ folder, openMoveDialog }: FolderInnerMenuProps): Reac > + + icon={} + componentsProps={{ + menuItem: { component: "a", href: downloadUrl, target: "_blank" }, + }} + > + + } onClick={() => { diff --git a/packages/api/cms-api/package.json b/packages/api/cms-api/package.json index 08c5c60418..ea87cfba26 100644 --- a/packages/api/cms-api/package.json +++ b/packages/api/cms-api/package.json @@ -54,6 +54,7 @@ "graphql-type-json": "^0.3.2", "hasha": "^5.2.2", "jsonwebtoken": "^8.5.1", + "jszip": "^3.10.1", "jwks-rsa": "^3.0.0", "lodash.isequal": "^4.0.0", "mime": "^3.0.0", diff --git a/packages/api/cms-api/src/dam/dam.module.ts b/packages/api/cms-api/src/dam/dam.module.ts index 236706d30a..8be722dd32 100644 --- a/packages/api/cms-api/src/dam/dam.module.ts +++ b/packages/api/cms-api/src/dam/dam.module.ts @@ -18,6 +18,7 @@ import { FileValidationService } from "./files/file-validation.service"; import { createFilesController } from "./files/files.controller"; import { createFilesResolver } from "./files/files.resolver"; import { FilesService } from "./files/files.service"; +import { FoldersController } from "./files/folders.controller"; import { createFoldersResolver } from "./files/folders.resolver"; import { FoldersService } from "./files/folders.service"; import { CalculateDominantImageColor } from "./images/calculateDominantImageColor.console"; @@ -120,7 +121,7 @@ export class DamModule { FileValidationService, FileUploadService, ], - controllers: [createFilesController({ Scope }), ImagesController], + controllers: [createFilesController({ Scope }), FoldersController, ImagesController], exports: [ImgproxyService, FilesService, FoldersService, ImagesService, ScaledImagesCacheService, damConfigProvider, FileUploadService], }; } diff --git a/packages/api/cms-api/src/dam/files/folders.controller.ts b/packages/api/cms-api/src/dam/files/folders.controller.ts new file mode 100644 index 0000000000..2b94a2ada0 --- /dev/null +++ b/packages/api/cms-api/src/dam/files/folders.controller.ts @@ -0,0 +1,34 @@ +import { Controller, ForbiddenException, Get, Inject, NotFoundException, Param, Res } from "@nestjs/common"; +import { Response } from "express"; + +import { CurrentUserInterface } from "../../auth/current-user/current-user"; +import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator"; +import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; +import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; +import { FoldersService } from "./folders.service"; + +@Controller("dam/folders") +export class FoldersController { + constructor( + private readonly foldersService: FoldersService, + @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, + ) {} + + @Get("/:folderId/zip") + async createZip(@Param("folderId") folderId: string, @Res() res: Response, @GetCurrentUser() user: CurrentUserInterface): Promise { + const folder = await this.foldersService.findOneById(folderId); + if (!folder) { + throw new NotFoundException("Folder not found"); + } + + if (folder.scope && !this.accessControlService.isAllowed(user, "dam", folder.scope)) { + throw new ForbiddenException("The current user is not allowed to access this scope and download this folder."); + } + + const zipStream = await this.foldersService.createZipStreamFromFolder(folderId); + + res.setHeader("Content-Disposition", `attachment; filename="${folder.name}.zip"`); + res.setHeader("Content-Type", "application/zip"); + zipStream.pipe(res); + } +} diff --git a/packages/api/cms-api/src/dam/files/folders.service.ts b/packages/api/cms-api/src/dam/files/folders.service.ts index 4ab4ed92fe..eb92d0eb42 100644 --- a/packages/api/cms-api/src/dam/files/folders.service.ts +++ b/packages/api/cms-api/src/dam/files/folders.service.ts @@ -2,15 +2,20 @@ import { MikroORM } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityRepository, QueryBuilder } from "@mikro-orm/postgresql"; import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import JSZip from "jszip"; import isEqual from "lodash.isequal"; +import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; import { CometEntityNotFoundException } from "../../common/errors/entity-not-found.exception"; import { SortDirection } from "../../common/sorting/sort-direction.enum"; +import { DamConfig } from "../dam.config"; +import { DAM_CONFIG } from "../dam.constants"; import { DamScopeInterface } from "../types"; import { DamFolderListPositionArgs, FolderArgsInterface } from "./dto/folder.args"; import { UpdateFolderInput } from "./dto/folder.input"; import { FOLDER_TABLE_NAME, FolderInterface } from "./entities/folder.entity"; import { FilesService } from "./files.service"; +import { createHashedPath } from "./files.utils"; export const withFoldersSelect = ( qb: QueryBuilder, @@ -75,6 +80,8 @@ export class FoldersService { constructor( @InjectRepository("DamFolder") private readonly foldersRepository: EntityRepository, @Inject(forwardRef(() => FilesService)) private readonly filesService: FilesService, + @Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService, + @Inject(DAM_CONFIG) private readonly config: DamConfig, private readonly orm: MikroORM, ) {} @@ -342,6 +349,53 @@ export class FoldersService { return mpath.map((id) => folders.find((folder) => folder.id === id) as FolderInterface); } + async createZipStreamFromFolder(folderId: string): Promise { + const zip = new JSZip(); + + await this.addFolderToZip(folderId, zip); + + return zip.generateNodeStream({ streamFiles: true }); + } + + private async addFolderToZip(folderId: string, zip: JSZip): Promise { + const files = await this.filesService.findAll({ folderId: folderId }); + const subfolders = await this.findAllByParentId({ parentId: folderId }); + + for (const file of files) { + const fileStream = await this.blobStorageBackendService.getFile(this.config.filesDirectory, createHashedPath(file.contentHash)); + + zip.file(file.name, fileStream); + } + const countedSubfolderNames: Record = {}; + + for (const subfolder of subfolders) { + const subfolderName = subfolder.name; + const updatedSubfolderName = this.getUniqueFolderName(subfolderName, countedSubfolderNames); + + const subfolderZip = zip.folder(updatedSubfolderName); + if (!subfolderZip) { + throw new Error(`Error while creating zip from folder with id ${folderId}`); + } + await this.addFolderToZip(subfolder.id, subfolderZip); + } + } + + private getUniqueFolderName(folderName: string, countedFolderNames: Record) { + if (!countedFolderNames[folderName]) { + countedFolderNames[folderName] = 1; + } else { + countedFolderNames[folderName]++; + } + + const duplicateCount = countedFolderNames[folderName]; + + let updatedFolderName = folderName; + if (duplicateCount > 1) { + updatedFolderName = `${folderName} ${duplicateCount}`; + } + return updatedFolderName; + } + private selectQueryBuilder(): QueryBuilder { return this.foldersRepository .createQueryBuilder("folder") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f944f4bd83..80bbb90ab8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2199,6 +2199,9 @@ importers: jsonwebtoken: specifier: ^8.5.1 version: 8.5.1 + jszip: + specifier: ^3.10.1 + version: 3.10.1 jwks-rsa: specifier: ^3.0.0 version: 3.0.1 From 8eb13750bdd7bf9d31567bf306b4ac5a46c8baa6 Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Mon, 12 Feb 2024 12:33:19 +0100 Subject: [PATCH 07/32] Add `SaveBoundary` to allow saving multiple things (e.g., forms) with a centralized save button (#1448) This introduces a SaveBoundary (context) that can contain multiple areas that get saved together using a SaveBoundarySaveButton. Any component can register itself in the SaveBoundary by providing a hasChanges boolean and a doSave method. This also adds a default implementation for FinalForm: - if FinalForm is rendered inside a SaveBoundary, it registers - else the FinalForm renders (like previously) a router Prompt Prompts are not needed for the inner components, instead SaveBoundary renders a single prompt containing all hasChanges from registered components. See products admin for an implementation example. Also changed in Demo Products: ProductsPage now contains Toolbar and Save Button for Detail Page. That way the form is now completely independent of where it is used and could also be put into an EditDialog. #### Alternative: A possible alternative would be to re-use the router Prompt component that also has a save action. But I decided for an additional implementation because: - PromptHandler stores dirty in a ref instead of state, so enabling/disabling save button is not possible (could be changed to state though) - Prompt has no clear edges, as a new SubmissionBoundary has --- see #1449 for additional usage of SubmissionBoundary --------- Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> --- .changeset/silver-drinks-perform.md | 7 + demo/admin/src/products/ProductForm.gql.ts | 1 - demo/admin/src/products/ProductForm.tsx | 45 +---- .../src/products/ProductPriceForm.gql.ts | 29 +++ demo/admin/src/products/ProductPriceForm.tsx | 104 ++++++++++ .../src/products/ProductVariantsGrid.tsx | 181 ++++++++++++++++++ demo/admin/src/products/ProductsPage.tsx | 65 ++++++- packages/admin/admin/src/FinalForm.tsx | 94 ++++++--- packages/admin/admin/src/index.ts | 10 + .../admin/src/saveBoundary/SaveBoundary.tsx | 137 +++++++++++++ .../saveBoundary/SaveBoundarySaveButton.tsx | 21 ++ .../src/admin/save-boundary/SaveBoundary.tsx | 60 ++++++ 12 files changed, 680 insertions(+), 74 deletions(-) create mode 100644 .changeset/silver-drinks-perform.md create mode 100644 demo/admin/src/products/ProductPriceForm.gql.ts create mode 100644 demo/admin/src/products/ProductPriceForm.tsx create mode 100644 demo/admin/src/products/ProductVariantsGrid.tsx create mode 100644 packages/admin/admin/src/saveBoundary/SaveBoundary.tsx create mode 100644 packages/admin/admin/src/saveBoundary/SaveBoundarySaveButton.tsx create mode 100644 storybook/src/admin/save-boundary/SaveBoundary.tsx diff --git a/.changeset/silver-drinks-perform.md b/.changeset/silver-drinks-perform.md new file mode 100644 index 0000000000..83deb27b7b --- /dev/null +++ b/.changeset/silver-drinks-perform.md @@ -0,0 +1,7 @@ +--- +"@comet/admin": minor +--- + +Add `SaveBoundary` and `SaveBoundarySaveButton` that helps implementing multiple forms with a centralized save button + +Render a `Savable` Component anywhere below a `SaveBoundary`. For `FinalForm` this hasn't to be done manually. \ No newline at end of file diff --git a/demo/admin/src/products/ProductForm.gql.ts b/demo/admin/src/products/ProductForm.gql.ts index 311d38a87d..fabed48c8d 100644 --- a/demo/admin/src/products/ProductForm.gql.ts +++ b/demo/admin/src/products/ProductForm.gql.ts @@ -5,7 +5,6 @@ export const productFormFragment = gql` title slug description - price type inStock image diff --git a/demo/admin/src/products/ProductForm.tsx b/demo/admin/src/products/ProductForm.tsx index cc38b67ecb..2ed3024a46 100644 --- a/demo/admin/src/products/ProductForm.tsx +++ b/demo/admin/src/products/ProductForm.tsx @@ -4,25 +4,17 @@ import { FinalForm, FinalFormCheckbox, FinalFormInput, - FinalFormSaveSplitButton, FinalFormSelect, FinalFormSubmitEvent, Loading, MainContent, - Toolbar, - ToolbarActions, - ToolbarFillSpace, - ToolbarItem, - ToolbarTitleItem, useAsyncOptionsProps, useFormApiRef, - useStackApi, useStackSwitchApi, } from "@comet/admin"; -import { ArrowLeft } from "@comet/admin-icons"; import { BlockState, createFinalFormBlock } from "@comet/blocks-admin"; import { DamImageBlock, EditPageLayout, queryUpdatedAt, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin"; -import { FormControlLabel, IconButton, MenuItem } from "@mui/material"; +import { FormControlLabel, MenuItem } from "@mui/material"; import { GQLProductType } from "@src/graphql.generated"; import { FormApi } from "final-form"; import { filter } from "graphql-anywhere"; @@ -48,11 +40,11 @@ import { GQLProductQuery, GQLProductQueryVariables, GQLProductTagsQuery, + GQLProductTagsQueryVariables, GQLProductTagsSelectFragment, GQLUpdateProductMutation, GQLUpdateProductMutationVariables, } from "./ProductForm.gql.generated"; -import { GQLProductTagsListQueryVariables } from "./tags/ProductTagTable.generated"; interface FormProps { id?: string; @@ -62,13 +54,11 @@ const rootBlocks = { image: DamImageBlock, }; -type FormValues = Omit & { - price: string; +type FormValues = Omit & { image: BlockState; }; function ProductForm({ id }: FormProps): React.ReactElement { - const stackApi = useStackApi(); const client = useApolloClient(); const mode = id ? "edit" : "add"; const formApiRef = useFormApiRef(); @@ -82,7 +72,6 @@ function ProductForm({ id }: FormProps): React.ReactElement { const initialValues: Partial = data?.product ? { ...filter(productFormFragment, data.product), - price: String(data.product.price), image: rootBlocks.image.input2State(data.product.image), } : { @@ -106,7 +95,6 @@ function ProductForm({ id }: FormProps): React.ReactElement { if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected"); const output = { ...formValues, - price: parseFloat(formValues.price), image: rootBlocks.image.state2Output(formValues.image), type: formValues.type as GQLProductType, category: formValues.category?.id, @@ -144,7 +132,7 @@ function ProductForm({ id }: FormProps): React.ReactElement { return categories.data.productCategories.nodes; }); const tagsSelectAsyncProps = useAsyncOptionsProps(async () => { - const tags = await client.query({ query: productTagsQuery }); + const tags = await client.query({ query: productTagsQuery }); return tags.data.productTags.nodes; }); @@ -168,24 +156,6 @@ function ProductForm({ id }: FormProps): React.ReactElement { {() => ( {saveConflict.dialogs} - - - - - - - - - {({ input }) => - input.value ? input.value : - } - - - - - - - option.title} /> - } - /> {(props) => ( & { + price: string; +}; + +function ProductPriceForm({ id }: FormProps): React.ReactElement { + const client = useApolloClient(); + const formApiRef = useFormApiRef(); + + const { data, error, loading, refetch } = useQuery(productPriceFormQuery, { + variables: { id }, + }); + + const initialValues: Partial = data?.product + ? { + ...filter(productPriceFormFragment, data.product), + price: String(data.product.price), + } + : {}; + + const saveConflict = useFormSaveConflict({ + checkConflict: async () => { + const updatedAt = await queryUpdatedAt(client, "product", id); + return resolveHasSaveConflict(data?.product.updatedAt, updatedAt); + }, + formApiRef, + loadLatestVersion: async () => { + await refetch(); + }, + }); + + const handleSubmit = async (formValues: FormValues, form: FormApi, event: FinalFormSubmitEvent) => { + if (await saveConflict.checkForConflicts()) throw new Error("Conflicts detected"); + const output = { + ...formValues, + price: parseFloat(formValues.price), + }; + await client.mutate({ + mutation: updateProductPriceFormMutation, + variables: { id, input: output, lastUpdatedAt: data?.product.updatedAt }, + }); + }; + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return ( + + apiRef={formApiRef} + onSubmit={handleSubmit} + mode="edit" + initialValues={initialValues} + initialValuesEqual={isEqual} //required to compare block data correctly + onAfterSubmit={(values, form) => { + //don't go back automatically TODO remove this automatismn + }} + subscription={{}} + > + {() => ( + + {saveConflict.dialogs} + + } + /> + + + )} + + ); +} + +export default ProductPriceForm; diff --git a/demo/admin/src/products/ProductVariantsGrid.tsx b/demo/admin/src/products/ProductVariantsGrid.tsx new file mode 100644 index 0000000000..d64319f0c4 --- /dev/null +++ b/demo/admin/src/products/ProductVariantsGrid.tsx @@ -0,0 +1,181 @@ +import { useQuery } from "@apollo/client"; +import { + GridFilterButton, + StackLink, + Toolbar, + ToolbarAutomaticTitleItem, + ToolbarFillSpace, + ToolbarItem, + useBufferedRowCount, + useDataGridRemote, + usePersistentColumnState, +} from "@comet/admin"; +import { Add as AddIcon, Edit } from "@comet/admin-icons"; +import { Box, Button, IconButton } from "@mui/material"; +import { DataGridPro, GridColDef, GridToolbarQuickFilter } from "@mui/x-data-grid-pro"; +import gql from "graphql-tag"; +import * as React from "react"; +import { FormattedMessage } from "react-intl"; + +import { + //GQLCreateProductMutation, + //GQLCreateProductMutationVariables, + //GQLDeleteProductMutation, + //GQLDeleteProductMutationVariables, + GQLProductVariantsListFragment, + GQLProductVariantsListQuery, + GQLProductVariantsListQueryVariables, + //GQLUpdateProductVisibilityMutation, + //GQLUpdateProductVisibilityMutationVariables, +} from "./ProductVariantsGrid.generated"; + +function ProductVariantsGridToolbar() { + return ( + + + + + + + + + + + + + + ); +} + +function ProductVariantsGrid({ productId }: { productId: string }) { + const dataGridProps = { ...useDataGridRemote(), ...usePersistentColumnState("ProductVariantsGrid") }; + //const sortModel = dataGridProps.sortModel; + //const client = useApolloClient(); + + const columns: GridColDef[] = [ + { field: "name", headerName: "Name", width: 150 }, + /* + { + field: "visible", + headerName: "Visible", + width: 100, + type: "boolean", + renderCell: (params) => { + return ( + { + await client.mutate({ + mutation: updateProductVisibilityMutation, + variables: { id: params.row.id, visible }, + optimisticResponse: { + __typename: "Mutation", + updateProductVisibility: { __typename: "Product", id: params.row.id, visible }, + }, + }); + }} + /> + ); + }, + }, + */ + { + field: "action", + headerName: "", + sortable: false, + filterable: false, + renderCell: (params) => { + return ( + <> + + + + {/* + + */} + + ); + }, + }, + ]; + + const { data, loading, error } = useQuery(productVariantsQuery, { + variables: { + productId, + /* + ...muiGridFilterToGql(columns, dataGridProps.filterModel), + offset: dataGridProps.page * dataGridProps.pageSize, + limit: dataGridProps.pageSize, + sort: muiGridSortToGql(sortModel), + */ + }, + }); + const rows = data?.product.variants ?? []; + const rowCount = useBufferedRowCount(data?.product.variants.length); + + return ( + + + + ); +} + +const productVariantsFragment = gql` + fragment ProductVariantsList on ProductVariant { + id + name + } +`; + +const productVariantsQuery = gql` + query ProductVariantsList($productId: ID!) { + product(id: $productId) { + variants { + ...ProductVariantsList + } + } + } + ${productVariantsFragment} +`; +/* +const deleteProductMutation = gql` + mutation DeleteProductVariant($id: ID!) { + deleteProduct(id: $id) + } +`; + +const createProductMutation = gql` + mutation CreateProductVariant($input: ProductVariantInput!) { + createProduct(input: $input) { + id + } + } +`; +*/ +/* +const updateProductVisibilityMutation = gql` + mutation UpdateProductVisibility($id: ID!, $visible: Boolean!) { + updateProductVariantVisibility(id: $id, visible: $visible) { + id + visible + } + } +`; +*/ + +export default ProductVariantsGrid; diff --git a/demo/admin/src/products/ProductsPage.tsx b/demo/admin/src/products/ProductsPage.tsx index 3592cc7973..1606ea5b36 100644 --- a/demo/admin/src/products/ProductsPage.tsx +++ b/demo/admin/src/products/ProductsPage.tsx @@ -1,9 +1,24 @@ -import { Stack, StackPage, StackSwitch } from "@comet/admin"; +import { + RouterTab, + RouterTabs, + SaveBoundary, + SaveBoundarySaveButton, + Stack, + StackPage, + StackSwitch, + Toolbar, + ToolbarActions, + ToolbarAutomaticTitleItem, + ToolbarBackButton, + ToolbarFillSpace, +} from "@comet/admin"; import React from "react"; import { useIntl } from "react-intl"; import ProductForm from "./ProductForm"; +import ProductPriceForm from "./ProductPriceForm"; import ProductsGrid from "./ProductsGrid"; +import ProductVariantsGrid from "./ProductVariantsGrid"; const ProductsPage: React.FC = () => { const intl = useIntl(); @@ -15,7 +30,53 @@ const ProductsPage: React.FC = () => { - {(selectedId) => } + {(selectedId) => ( + + + + + + + + + + + + + + + + + + + + + + + {(selectedId) => <>TODO: edit variant {selectedId}} + + + TODO: add variant + + + + + + )} diff --git a/packages/admin/admin/src/FinalForm.tsx b/packages/admin/admin/src/FinalForm.tsx index a469c2f933..efd15f1ad0 100644 --- a/packages/admin/admin/src/FinalForm.tsx +++ b/packages/admin/admin/src/FinalForm.tsx @@ -2,7 +2,7 @@ import { getApolloContext } from "@apollo/client"; import { Config, Decorator, FORM_ERROR, FormApi, FormSubscription, MutableState, Mutator, SubmissionErrors, ValidationErrors } from "final-form"; import setFieldData from "final-form-set-field-data"; import * as React from "react"; -import { AnyObject, Form, FormRenderProps, RenderableProps } from "react-final-form"; +import { AnyObject, Form, FormRenderProps, FormSpy, RenderableProps } from "react-final-form"; import { useIntl } from "react-intl"; import { useEditDialogFormApi } from "./EditDialogFormApiContext"; @@ -11,6 +11,7 @@ import { FinalFormContext, FinalFormContextProvider } from "./form/FinalFormCont import { messages } from "./messages"; import { RouterPrompt } from "./router/Prompt"; import { useSubRoutePrefix } from "./router/SubRoute"; +import { Savable, useSaveBoundaryApi } from "./saveBoundary/SaveBoundary"; import { TableQueryContext } from "./table/TableQueryContext"; export const useFormApiRef = , InitialFormValues = Partial>() => @@ -60,6 +61,40 @@ const getSubmitEvent: Mutator = (args: any[], state: MutableState Promise; + subRoutePath: string; + formApi: FormApi; +}) { + const saveBoundaryApi = useSaveBoundaryApi(); + const intl = useIntl(); + + if (saveBoundaryApi) { + //render no RouterPrompt if we are inside a SaveBoundary + return <>{children}; + } + return ( + { + if (formApi.getState().dirty) { + return intl.formatMessage(messages.saveUnsavedChanges); + } + return true; + }} + saveAction={doSave} + subRoutePath={subRoutePath} + > + {children} + + ); +} + export class FinalFormSubmitEvent extends Event { navigatingBack?: boolean; } @@ -86,8 +121,8 @@ export function FinalForm(props: IProps) { ); function RenderForm({ formContext = {}, ...formRenderProps }: FormRenderProps & { formContext: Partial }) { - const intl = useIntl(); const subRoutePrefix = useSubRoutePrefix(); + const saveBoundaryApi = useSaveBoundaryApi(); if (props.apiRef) props.apiRef.current = formRenderProps.form; const { mutators } = formRenderProps.form; const setFieldData = mutators.setFieldData as (...args: any[]) => any; @@ -148,36 +183,35 @@ export function FinalForm(props: IProps) { } }, [formRenderProps.values, setFieldData, registeredFields]); - return ( - - { - if (formRenderProps.form.getState().dirty) { - return intl.formatMessage(messages.saveUnsavedChanges); - } - return true; - }} - saveAction={async () => { - editDialogFormApi?.onFormStatusChange("saving"); - const hasValidationErrors = await waitForValidationToFinish(formRenderProps.form); - - if (hasValidationErrors) { - editDialogFormApi?.onFormStatusChange("error"); - return false; - } + const doSave = React.useCallback(async () => { + editDialogFormApi?.onFormStatusChange("saving"); + const hasValidationErrors = await waitForValidationToFinish(formRenderProps.form); + if (hasValidationErrors) { + editDialogFormApi?.onFormStatusChange("error"); + return false; + } - const submissionErrors = await formRenderProps.form.submit(); + const submissionErrors = await formRenderProps.form.submit(); + if (submissionErrors) { + editDialogFormApi?.onFormStatusChange("error"); + return false; + } - if (submissionErrors) { - editDialogFormApi?.onFormStatusChange("error"); - return false; - } + return true; + }, [formRenderProps.form]); - return true; - }} - // TODO DirtyHandler removal: do we need a resetAction functionality here? - subRoutePath={subRoutePath} - > + return ( + + {saveBoundaryApi && ( + + {(props) => ( + <> + + + )} + + )} +
{renderComponent( @@ -193,7 +227,7 @@ export function FinalForm(props: IProps) {
{formRenderProps.submitError || formRenderProps.error}
)} - + ); } diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index 46358bf73f..8b70d00dbb 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -121,6 +121,16 @@ export { RouterPromptHandler, SaveAction } from "./router/PromptHandler"; export { SubRoute, SubRouteIndexRoute, useSubRoutePrefix } from "./router/SubRoute"; export { RowActionsItem, RowActionsItemProps } from "./rowActions/RowActionsItem"; export { RowActionsMenu, RowActionsMenuProps } from "./rowActions/RowActionsMenu"; +export { + Savable, + SavableProps, + SaveBoundary, + SaveBoundaryApi, + SaveBoundaryApiContext, + useSavable, + useSaveBoundaryApi, +} from "./saveBoundary/SaveBoundary"; +export { SaveBoundarySaveButton } from "./saveBoundary/SaveBoundarySaveButton"; export { Selected } from "./Selected"; export { ISelectionRenderPropArgs, Selection, useSelection } from "./Selection"; export { ISelectionApi } from "./SelectionApi"; diff --git a/packages/admin/admin/src/saveBoundary/SaveBoundary.tsx b/packages/admin/admin/src/saveBoundary/SaveBoundary.tsx new file mode 100644 index 0000000000..91c2eadcb2 --- /dev/null +++ b/packages/admin/admin/src/saveBoundary/SaveBoundary.tsx @@ -0,0 +1,137 @@ +import * as React from "react"; +import { useIntl } from "react-intl"; +import useConstant from "use-constant"; +import { v4 as uuid } from "uuid"; + +import { messages } from "../messages"; +import { RouterPrompt } from "../router/Prompt"; + +export type SaveActionSuccess = boolean; +export interface SaveBoundaryApi { + save: () => Promise; + register: (id: string, props: SavableProps) => void; + unregister: (id: string) => void; +} +export interface Savable { + hasErrors: boolean; + hasChanges: boolean; + saving: boolean; +} + +export const SaveBoundaryApiContext = React.createContext(undefined); +export function useSaveBoundaryApi() { + return React.useContext(SaveBoundaryApiContext); +} + +export const SavableContext = React.createContext(undefined); +export function useSavable() { + return React.useContext(SavableContext); +} + +interface SaveBoundaryProps { + children: React.ReactNode; + subRoutePath?: string; + onAfterSave?: () => void; +} + +export function SaveBoundary({ onAfterSave, ...props }: SaveBoundaryProps) { + const [saving, setSaving] = React.useState(false); + const [hasErrors, setHasErrors] = React.useState(false); + const [hasChanges, setHasChanges] = React.useState(false); + const saveStates = React.useRef>({}); + const intl = useIntl(); + + const save = React.useCallback(async (): Promise => { + setHasErrors(false); + setSaving(true); + try { + const saveSuccess = !( + await Promise.all( + Object.values(saveStates.current).map((state) => { + return state.doSave(); + }), + ) + ).some((saveSuccess) => !saveSuccess); + if (!saveSuccess) { + setHasErrors(true); + } else { + onAfterSave?.(); + } + return saveSuccess; + } catch (error: unknown) { + setHasErrors(true); + throw error; + } finally { + setSaving(false); + } + }, [onAfterSave]); + + const onSaveStatesChanged = React.useCallback(() => { + const hasChanges = Object.values(saveStates.current).some((saveState) => saveState.hasChanges); + setHasChanges(hasChanges); + }, []); + + const register = React.useCallback( + (id: string, props: SavableProps) => { + saveStates.current[id] = props; + onSaveStatesChanged(); + }, + [onSaveStatesChanged], + ); + const unregister = React.useCallback( + (id: string) => { + delete saveStates.current[id]; + onSaveStatesChanged(); + }, + [onSaveStatesChanged], + ); + + return ( + { + if (hasChanges) { + return intl.formatMessage(messages.saveUnsavedChanges); + } + return true; + }} + saveAction={save} + subRoutePath={props.subRoutePath} + > + + + {props.children} + + + + ); +} + +export interface SavableProps { + hasChanges: boolean; + doSave: () => Promise | SaveActionSuccess; +} + +export function Savable({ doSave, hasChanges }: SavableProps) { + const id = useConstant(() => uuid()); + const saveBoundaryApi = useSaveBoundaryApi(); + if (!saveBoundaryApi) throw new Error("Savable must be inside SaveBoundary"); + React.useEffect(() => { + saveBoundaryApi.register(id, { doSave, hasChanges }); + return function cleanup() { + saveBoundaryApi.unregister(id); + }; + }, [id, doSave, hasChanges, saveBoundaryApi]); + return null; +} diff --git a/packages/admin/admin/src/saveBoundary/SaveBoundarySaveButton.tsx b/packages/admin/admin/src/saveBoundary/SaveBoundarySaveButton.tsx new file mode 100644 index 0000000000..1c1133dbf0 --- /dev/null +++ b/packages/admin/admin/src/saveBoundary/SaveBoundarySaveButton.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { SaveButton, SaveButtonProps } from "../common/buttons/save/SaveButton"; +import { useSavable, useSaveBoundaryApi } from "./SaveBoundary"; + +export function SaveBoundarySaveButton(props: SaveButtonProps) { + const saveBoundaryState = useSavable(); + const saveBoundaryApi = useSaveBoundaryApi(); + if (!saveBoundaryState || !saveBoundaryApi) throw new Error("SaveBoundarySaveButton must be inside SaveBoundary"); + return ( + { + return saveBoundaryApi.save(); + }} + {...props} + /> + ); +} diff --git a/storybook/src/admin/save-boundary/SaveBoundary.tsx b/storybook/src/admin/save-boundary/SaveBoundary.tsx new file mode 100644 index 0000000000..98e8c6160f --- /dev/null +++ b/storybook/src/admin/save-boundary/SaveBoundary.tsx @@ -0,0 +1,60 @@ +import { Savable, SaveBoundary, SaveBoundarySaveButton } from "@comet/admin"; +import { storiesOf } from "@storybook/react"; +import * as React from "react"; + +import { storyRouterDecorator } from "../../story-router.decorator"; + +async function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +function DemoForm() { + console.log("Render DemoForm"); + const [saving, setSaving] = React.useState(false); + const [input, setInput] = React.useState(""); + + const doSave = React.useCallback(async () => { + setSaving(true); + await delay(1000); + setSaving(false); + if (input == "err") { + return false; + } + return true; + }, [input]); + return ( +
+ DemoForm + setInput(e.target.value)} /> + {saving && <>Saving...} +
+ ); +} +function UnrelatedChild() { + console.log("Render UnrelatedChild"); + return

UnrelatedChild

; +} + +function SaveButtonContainer() { + console.log("Render SaveButtonContainer"); + return ; +} + +function Story() { + return ( + + + + + + + ); +} + +storiesOf("@comet/admin/save-range", module) + .addDecorator(storyRouterDecorator()) + .add("SaveBoundary", () => ); From c6cf86138926aebc063bfdb9ee10cad799e252b2 Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Tue, 13 Feb 2024 08:57:42 +0100 Subject: [PATCH 08/32] Remove unused RequiredPermissionArgs type (#1691) --- .../decorators/required-permission.decorator.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts b/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts index 0e46e761c4..a1a7bc0cd7 100644 --- a/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts +++ b/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts @@ -1,20 +1,7 @@ -import { EntityClass, EntityManager } from "@mikro-orm/core"; import { CustomDecorator, SetMetadata } from "@nestjs/common"; -import { Request } from "express"; -import { CurrentUser } from "../dto/current-user"; -import { ContentScope } from "../interfaces/content-scope.interface"; import { Permission } from "../interfaces/user-permission.interface"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RequiredPermissionArgs = { - args: ArgsType; - getScopeFromEntity: (entityClass: EntityClass, id: string) => Promise; - user: CurrentUser; - entityManager: EntityManager; - request: Request; -}; - type RequiredPermissionOptions = { skipScopeCheck?: boolean; }; From ef84331fa8e427872dfb3b02a4412afcac07643e Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Tue, 13 Feb 2024 08:58:56 +0100 Subject: [PATCH 09/32] Fix type of RequiredPermission to accept a non-array string for a single permission (#1690) --- .changeset/serious-bikes-wink.md | 5 +++++ .../src/user-permissions/auth/user-permissions.guard.ts | 4 ++-- .../decorators/required-permission.decorator.ts | 9 ++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 .changeset/serious-bikes-wink.md diff --git a/.changeset/serious-bikes-wink.md b/.changeset/serious-bikes-wink.md new file mode 100644 index 0000000000..0d2a59f0d5 --- /dev/null +++ b/.changeset/serious-bikes-wink.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-api": patch +--- + +Fix type of @RequiredPermission to accept a non-array string for a single permission diff --git a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts index 7bd402cf3b..3e1f6f5a60 100644 --- a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts +++ b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts @@ -4,7 +4,7 @@ import { GqlContextType, GqlExecutionContext } from "@nestjs/graphql"; import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { ContentScopeService } from "../content-scope.service"; -import { RequiredPermission } from "../decorators/required-permission.decorator"; +import { RequiredPermissionMetadata } from "../decorators/required-permission.decorator"; import { ContentScope } from "../interfaces/content-scope.interface"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions.types"; @@ -31,7 +31,7 @@ export class UserPermissionsGuard implements CanActivate { const user = request.user as CurrentUserInterface | undefined; if (!user) return false; - const requiredPermission = this.reflector.getAllAndOverride("requiredPermission", [ + const requiredPermission = this.reflector.getAllAndOverride("requiredPermission", [ context.getHandler(), context.getClass(), ]); diff --git a/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts b/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts index a1a7bc0cd7..71800b7fee 100644 --- a/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts +++ b/packages/api/cms-api/src/user-permissions/decorators/required-permission.decorator.ts @@ -6,11 +6,14 @@ type RequiredPermissionOptions = { skipScopeCheck?: boolean; }; -export type RequiredPermission = { +export type RequiredPermissionMetadata = { requiredPermission: (keyof Permission)[] | keyof Permission; options: RequiredPermissionOptions | undefined; }; -export const RequiredPermission = (requiredPermission: (keyof Permission)[], options?: RequiredPermissionOptions): CustomDecorator => { - return SetMetadata("requiredPermission", { requiredPermission, options }); +export const RequiredPermission = ( + requiredPermission: (keyof Permission)[] | keyof Permission, + options?: RequiredPermissionOptions, +): CustomDecorator => { + return SetMetadata("requiredPermission", { requiredPermission, options }); }; From 7ea5f61fb93deba11cc152692f4813409a067b35 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Tue, 13 Feb 2024 13:18:14 +0100 Subject: [PATCH 10/32] Use `useCurrentUser` hook where possible (#1677) --------- Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> --- .changeset/neat-rivers-hammer.md | 5 ++++ .../src/common/header/UserHeaderItem.tsx | 29 ++++--------------- .../src/dashboard/DefaultGreeting.tsx | 20 ++----------- 3 files changed, 13 insertions(+), 41 deletions(-) create mode 100644 .changeset/neat-rivers-hammer.md diff --git a/.changeset/neat-rivers-hammer.md b/.changeset/neat-rivers-hammer.md new file mode 100644 index 0000000000..23d0da073b --- /dev/null +++ b/.changeset/neat-rivers-hammer.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-admin": patch +--- + +Use `useCurrentUser` hook where possible diff --git a/packages/admin/cms-admin/src/common/header/UserHeaderItem.tsx b/packages/admin/cms-admin/src/common/header/UserHeaderItem.tsx index 5b6cdb7bb4..4316bb9bb6 100644 --- a/packages/admin/cms-admin/src/common/header/UserHeaderItem.tsx +++ b/packages/admin/cms-admin/src/common/header/UserHeaderItem.tsx @@ -6,7 +6,7 @@ import React from "react"; import { FormattedMessage } from "react-intl"; import { AboutModal } from "./about/AboutModal"; -import { GQLCurrentUserQuery, GQLSignOutMutation } from "./UserHeaderItem.generated"; +import { GQLSignOutMutation } from "./UserHeaderItem.generated"; const DropdownContent = styled(Box)` width: 250px; @@ -24,21 +24,9 @@ const Separator = styled(Box)` margin-bottom: 20px; `; -const LoadingWrapper = styled("div")` - width: 60px; - height: 100%; - border-left: 1px solid rgba(255, 255, 255, 0.2); -`; +import { gql, useMutation } from "@apollo/client"; -import { gql, useMutation, useQuery } from "@apollo/client"; - -const currentUserQuery = gql` - query CurrentUser { - currentUser { - name - } - } -`; +import { useCurrentUser } from "../../userPermissions/hooks/currentUser"; const signOutMutation = gql` mutation SignOut { @@ -53,19 +41,12 @@ interface UserHeaderItemProps { export function UserHeaderItem(props: UserHeaderItemProps): React.ReactElement { const { aboutModalLogo } = props; + const user = useCurrentUser(); const [showAboutModal, setShowAboutModal] = React.useState(false); - const { loading, data } = useQuery(currentUserQuery); const [signOut, { loading: isSigningOut }] = useMutation(signOutMutation); - if (loading || !data) - return ( - - - - ); - return ( - }> + }> - - ); - } + isUnauthenticated = statusCode === StatusCodes.UNAUTHORIZED; } } + if (isUnauthenticated) { + title = ; + userMessage = ( + <> + + + + + + ); + } + errorDialogVar({ title, error: error ?? "Unknown error", diff --git a/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx b/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx index eb0df6fdf2..baea62a2f3 100644 --- a/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx +++ b/packages/admin/cms-admin/src/userPermissions/hooks/currentUser.tsx @@ -1,5 +1,6 @@ import { gql, useQuery } from "@apollo/client"; import { Loading } from "@comet/admin"; +import { Button, Typography } from "@mui/material"; import isEqual from "lodash.isequal"; import React from "react"; @@ -38,7 +39,25 @@ export const CurrentUserProvider: React.FC<{ } `); - if (error) throw error.message; + // As this Provider is very high up in the tree, don't rely on ErrorBoundary or a configured intl-Provider here + if (error) { + const isUnauthenticated = error.graphQLErrors.some( + (e) => e.extensions?.exception?.status === 401 || e.extensions?.code === "UNAUTHENTICATED", + ); + return ( + <> + {isUnauthenticated ? "Your access-token is invalid. Re-login might help." : error.message} + {isUnauthenticated && ( + + )} + + ); + } if (!data) return ; diff --git a/packages/api/cms-api/src/auth/guards/comet.guard.ts b/packages/api/cms-api/src/auth/guards/comet.guard.ts index 621fb735fd..c78e4cc03d 100644 --- a/packages/api/cms-api/src/auth/guards/comet.guard.ts +++ b/packages/api/cms-api/src/auth/guards/comet.guard.ts @@ -1,4 +1,4 @@ -import { CanActivate, ExecutionContext, HttpException, Injectable, mixin } from "@nestjs/common"; +import { CanActivate, ExecutionContext, Injectable, mixin, UnauthorizedException } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { GqlExecutionContext } from "@nestjs/graphql"; import { AuthGuard, IAuthGuard, Type } from "@nestjs/passport"; @@ -19,14 +19,14 @@ export function createCometAuthGuard(type?: string | string[]): Type } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - handleRequest(err: unknown, user: any): CurrentUserInterface { + handleRequest(err: unknown, user: any, info: any): CurrentUserInterface { if (err) { throw err; } if (user) { return user; } - throw new HttpException("UserNotAuthenticated", 200); + throw new UnauthorizedException(info[0]?.message); } async canActivate(context: ExecutionContext): Promise { From 84062ecca15dd97ca47c7657e658d991487a0f20 Mon Sep 17 00:00:00 2001 From: David Schwarz <118435139+VP-DS@users.noreply.github.com> Date: Tue, 13 Feb 2024 15:28:10 +0100 Subject: [PATCH 14/32] Reset the scroll position of nested routes when navigating outside (#1659) https://github.com/vivid-planet/comet/assets/118435139/fe74a258-9ff2-40f4-ad03-c5e17f6358bf https://github.com/vivid-planet/comet/assets/118435139/43d4560f-8da8-42e4-a991-b66f493d5510 COM-405 --- .../admin/blocks-admin/src/common/useScrollRestoration.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/admin/blocks-admin/src/common/useScrollRestoration.ts b/packages/admin/blocks-admin/src/common/useScrollRestoration.ts index 3aaf1e6aa5..0f82c83acb 100644 --- a/packages/admin/blocks-admin/src/common/useScrollRestoration.ts +++ b/packages/admin/blocks-admin/src/common/useScrollRestoration.ts @@ -1,8 +1,6 @@ import * as React from "react"; import { useLocation } from "react-router"; -const scrollPositions: Record = {}; - interface UseScrollRestorationProps { ref: React.MutableRefObject; onScroll: () => void; @@ -14,15 +12,17 @@ export function useScrollRestoration(identifier: st const ref = React.useRef(null); + const scrollPositionsRef = React.useRef>({}); + React.useLayoutEffect((): void => { if (ref.current) { - ref.current.scrollTo(0, scrollPositions[identifierForRoute] ?? 0); + ref.current.scrollTo(0, scrollPositionsRef.current[identifierForRoute] ?? 0); } }, [identifierForRoute]); const onScroll = React.useCallback(() => { if (ref.current) { - scrollPositions[identifierForRoute] = ref.current.scrollTop; + scrollPositionsRef.current[identifierForRoute] = ref.current.scrollTop; } }, [identifierForRoute]); From f416510b5e96e579308133f6d0bdbbbb23570b94 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Thu, 15 Feb 2024 09:17:00 +0100 Subject: [PATCH 15/32] Remove CurrentUserLoader and CurrentUserInterface (#1683) Removes the unnecessary layer between auth-strategies and the CurrentUser-object. Since the UserPermissionsModule is mandatory by any means it is allowed for it to provide the one and only CurrentUser-DTO. This change is mainly internal facing. However, the usage of CurrentUserInterface has to be replace by CurrentUser. --- .changeset/plenty-humans-grow.md | 7 ++ demo/api/schema.gql | 82 +++++++++---------- demo/api/src/auth/auth.module.ts | 6 +- .../docs/migration/migration-from-v5-to-v6.md | 40 ++++++--- packages/api/cms-api/generate-schema.ts | 18 +--- packages/api/cms-api/schema.gql | 82 +++++++++---------- .../src/access-log/access-log.interceptor.ts | 4 +- .../auth/current-user/current-user-loader.ts | 7 -- .../src/auth/current-user/current-user.ts | 12 --- .../decorators/get-current-user.decorator.ts | 6 +- .../cms-api/src/auth/guards/comet.guard.ts | 2 +- .../src/auth/resolver/auth.resolver.ts | 16 ++-- .../strategies/auth-proxy-jwt.strategy.ts | 25 +++--- .../strategies/static-authed-user.strategy.ts | 19 +++-- .../src/builds/build-templates.resolver.ts | 4 +- .../src/builds/build-templates.service.ts | 4 +- .../api/cms-api/src/builds/builds.resolver.ts | 8 +- .../api/cms-api/src/builds/builds.service.ts | 8 +- .../src/cron-jobs/cron-jobs.resolver.ts | 8 +- .../cms-api/src/cron-jobs/jobs.resolver.ts | 4 +- .../cms-api/src/dam/files/files.controller.ts | 6 +- .../cms-api/src/dam/files/files.resolver.ts | 6 +- .../cms-api/src/dam/files/files.service.ts | 4 +- .../src/dam/files/folders.controller.ts | 4 +- .../src/dam/images/images.controller.ts | 6 +- packages/api/cms-api/src/index.ts | 2 - .../access-control.service.ts | 4 +- .../auth/current-user-loader.ts | 15 ---- .../auth/user-permissions.guard.ts | 4 +- .../src/user-permissions/dto/current-user.ts | 3 +- .../user-permissions.module.ts | 8 +- .../user-permissions.service.ts | 10 +-- .../user-permissions.types.ts | 4 +- 33 files changed, 198 insertions(+), 240 deletions(-) create mode 100644 .changeset/plenty-humans-grow.md delete mode 100644 packages/api/cms-api/src/auth/current-user/current-user-loader.ts delete mode 100644 packages/api/cms-api/src/auth/current-user/current-user.ts delete mode 100644 packages/api/cms-api/src/user-permissions/auth/current-user-loader.ts diff --git a/.changeset/plenty-humans-grow.md b/.changeset/plenty-humans-grow.md new file mode 100644 index 0000000000..d47fd60dc1 --- /dev/null +++ b/.changeset/plenty-humans-grow.md @@ -0,0 +1,7 @@ +--- +"@comet/cms-api": minor +--- + +Remove `CurrentUserLoader` and `CurrentUserInterface` + +Overriding the the current user in the application isn't supported anymore when using the new `UserPermissionsModule`, which provides the current user DTO itself. diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 7f6ea2eec7..2d2b054fdb 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -2,6 +2,47 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type CurrentUserPermission { + permission: String! + contentScopes: [JSONObject!]! +} + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type CurrentUser { + id: String! + name: String! + email: String! + language: String! + permissions: [CurrentUserPermission!]! +} + +type UserPermission { + id: ID! + source: UserPermissionSource! + permission: String! + validFrom: DateTime + validTo: DateTime + reason: String + requestedBy: String + approvedBy: String + overrideContentScopes: Boolean! + contentScopes: [JSONObject!]! +} + +enum UserPermissionSource { + MANUAL + BY_RULE +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + type Dependency { rootId: String! rootGraphqlObjectType: String! @@ -44,11 +85,6 @@ type DamFileImage { url(width: Int!, height: Int!): String } -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") - type DamFileLicense { type: LicenseType details: String @@ -69,11 +105,6 @@ enum LicenseType { RIGHTS_MANAGED } -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - type BuildTemplate { id: ID! name: String! @@ -144,19 +175,6 @@ type FilenameResponse { isOccupied: Boolean! } -type CurrentUserPermission { - permission: String! - contentScopes: [JSONObject!]! -} - -type CurrentUser { - id: String! - name: String! - email: String! - language: String! - permissions: [CurrentUserPermission!]! -} - type User { id: String! name: String! @@ -164,24 +182,6 @@ type User { language: String! } -type UserPermission { - id: ID! - source: UserPermissionSource! - permission: String! - validFrom: DateTime - validTo: DateTime - reason: String - requestedBy: String - approvedBy: String - overrideContentScopes: Boolean! - contentScopes: [JSONObject!]! -} - -enum UserPermissionSource { - MANUAL - BY_RULE -} - type PaginatedUserList { nodes: [User!]! totalCount: Int! diff --git a/demo/api/src/auth/auth.module.ts b/demo/api/src/auth/auth.module.ts index 03e8a30aab..ff1806ab0f 100644 --- a/demo/api/src/auth/auth.module.ts +++ b/demo/api/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy, CurrentUser } from "@comet/cms-api"; +import { createAuthResolver, createCometAuthGuard, createStaticAuthedUserStrategy } from "@comet/cms-api"; import { Module } from "@nestjs/common"; import { APP_GUARD } from "@nestjs/core"; @@ -11,9 +11,7 @@ import { UserService } from "./user.service"; createStaticAuthedUserStrategy({ staticAuthedUser: staticUsers[0].id, }), - createAuthResolver({ - currentUser: CurrentUser, - }), + createAuthResolver(), { provide: APP_GUARD, useClass: createCometAuthGuard(["static-authed-user"]), diff --git a/docs/docs/migration/migration-from-v5-to-v6.md b/docs/docs/migration/migration-from-v5-to-v6.md index 9805c4394e..13d45ec364 100644 --- a/docs/docs/migration/migration-from-v5-to-v6.md +++ b/docs/docs/migration/migration-from-v5-to-v6.md @@ -5,21 +5,19 @@ sidebar_position: 1 # Migrating from v5 to v6 -First, execute `npx @comet/upgrade@latest v6` in the root of your project. +First, execute `npx @comet/upgrade@latest v6` in the root of your project. It automatically installs the new versions of all `@comet` libraries, runs an ESLint autofix and handles some of the necessary renames.
Renames handled by @comet/upgrade -- `JobStatus` -> `KubernetesJobStatus` in API -- `@SubjectEntity` -> `@AffectedEntity` in API -- `BuildRuntime` -> `JobRuntime` in Admin +- `JobStatus` -> `KubernetesJobStatus` in API +- `@SubjectEntity` -> `@AffectedEntity` in API +- `BuildRuntime` -> `JobRuntime` in Admin
- - ## API ### User Permissions @@ -54,6 +52,13 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES }), ``` + Furthermore, it's not necessary anymore to provide the CurrentUser + + ```diff + createAuthResolver({ + - currentUser: CurrentUser, + ``` + Change imports of removed classes ```diff @@ -61,7 +66,14 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES + import { CurrentUser } from "@comet/cms-api"; ``` - It shouldn't be necessary to override these classes anymore. However, if you really need it, provide the CurrentUserLoader with `CURRENT_USER_LOADER`. + Replace occurrences of CurrentUserInterface + + ```diff + - @GetCurrentUser() user: CurrentUserInterface; + + @GetCurrentUser() user: CurrentUser; + ``` + + It is not possible anymore to use a custom CurrentUserLoader neither to augment/use the CurrentUserInterface. 3. Create interface for `availablePermissions` similar to the already existing interface `interface ContentScope` @@ -103,7 +115,7 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES 5. Replace `ContentScopeModule` with `UserPermissionsModule` - Remove `ContentScopeModule`: + Remove `ContentScopeModule`: ```diff - ContentScopeModule.forRoot({ @@ -111,7 +123,7 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES - }), ``` - Add `UserPermissionsModule`: + Add `UserPermissionsModule`: ```ts UserPermissionsModule.forRootAsync({ @@ -176,14 +188,20 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES ```tsx } to={`${match.url}/user-permissions`} /> ``` ```tsx - + ``` ### Sites Config diff --git a/packages/api/cms-api/generate-schema.ts b/packages/api/cms-api/generate-schema.ts index 638039767d..6600c9c977 100644 --- a/packages/api/cms-api/generate-schema.ts +++ b/packages/api/cms-api/generate-schema.ts @@ -9,7 +9,6 @@ import { createAuthResolver, createPageTreeResolver, createRedirectsResolver, - CurrentUserInterface, DependenciesResolverFactory, DependentsResolverFactory, DocumentInterface, @@ -29,7 +28,6 @@ import { createFilesResolver } from "./src/dam/files/files.resolver"; import { createFoldersResolver } from "./src/dam/files/folders.resolver"; import { RedirectInputFactory } from "./src/redirects/dto/redirect-input.factory"; import { RedirectEntityFactory } from "./src/redirects/entities/redirect-entity.factory"; -import { CurrentUserPermission } from "./src/user-permissions/dto/current-user"; import { UserResolver } from "./src/user-permissions/user.resolver"; import { UserContentScopesResolver } from "./src/user-permissions/user-content-scopes.resolver"; import { UserPermissionResolver } from "./src/user-permissions/user-permission.resolver"; @@ -48,20 +46,6 @@ class Page implements DocumentInterface { updatedAt: Date; } -@ObjectType() -class CurrentUser implements CurrentUserInterface { - @Field() - id: string; - @Field() - name: string; - @Field() - email: string; - @Field() - language: string; - @Field(() => [CurrentUserPermission]) - permissions: CurrentUserPermission[]; -} - async function generateSchema(): Promise { console.info("Generating schema.gql..."); @@ -84,7 +68,7 @@ async function generateSchema(): Promise { }); // no scope const PageTreeDependentsResolver = DependentsResolverFactory.create(PageTreeNode); - const AuthResolver = createAuthResolver({ currentUser: CurrentUser }); + const AuthResolver = createAuthResolver({}); const RedirectsDependenciesResolver = DependenciesResolverFactory.create(RedirectEntity); const Folder = createFolderEntity(); diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 29cc6098ac..7aa619e39f 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -1,3 +1,44 @@ +type CurrentUserPermission { + permission: String! + contentScopes: [JSONObject!]! +} + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type CurrentUser { + id: String! + name: String! + email: String! + language: String! + permissions: [CurrentUserPermission!]! +} + +type UserPermission { + id: ID! + source: UserPermissionSource! + permission: String! + validFrom: DateTime + validTo: DateTime + reason: String + requestedBy: String + approvedBy: String + overrideContentScopes: Boolean! + contentScopes: [JSONObject!]! +} + +enum UserPermissionSource { + MANUAL + BY_RULE +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + type Dependency { rootId: String! rootGraphqlObjectType: String! @@ -40,11 +81,6 @@ type DamFileImage { url(width: Int!, height: Int!): String } -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") - type DamFileLicense { type: LicenseType details: String @@ -65,11 +101,6 @@ enum LicenseType { RIGHTS_MANAGED } -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - type BuildTemplate { id: ID! name: String! @@ -140,11 +171,6 @@ type FilenameResponse { isOccupied: Boolean! } -type CurrentUserPermission { - permission: String! - contentScopes: [JSONObject!]! -} - type User { id: String! name: String! @@ -152,24 +178,6 @@ type User { language: String! } -type UserPermission { - id: ID! - source: UserPermissionSource! - permission: String! - validFrom: DateTime - validTo: DateTime - reason: String - requestedBy: String - approvedBy: String - overrideContentScopes: Boolean! - contentScopes: [JSONObject!]! -} - -enum UserPermissionSource { - MANUAL - BY_RULE -} - type PaginatedUserList { nodes: [User!]! totalCount: Int! @@ -219,14 +227,6 @@ input DependentFilter { rootColumnName: String } -type CurrentUser { - id: String! - name: String! - email: String! - language: String! - permissions: [CurrentUserPermission!]! -} - type Redirect implements DocumentInterface { id: ID! updatedAt: DateTime! diff --git a/packages/api/cms-api/src/access-log/access-log.interceptor.ts b/packages/api/cms-api/src/access-log/access-log.interceptor.ts index 86f0fbebba..fc4e0c4d89 100644 --- a/packages/api/cms-api/src/access-log/access-log.interceptor.ts +++ b/packages/api/cms-api/src/access-log/access-log.interceptor.ts @@ -2,7 +2,7 @@ import { CallHandler, ExecutionContext, Inject, Injectable, Logger, NestIntercep import { GqlExecutionContext } from "@nestjs/graphql"; import { GraphQLResolveInfo } from "graphql"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; +import { CurrentUser } from "../user-permissions/dto/current-user"; import { SHOULD_LOG_REQUEST } from "./access-log.constants"; import { ShouldLogRequest } from "./access-log.module"; @@ -79,7 +79,7 @@ export class AccessLogInterceptor implements NestInterceptor { return next.handle(); } - private pushUserToRequestData(user: CurrentUserInterface, requestData: string[]) { + private pushUserToRequestData(user: CurrentUser, requestData: string[]) { if (user) { requestData.push(`user: ${user.id} (${user.name})`); } diff --git a/packages/api/cms-api/src/auth/current-user/current-user-loader.ts b/packages/api/cms-api/src/auth/current-user/current-user-loader.ts deleted file mode 100644 index b962ad6fb6..0000000000 --- a/packages/api/cms-api/src/auth/current-user/current-user-loader.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CurrentUserInterface } from "./current-user"; - -export interface CurrentUserLoaderInterface { - load: (userId: string, data?: unknown) => Promise; -} - -export const CURRENT_USER_LOADER = "current-user-loader"; diff --git a/packages/api/cms-api/src/auth/current-user/current-user.ts b/packages/api/cms-api/src/auth/current-user/current-user.ts deleted file mode 100644 index 64db83a0a7..0000000000 --- a/packages/api/cms-api/src/auth/current-user/current-user.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ContentScope } from "../../user-permissions/interfaces/content-scope.interface"; - -export interface CurrentUserInterface { - id: string; - name: string; - email: string; - language: string; - permissions?: { - permission: string; - contentScopes: ContentScope[]; - }[]; -} diff --git a/packages/api/cms-api/src/auth/decorators/get-current-user.decorator.ts b/packages/api/cms-api/src/auth/decorators/get-current-user.decorator.ts index 597f55b939..5a01c20b19 100644 --- a/packages/api/cms-api/src/auth/decorators/get-current-user.decorator.ts +++ b/packages/api/cms-api/src/auth/decorators/get-current-user.decorator.ts @@ -1,10 +1,10 @@ import { createParamDecorator, ExecutionContext } from "@nestjs/common"; import { GqlExecutionContext } from "@nestjs/graphql"; -import { CurrentUserInterface } from "../current-user/current-user"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; -// Allows injecting Current User into resolvers via `@GetCurrentUser() user: CurrentUserInterface` -export const GetCurrentUser = createParamDecorator((data: unknown, context: ExecutionContext): CurrentUserInterface => { +// Allows injecting Current User into resolvers via `@GetCurrentUser() user: CurrentUser` +export const GetCurrentUser = createParamDecorator((data: unknown, context: ExecutionContext): CurrentUser => { if (context.getType().toString() === "graphql") { return GqlExecutionContext.create(context).getContext().req.user; } else { diff --git a/packages/api/cms-api/src/auth/guards/comet.guard.ts b/packages/api/cms-api/src/auth/guards/comet.guard.ts index 621fb735fd..b565f8419b 100644 --- a/packages/api/cms-api/src/auth/guards/comet.guard.ts +++ b/packages/api/cms-api/src/auth/guards/comet.guard.ts @@ -19,7 +19,7 @@ export function createCometAuthGuard(type?: string | string[]): Type } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any - handleRequest(err: unknown, user: any): CurrentUserInterface { + handleRequest(err: unknown, user: any): CurrentUser { if (err) { throw err; } diff --git a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts index 9e80af991a..333aee4bbf 100644 --- a/packages/api/cms-api/src/auth/resolver/auth.resolver.ts +++ b/packages/api/cms-api/src/auth/resolver/auth.resolver.ts @@ -3,31 +3,31 @@ import { Context, Mutation, Query, Resolver } from "@nestjs/graphql"; import { IncomingMessage } from "http"; import { SkipBuild } from "../../builds/skip-build.decorator"; -import { CurrentUserInterface } from "../current-user/current-user"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; import { GetCurrentUser } from "../decorators/get-current-user.decorator"; import { PublicApi } from "../decorators/public-api.decorator"; interface AuthResolverConfig { - currentUser: Type; + currentUser?: Type; // TODO Remove in future version as it is not used and here for backwards compatibility endSessionEndpoint?: string; postLogoutRedirectUri?: string; } -export function createAuthResolver(config: AuthResolverConfig): Type { - @Resolver(() => config.currentUser) +export function createAuthResolver(config?: AuthResolverConfig): Type { + @Resolver(() => CurrentUser) @PublicApi() class AuthResolver { - @Query(() => config.currentUser) - async currentUser(@GetCurrentUser() user: typeof config.currentUser): Promise { + @Query(() => CurrentUser) + async currentUser(@GetCurrentUser() user: CurrentUser): Promise { return user; } @Mutation(() => String) @SkipBuild() async currentUserSignOut(@Context("req") req: IncomingMessage): Promise { - let signOutUrl = config.postLogoutRedirectUri || "/"; + let signOutUrl = config?.postLogoutRedirectUri || "/"; - if (req.headers["authorization"] && config.endSessionEndpoint) { + if (req.headers["authorization"] && config?.endSessionEndpoint) { const url = new URL(config.endSessionEndpoint); url.search = new URLSearchParams({ id_token_hint: req.headers["authorization"].substring(7), diff --git a/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts b/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts index 34842c7071..d85cdfffb1 100644 --- a/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts +++ b/packages/api/cms-api/src/auth/strategies/auth-proxy-jwt.strategy.ts @@ -1,11 +1,11 @@ -import { Inject, Injectable, Optional } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PassportStrategy, Type } from "@nestjs/passport"; import { JwtPayload } from "jsonwebtoken"; import { passportJwtSecret } from "jwks-rsa"; import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt"; -import { CurrentUserInterface } from "../current-user/current-user"; -import { CURRENT_USER_LOADER, CurrentUserLoaderInterface } from "../current-user/current-user-loader"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; +import { UserPermissionsService } from "../../user-permissions/user-permissions.service"; interface AuthProxyJwtStrategyConfig { jwksUri: string; @@ -22,7 +22,7 @@ export function createAuthProxyJwtStrategy({ }: AuthProxyJwtStrategyConfig): Type { @Injectable() class AuthProxyJwtStrategy extends PassportStrategy(Strategy, strategyName) { - constructor(@Optional() @Inject(CURRENT_USER_LOADER) private readonly currentUserLoader: CurrentUserLoaderInterface) { + constructor(private readonly service: UserPermissionsService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKeyProvider: passportJwtSecret({ @@ -33,17 +33,14 @@ export function createAuthProxyJwtStrategy({ }); } - async validate(data: JwtPayload): Promise { + async validate(data: JwtPayload): Promise { if (!data.sub) throw new Error("JwtPayload does not contain sub."); - if (!this.currentUserLoader) { - return { - id: data.sub, - name: data.name, - email: data.email, - language: data.language, - }; - } - return this.currentUserLoader.load(data.sub, data); + return this.service.createCurrentUser({ + id: data.sub, + name: data.name, + email: data.email, + language: data.language, + }); } } return AuthProxyJwtStrategy; diff --git a/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts b/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts index 5b0a97a673..e1953cb02c 100644 --- a/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts +++ b/packages/api/cms-api/src/auth/strategies/static-authed-user.strategy.ts @@ -1,28 +1,29 @@ -import { Inject, Injectable, Optional } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { PassportStrategy, Type } from "@nestjs/passport"; import { Strategy } from "passport-custom"; -import { CurrentUserInterface } from "../current-user/current-user"; -import { CURRENT_USER_LOADER, CurrentUserLoaderInterface } from "../current-user/current-user-loader"; +import { UserPermissionsService } from "../..//user-permissions/user-permissions.service"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; +import { User } from "../../user-permissions/dto/user"; interface StaticAuthedUserStrategyConfig { - staticAuthedUser: CurrentUserInterface | string; + staticAuthedUser: User | string; userExtraData?: unknown; } export function createStaticAuthedUserStrategy(config: StaticAuthedUserStrategyConfig): Type { @Injectable() class StaticAuthedUserStrategy extends PassportStrategy(Strategy, "static-authed-user") { - constructor(@Optional() @Inject(CURRENT_USER_LOADER) private readonly currentUserLoader: CurrentUserLoaderInterface) { + constructor(private readonly service: UserPermissionsService) { super(); } - async validate(): Promise { + async validate(): Promise { if (typeof config.staticAuthedUser === "string") { - if (!this.currentUserLoader) throw new Error("You have to provide CURRENT_USER_LOADER when setting staticAuthedUser as string"); - return this.currentUserLoader.load(config.staticAuthedUser, config.userExtraData); + const user = await this.service.getUser(config.staticAuthedUser); + return this.service.createCurrentUser(user); } - return config.staticAuthedUser; + return this.service.createCurrentUser(config.staticAuthedUser); } } return StaticAuthedUserStrategy; diff --git a/packages/api/cms-api/src/builds/build-templates.resolver.ts b/packages/api/cms-api/src/builds/build-templates.resolver.ts index 7a855a9f75..5bdf670771 100644 --- a/packages/api/cms-api/src/builds/build-templates.resolver.ts +++ b/packages/api/cms-api/src/builds/build-templates.resolver.ts @@ -1,10 +1,10 @@ import { Query, Resolver } from "@nestjs/graphql"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; import { GetCurrentUser } from "../auth/decorators/get-current-user.decorator"; import { LABEL_ANNOTATION } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; +import { CurrentUser } from "../user-permissions/dto/current-user"; import { BuildTemplatesService } from "./build-templates.service"; import { BuildTemplateObject } from "./dto/build-template.object"; @@ -14,7 +14,7 @@ export class BuildTemplatesResolver { constructor(private readonly kubernetesService: KubernetesService, private readonly buildTemplatesService: BuildTemplatesService) {} @Query(() => [BuildTemplateObject]) - async buildTemplates(@GetCurrentUser() user: CurrentUserInterface): Promise { + async buildTemplates(@GetCurrentUser() user: CurrentUser): Promise { if (this.kubernetesService.localMode) { throw Error("Not available in local mode!"); } diff --git a/packages/api/cms-api/src/builds/build-templates.service.ts b/packages/api/cms-api/src/builds/build-templates.service.ts index 153e0a657e..af2cebec55 100644 --- a/packages/api/cms-api/src/builds/build-templates.service.ts +++ b/packages/api/cms-api/src/builds/build-templates.service.ts @@ -1,9 +1,9 @@ import { V1CronJob } from "@kubernetes/client-node"; import { Inject, Injectable } from "@nestjs/common"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; import { INSTANCE_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { CurrentUser } from "../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; import { BUILDER_LABEL } from "./builds.constants"; @@ -15,7 +15,7 @@ export class BuildTemplatesService { @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, ) {} - async getAllowedBuilderCronJobs(user: CurrentUserInterface): Promise { + async getAllowedBuilderCronJobs(user: CurrentUser): Promise { return (await this.getAllBuilderCronJobs()).filter((cronJob) => { return this.accessControlService.isAllowed(user, "builds", this.kubernetesService.getContentScope(cronJob) ?? {}); }); diff --git a/packages/api/cms-api/src/builds/builds.resolver.ts b/packages/api/cms-api/src/builds/builds.resolver.ts index 04431d8eed..adcf0c8308 100644 --- a/packages/api/cms-api/src/builds/builds.resolver.ts +++ b/packages/api/cms-api/src/builds/builds.resolver.ts @@ -2,11 +2,11 @@ import { V1CronJob } from "@kubernetes/client-node"; import { Inject } from "@nestjs/common"; import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; import { GetCurrentUser } from "../auth/decorators/get-current-user.decorator"; import { INSTANCE_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; +import { CurrentUser } from "../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; import { BuildsService } from "./builds.service"; @@ -27,7 +27,7 @@ export class BuildsResolver { @Mutation(() => Boolean) @SkipBuild() async createBuilds( - @GetCurrentUser() user: CurrentUserInterface, + @GetCurrentUser() user: CurrentUser, @Args("input", { type: () => CreateBuildsInput }) { names }: CreateBuildsInput, ): Promise { const cronJobs: V1CronJob[] = []; @@ -47,12 +47,12 @@ export class BuildsResolver { } @Query(() => [Build]) - async builds(@GetCurrentUser() user: CurrentUserInterface, @Args("limit", { nullable: true }) limit?: number): Promise { + async builds(@GetCurrentUser() user: CurrentUser, @Args("limit", { nullable: true }) limit?: number): Promise { return this.buildsService.getBuilds(user, { limit: limit }); } @Query(() => AutoBuildStatus) - async autoBuildStatus(@GetCurrentUser() user: CurrentUserInterface): Promise { + async autoBuildStatus(@GetCurrentUser() user: CurrentUser): Promise { return this.buildsService.getAutoBuildStatus(user); } } diff --git a/packages/api/cms-api/src/builds/builds.service.ts b/packages/api/cms-api/src/builds/builds.service.ts index 127d7e0fb4..c29e02c57d 100644 --- a/packages/api/cms-api/src/builds/builds.service.ts +++ b/packages/api/cms-api/src/builds/builds.service.ts @@ -5,10 +5,10 @@ import { Inject, Injectable } from "@nestjs/common"; import parser from "cron-parser"; import { format } from "date-fns"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; import { KubernetesJobStatus } from "../kubernetes/job-status.enum"; import { INSTANCE_LABEL, LABEL_ANNOTATION, PARENT_CRON_JOB_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; +import { CurrentUser } from "../user-permissions/dto/current-user"; import { ContentScope } from "../user-permissions/interfaces/content-scope.interface"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; @@ -29,7 +29,7 @@ export class BuildsService { @Inject(ACCESS_CONTROL_SERVICE) private accessControlService: AccessControlServiceInterface, ) {} - private async getAllowedBuildJobs(user: CurrentUserInterface): Promise { + private async getAllowedBuildJobs(user: CurrentUser): Promise { const allJobs = await this.kubernetesService.getAllJobs(`${BUILDER_LABEL} = true, ${INSTANCE_LABEL} = ${this.kubernetesService.helmRelease}`); return allJobs.filter((job) => { return this.accessControlService.isAllowed(user, "builds", this.kubernetesService.getContentScope(job) ?? {}); @@ -89,7 +89,7 @@ export class BuildsService { return this.createBuilds(trigger, builderCronJobs); } - async getBuilds(user: CurrentUserInterface, options?: { limit?: number | undefined }): Promise { + async getBuilds(user: CurrentUser, options?: { limit?: number | undefined }): Promise { if (this.kubernetesService.localMode) { throw Error("Not available in local mode!"); } @@ -114,7 +114,7 @@ export class BuildsService { ); } - async getAutoBuildStatus(user: CurrentUserInterface): Promise { + async getAutoBuildStatus(user: CurrentUser): Promise { if (this.kubernetesService.localMode) { throw Error("Not available in local mode!"); } diff --git a/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts b/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts index 14ba4a80cf..9114677515 100644 --- a/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts +++ b/packages/api/cms-api/src/cron-jobs/cron-jobs.resolver.ts @@ -2,7 +2,6 @@ import { Inject } from "@nestjs/common"; import { Args, Mutation, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; import { format } from "date-fns"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; import { GetCurrentUser } from "../auth/decorators/get-current-user.decorator"; import { BUILDER_LABEL } from "../builds/builds.constants"; import { SkipBuild } from "../builds/skip-build.decorator"; @@ -10,6 +9,7 @@ import { KubernetesJobStatus } from "../kubernetes/job-status.enum"; import { INSTANCE_LABEL } from "../kubernetes/kubernetes.constants"; import { KubernetesService } from "../kubernetes/kubernetes.service"; import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; +import { CurrentUser } from "../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; import { CronJobsService } from "./cron-jobs.service"; @@ -28,7 +28,7 @@ export class CronJobsResolver { ) {} @Query(() => [CronJob]) - async kubernetesCronJobs(@GetCurrentUser() user: CurrentUserInterface): Promise { + async kubernetesCronJobs(@GetCurrentUser() user: CurrentUser): Promise { if (this.kubernetesService.localMode) { throw Error("Not available in local mode!"); } @@ -49,7 +49,7 @@ export class CronJobsResolver { } @Query(() => CronJob) - async kubernetesCronJob(@Args("name") name: string, @GetCurrentUser() user: CurrentUserInterface): Promise { + async kubernetesCronJob(@Args("name") name: string, @GetCurrentUser() user: CurrentUser): Promise { if (this.kubernetesService.localMode) { throw Error("Not available in local mode!"); } @@ -65,7 +65,7 @@ export class CronJobsResolver { @Mutation(() => Job) @SkipBuild() - async triggerKubernetesCronJob(@Args("name") name: string, @GetCurrentUser() user: CurrentUserInterface): Promise { + async triggerKubernetesCronJob(@Args("name") name: string, @GetCurrentUser() user: CurrentUser): Promise { const cronJob = await this.kubernetesService.getCronJob(name); const contentScope = this.kubernetesService.getContentScope(cronJob); if (contentScope && !this.accessControlService.isAllowed(user, "builds", contentScope)) { diff --git a/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts b/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts index b11362b397..62c36cc823 100644 --- a/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts +++ b/packages/api/cms-api/src/cron-jobs/jobs.resolver.ts @@ -1,10 +1,10 @@ import { Inject } from "@nestjs/common"; import { Args, Query, Resolver } from "@nestjs/graphql"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; import { GetCurrentUser } from "../auth/decorators/get-current-user.decorator"; import { KubernetesService } from "../kubernetes/kubernetes.service"; import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator"; +import { CurrentUser } from "../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions/user-permissions.types"; import { Job } from "./dto/job.object"; @@ -20,7 +20,7 @@ export class JobsResolver { ) {} @Query(() => [Job]) - async kubernetesJobs(@Args("cronJobName") cronJobName: string, @GetCurrentUser() user: CurrentUserInterface): Promise { + async kubernetesJobs(@Args("cronJobName") cronJobName: string, @GetCurrentUser() user: CurrentUser): Promise { if (this.kubernetesService.localMode) { throw Error("Not available in local mode!"); } diff --git a/packages/api/cms-api/src/dam/files/files.controller.ts b/packages/api/cms-api/src/dam/files/files.controller.ts index 12e87db69c..a1b603d3b0 100644 --- a/packages/api/cms-api/src/dam/files/files.controller.ts +++ b/packages/api/cms-api/src/dam/files/files.controller.ts @@ -18,12 +18,12 @@ import { validate } from "class-validator"; import { Response } from "express"; import { OutgoingHttpHeaders } from "http"; -import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator"; import { DisableGlobalGuard } from "../../auth/decorators/global-guard-disable.decorator"; import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; import { CometValidationException } from "../../common/errors/validation.exception"; import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { CDN_ORIGIN_CHECK_HEADER, DamConfig } from "../dam.config"; @@ -65,7 +65,7 @@ export function createFilesController({ Scope: PassedScope }: { Scope?: Type { const transformedBody = plainToInstance(UploadFileBody, body); const errors = await validate(transformedBody, { whitelist: true, forbidNonWhitelisted: true }); @@ -87,7 +87,7 @@ export function createFilesController({ Scope: PassedScope }: { Scope?: Type { const file = await this.filesService.findOneById(fileId); diff --git a/packages/api/cms-api/src/dam/files/files.resolver.ts b/packages/api/cms-api/src/dam/files/files.resolver.ts index 06137e4eef..6074500523 100644 --- a/packages/api/cms-api/src/dam/files/files.resolver.ts +++ b/packages/api/cms-api/src/dam/files/files.resolver.ts @@ -4,13 +4,13 @@ import { Inject, NotFoundException, Type } from "@nestjs/common"; import { Args, ID, Mutation, ObjectType, Parent, Query, ResolveField, Resolver } from "@nestjs/graphql"; import { basename, extname } from "path"; -import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator"; import { SkipBuild } from "../../builds/skip-build.decorator"; import { CometValidationException } from "../../common/errors/validation.exception"; import { PaginatedResponseFactory } from "../../common/pagination/paginated-response.factory"; import { AffectedEntity } from "../../user-permissions/decorators/affected-entity.decorator"; import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { DAM_FILE_VALIDATION_SERVICE } from "../dam.constants"; @@ -118,7 +118,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type MoveDamFilesArgs }) { fileIds, targetFolderId }: MoveDamFilesArgs, - @GetCurrentUser() user: CurrentUserInterface, + @GetCurrentUser() user: CurrentUser, ): Promise { let targetFolder = null; if (targetFolderId !== null) { @@ -147,7 +147,7 @@ export function createFilesResolver({ File, Scope: PassedScope }: { File: Type CopyFilesResponse) @SkipBuild() async copyFilesToScope( - @GetCurrentUser() user: CurrentUserInterface, + @GetCurrentUser() user: CurrentUser, @Args("fileIds", { type: () => [ID] }) fileIds: string[], @Args("inboxFolderId", { type: () => ID, diff --git a/packages/api/cms-api/src/dam/files/files.service.ts b/packages/api/cms-api/src/dam/files/files.service.ts index 4433792d8b..bce2390cd0 100644 --- a/packages/api/cms-api/src/dam/files/files.service.ts +++ b/packages/api/cms-api/src/dam/files/files.service.ts @@ -12,11 +12,11 @@ import { basename, extname, parse } from "path"; import probe from "probe-image-size"; import * as rimraf from "rimraf"; -import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; import { CometEntityNotFoundException } from "../../common/errors/entity-not-found.exception"; import { SortDirection } from "../../common/sorting/sort-direction.enum"; import { ContentScopeService } from "../../user-permissions/content-scope.service"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { FocalPoint } from "../common/enums/focal-point.enum"; @@ -457,7 +457,7 @@ export class FilesService { return this.create(fileInput); } - async copyFilesToScope({ user, fileIds, inboxFolderId }: { user: CurrentUserInterface; fileIds: string[]; inboxFolderId: string }) { + async copyFilesToScope({ user, fileIds, inboxFolderId }: { user: CurrentUser; fileIds: string[]; inboxFolderId: string }) { const inboxFolder = await this.foldersService.findOneById(inboxFolderId); if (!inboxFolder) { throw new Error("Specified inbox folder doesn't exist."); diff --git a/packages/api/cms-api/src/dam/files/folders.controller.ts b/packages/api/cms-api/src/dam/files/folders.controller.ts index 2b94a2ada0..8af5deb2d1 100644 --- a/packages/api/cms-api/src/dam/files/folders.controller.ts +++ b/packages/api/cms-api/src/dam/files/folders.controller.ts @@ -1,8 +1,8 @@ import { Controller, ForbiddenException, Get, Inject, NotFoundException, Param, Res } from "@nestjs/common"; import { Response } from "express"; -import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { FoldersService } from "./folders.service"; @@ -15,7 +15,7 @@ export class FoldersController { ) {} @Get("/:folderId/zip") - async createZip(@Param("folderId") folderId: string, @Res() res: Response, @GetCurrentUser() user: CurrentUserInterface): Promise { + async createZip(@Param("folderId") folderId: string, @Res() res: Response, @GetCurrentUser() user: CurrentUser): Promise { const folder = await this.foldersService.findOneById(folderId); if (!folder) { throw new NotFoundException("Folder not found"); diff --git a/packages/api/cms-api/src/dam/images/images.controller.ts b/packages/api/cms-api/src/dam/images/images.controller.ts index 0e52a67f32..6e8505aa1f 100644 --- a/packages/api/cms-api/src/dam/images/images.controller.ts +++ b/packages/api/cms-api/src/dam/images/images.controller.ts @@ -6,11 +6,11 @@ import mime from "mime"; import fetch from "node-fetch"; import { PassThrough } from "stream"; -import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { GetCurrentUser } from "../../auth/decorators/get-current-user.decorator"; import { DisableGlobalGuard } from "../../auth/decorators/global-guard-disable.decorator"; import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; +import { CurrentUser } from "../../user-permissions/dto/current-user"; import { ACCESS_CONTROL_SERVICE } from "../../user-permissions/user-permissions.constants"; import { AccessControlServiceInterface } from "../../user-permissions/user-permissions.types"; import { ScaledImagesCacheService } from "../cache/scaled-images-cache.service"; @@ -59,7 +59,7 @@ export class ImagesController { @Param() params: ImageParams, @Headers("Accept") accept: string, @Res() res: Response, - @GetCurrentUser() user: CurrentUserInterface, + @GetCurrentUser() user: CurrentUser, ): Promise { if (params.cropArea.focalPoint !== FocalPoint.SMART) { throw new NotFoundException(); @@ -85,7 +85,7 @@ export class ImagesController { @Param() params: ImageParams, @Headers("Accept") accept: string, @Res() res: Response, - @GetCurrentUser() user: CurrentUserInterface, + @GetCurrentUser() user: CurrentUser, ): Promise { if (params.cropArea.focalPoint === FocalPoint.SMART) { throw new NotFoundException(); diff --git a/packages/api/cms-api/src/index.ts b/packages/api/cms-api/src/index.ts index f009fe953f..70afa4c17f 100644 --- a/packages/api/cms-api/src/index.ts +++ b/packages/api/cms-api/src/index.ts @@ -1,8 +1,6 @@ import "reflect-metadata"; export { AccessLogModule } from "./access-log/access-log.module"; -export { CurrentUserInterface } from "./auth/current-user/current-user"; -export { CURRENT_USER_LOADER, CurrentUserLoaderInterface } from "./auth/current-user/current-user-loader"; export { GetCurrentUser } from "./auth/decorators/get-current-user.decorator"; export { DisableGlobalGuard } from "./auth/decorators/global-guard-disable.decorator"; export { PublicApi } from "./auth/decorators/public-api.decorator"; diff --git a/packages/api/cms-api/src/user-permissions/access-control.service.ts b/packages/api/cms-api/src/user-permissions/access-control.service.ts index 5c6abf61e5..b3e1bc7caf 100644 --- a/packages/api/cms-api/src/user-permissions/access-control.service.ts +++ b/packages/api/cms-api/src/user-permissions/access-control.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; -import { CurrentUserInterface } from "../auth/current-user/current-user"; +import { CurrentUser } from "./dto/current-user"; import { ContentScope } from "./interfaces/content-scope.interface"; import { Permission } from "./interfaces/user-permission.interface"; import { AccessControlServiceInterface } from "./user-permissions.types"; @@ -10,7 +10,7 @@ export abstract class AbstractAccessControlService implements AccessControlServi private checkContentScope(userContentScopes: ContentScope[], contentScope: ContentScope): boolean { return userContentScopes.some((cs) => Object.entries(contentScope).every(([scope, value]) => cs[scope] === value)); } - isAllowed(user: CurrentUserInterface, permission: keyof Permission, contentScope?: ContentScope): boolean { + isAllowed(user: CurrentUser, permission: keyof Permission, contentScope?: ContentScope): boolean { if (!user.permissions) return false; return user.permissions.some((p) => p.permission === permission && (!contentScope || this.checkContentScope(p.contentScopes, contentScope))); } diff --git a/packages/api/cms-api/src/user-permissions/auth/current-user-loader.ts b/packages/api/cms-api/src/user-permissions/auth/current-user-loader.ts deleted file mode 100644 index 1b579a79b9..0000000000 --- a/packages/api/cms-api/src/user-permissions/auth/current-user-loader.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { CurrentUserLoaderInterface } from "src/auth/current-user/current-user-loader"; - -import { UserPermissionsService } from "../user-permissions.service"; - -@Injectable() -export class UserPermissionsCurrentUserLoader implements CurrentUserLoaderInterface { - constructor(private readonly service: UserPermissionsService) {} - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async load(userId: string, data?: any) { - const user = await this.service.getUser(userId); - return { ...(await this.service.createCurrentUser(user)), ...data }; - } -} diff --git a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts index 3e1f6f5a60..95500beffe 100644 --- a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts +++ b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts @@ -2,9 +2,9 @@ import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/commo import { Reflector } from "@nestjs/core"; import { GqlContextType, GqlExecutionContext } from "@nestjs/graphql"; -import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { ContentScopeService } from "../content-scope.service"; import { RequiredPermissionMetadata } from "../decorators/required-permission.decorator"; +import { CurrentUser } from "../dto/current-user"; import { ContentScope } from "../interfaces/content-scope.interface"; import { ACCESS_CONTROL_SERVICE } from "../user-permissions.constants"; import { AccessControlServiceInterface } from "../user-permissions.types"; @@ -28,7 +28,7 @@ export class UserPermissionsGuard implements CanActivate { const request = context.getType().toString() === "graphql" ? GqlExecutionContext.create(context).getContext().req : context.switchToHttp().getRequest(); - const user = request.user as CurrentUserInterface | undefined; + const user = request.user as CurrentUser | undefined; if (!user) return false; const requiredPermission = this.reflector.getAllAndOverride("requiredPermission", [ diff --git a/packages/api/cms-api/src/user-permissions/dto/current-user.ts b/packages/api/cms-api/src/user-permissions/dto/current-user.ts index 7c5285bd53..d895a76739 100644 --- a/packages/api/cms-api/src/user-permissions/dto/current-user.ts +++ b/packages/api/cms-api/src/user-permissions/dto/current-user.ts @@ -1,7 +1,6 @@ import { Field, ObjectType } from "@nestjs/graphql"; import { GraphQLJSONObject } from "graphql-type-json"; -import { CurrentUserInterface } from "../../auth/current-user/current-user"; import { ContentScope } from "../interfaces/content-scope.interface"; @ObjectType() @@ -13,7 +12,7 @@ export class CurrentUserPermission { } @ObjectType() -export class CurrentUser implements CurrentUserInterface { +export class CurrentUser { @Field() id: string; @Field() diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.module.ts b/packages/api/cms-api/src/user-permissions/user-permissions.module.ts index c2660bd804..4caedad73e 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.module.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.module.ts @@ -2,8 +2,6 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; import { DynamicModule, Global, Module, Provider } from "@nestjs/common"; import { APP_GUARD } from "@nestjs/core"; -import { CURRENT_USER_LOADER } from "../auth/current-user/current-user-loader"; -import { UserPermissionsCurrentUserLoader } from "./auth/current-user-loader"; import { UserPermissionsGuard } from "./auth/user-permissions.guard"; import { ContentScopeService } from "./content-scope.service"; import { UserContentScopes } from "./entities/user-content-scopes.entity"; @@ -28,17 +26,13 @@ import { UserResolver, UserPermissionResolver, UserContentScopesResolver, - { - provide: CURRENT_USER_LOADER, - useClass: UserPermissionsCurrentUserLoader, - }, ContentScopeService, { provide: APP_GUARD, useClass: UserPermissionsGuard, }, ], - exports: [CURRENT_USER_LOADER, ContentScopeService, ACCESS_CONTROL_SERVICE], + exports: [ContentScopeService, ACCESS_CONTROL_SERVICE, UserPermissionsService], }) export class UserPermissionsModule { static forRoot(options: UserPermissionsModuleSyncOptions): DynamicModule { diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.service.ts b/packages/api/cms-api/src/user-permissions/user-permissions.service.ts index d884bfc1dc..89ca1b9d1d 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.service.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.service.ts @@ -149,13 +149,9 @@ export class UserPermissionsService { return p; }); - const currentUser = new CurrentUser(); - return Object.assign(currentUser, { - id: user.id, - name: user.name, - email: user.email ?? "", - language: user.language, + return { + ...user, permissions, - }); + }; } } diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.types.ts b/packages/api/cms-api/src/user-permissions/user-permissions.types.ts index 0d66e8ff61..b2cf8a6205 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.types.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.types.ts @@ -1,6 +1,6 @@ import { ModuleMetadata, Type } from "@nestjs/common"; -import { CurrentUserInterface } from "src/auth/current-user/current-user"; +import { CurrentUser } from "./dto/current-user"; import { FindUsersArgs } from "./dto/paginated-user-list"; import { User } from "./dto/user"; import { UserPermission } from "./entities/user-permission.entity"; @@ -23,7 +23,7 @@ export type PermissionsForUser = PermissionForUser[] | UserPermissions.allPermis export type ContentScopesForUser = ContentScope[] | UserPermissions.allContentScopes; export interface AccessControlServiceInterface { - isAllowed(user: CurrentUserInterface, permission: keyof Permission, contentScope?: ContentScope): boolean; + isAllowed(user: CurrentUser, permission: keyof Permission, contentScope?: ContentScope): boolean; getPermissionsForUser?: (user: User) => Promise | PermissionsForUser; getContentScopesForUser?: (user: User) => Promise | ContentScopesForUser; } From a4fac913f2b90d74a1780cd842a41b85bd8d41c5 Mon Sep 17 00:00:00 2001 From: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> Date: Mon, 19 Feb 2024 09:19:45 +0100 Subject: [PATCH 16/32] Rework `Alert` component (#1655) - Use theme wherever possible - Move styles where they're more fitting - Fix some paddings --- .changeset/soft-hotels-rhyme.md | 10 +++++ .../src/componentsTheme/MuiAlert.tsx | 22 +++++----- .../src/componentsTheme/MuiAlertTitle.tsx | 12 ++++++ .../src/componentsTheme/getComponentsTheme.ts | 10 +++-- packages/admin/admin/src/alert/Alert.tsx | 40 +++++++------------ .../cms-admin/src/pages/useSaveConflict.tsx | 6 +-- 6 files changed, 58 insertions(+), 42 deletions(-) create mode 100644 .changeset/soft-hotels-rhyme.md create mode 100644 packages/admin/admin-theme/src/componentsTheme/MuiAlertTitle.tsx diff --git a/.changeset/soft-hotels-rhyme.md b/.changeset/soft-hotels-rhyme.md new file mode 100644 index 0000000000..c8a2de2b63 --- /dev/null +++ b/.changeset/soft-hotels-rhyme.md @@ -0,0 +1,10 @@ +--- +"@comet/admin-theme": minor +"@comet/admin": minor +--- + +Rework `Alert` component + +- Use theme wherever possible +- Move styles where they're more fitting +- Fix some paddings diff --git a/packages/admin/admin-theme/src/componentsTheme/MuiAlert.tsx b/packages/admin/admin-theme/src/componentsTheme/MuiAlert.tsx index 17710c670b..8569d8806a 100644 --- a/packages/admin/admin-theme/src/componentsTheme/MuiAlert.tsx +++ b/packages/admin/admin-theme/src/componentsTheme/MuiAlert.tsx @@ -4,7 +4,7 @@ import React from "react"; import { mergeOverrideStyles } from "../utils/mergeOverrideStyles"; import { GetMuiComponentTheme } from "./getComponentsTheme"; -export const getMuiAlert: GetMuiComponentTheme<"MuiAlert"> = (component, { palette }) => ({ +export const getMuiAlert: GetMuiComponentTheme<"MuiAlert"> = (component, { palette, spacing, shadows }) => ({ ...component, defaultProps: { variant: "outlined", @@ -20,26 +20,30 @@ export const getMuiAlert: GetMuiComponentTheme<"MuiAlert"> = (component, { palet styleOverrides: mergeOverrideStyles<"MuiAlert">(component?.styleOverrides, { root: {}, outlined: { - borderLeftWidth: 5, - backgroundColor: "#fff", - borderRadius: 4, + backgroundColor: palette.background.paper, color: palette.grey[800], + borderRadius: spacing(1), + borderLeftWidth: spacing(1), + boxShadow: shadows[2], }, outlinedSuccess: { - borderColor: "#14CC33", + borderColor: palette.success.main, }, outlinedInfo: { - borderColor: "#29B6F6", + borderColor: palette.info.main, }, outlinedWarning: { - borderColor: "#FFB31A", + borderColor: palette.warning.main, }, outlinedError: { - borderColor: "#D11700", + borderColor: palette.error.main, }, icon: { marginRight: 0, - padding: 0, + padding: spacing("2px", 0), + }, + message: { + padding: spacing(0, 0, 0, 2), }, }), }); diff --git a/packages/admin/admin-theme/src/componentsTheme/MuiAlertTitle.tsx b/packages/admin/admin-theme/src/componentsTheme/MuiAlertTitle.tsx new file mode 100644 index 0000000000..af89165281 --- /dev/null +++ b/packages/admin/admin-theme/src/componentsTheme/MuiAlertTitle.tsx @@ -0,0 +1,12 @@ +import { mergeOverrideStyles } from "../utils/mergeOverrideStyles"; +import { GetMuiComponentTheme } from "./getComponentsTheme"; + +export const getMuiAlertTitle: GetMuiComponentTheme<"MuiAlertTitle"> = (component, { spacing }) => ({ + ...component, + styleOverrides: mergeOverrideStyles<"MuiAlertTitle">(component?.styleOverrides, { + root: { + marginBottom: spacing(1), + fontWeight: 600, + }, + }), +}); diff --git a/packages/admin/admin-theme/src/componentsTheme/getComponentsTheme.ts b/packages/admin/admin-theme/src/componentsTheme/getComponentsTheme.ts index d69840a9b2..9bb43b9a9f 100644 --- a/packages/admin/admin-theme/src/componentsTheme/getComponentsTheme.ts +++ b/packages/admin/admin-theme/src/componentsTheme/getComponentsTheme.ts @@ -7,6 +7,7 @@ import { Spacing } from "@mui/system"; import { getMuiAccordion } from "./MuiAccordion"; import { getMuiAlert } from "./MuiAlert"; +import { getMuiAlertTitle } from "./MuiAlertTitle"; import { getMuiAppBar } from "./MuiAppBar"; import { getMuiAutocomplete } from "./MuiAutocomplete"; import { getMuiButton } from "./MuiButton"; @@ -63,6 +64,8 @@ export type GetMuiComponentTheme ({ ...components, MuiAccordion: getMuiAccordion(components.MuiAccordion, themeData), + MuiAlert: getMuiAlert(components.MuiAlert, themeData), + MuiAlertTitle: getMuiAlertTitle(components.MuiAlertTitle, themeData), MuiAppBar: getMuiAppBar(components.MuiAppBar, themeData), MuiAutocomplete: getMuiAutocomplete(components.MuiAutocomplete, themeData), MuiButton: getMuiButton(components.MuiButton, themeData), @@ -78,20 +81,20 @@ export const getComponentsTheme = (components: Components, themeData: ThemeData) MuiDialogTitle: getMuiDialogTitle(components.MuiDialogTitle, themeData), MuiDrawer: getMuiDrawer(components.MuiDrawer, themeData), MuiFormControlLabel: getMuiFormControlLabel(components.MuiFormControlLabel, themeData), - MuiFormLabel: getMuiFormLabel(components.MuiFormLabel, themeData), MuiFormHelperText: getMuiFormHelperText(components.MuiFormHelperText, themeData), + MuiFormLabel: getMuiFormLabel(components.MuiFormLabel, themeData), MuiIconButton: getMuiIconButton(components.MuiIconButton, themeData), + MuiInput: getMuiInput(components.MuiInput, themeData), MuiInputAdornment: getMuiInputAdornment(components.MuiInputAdornment, themeData), MuiInputBase: getMuiInputBase(components.MuiInputBase, themeData), - MuiInput: getMuiInput(components.MuiInput, themeData), MuiLinearProgress: getMuiLinearProgress(components.MuiLinearProgress, themeData), MuiLink: getMuiLink(components.MuiLink, themeData), MuiListItem: getMuiListItem(components.MuiListItem, themeData), + MuiNativeSelect: getMuiNativeSelect(components.MuiNativeSelect, themeData), MuiPaper: getMuiPaper(components.MuiPaper, themeData), MuiPopover: getMuiPopover(components.MuiPopover, themeData), MuiRadio: getMuiRadio(components.MuiRadio, themeData), MuiSelect: getMuiSelect(components.MuiSelect, themeData), - MuiNativeSelect: getMuiNativeSelect(components.MuiNativeSelect, themeData), MuiSvgIcon: getMuiSvgIcon(components.MuiSvgIcon, themeData), MuiSwitch: getMuiSwitch(components.MuiSwitch, themeData), MuiTab: getMuiTab(components.MuiTab, themeData), @@ -102,5 +105,4 @@ export const getComponentsTheme = (components: Components, themeData: ThemeData) MuiToggleButtonGroup: getMuiToggleButtonGroup(components.MuiToggleButtonGroup, themeData), MuiTooltip: getMuiTooltip(components.MuiTooltip, themeData), MuiTypography: getMuiTypography(components.MuiTypography, themeData), - MuiAlert: getMuiAlert(components.MuiAlert, themeData), }); diff --git a/packages/admin/admin/src/alert/Alert.tsx b/packages/admin/admin/src/alert/Alert.tsx index 8d95c38ee8..4b20219797 100644 --- a/packages/admin/admin/src/alert/Alert.tsx +++ b/packages/admin/admin/src/alert/Alert.tsx @@ -13,40 +13,28 @@ export interface AlertProps { action?: React.ReactNode; } -export type AlertClassKey = "root" | "message" | "title" | "text" | "action" | "closeIcon" | "hasTitle"; +export type AlertClassKey = "root" | "message" | "title" | "text" | "action" | "closeIcon" | "hasTitle" | "singleRow"; const styles = (theme: Theme) => createStyles({ root: { - display: "flex", - alignItems: "center", - backgroundColor: theme.palette.background.paper, - borderRadius: 4, - boxShadow: theme.shadows[2], - position: "relative", - padding: theme.spacing(2, "12px", 2, 4), - minHeight: 40, // to ensure consistent height for the content, regardless of the presence of a button or close icon, in order to set the outer padding correctly + padding: theme.spacing(4, "12px", 4, 4), }, message: { display: "flex", alignItems: "center", flexGrow: 1, - padding: 0, - paddingLeft: theme.spacing(2), - marginBottom: 0, - }, - title: { - fontWeight: 600, - marginBottom: theme.spacing(1), }, + title: {}, text: { flexGrow: 1, - marginRight: theme.spacing(4), }, action: {}, closeIcon: {}, hasTitle: { + position: "relative", alignItems: "flex-start", + padding: theme.spacing(4, 6, "8px", 3), [`& .${buttonClasses.text}`]: { marginLeft: -15, @@ -58,27 +46,29 @@ const styles = (theme: Theme) => "& $closeIcon": { position: "absolute", - right: 10, - top: 10, + right: 2, + top: 2, }, "& $message": { flexDirection: "column", alignItems: "flex-start", }, - "&$root": { - paddingBottom: "6px", - paddingTop: theme.spacing(4), - }, + }, + singleRow: { + display: "flex", + alignItems: "center", + padding: theme.spacing(2, "12px", 2, 4), }, }); const Alert = React.forwardRef>( ({ severity = "info", title, children, classes, onClose, action }, ref) => { + const singleRow = !title && (action || onClose); return ( {children} -
{action}
+ {action &&
{action}
} {onClose && ( diff --git a/packages/admin/cms-admin/src/pages/useSaveConflict.tsx b/packages/admin/cms-admin/src/pages/useSaveConflict.tsx index 3f923cc1c5..b35c458e36 100644 --- a/packages/admin/cms-admin/src/pages/useSaveConflict.tsx +++ b/packages/admin/cms-admin/src/pages/useSaveConflict.tsx @@ -1,7 +1,5 @@ -import { useSnackbarApi } from "@comet/admin"; -// TODO Our Alert currently can't be used inside a Snackbar -// eslint-disable-next-line no-restricted-imports -import { Alert, Snackbar } from "@mui/material"; +import { Alert, useSnackbarApi } from "@comet/admin"; +import { Snackbar } from "@mui/material"; import * as React from "react"; import { FormattedMessage } from "react-intl"; From 7ea43eb34f93fcc2b991b015fdfbfc3cc8f2f250 Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Mon, 19 Feb 2024 09:31:06 +0100 Subject: [PATCH 17/32] Make userService optional (#1684) Allows easier migration as the userService is only needed for the UserPermissions administration panel --- .changeset/afraid-horses-hide.md | 7 +++ demo/api/src/auth/auth.module.ts | 2 +- .../docs/migration/migration-from-v5-to-v6.md | 56 +++++++++++++------ .../user-content-scopes.resolver.ts | 2 +- .../user-permission.resolver.ts | 4 +- .../user-permissions.module.ts | 12 ++-- .../user-permissions.service.ts | 34 ++++++----- .../user-permissions.types.ts | 4 +- 8 files changed, 75 insertions(+), 46 deletions(-) create mode 100644 .changeset/afraid-horses-hide.md diff --git a/.changeset/afraid-horses-hide.md b/.changeset/afraid-horses-hide.md new file mode 100644 index 0000000000..e936f1d6db --- /dev/null +++ b/.changeset/afraid-horses-hide.md @@ -0,0 +1,7 @@ +--- +"@comet/cms-api": minor +--- + +Make the `UserService`-option of the `UserPermissionsModule` optional. + +The service is still necessary though for the Administration-Panel. diff --git a/demo/api/src/auth/auth.module.ts b/demo/api/src/auth/auth.module.ts index ff1806ab0f..012040b493 100644 --- a/demo/api/src/auth/auth.module.ts +++ b/demo/api/src/auth/auth.module.ts @@ -9,7 +9,7 @@ import { UserService } from "./user.service"; @Module({ providers: [ createStaticAuthedUserStrategy({ - staticAuthedUser: staticUsers[0].id, + staticAuthedUser: staticUsers[0], }), createAuthResolver(), { diff --git a/docs/docs/migration/migration-from-v5-to-v6.md b/docs/docs/migration/migration-from-v5-to-v6.md index 13d45ec364..81f4eacfe3 100644 --- a/docs/docs/migration/migration-from-v5-to-v6.md +++ b/docs/docs/migration/migration-from-v5-to-v6.md @@ -86,20 +86,7 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES export {}; ``` -4. Create necessary services for the `UserPermissionsModule` (either in a new module or where it fits best) - - ```ts - // Attention: might already being provided by the library which syncs the users - @Injectable() - export class UserService implements UserPermissionsUserServiceInterface { - getUser(id: string): User { - ... - } - findUsers(args: FindUsersArgs): Users { - ... - } - } - ``` +4. Create the `AccessControlService` for the `UserPermissionsModule` (either in a new module or where it fits best) ```ts @Injectable() @@ -127,13 +114,12 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES ```ts UserPermissionsModule.forRootAsync({ - useFactory: (userService: UserService, accessControlService: AccessControlService) => ({ + useFactory: (accessControlService: AccessControlService) => ({ availablePermissions: [/* Array of strings defined in interface Permission */], availableContentScopes: [/* Array of content Scopes */], - userService, accessControlService, }), - inject: [UserService, AccessControlService], + inject: [AccessControlService], imports: [/* Modules which provide the services injected in useFactory */], }), ``` @@ -153,6 +139,40 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES - @AllowForRole(...) ``` +7. Optional: Add the `UserService` (required for Administration Panel, see Admin) + + Create a `UserService`: + + ```ts + // Attention: might already being provided by the library which syncs the users + @Injectable() + export class UserService implements UserPermissionsUserServiceInterface { + getUser(id: string): User { + ... + } + findUsers(args: FindUsersArgs): Users { + ... + } + } + ``` + + Add it to the `UserPermissionsModule`: + + ```diff + UserPermissionsModule.forRootAsync({ + + useFactory: (accessControlService: AccessControlService, userService: UserService) => ({ + - useFactory: (accessControlService: AccessControlService) => ({ + availablePermissions: [/* Array of strings defined in interface Permission */], + availableContentScopes: [/* Array of content Scopes */], + + userService, + accessControlService, + }), + + inject: [AccessControlService, UserService], + - inject: [AccessControlService], + imports: [/* Modules which provide the services injected in useFactory */], + }), + ``` + ## Admin ### User Permissions @@ -184,7 +204,7 @@ It automatically installs the new versions of all `@comet` libraries, runs an ES + const allowedUserDomains = user.allowedContentScopes.map((contentScope) => contentScope.domain); ``` -3. Add the `UserPermissionsPage` +3. Optional: Add the Adminstration Panel ```tsx Boolean, nullable: true }) skipManual = false, ): Promise { return this.userService.normalizeContentScopes( - await this.userService.getContentScopes(userId, !skipManual), + await this.userService.getContentScopes(await this.userService.getUser(userId), !skipManual), await this.userService.getAvailableContentScopes(), ); } diff --git a/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts b/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts index 1c97e6c9a2..ec6da700fe 100644 --- a/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts +++ b/packages/api/cms-api/src/user-permissions/user-permission.resolver.ts @@ -26,7 +26,7 @@ export class UserPermissionResolver { @Query(() => [UserPermission]) async userPermissionsPermissionList(@Args() args: UserPermissionListArgs): Promise { - return this.service.getPermissions(args.userId); + return this.service.getPermissions(await this.service.getUser(args.userId)); } @Query(() => UserPermission) @@ -96,7 +96,7 @@ export class UserPermissionResolver { if (!userId) { throw new Error(`Permission not found: ${id}`); } - for (const p of await this.service.getPermissions(userId)) { + for (const p of await this.service.getPermissions(await this.service.getUser(userId))) { if (p.id === id) return p; } throw new Error("Permission not found"); diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.module.ts b/packages/api/cms-api/src/user-permissions/user-permissions.module.ts index 4caedad73e..d8eafe42c6 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.module.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.module.ts @@ -43,14 +43,18 @@ export class UserPermissionsModule { provide: USER_PERMISSIONS_OPTIONS, useValue: options, }, - { - provide: USER_PERMISSIONS_USER_SERVICE, - useClass: options.UserService, - }, { provide: ACCESS_CONTROL_SERVICE, useClass: options.AccessControlService, }, + ...(options.UserService + ? [ + { + provide: USER_PERMISSIONS_USER_SERVICE, + useClass: options.UserService, + }, + ] + : []), ], }; } diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.service.ts b/packages/api/cms-api/src/user-permissions/user-permissions.service.ts index 89ca1b9d1d..2fa50dc58a 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.service.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.service.ts @@ -1,6 +1,6 @@ import { EntityRepository } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; -import { Inject, Injectable } from "@nestjs/common"; +import { Inject, Injectable, Optional } from "@nestjs/common"; import { isFuture, isPast } from "date-fns"; import isEqual from "lodash.isequal"; import getUuid from "uuid-by-string"; @@ -24,7 +24,7 @@ import { export class UserPermissionsService { constructor( @Inject(USER_PERMISSIONS_OPTIONS) private readonly options: UserPermissionsOptions, - @Inject(USER_PERMISSIONS_USER_SERVICE) private readonly userService: UserPermissionsUserServiceInterface, + @Inject(USER_PERMISSIONS_USER_SERVICE) @Optional() private readonly userService: UserPermissionsUserServiceInterface | undefined, @Inject(ACCESS_CONTROL_SERVICE) private readonly accessControlService: AccessControlServiceInterface, @InjectRepository(UserPermission) private readonly permissionRepository: EntityRepository, @InjectRepository(UserContentScopes) private readonly contentScopeRepository: EntityRepository, @@ -41,10 +41,12 @@ export class UserPermissionsService { } async getUser(id: string): Promise { + if (!this.userService) throw new Error("For this functionality you need to define the userService in the UserPermissionsModule."); return this.userService.getUser(id); } async findUsers(args: FindUsersArgs): Promise<[User[], number]> { + if (!this.userService) throw new Error("For this functionality you need to define the userService in the UserPermissionsModule."); return this.userService.findUsers(args); } @@ -57,18 +59,17 @@ export class UserPermissionsService { }); } - async getPermissions(userId: string): Promise { + async getPermissions(user: User): Promise { const availablePermissions = await this.getAvailablePermissions(); const permissions = ( await this.permissionRepository.find({ - $and: [{ userId }, { permission: { $in: availablePermissions } }], + $and: [{ userId: user.id }, { permission: { $in: availablePermissions } }], }) ).map((p) => { p.source = UserPermissionSource.MANUAL; return p; }); if (this.accessControlService.getPermissionsForUser) { - const user = await this.getUser(userId); if (user) { let permissionsByRule = await this.accessControlService.getPermissionsForUser(user); if (permissionsByRule === UserPermissions.allPermissions) { @@ -78,7 +79,7 @@ export class UserPermissionsService { const permission = new UserPermission(); permission.id = getUuid(JSON.stringify(p)); permission.source = UserPermissionSource.BY_RULE; - permission.userId = userId; + permission.userId = user.id; permission.overrideContentScopes = !!p.contentScopes; permission.assign(p); permissions.push(permission); @@ -94,24 +95,21 @@ export class UserPermissionsService { ); } - async getContentScopes(userId: string, includeContentScopesManual = true): Promise { + async getContentScopes(user: User, includeContentScopesManual = true): Promise { const contentScopes: ContentScope[] = []; const availableContentScopes = await this.getAvailableContentScopes(); if (this.accessControlService.getContentScopesForUser) { - const user = await this.getUser(userId); - if (user) { - const userContentScopes = await this.accessControlService.getContentScopesForUser(user); - if (userContentScopes === UserPermissions.allContentScopes) { - contentScopes.push(...availableContentScopes); - } else { - contentScopes.push(...userContentScopes); - } + const userContentScopes = await this.accessControlService.getContentScopesForUser(user); + if (userContentScopes === UserPermissions.allContentScopes) { + contentScopes.push(...availableContentScopes); + } else { + contentScopes.push(...userContentScopes); } } if (includeContentScopesManual) { - const entity = await this.contentScopeRepository.findOne({ userId }); + const entity = await this.contentScopeRepository.findOne({ userId: user.id }); if (entity) { contentScopes.push(...entity.contentScopes.filter((value) => availableContentScopes.some((cs) => isEqual(cs, value)))); } @@ -128,8 +126,8 @@ export class UserPermissionsService { async createCurrentUser(user: User): Promise { const availableContentScopes = await this.getAvailableContentScopes(); - const userContentScopes = await this.getContentScopes(user.id); - const permissions = (await this.getPermissions(user.id)) + const userContentScopes = await this.getContentScopes(user); + const permissions = (await this.getPermissions(user)) .filter((p) => (!p.validFrom || isPast(p.validFrom)) && (!p.validTo || isFuture(p.validTo))) .reduce((acc: CurrentUser["permissions"], userPermission) => { const contentScopes = userPermission.overrideContentScopes ? userPermission.contentScopes : userContentScopes; diff --git a/packages/api/cms-api/src/user-permissions/user-permissions.types.ts b/packages/api/cms-api/src/user-permissions/user-permissions.types.ts index b2cf8a6205..23fc1fd347 100644 --- a/packages/api/cms-api/src/user-permissions/user-permissions.types.ts +++ b/packages/api/cms-api/src/user-permissions/user-permissions.types.ts @@ -38,12 +38,12 @@ export interface UserPermissionsOptions { availableContentScopes?: ContentScope[]; } export interface UserPermissionsModuleSyncOptions extends UserPermissionsOptions { - UserService: Type; + UserService?: Type; AccessControlService: Type; } export interface UserPermissionsAsyncOptions extends UserPermissionsOptions { - userService: UserPermissionsUserServiceInterface; + userService?: UserPermissionsUserServiceInterface; accessControlService: AccessControlServiceInterface; } From c6d55b53f0ba4449d8decb55edb224310c9c55f7 Mon Sep 17 00:00:00 2001 From: Johannes Munker <56400587+jomunker@users.noreply.github.com> Date: Mon, 19 Feb 2024 09:44:45 +0100 Subject: [PATCH 18/32] Use `isURL` instead of `isHref` in `validateUrl` validator (#1702) Fix `validateUrl` validator by using `isURL` instead of `isHref` validation. --- packages/admin/cms-admin/src/validation/validateUrl.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/admin/cms-admin/src/validation/validateUrl.tsx b/packages/admin/cms-admin/src/validation/validateUrl.tsx index f54ff10cae..93167403f9 100644 --- a/packages/admin/cms-admin/src/validation/validateUrl.tsx +++ b/packages/admin/cms-admin/src/validation/validateUrl.tsx @@ -1,10 +1,9 @@ +import { isURL } from "class-validator"; import React from "react"; import { FormattedMessage } from "react-intl"; -import { isHref } from "./isHref"; - export function validateUrl(url: string) { - if (url && !isHref(url)) { + if (url && !isURL(url)) { return ; } } From 1f6c58e8afe370c9be14355211bfb13dd175061c Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Mon, 19 Feb 2024 09:48:52 +0100 Subject: [PATCH 19/32] API Generator: support GraphQLJSONObject input for fields that are not a InputType class (#1688) Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> --- .changeset/twenty-cobras-cough.md | 5 ++ .../generate-crud-input-json.spec.ts | 47 +++++++++++++++++++ .../src/generator/generate-crud-input.ts | 10 +++- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .changeset/twenty-cobras-cough.md diff --git a/.changeset/twenty-cobras-cough.md b/.changeset/twenty-cobras-cough.md new file mode 100644 index 0000000000..466d8dd5b2 --- /dev/null +++ b/.changeset/twenty-cobras-cough.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-api": patch +--- + +API Generator: support GraphQLJSONObject input for fields that are not a InputType class diff --git a/packages/api/cms-api/src/generator/generate-crud-input-json.spec.ts b/packages/api/cms-api/src/generator/generate-crud-input-json.spec.ts index 61b5a5a00e..6a72923b2a 100644 --- a/packages/api/cms-api/src/generator/generate-crud-input-json.spec.ts +++ b/packages/api/cms-api/src/generator/generate-crud-input-json.spec.ts @@ -51,6 +51,15 @@ export class TestEntityWithEmbedded extends BaseEntity TestEmbedded) foo: TestEmbedded; } + +@Entity() +export class TestEntityWithRecord extends BaseEntity { + @PrimaryKey({ type: "uuid" }) + id: string = uuid(); + + @Property({ type: "json" }) + foo: Record; +} describe("GenerateCrudInputJson", () => { describe("input class literal array", () => { it("should be a valid generated ts file", async () => { @@ -163,4 +172,42 @@ describe("GenerateCrudInputJson", () => { orm.close(); }); }); + + describe("input class record", () => { + it("should be a valid generated ts file", async () => { + LazyMetadataStorage.load(); + const orm = await MikroORM.init({ + type: "postgresql", + dbName: "test-db", + entities: [TestEntityWithRecord], + }); + + const out = await generateCrudInput({ targetDirectory: __dirname }, orm.em.getMetadata().get("TestEntityWithRecord")); + const lintedOutput = await lintSource(out[0].content); + const source = parseSource(lintedOutput); + + const classes = source.getClasses(); + expect(classes.length).toBe(2); + + { + const cls = classes[0]; + const structure = cls.getStructure(); + + expect(structure.properties?.length).toBe(1); + expect(structure.properties?.[0].name).toBe("foo"); + expect(structure.properties?.[0].type).toBe("Record"); + expect(structure.properties?.[0].decorators?.[0].name).toBe("IsNotEmpty"); + expect(structure.properties?.[0].decorators?.[1].name).toBe("Field"); + expect(structure.properties?.[0].decorators?.[1].arguments).toStrictEqual(["() => GraphQLJSONObject"]); + } + { + const cls = classes[1]; //update dto + const structure = cls.getStructure(); + + expect(structure.properties?.length).toBe(0); + } + + orm.close(); + }); + }); }); diff --git a/packages/api/cms-api/src/generator/generate-crud-input.ts b/packages/api/cms-api/src/generator/generate-crud-input.ts index 4af6057777..6eb9e50dd8 100644 --- a/packages/api/cms-api/src/generator/generate-crud-input.ts +++ b/packages/api/cms-api/src/generator/generate-crud-input.ts @@ -274,21 +274,27 @@ export async function generateCrudInput( } else if (type == "boolean[]") { decorators.push(`@Field(() => [Boolean], ${fieldOptions})`); decorators.push("@IsBoolean({ each: true })"); - } else { + } else if (tsType.getArrayElementTypeOrThrow().isClass()) { const nestedClassName = tsType.getArrayElementTypeOrThrow().getText(tsProp); const importPath = findInputClassImportPath(nestedClassName, generatorOptions, metadata); imports.push({ name: nestedClassName, importPath }); decorators.push(`@ValidateNested()`); decorators.push(`@Type(() => ${nestedClassName})`); decorators.push(`@Field(() => [${nestedClassName}], ${fieldOptions})`); + } else { + decorators.push(`@Field(() => [GraphQLJSONObject], ${fieldOptions}) // Warning: this input is not validated properly`); } - } else { + } else if (tsType.isClass()) { const nestedClassName = tsType.getText(tsProp); const importPath = findInputClassImportPath(nestedClassName, generatorOptions, metadata); imports.push({ name: nestedClassName, importPath }); decorators.push(`@ValidateNested()`); decorators.push(`@Type(() => ${nestedClassName})`); decorators.push(`@Field(() => ${nestedClassName}${prop.nullable ? ", { nullable: true }" : ""})`); + } else { + decorators.push( + `@Field(() => GraphQLJSONObject${prop.nullable ? ", { nullable: true }" : ""}) // Warning: this input is not validated properly`, + ); } } else if (prop.type == "uuid") { const initializer = morphTsProperty(prop.name, metadata).getInitializer()?.getText(); From 6cbb9e7bf4e12f8c50a15db6cd56e55a5033a16d Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Mon, 19 Feb 2024 10:24:27 +0100 Subject: [PATCH 20/32] Admin Generator (Future): Implement possibility to use custom field validation functions (#1710) Usage: ``` { type: "text", name: "title", validate: { name: "validateTitle", import: "./validateTitle" }, }, ``` see also demo in this pr and it's generated code --- .../products/future/ProductForm.cometGen.ts | 1 + .../products/future/generated/ProductForm.tsx | 2 ++ .../src/products/future/validateTitle.tsx | 8 +++++++ .../src/generator/future/generateFormField.ts | 24 +++++++++++++++++-- .../src/generator/future/generator.ts | 8 +++---- 5 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 demo/admin/src/products/future/validateTitle.tsx diff --git a/demo/admin/src/products/future/ProductForm.cometGen.ts b/demo/admin/src/products/future/ProductForm.cometGen.ts index 621fdc406c..5bde89adb8 100644 --- a/demo/admin/src/products/future/ProductForm.cometGen.ts +++ b/demo/admin/src/products/future/ProductForm.cometGen.ts @@ -11,6 +11,7 @@ export const ProductForm: FormConfig = { name: "title", label: "Titel", // default is generated from name (camelCaseToHumanReadable) required: true, // default is inferred from gql schema + validate: { name: "validateTitle", import: "./validateTitle" }, }, { type: "text", name: "slug" }, { type: "text", name: "description", label: "Description", multiline: true }, diff --git a/demo/admin/src/products/future/generated/ProductForm.tsx b/demo/admin/src/products/future/generated/ProductForm.tsx index 47bdbb2e10..1ddb275333 100644 --- a/demo/admin/src/products/future/generated/ProductForm.tsx +++ b/demo/admin/src/products/future/generated/ProductForm.tsx @@ -31,6 +31,7 @@ import isEqual from "lodash.isequal"; import React from "react"; import { FormattedMessage } from "react-intl"; +import { validateTitle } from "../validateTitle"; import { createProductMutation, productFormFragment, productQuery, updateProductMutation } from "./ProductForm.gql"; import { GQLCreateProductMutation, @@ -165,6 +166,7 @@ export function ProductForm({ id }: FormProps): React.ReactElement { name="title" component={FinalFormInput} label={} + validate={validateTitle} /> + ) : undefined; +} diff --git a/packages/admin/cms-admin/src/generator/future/generateFormField.ts b/packages/admin/cms-admin/src/generator/future/generateFormField.ts index f0c3c5a4f6..6661a64f8f 100644 --- a/packages/admin/cms-admin/src/generator/future/generateFormField.ts +++ b/packages/admin/cms-admin/src/generator/future/generateFormField.ts @@ -33,6 +33,21 @@ export function generateFormField( //TODO verify introspectionField.type is compatbile with config.type const imports: Imports = []; + + let validateCode = ""; + if (config.validate) { + let importPath = config.validate.import; + if (importPath.startsWith("./")) { + //go one level up as generated files are in generated subfolder + importPath = `.${importPath}`; + } + imports.push({ + name: config.validate.name, + importPath, + }); + validateCode = `validate={${config.validate.name}}`; + } + let code = ""; if (config.type == "text") { code = ` @@ -43,6 +58,7 @@ export function generateFormField( name="${name}" component={FinalFormInput} label={} + ${validateCode} />`; } else if (config.type == "number") { code = ` @@ -53,10 +69,11 @@ export function generateFormField( component={FinalFormInput} type="number" label={} + ${validateCode} />`; //TODO MUI suggest not using type=number https://mui.com/material-ui/react-text-field/#type-quot-number-quot } else if (config.type == "boolean") { - code = ` + code = ` {(props) => ( } @@ -72,6 +89,7 @@ export function generateFormField( name="${name}" component={FinalFormDatePicker} label={} + ${validateCode} />`; } else if (config.type == "block") { imports.push({ @@ -93,7 +111,9 @@ export function generateFormField( code = `}> + label={} + ${validateCode} + > {(props) => ${values diff --git a/packages/admin/cms-admin/src/generator/future/generator.ts b/packages/admin/cms-admin/src/generator/future/generator.ts index 31a0a6bec7..cbeeaa7ace 100644 --- a/packages/admin/cms-admin/src/generator/future/generator.ts +++ b/packages/admin/cms-admin/src/generator/future/generator.ts @@ -8,7 +8,7 @@ import { generateForm } from "./generateForm"; import { generateGrid } from "./generateGrid"; import { writeGenerated } from "./utils/writeGenerated"; -type BlockReference = { +type ImportReference = { name: string; import: string; }; @@ -21,8 +21,8 @@ export type FormFieldConfig = ( // TODO | { type: "dateTime" } | { type: "staticSelect"; values?: string[] } | { type: "asyncSelect"; values?: string[] } - | { type: "block"; block: BlockReference } -) & { name: keyof T; label?: string; required?: boolean }; + | { type: "block"; block: ImportReference } +) & { name: keyof T; label?: string; required?: boolean; validate?: ImportReference }; export type FormConfig = { type: "form"; @@ -41,7 +41,7 @@ export type GridColumnConfig = ( | { type: "date" } | { type: "dateTime" } | { type: "staticSelect"; values?: string[] } - | { type: "block"; block: BlockReference } + | { type: "block"; block: ImportReference } ) & { name: keyof T; headerName?: string; width?: number }; export type GridConfig = { type: "grid"; From 737ab3b90e37abb1cf5a010cd2499c8fee4a78bc Mon Sep 17 00:00:00 2001 From: Franz Unger Date: Mon, 19 Feb 2024 10:38:40 +0100 Subject: [PATCH 21/32] Allow returning multiple content scopes in `ScopedEntity`-decorator (#1708) Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> --- .changeset/mighty-forks-exercise.md | 5 +++++ .../src/builds/changes-checker.interceptor.ts | 21 +++++++++++-------- .../auth/user-permissions.guard.ts | 12 +++++++---- .../user-permissions/content-scope.service.ts | 10 +++++++-- .../decorators/scoped-entity.decorator.ts | 4 ++-- 5 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 .changeset/mighty-forks-exercise.md diff --git a/.changeset/mighty-forks-exercise.md b/.changeset/mighty-forks-exercise.md new file mode 100644 index 0000000000..bea5288811 --- /dev/null +++ b/.changeset/mighty-forks-exercise.md @@ -0,0 +1,5 @@ +--- +"@comet/cms-api": minor +--- + +Allow returning multiple content scopes in `ScopedEntity`-decorator diff --git a/packages/api/cms-api/src/builds/changes-checker.interceptor.ts b/packages/api/cms-api/src/builds/changes-checker.interceptor.ts index 43f18ee550..90297a3665 100644 --- a/packages/api/cms-api/src/builds/changes-checker.interceptor.ts +++ b/packages/api/cms-api/src/builds/changes-checker.interceptor.ts @@ -29,17 +29,20 @@ export class ChangesCheckerInterceptor implements NestInterceptor { this.reflector.get(SKIP_BUILD_METADATA_KEY, context.getClass()); if (!skipBuild) { - const scope = await this.contentScopeService.inferScopeFromExecutionContext(context); - - if (process.env.NODE_ENV === "development" && this.changeAffectsAllScopes(scope)) { - if (operation.name) { - console.warn(`Mutation "${operation.name.value}" affects all scopes. Are you sure this is correct?`); - } else { - console.warn(`Unknown mutation affects all scopes. Are you sure this is correct?`); + const scopes = await this.contentScopeService.inferScopesFromExecutionContext(context); + if (scopes) { + for (const scope of scopes) { + if (process.env.NODE_ENV === "development" && this.changeAffectsAllScopes(scope)) { + if (operation.name) { + console.warn(`Mutation "${operation.name.value}" affects all scopes. Are you sure this is correct?`); + } else { + console.warn(`Unknown mutation affects all scopes. Are you sure this is correct?`); + } + } + + await this.buildsService.setChangesSinceLastBuild(scope); } } - - await this.buildsService.setChangesSinceLastBuild(scope); } } } diff --git a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts index 95500beffe..6d9a1241f4 100644 --- a/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts +++ b/packages/api/cms-api/src/user-permissions/auth/user-permissions.guard.ts @@ -39,10 +39,10 @@ export class UserPermissionsGuard implements CanActivate { throw new Error(`RequiredPermission decorator is missing in ${context.getClass().name}::${context.getHandler().name}()`); } - let contentScope: ContentScope | undefined; + let requiredContentScopes: ContentScope[] | undefined; if (!this.isResolvingGraphQLField(context) && !requiredPermission.options?.skipScopeCheck) { - contentScope = await this.contentScopeService.inferScopeFromExecutionContext(context); - if (!contentScope) { + requiredContentScopes = await this.contentScopeService.inferScopesFromExecutionContext(context); + if (!requiredContentScopes) { throw new Error( `Could not get ContentScope. Either pass a scope-argument or add @AffectedEntity()-decorator or enable skipScopeCheck in @RequiredPermission() (${ context.getClass().name @@ -57,7 +57,11 @@ export class UserPermissionsGuard implements CanActivate { if (requiredPermissions.length === 0) { throw new Error(`RequiredPermission decorator has empty permissions in ${context.getClass().name}::${context.getHandler().name}()`); } - return requiredPermissions.some((permission) => this.accessControlService.isAllowed(user, permission, contentScope)); + return requiredPermissions.some((permission) => + requiredContentScopes + ? requiredContentScopes.some((contentScope) => this.accessControlService.isAllowed(user, permission, contentScope)) + : this.accessControlService.isAllowed(user, permission), + ); } // See https://docs.nestjs.com/graphql/other-features#execute-enhancers-at-the-field-resolver-level diff --git a/packages/api/cms-api/src/user-permissions/content-scope.service.ts b/packages/api/cms-api/src/user-permissions/content-scope.service.ts index a940d90e5f..689863142e 100644 --- a/packages/api/cms-api/src/user-permissions/content-scope.service.ts +++ b/packages/api/cms-api/src/user-permissions/content-scope.service.ts @@ -21,12 +21,12 @@ export class ContentScopeService { return isEqual({ ...scope1 }, { ...scope2 }); } - async inferScopeFromExecutionContext(context: ExecutionContext): Promise { + async inferScopeFromExecutionContext(context: ExecutionContext): Promise { const args = await this.getArgs(context); const affectedEntity = this.reflector.getAllAndOverride("affectedEntity", [context.getHandler(), context.getClass()]); if (affectedEntity) { - let contentScope: ContentScope | undefined; + let contentScope: ContentScope | ContentScope[] | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any const repo = this.orm.em.getRepository(affectedEntity.entity); if (affectedEntity.options.idArg) { @@ -74,6 +74,12 @@ export class ContentScopeService { } } + async inferScopesFromExecutionContext(context: ExecutionContext): Promise { + const scope = await this.inferScopeFromExecutionContext(context); + if (scope === undefined) return scope; + return Array.isArray(scope) ? scope : [scope]; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private async getArgs(context: ExecutionContext): Promise> { if (context.getType().toString() === "graphql") { diff --git a/packages/api/cms-api/src/user-permissions/decorators/scoped-entity.decorator.ts b/packages/api/cms-api/src/user-permissions/decorators/scoped-entity.decorator.ts index 70a084b0f7..7aead92047 100644 --- a/packages/api/cms-api/src/user-permissions/decorators/scoped-entity.decorator.ts +++ b/packages/api/cms-api/src/user-permissions/decorators/scoped-entity.decorator.ts @@ -4,10 +4,10 @@ import { ContentScope } from "../../user-permissions/interfaces/content-scope.in export interface ScopedEntityMeta { // eslint-disable-next-line @typescript-eslint/no-explicit-any - fn: (entity: any) => Promise; + fn: (entity: any) => Promise; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const ScopedEntity = (fn: (entity: any) => Promise): CustomDecorator => { +export const ScopedEntity = (fn: (entity: any) => Promise): CustomDecorator => { return SetMetadata("scopedEntity", { fn }); }; From cd88d94b4be447613c40e4669ad98c10698e11d0 Mon Sep 17 00:00:00 2001 From: andrearutrecht <122883866+andrearutrecht@users.noreply.github.com> Date: Mon, 19 Feb 2024 14:35:19 +0100 Subject: [PATCH 22/32] Admin Generator (Future): Support `helperText` `Field` prop in Forms (#1689) Support `helperText` `Field` prop in Forms
Screenshot of Demo:

Screenshot 2024-02-15 at 09 06 28

- [ ] Add changeset (if necessary) --------- Co-authored-by: Niko Sams --- .../products/future/ProductForm.cometGen.ts | 2 +- .../products/future/generated/ProductForm.tsx | 1 + .../src/generator/future/generateFormField.ts | 34 +++++++++++++++++-- .../src/generator/future/generator.ts | 2 +- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/demo/admin/src/products/future/ProductForm.cometGen.ts b/demo/admin/src/products/future/ProductForm.cometGen.ts index 5bde89adb8..9cce26495a 100644 --- a/demo/admin/src/products/future/ProductForm.cometGen.ts +++ b/demo/admin/src/products/future/ProductForm.cometGen.ts @@ -17,7 +17,7 @@ export const ProductForm: FormConfig = { { type: "text", name: "description", label: "Description", multiline: true }, { type: "staticSelect", name: "type", label: "Type" /*, values: from gql schema (TODO overridable)*/ }, //TODO { type: "asyncSelect", name: "category", label: "Category" /*, endpoint: from gql schema (overridable)*/ }, - { type: "number", name: "price" }, + { type: "number", name: "price", helperText: "Enter price in this format: 123,45" }, { type: "boolean", name: "inStock" }, { type: "date", name: "availableSince" }, { type: "block", name: "image", label: "Image", block: { name: "PixelImageBlock", import: "@comet/cms-admin" } }, diff --git a/demo/admin/src/products/future/generated/ProductForm.tsx b/demo/admin/src/products/future/generated/ProductForm.tsx index 1ddb275333..9c8bb90ea4 100644 --- a/demo/admin/src/products/future/generated/ProductForm.tsx +++ b/demo/admin/src/products/future/generated/ProductForm.tsx @@ -207,6 +207,7 @@ export function ProductForm({ id }: FormProps): React.ReactElement { component={FinalFormInput} type="number" label={} + helperText={} /> {(props) => ( diff --git a/packages/admin/cms-admin/src/generator/future/generateFormField.ts b/packages/admin/cms-admin/src/generator/future/generateFormField.ts index 6661a64f8f..8ec4336755 100644 --- a/packages/admin/cms-admin/src/generator/future/generateFormField.ts +++ b/packages/admin/cms-admin/src/generator/future/generateFormField.ts @@ -58,6 +58,11 @@ export function generateFormField( name="${name}" component={FinalFormInput} label={} + ${ + config.helperText + ? `helperText={}` + : "" + } ${validateCode} />`; } else if (config.type == "number") { @@ -69,6 +74,13 @@ export function generateFormField( component={FinalFormInput} type="number" label={} + ${ + config.helperText + ? `helperText={}` + : "" + } ${validateCode} />`; //TODO MUI suggest not using type=number https://mui.com/material-ui/react-text-field/#type-quot-number-quot @@ -78,6 +90,13 @@ export function generateFormField( } control={} + ${ + config.helperText + ? `helperText={}` + : "" + } /> )} `; @@ -89,6 +108,13 @@ export function generateFormField( name="${name}" component={FinalFormDatePicker} label={} + ${ + config.helperText + ? `helperText={}` + : "" + } ${validateCode} />`; } else if (config.type == "block") { @@ -111,9 +137,13 @@ export function generateFormField( code = `} + label={}> + ${ + config.helperText + ? `helperText={}` + : "" + } ${validateCode} - > {(props) => ${values diff --git a/packages/admin/cms-admin/src/generator/future/generator.ts b/packages/admin/cms-admin/src/generator/future/generator.ts index cbeeaa7ace..21bc921bd5 100644 --- a/packages/admin/cms-admin/src/generator/future/generator.ts +++ b/packages/admin/cms-admin/src/generator/future/generator.ts @@ -22,7 +22,7 @@ export type FormFieldConfig = ( | { type: "staticSelect"; values?: string[] } | { type: "asyncSelect"; values?: string[] } | { type: "block"; block: ImportReference } -) & { name: keyof T; label?: string; required?: boolean; validate?: ImportReference }; +) & { name: keyof T; label?: string; required?: boolean; validate?: ImportReference; helperText?: string }; export type FormConfig = { type: "form"; From 2566a5e99f2833c78bd808fc824a4e4935d33ab7 Mon Sep 17 00:00:00 2001 From: juliawegmayr <109900447+juliawegmayr@users.noreply.github.com> Date: Tue, 20 Feb 2024 09:28:58 +0100 Subject: [PATCH 23/32] Use new field components (#1678) Use new field components to simplify forms. New available field components: - TextField - TextAreaField - SearchField - SelectField - CheckboxField - SwitchField - ColorField - DateField - DateRangeField - TimeField - TimeRangeField - DateTimeField --------- Co-authored-by: Julia Wegmayr --- demo/admin/src/common/ComponentDemo.tsx | 179 +++++++----------- demo/admin/src/common/EditPageNode.tsx | 20 +- .../admin/src/common/blocks/HeadlineBlock.tsx | 24 +-- demo/admin/src/news/blocks/NewsLinkBlock.tsx | 4 +- .../src/predefinedPage/EditPredefinedPage.tsx | 21 +- demo/admin/src/products/ProductForm.tsx | 52 ++--- .../categories/ProductCategoryForm.tsx | 18 +- .../src/products/tags/ProductTagForm.tsx | 10 +- .../userGroups/UserGroupContextMenuItem.tsx | 20 +- 9 files changed, 123 insertions(+), 225 deletions(-) diff --git a/demo/admin/src/common/ComponentDemo.tsx b/demo/admin/src/common/ComponentDemo.tsx index a73aa8b369..cb5c6cc75c 100644 --- a/demo/admin/src/common/ComponentDemo.tsx +++ b/demo/admin/src/common/ComponentDemo.tsx @@ -1,14 +1,15 @@ import { + CheckboxField, Field, FieldContainer, FinalForm, - FinalFormCheckbox, - FinalFormInput, FinalFormRadio, - FinalFormSelect, - FinalFormSwitch, MainContent, + SelectField, Stack, + SwitchField, + TextAreaField, + TextField, } from "@comet/admin"; import { Add, FocusPointCenter, FocusPointNortheast, FocusPointNorthwest, FocusPointSoutheast, FocusPointSouthwest, Snips } from "@comet/admin-icons"; import { @@ -128,51 +129,35 @@ export function ComponentDemo(): React.ReactElement { }} initialValues={{ richText: RichTextBlock.defaultValues() }} > - - - - {(props) => ( - - Option 1 - Option 2 - Option 3 - - )} - - - - - - {(props) => ( - - Option 1 - Option 2 - Option 3 - - )} - - - - {(props) => ( - - Option 1 - Option 2 - Option 3 - - )} - - - - {(props) => ( - - Option 1 - Option 2 - Option 3 - - )} - - - + + + Option 1 + Option 2 + Option 3 + + + + + + Option 1 + Option 2 + Option 3 + + + + Option 1 + Option 2 + Option 3 + + + + Option 1 + Option 2 + Option 3 + + + @@ -183,32 +168,24 @@ export function ComponentDemo(): React.ReactElement { } > - {(props) => ( - - 1 - 2 - 3 - - )} - - - - {(props) => ( - - - } primary="Option 1" secondary="Secondary text" /> - - - } primary="Option 2" secondary="Secondary text" /> - - - } primary="Option 3" secondary="Secondary text" /> - - - )} - - - + 1 + 2 + 3 + + + + + } primary="Option 1" secondary="Secondary text" /> + + + } primary="Option 2" secondary="Secondary text" /> + + + } primary="Option 3" secondary="Secondary text" /> + + + + @@ -224,24 +201,14 @@ export function ComponentDemo(): React.ReactElement { - - {(props) => } />} - - - {(props) => } />} - - - {(props) => } />} - + + + - - {(props) => } />} - + - - {(props) => } />} - + - - {(props) => ( - - 2:3 - 4:3 - 16:9 - - )} - - - {(props) => ( - - 0% - 10% - 20% - - )} - - - {(props) => } />} - + + 2:3 + 4:3 + 16:9 + + + 0% + 10% + 20% + + diff --git a/demo/admin/src/common/EditPageNode.tsx b/demo/admin/src/common/EditPageNode.tsx index cec67370ec..0c7dc1b4b4 100644 --- a/demo/admin/src/common/EditPageNode.tsx +++ b/demo/admin/src/common/EditPageNode.tsx @@ -1,5 +1,5 @@ import { gql } from "@apollo/client"; -import { Field, FinalFormSelect } from "@comet/admin"; +import { SelectField } from "@comet/admin"; import { createEditPageNode } from "@comet/cms-admin"; import { Box, Divider, MenuItem } from "@mui/material"; import * as React from "react"; @@ -43,22 +43,18 @@ export const EditPageNode = createEditPageNode({ - } name="userGroup" variant="horizontal" fullWidth > - {(props) => ( - - {userGroupOptions.map((option) => ( - - {option.label} - - ))} - - )} - + {userGroupOptions.map((option) => ( + + {option.label} + + ))} + ), }); diff --git a/demo/admin/src/common/blocks/HeadlineBlock.tsx b/demo/admin/src/common/blocks/HeadlineBlock.tsx index df86eabf51..c5a61d0f5f 100644 --- a/demo/admin/src/common/blocks/HeadlineBlock.tsx +++ b/demo/admin/src/common/blocks/HeadlineBlock.tsx @@ -1,4 +1,4 @@ -import { Field, FinalFormInput, FinalFormSelect } from "@comet/admin"; +import { SelectField, TextField } from "@comet/admin"; import { BlockCategory, BlocksFinalForm, createCompositeBlock, createCompositeSetting } from "@comet/blocks-admin"; import { createRichTextBlock } from "@comet/cms-admin"; import { MenuItem } from "@mui/material"; @@ -29,7 +29,7 @@ export const HeadlineBlock = createCompositeBlock( onSubmit={({ eyebrow }) => updateState(eyebrow)} initialValues={{ eyebrow: state }} > - + ), }), @@ -46,18 +46,14 @@ export const HeadlineBlock = createCompositeBlock( onSubmit={({ level }) => updateState(level)} initialValues={{ level: state }} > - - {(props) => ( - - Header One - Header Two - Header Three - Header Four - Header Five - Header Six - - )} - + + Header One + Header Two + Header Three + Header Four + Header Five + Header Six + ), }), diff --git a/demo/admin/src/news/blocks/NewsLinkBlock.tsx b/demo/admin/src/news/blocks/NewsLinkBlock.tsx index 5ab7c2389f..84a79e1d34 100644 --- a/demo/admin/src/news/blocks/NewsLinkBlock.tsx +++ b/demo/admin/src/news/blocks/NewsLinkBlock.tsx @@ -1,4 +1,4 @@ -import { Field, FinalFormInput } from "@comet/admin"; +import { TextField } from "@comet/admin"; import { BlockInterface, BlocksFinalForm, createBlockSkeleton, LinkBlockInterface } from "@comet/blocks-admin"; import { NewsLinkBlockData, NewsLinkBlockInput } from "@src/blocks.generated"; import * as React from "react"; @@ -17,7 +17,7 @@ const NewsLinkBlock: BlockInterface { return ( - + ); }, diff --git a/demo/admin/src/predefinedPage/EditPredefinedPage.tsx b/demo/admin/src/predefinedPage/EditPredefinedPage.tsx index ac2040a16f..479dcd5175 100644 --- a/demo/admin/src/predefinedPage/EditPredefinedPage.tsx +++ b/demo/admin/src/predefinedPage/EditPredefinedPage.tsx @@ -1,12 +1,11 @@ import { gql, useMutation, useQuery } from "@apollo/client"; import { - Field, FinalForm, - FinalFormSelect, Loading, MainContent, messages, SaveButton, + SelectField, SplitButton, Toolbar, ToolbarFillSpace, @@ -118,17 +117,13 @@ export const EditPredefinedPage: React.FC = ({ id }) => { - } name="type" fullWidth> - {(props) => ( - - {predefinedPageOptions.map((item, index) => ( - - {item.name} - - ))} - - )} - + } name="type" fullWidth> + {predefinedPageOptions.map((item, index) => ( + + {item.name} + + ))} + ); diff --git a/demo/admin/src/products/ProductForm.tsx b/demo/admin/src/products/ProductForm.tsx index 2ed3024a46..5a163eea89 100644 --- a/demo/admin/src/products/ProductForm.tsx +++ b/demo/admin/src/products/ProductForm.tsx @@ -1,20 +1,22 @@ import { useApolloClient, useQuery } from "@apollo/client"; import { + CheckboxField, Field, FinalForm, - FinalFormCheckbox, - FinalFormInput, FinalFormSelect, FinalFormSubmitEvent, Loading, MainContent, + SelectField, + TextAreaField, + TextField, useAsyncOptionsProps, useFormApiRef, useStackSwitchApi, } from "@comet/admin"; import { BlockState, createFinalFormBlock } from "@comet/blocks-admin"; import { DamImageBlock, EditPageLayout, queryUpdatedAt, resolveHasSaveConflict, useFormSaveConflict } from "@comet/cms-admin"; -import { FormControlLabel, MenuItem } from "@mui/material"; +import { MenuItem } from "@mui/material"; import { GQLProductType } from "@src/graphql.generated"; import { FormApi } from "final-form"; import { filter } from "graphql-anywhere"; @@ -157,38 +159,19 @@ function ProductForm({ id }: FormProps): React.ReactElement { {saveConflict.dialogs} - } - /> - } /> + } /> + } - /> - } /> - - {(props) => ( - - Cap - Shirt - Tie - - )} - + + Cap + Shirt + Tie + option.title} /> - - {(props) => ( - } - control={} - /> - )} - + } fullWidth /> {createFinalFormBlock(rootBlocks.image)} diff --git a/demo/admin/src/products/categories/ProductCategoryForm.tsx b/demo/admin/src/products/categories/ProductCategoryForm.tsx index 4705b5d3f7..9e74209527 100644 --- a/demo/admin/src/products/categories/ProductCategoryForm.tsx +++ b/demo/admin/src/products/categories/ProductCategoryForm.tsx @@ -2,10 +2,10 @@ import { useApolloClient, useQuery } from "@apollo/client"; import { Field, FinalForm, - FinalFormInput, FinalFormSaveSplitButton, FinalFormSubmitEvent, MainContent, + TextField, Toolbar, ToolbarActions, ToolbarFillSpace, @@ -152,20 +152,8 @@ function ProductCategoryForm({ id }: FormProps): React.ReactElement { - } - /> - } - /> + } /> + } /> )} diff --git a/demo/admin/src/products/tags/ProductTagForm.tsx b/demo/admin/src/products/tags/ProductTagForm.tsx index b79512620e..f246049be9 100644 --- a/demo/admin/src/products/tags/ProductTagForm.tsx +++ b/demo/admin/src/products/tags/ProductTagForm.tsx @@ -2,10 +2,10 @@ import { useApolloClient, useQuery } from "@apollo/client"; import { Field, FinalForm, - FinalFormInput, FinalFormSaveSplitButton, FinalFormSubmitEvent, MainContent, + TextField, Toolbar, ToolbarActions, ToolbarFillSpace, @@ -149,13 +149,7 @@ function ProductTagForm({ id }: FormProps): React.ReactElement { - } - /> + } /> )} diff --git a/demo/admin/src/userGroups/UserGroupContextMenuItem.tsx b/demo/admin/src/userGroups/UserGroupContextMenuItem.tsx index d2be7f8cea..600ba891ff 100644 --- a/demo/admin/src/userGroups/UserGroupContextMenuItem.tsx +++ b/demo/admin/src/userGroups/UserGroupContextMenuItem.tsx @@ -1,4 +1,4 @@ -import { Field, FinalFormSelect, messages } from "@comet/admin"; +import { messages, SelectField } from "@comet/admin"; import { Account } from "@comet/admin-icons"; import { Button, Dialog, DialogActions, DialogContent, DialogTitle, ListItemIcon, MenuItem } from "@mui/material"; import { GQLUserGroup } from "@src/graphql.generated"; @@ -62,17 +62,13 @@ function UserGroupContextMenuItem({ item, onChange, onMenuClose }: Props): JSX.E {({ handleSubmit }) => (
- - {(props) => ( - - {userGroupOptions.map((option) => ( - - {option.label} - - ))} - - )} - + + {userGroupOptions.map((option) => ( + + {option.label} + + ))} +