Explorar o código

Merge branch 'master' into imprv/test-code-delte-non-public

yohei0125 %!s(int64=4) %!d(string=hai) anos
pai
achega
3742f2955b

+ 1 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -474,6 +474,7 @@
     "group_example": "e.g. : Group1",
     "parent_group": "Parent Group",
     "select_parent_group": "Select Parent Group",
+    "release_parent_group": "Release parent group",
     "add_modal": {
       "add_user": "Add a user to the created group",
       "search_option": "Search option",

+ 7 - 5
packages/app/resource/locales/en_US/translation.json

@@ -667,11 +667,12 @@
     "page_listing_1_desc": "Show pages that are restricted by 'Only me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User group",
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
-    "page_access_and_delete_rights": "Page access / Delete rights",
-    "deletion": "Restrict trashing of a selected single page",
-    "deletion_explain": "Restricts users who can trash a selected single page.",
-    "complete_deletion": "Restrict complete deletion of a selected single page",
-    "complete_deletion_explain": "Restricts users who can completely delete a selected single page.",
+    "page_access_rights": "Page access",
+    "page_delete_rights": "Delete rights",
+    "deletion": "Restrict trashing of the selected single page",
+    "deletion_explain": "Restricts users who can trash the selected single page.",
+    "complete_deletion": "Restrict complete deletion of the selected single page",
+    "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
     "recursive_deletion": "Restrict trashing of pages including descendants",
     "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
     "recursive_complete_deletion": "Restrict complete deletion of pages including descendants",
@@ -684,6 +685,7 @@
     "max_age": "Max age (msec)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_caution": "Restarting the server is required after you modify this value.",
+    "page_delete_rights_caution": "The \"operation including the descendants\" setting is forced to be stronger than the \"operation for only the selected page\" setting.",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",

+ 1 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -473,6 +473,7 @@
     "group_example": "例: Group1",
     "parent_group": "親グループ",
     "select_parent_group": "親グループを選択",
+    "release_parent_group": "親グループの解除",
     "add_modal": {
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",

+ 3 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -666,7 +666,8 @@
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
-    "page_access_and_delete_rights": "ページの閲覧・削除権限",
+    "page_access_rights": "ページの閲覧権限",
+    "page_delete_rights": "ページの削除権限",
     "deletion": "ページをゴミ箱に入れる(単体のみの操作)",
     "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
     "complete_deletion": "ページを完全削除する(単体のみの操作)",
@@ -683,6 +684,7 @@
     "max_age": "有効期間 (ミリ秒)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
+    "page_delete_rights_caution": "「子孫を含む操作」の設定値は、「単体のみの操作」の設定値よりも強いものに強制されます。",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",

+ 1 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -483,6 +483,7 @@
     "group_example": "e.g.:第1组",
     "parent_group": "父母组",
     "select_parent_group": "选择父组",
+    "release_parent_group": "Release parent group",
     "add_modal": {
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",

+ 3 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -625,7 +625,8 @@
 		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
 		"page_listing_2": "页面列表/搜索<br>受用户组限制",
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
-    "page_access_and_delete_rights": "页面访问/删除权限",
+    "page_access_rights": "页面访问",
+    "page_delete_rights": "删除权限",
     "deletion": "限制捣毁一个选定的单一页面",
     "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
     "complete_deletion": "限制完全删除一个选定的单页",
@@ -642,6 +643,7 @@
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
+    "page_delete_rights_caution": "\"包括后代的操作\" 的设置被迫强于 \"只对选定的页面进行操作\" 的设置。",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",

+ 1 - 1
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -27,8 +27,8 @@ export default class AdminGeneralSecurityContainer extends Container {
       // set dummy value tile for using suspense
       currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
       currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
-      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
+      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,

+ 125 - 22
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -3,6 +3,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import { validateDeleteConfigs } from '~/utils/page-delete-config';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
@@ -10,19 +11,59 @@ import AppContainer from '~/client/services/AppContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
 // used as the prefix of translation
-const DeletionType = Object.freeze({
+const DeletionTypeForT = Object.freeze({
   Deletion: 'deletion',
   CompleteDeletion: 'complete_deletion',
   RecursiveDeletion: 'recursive_deletion',
   RecursiveCompleteDeletion: 'recursive_complete_deletion',
 });
 
+const DeletionType = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'completeDeletion',
+  RecursiveDeletion: 'recursiveDeletion',
+  RecursiveCompleteDeletion: 'recursiveCompleteDeletion',
+});
+
+const getDeletionTypeForT = (deletionType) => {
+  switch (deletionType) {
+    case DeletionType.Deletion:
+      return DeletionTypeForT.Deletion;
+    case DeletionType.RecursiveDeletion:
+      return DeletionTypeForT.RecursiveDeletion;
+    case DeletionType.CompleteDeletion:
+      return DeletionTypeForT.CompleteDeletion;
+    case DeletionType.RecursiveCompleteDeletion:
+      return DeletionTypeForT.RecursiveCompleteDeletion;
+  }
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.RecursiveDeletion or DeletionType.RecursiveCompleteDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isRecursiveDeletion = (deletionType) => {
+  return deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.Deletion or DeletionType.RecursiveDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+const isTypeDeletion = (deletionType) => {
+  return deletionType === DeletionType.Deletion || deletionType === DeletionType.RecursiveDeletion;
+};
+
 class SecuritySetting extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
+    this.getRecursiveDeletionConfigState = this.getRecursiveDeletionConfigState.bind(this);
+    this.setDeletionConfigState = this.setDeletionConfigState.bind(this);
     this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
   }
 
@@ -37,12 +78,53 @@ class SecuritySetting extends React.Component {
     }
   }
 
-  renderPageDeletePermissionDropdown(currentState, setState, deletionType, t) {
-    const isRecursiveDeletion = deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+  getRecursiveDeletionConfigState(deletionType) {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    if (isTypeDeletion(deletionType)) {
+      return [
+        adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
+        adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+      ];
+    }
+
+    return [
+      adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
+      adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
+    ];
+  }
+
+  /**
+   * Force update deletion config for recursive operation when the deletion config for general operation is updated.
+   * @param deletionType Deletion type
+   */
+  setDeletionConfigState(newState, setState, deletionType) {
+    if (isRecursiveDeletion(deletionType)) {
+      setState(newState);
+
+      return;
+    }
+
+    const [recursiveState, setRecursiveState] = this.getRecursiveDeletionConfigState(deletionType);
+    const shouldForceUpdate = !validateDeleteConfigs(newState, recursiveState);
+    if (shouldForceUpdate) {
+      setState(newState);
+      setRecursiveState(newState);
+    }
+    else {
+      setState(newState);
+    }
+
+    return;
+  }
+
+  renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled) {
+    const { t } = this.props;
+
     return (
-      <div className="row mb-4">
+      <div key={`page-delete-permission-dropdown-${deletionType}`} className="row mb-4">
         <div className="col-md-3 text-md-right mb-2">
-          <strong>{t(`security_setting.${deletionType}`)}</strong>
+          <strong>{t(`security_setting.${getDeletionTypeForT(deletionType)}`)}</strong>
         </div>
         <div className="col-md-6">
           <div className="dropdown">
@@ -56,20 +138,19 @@ class SecuritySetting extends React.Component {
             >
               <span className="float-left">
                 {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
-                {(currentState === PageDeleteConfigValue.Anyone || currentState == null)
-                    && t('security_setting.anyone')}
+                {(currentState === PageDeleteConfigValue.Anyone || currentState == null) && t('security_setting.anyone')}
                 {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
                 {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
               </span>
             </button>
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               {
-                isRecursiveDeletion
+                isRecursiveDeletion(deletionType)
                   ? (
                     <button
                       className="dropdown-item"
                       type="button"
-                      onClick={() => { setState(PageDeleteConfigValue.Inherit) }}
+                      onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
                     >
                       {t('security_setting.inherit')}
                     </button>
@@ -78,29 +159,29 @@ class SecuritySetting extends React.Component {
                     <button
                       className="dropdown-item"
                       type="button"
-                      onClick={() => { setState(PageDeleteConfigValue.Anyone) }}
+                      onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
                     >
                       {t('security_setting.anyone')}
                     </button>
                   )
               }
               <button
-                className="dropdown-item"
+                className={`dropdown-item ${isButtonDisabled ? 'disabled' : ''}`}
                 type="button"
-                onClick={() => { setState(PageDeleteConfigValue.AdminOnly) }}
+                onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminAndAuthor, setState, deletionType) }}
               >
-                {t('security_setting.admin_only')}
+                {t('security_setting.admin_and_author')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { setState(PageDeleteConfigValue.AdminAndAuthor) }}
+                onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminOnly, setState, deletionType) }}
               >
-                {t('security_setting.admin_and_author')}
+                {t('security_setting.admin_only')}
               </button>
             </div>
             <p className="form-text text-muted small">
-              {t(`security_setting.${deletionType}_explain`)}
+              {t(`security_setting.${getDeletionTypeForT(deletionType)}_explain`)}
             </p>
           </div>
         </div>
@@ -115,6 +196,14 @@ class SecuritySetting extends React.Component {
       currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority,
     } = adminGeneralSecurityContainer.state;
 
+    const isButtonDisabledForDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
+
+    const isButtonDisabledForCompleteDeletion = !validateDeleteConfigs(
+      adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
+    );
+
     return (
       <React.Fragment>
         <h2 className="alert-anchor border-bottom">
@@ -181,7 +270,7 @@ class SecuritySetting extends React.Component {
           </tbody>
         </table>
 
-        <h4>{t('security_setting.page_access_and_delete_rights')}</h4>
+        <h4>{t('security_setting.page_access_rights')}</h4>
         <div className="row mb-4">
           <div className="col-md-3 text-md-right py-2">
             <strong>{t('security_setting.Guest Users Access')}</strong>
@@ -226,15 +315,29 @@ class SecuritySetting extends React.Component {
           </div>
         </div>
 
+        <h4>{t('security_setting.page_delete_rights')}</h4>
+        <div className="row">
+          <p className="card well col-9">
+            <span className="text-warning">
+              <i className="icon-info"></i> {t('security_setting.page_delete_rights_caution')}
+            </span>
+          </p>
+        </div>
+        <div className="row mb-4"></div>
         {/* Render PageDeletePermissionDropdown */}
         {
           [
-            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion],
-            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion],
-            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion],
+            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
+            // eslint-disable-next-line max-len
+            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion, isButtonDisabledForDeletion],
+          ].map(arr => this.renderPageDeletePermissionDropdown(arr[0], arr[1], arr[2], arr[3]))
+        }
+        {
+          [
+            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion, false],
             // eslint-disable-next-line max-len
-            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion],
-          ].map(arr => this.renderPageDeletePermissionDropdown(arr[0], arr[1], arr[2], t))
+            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion, isButtonDisabledForCompleteDeletion],
+          ].map(arr => this.renderPageDeletePermissionDropdown(arr[0], arr[1], arr[2], arr[3]))
         }
 
         <h4>{t('security_setting.session')}</h4>

+ 9 - 0
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -132,6 +132,15 @@ const UserGroupForm: FC<Props> = (props: Props) => {
                   </>
                 )
               }
+
+              <div className="dropdown-divider" />
+
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setSelectedParent(undefined) }}
+              >{t('admin:user_group_management.release_parent_group')}
+              </button>
             </div>
           </div>
         </div>

+ 1 - 1
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -142,7 +142,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Duplicate */}
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem onClick={duplicateItemClickedHandler}>
+          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
             <i className="icon-fw icon-docs"></i>
             {t('Duplicate')}
           </DropdownItem>

+ 1 - 1
packages/app/src/components/PageDuplicateModal.tsx

@@ -163,7 +163,7 @@ const PageDuplicateModal = (): JSX.Element => {
     || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath);
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} className="grw-duplicate-page" autoFocus={false}>
+    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} data-testid="page-duplicate-modal" className="grw-duplicate-page" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeDuplicateModal} className="bg-primary text-light">
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>

+ 6 - 6
packages/app/src/interfaces/page-delete-config.ts

@@ -4,34 +4,34 @@ export const PageDeleteConfigValue = {
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageDeleteConfigValue = typeof PageDeleteConfigValue[keyof typeof PageDeleteConfigValue];
+export type IPageDeleteConfigValue = typeof PageDeleteConfigValue[keyof typeof PageDeleteConfigValue];
 
-export type PageDeleteConfigValueToProcessValidation = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type IPageDeleteConfigValueToProcessValidation = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
 
 export const PageSingleDeleteConfigValue = {
   Anyone: 'anyOne', // must be "anyOne" (not "anyone") for backward compatibility
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
 } as const;
-export type PageSingleDeleteConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type PageSingleDeleteConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
 
 export const PageSingleDeleteCompConfigValue = {
   Anyone: 'anyOne', // must be "anyOne" (not "anyone") for backward compatibility
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
 } as const;
-export type PageSingleDeleteCompConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
+export type PageSingleDeleteCompConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Inherit>;
 
 export const PageRecursiveDeleteConfigValue = {
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageRecursiveDeleteConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
+export type PageRecursiveDeleteConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
 
 export const PageRecursiveDeleteCompConfigValue = {
   AdminAndAuthor: 'adminAndAuthor',
   AdminOnly: 'adminOnly',
   Inherit: 'inherit',
 } as const;
-export type PageRecursiveDeleteCompConfigValue = Exclude<PageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;
+export type PageRecursiveDeleteCompConfigValue = Exclude<IPageDeleteConfigValue, typeof PageDeleteConfigValue.Anyone>;

+ 13 - 1
packages/app/src/server/routes/apiv3/security-setting.js

@@ -3,6 +3,7 @@ import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 
@@ -589,12 +590,23 @@ module.exports = (crowi) => {
       'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
       'security:restrictGuestMode': req.body.restrictGuestMode,
       'security:pageDeletionAuthority': req.body.pageDeletionAuthority,
-      'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
+      'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
     };
+
+    // Validate delete config
+    const [singleAuthority1, recursiveAuthority1] = prepareDeleteConfigValuesForCalc(req.body.pageDeletionAuthority, req.body.pageRecursiveDeletionAuthority);
+    // eslint-disable-next-line max-len
+    const [singleAuthority2, recursiveAuthority2] = prepareDeleteConfigValuesForCalc(req.body.pageCompleteDeletionAuthority, req.body.pageRecursiveCompleteDeletionAuthority);
+    const isDeleteConfigNormalized = validateDeleteConfigs(singleAuthority1, recursiveAuthority1)
+      && validateDeleteConfigs(singleAuthority2, recursiveAuthority2);
+    if (!isDeleteConfigNormalized) {
+      return res.apiv3Err(new ErrorV3('Delete config values are not correct.', 'delete_config_not_normalized'));
+    }
+
     const wikiMode = await crowi.configManager.getConfig('crowi', 'security:wikiMode');
     if (wikiMode === 'private' || wikiMode === 'public') {
       logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');

+ 9 - 16
packages/app/src/server/service/page.ts

@@ -23,10 +23,11 @@ import { Ref } from '~/interfaces/common';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
-  PageDeleteConfigValue, PageDeleteConfigValueToProcessValidation,
+  PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
 import ActivityDefine from '../util/activityDefine';
+import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 const debug = require('debug')('growi:services:page');
 
@@ -215,34 +216,26 @@ class PageService {
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
-    const recursiveAuthority = this.calcRecursiveDeleteConfigValue(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
+    const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, pageCompleteDeletionAuthority, recursiveAuthority);
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
   canDelete(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
 
-    const recursiveAuthority = this.calcRecursiveDeleteConfigValue(pageDeletionAuthority, pageRecursiveDeletionAuthority);
+    const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, pageDeletionAuthority, recursiveAuthority);
-  }
-
-  private calcRecursiveDeleteConfigValue(confForSingle, confForRecursive) {
-    if (confForRecursive === PageDeleteConfigValue.Inherit) {
-      return confForSingle;
-    }
-
-    return confForRecursive;
+    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
   private canDeleteLogic(
       creatorId: ObjectIdLike,
       operator,
       isRecursively: boolean,
-      authority: PageDeleteConfigValueToProcessValidation | null,
-      recursiveAuthority: PageDeleteConfigValueToProcessValidation | null,
+      authority: IPageDeleteConfigValueToProcessValidation | null,
+      recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
   ): boolean {
     const isAdmin = operator.admin;
     const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
@@ -254,7 +247,7 @@ class PageService {
     return this.compareDeleteConfig(isAdmin, isOperator, authority);
   }
 
-  private compareDeleteConfig(isAdmin: boolean, isOperator: boolean, authority: PageDeleteConfigValueToProcessValidation | null): boolean {
+  private compareDeleteConfig(isAdmin: boolean, isOperator: boolean, authority: IPageDeleteConfigValueToProcessValidation | null): boolean {
     if (isAdmin) {
       return true;
     }

+ 62 - 0
packages/app/src/utils/page-delete-config.ts

@@ -0,0 +1,62 @@
+import {
+  PageDeleteConfigValue as Value, IPageDeleteConfigValueToProcessValidation,
+  IPageDeleteConfigValue,
+} from '~/interfaces/page-delete-config';
+
+/**
+ * Return true if "configForRecursive" is stronger than "configForSingle"
+ * Strength: "Admin" > "Admin and author" > "Anyone"
+ * @param configForSingle IPageDeleteConfigValueToProcessValidation
+ * @param configForRecursive IPageDeleteConfigValueToProcessValidation
+ * @returns boolean
+ */
+export const validateDeleteConfigs = (
+    configForSingle: IPageDeleteConfigValueToProcessValidation, configForRecursive: IPageDeleteConfigValueToProcessValidation,
+): boolean => {
+  if (configForSingle === Value.Anyone) {
+    switch (configForRecursive) {
+      case Value.Anyone:
+      case Value.AdminAndAuthor:
+      case Value.AdminOnly:
+        return true;
+    }
+  }
+
+  if (configForSingle === Value.AdminAndAuthor) {
+    switch (configForRecursive) {
+      case Value.Anyone:
+        return false;
+      case Value.AdminAndAuthor:
+      case Value.AdminOnly:
+        return true;
+    }
+  }
+
+  if (configForSingle === Value.AdminOnly) {
+    switch (configForRecursive) {
+      case Value.Anyone:
+      case Value.AdminAndAuthor:
+        return false;
+      case Value.AdminOnly:
+        return true;
+    }
+  }
+
+  return false;
+};
+
+/**
+ * Convert IPageDeleteConfigValue.Inherit to the calculable value
+ * @param confForSingle IPageDeleteConfigValueToProcessValidation
+ * @param confForRecursive IPageDeleteConfigValue
+ * @returns [(value for single), (value for recursive)]
+ */
+export const prepareDeleteConfigValuesForCalc = (
+    confForSingle: IPageDeleteConfigValueToProcessValidation, confForRecursive: IPageDeleteConfigValue,
+): [IPageDeleteConfigValueToProcessValidation, IPageDeleteConfigValueToProcessValidation] => {
+  if (confForRecursive === Value.Inherit) {
+    return [confForSingle, confForSingle];
+  }
+
+  return [confForSingle, confForRecursive];
+};

+ 1 - 2
packages/app/test/cypress/integration/2-basic-features/open-page-create-modal.spec.ts

@@ -26,8 +26,7 @@ context('Open PageCreateModal', () => {
   it("PageCreateModal is shown successfully", () => {
     cy.getByTestid('newPageBtn').click();
 
-    cy.getByTestid('page-create-modal').should('be.visible');
-    cy.screenshot(`${ssPrefix}-open`,{ capture: 'viewport' });
+    cy.getByTestid('page-create-modal').should('be.visible').screenshot(`${ssPrefix}-open`);
 
     cy.getByTestid('row-create-page-under-below').find('input.form-control').clear().type('/new-page');
     cy.getByTestid('btn-create-page-under-below').click();

+ 1 - 2
packages/app/test/cypress/integration/2-basic-features/open-page-delete-modal.spec.ts

@@ -30,8 +30,7 @@ context('Open Page Delete Modal', () => {
        cy.getByTestid('open-page-delete-modal-btn').click();
     });
 
-     cy.getByTestid('page-delete-modal').should('be.visible');
-     cy.screenshot(`${ssPrefix}-open-bootstrap4`,{ capture: 'viewport' });
+     cy.getByTestid('page-delete-modal').should('be.visible').screenshot(`${ssPrefix}-open-bootstrap4`);
   });
 
 });

+ 35 - 0
packages/app/test/cypress/integration/2-basic-features/open-page-duplicate-modal.spec.ts

@@ -0,0 +1,35 @@
+context('Open Page Duplicate Modal', () => {
+
+  const ssPrefix = 'access-to-page-duplicate-modal-';
+
+  let connectSid: string | undefined;
+
+  before(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    cy.getCookie('connect.sid').then(cookie => {
+      connectSid = cookie?.value;
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  beforeEach(() => {
+    if (connectSid != null) {
+      cy.setCookie('connect.sid', connectSid);
+      cy.visit('/');
+    }
+  });
+
+  it('PageDuplicateModal is shown successfully', () => {
+     cy.visit('/Sandbox/Bootstrap4', {  });
+     cy.get('#grw-subnav-container').within(() => {
+       cy.getByTestid('open-page-item-control-btn').click();
+       cy.getByTestid('open-page-duplicate-modal-btn').click();
+    });
+     cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}-open-bootstrap4`);
+  });
+
+});

+ 22 - 0
packages/app/test/unit/utils/page-delete-config.test.ts

@@ -0,0 +1,22 @@
+import { PageDeleteConfigValue } from '../../../src/interfaces/page-delete-config';
+import { validateDeleteConfigs } from '../../../src/utils/page-delete-config';
+
+describe('validateDeleteConfigs utility function', () => {
+  test('Should validate delete configs', () => {
+    const Anyone = PageDeleteConfigValue.Anyone;
+    const AdminAndAuthor = PageDeleteConfigValue.AdminAndAuthor;
+    const AdminOnly = PageDeleteConfigValue.AdminOnly;
+
+    expect(validateDeleteConfigs(Anyone, Anyone)).toBe(true);
+    expect(validateDeleteConfigs(Anyone, AdminAndAuthor)).toBe(true);
+    expect(validateDeleteConfigs(Anyone, AdminOnly)).toBe(true);
+
+    expect(validateDeleteConfigs(AdminAndAuthor, Anyone)).toBe(false);
+    expect(validateDeleteConfigs(AdminAndAuthor, AdminAndAuthor)).toBe(true);
+    expect(validateDeleteConfigs(AdminAndAuthor, AdminOnly)).toBe(true);
+
+    expect(validateDeleteConfigs(AdminOnly, Anyone)).toBe(false);
+    expect(validateDeleteConfigs(AdminOnly, AdminAndAuthor)).toBe(false);
+    expect(validateDeleteConfigs(AdminOnly, AdminOnly)).toBe(true);
+  });
+});

+ 3 - 3
yarn.lock

@@ -10169,9 +10169,9 @@ hoopy@^0.1.2:
   resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
 
 hosted-git-info@^2.1.4:
-  version "2.8.8"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
-  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
+  version "2.8.9"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
+  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 hosted-git-info@^4.0.0, hosted-git-info@^4.0.1:
   version "4.0.2"