Просмотр исходного кода

Merge branch 'feat/multiple-group-grant-for-page' into feat/136139-137890-page-duplicate-for-multiple-group-grant

Futa Arai 2 лет назад
Родитель
Сommit
44699683eb

+ 9 - 7
apps/app/public/static/locales/en_US/admin.json

@@ -38,10 +38,12 @@
     "page_delete": "Page Delete",
     "page_delete": "Page Delete",
     "page_delete_completely": "Page Delete Completely",
     "page_delete_completely": "Page Delete Completely",
     "other_options": "Other options",
     "other_options": "Other options",
-    "deletion_explain": "Restricts users who can trash the selected single page.",
-    "complete_deletion_explain": "Restricts users who can completely delete  selected single page.",
-    "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
-    "recursive_complete_deletion_explain": "Restricts users who can completely delete pages including descendants.",
+    "deletion_explanation": "Restricts users who can trash the selected single page.",
+    "complete_deletion_explanation": "Restricts users who can completely delete  selected single page.",
+    "recursive_deletion_explanation": "Restricts users who can trash pages including descendants.",
+    "recursive_complete_deletion_explanation": "Restricts users who can completely delete pages including descendants.",
+    "is_all_group_membership_required_for_page_complete_deletion": "Users other than admin and page author are required to belong to all groups that are granted page access",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "Effective when page access settings is set to \"Only specific groups\".",
     "inherit": "Inherit(Use the same setting as for a single page)",
     "inherit": "Inherit(Use the same setting as for a single page)",
     "admin_only": "Admin only",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
     "admin_and_author": "Admin and author",
@@ -857,12 +859,12 @@
     "return": "Return",
     "return": "Return",
     "clear": "Clear",
     "clear": "Clear",
     "activity_expiration_date": "Audit Log expiration date",
     "activity_expiration_date": "Audit Log expiration date",
-    "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
+    "activity_expiration_date_explanation": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
     "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "available_action_list": "Search / View All Available Actions",
     "available_action_list": "Search / View All Available Actions",
-    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
+    "available_action_list_explanation": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
     "action_list": "Action List",
-    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "disable_mode_explanation": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
     "docs_url": {
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
     }

+ 2 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -328,7 +328,8 @@
     "already_exists": "Page with the path already exists.",
     "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete",
     "user_not_admin": "Only admin user can delete",
-    "single_deletion_empty_pages": "Empty pages cannot be single deleted"
+    "single_deletion_empty_pages": "Empty pages cannot be single deleted",
+    "complete_deletion_not_allowed_for_user": "You are not allowed to delete this page completely"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "Revision list",
     "revision_list": "Revision list",

+ 9 - 7
apps/app/public/static/locales/ja_JP/admin.json

@@ -47,10 +47,12 @@
     "page_delete": "ゴミ箱に入れる",
     "page_delete": "ゴミ箱に入れる",
     "page_delete_completely": "完全に削除する",
     "page_delete_completely": "完全に削除する",
     "other_options": "その他のオプション",
     "other_options": "その他のオプション",
-    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
-    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
-    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
-    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "deletion_explanation": "ページをゴミ箱に入れることができるユーザーを制限します。",
+    "complete_deletion_explanation": "ページを完全削除することができるユーザーを制限します。",
+    "recursive_deletion_explanation": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
+    "recursive_complete_deletion_explanation": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "is_all_group_membership_required_for_page_complete_deletion": "管理者とページ作者以外はページに対する権限を持つ全てのグループに所属している必要がある",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "ページの権限設定が「特定のグループのみ」の場合有効になります。",
     "inherit": "単体のみと同じ",
     "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
     "admin_and_author": "管理者とページ作者が可能",
@@ -867,12 +869,12 @@
     "return": "戻る",
     "return": "戻る",
     "clear": "クリア",
     "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date": "監査ログの有効期限",
-    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "activity_expiration_date_explanation": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explanation": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "disable_mode_explanation": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
     "docs_url": {
     "docs_url": {
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
     }

+ 2 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -361,7 +361,8 @@
     "already_exists": "そのパスを持つページは既に存在しています。",
     "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが削除できます",
     "user_not_admin": "権限のあるユーザーのみが削除できます",
-    "single_deletion_empty_pages": "空ページの単体削除はできません"
+    "single_deletion_empty_pages": "空ページの単体削除はできません",
+    "complete_deletion_not_allowed_for_user": "ページを完全に削除する権限がありません"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "更新履歴",
     "revision_list": "更新履歴",

+ 9 - 7
apps/app/public/static/locales/zh_CN/admin.json

@@ -47,10 +47,12 @@
     "page_delete": "删除",
     "page_delete": "删除",
     "page_delete_completely": "彻底删除",
     "page_delete_completely": "彻底删除",
     "other_options": "其他选项",
     "other_options": "其他选项",
-    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
-    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
-    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
-    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
+    "deletion_explanation": "限制用户对选定的单一页面进行垃圾处理。",
+    "complete_deletion_explanation": "限制可以完全删除所选单页的用户。",
+    "recursive_deletion_explanation": "限制用户可以捣毁包括子孙在内的页面。",
+    "recursive_complete_deletion_explanation": "限制可以完全删除页面的用户,包括子孙。",
+    "is_all_group_membership_required_for_page_complete_deletion": "除管理员和页面作者之外的用户必须属于被授予页面访问权限的所有组",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "如果页面权限设置为\"仅限特定群体\",则会启用此功能。",
     "inherit": "继承(使用与单页相同的设置)。",
     "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
 		"admin_and_author": "管理员|作者",
@@ -866,12 +868,12 @@
     "return": "返回",
     "return": "返回",
     "clear": "清除",
     "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
     "activity_expiration_date": "审计日志的到期日",
-    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "activity_expiration_date_explanation": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
+    "available_action_list_explanation": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "disable_mode_explanation": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
     "docs_url": {
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
     }

+ 2 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -318,7 +318,8 @@
 		"already_exists": "具有该路径的页面已存在",
 		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以删除",
 		"user_not_admin": "仅管理员用户可以删除",
-    "single_deletion_empty_pages": "空的页面不能被单一删除"
+    "single_deletion_empty_pages": "空的页面不能被单一删除",
+    "complete_deletion_not_allowed_for_user": "您无权永久删除该页面"
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "修订清单",
     "revision_list": "修订清单",

+ 10 - 0
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -32,6 +32,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
       currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: true,
       previousPageRecursiveDeletionAuthority: null,
       previousPageRecursiveDeletionAuthority: null,
       previousPageRecursiveCompleteDeletionAuthority: null,
       previousPageRecursiveCompleteDeletionAuthority: null,
       expandOtherOptionsForDeletion: false,
       expandOtherOptionsForDeletion: false,
@@ -73,6 +74,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
       currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
       currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
       currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
       currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: generalSetting.isAllGroupMembershipRequiredForPageCompleteDeletion,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
@@ -154,6 +156,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
     this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
   }
   }
 
 
+  /**
+   * Switch isAllGroupMembershipRequiredForPageCompleteDeletion
+   */
+  switchIsAllGroupMembershipRequiredForPageCompleteDeletion() {
+    this.setState({ isAllGroupMembershipRequiredForPageCompleteDeletion: !this.state.isAllGroupMembershipRequiredForPageCompleteDeletion });
+  }
+
   /**
   /**
    * Change previousPageRecursiveDeletionAuthority
    * Change previousPageRecursiveDeletionAuthority
    */
    */
@@ -225,6 +234,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
       pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
       pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
       pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
       pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
       pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
+      isAllGroupMembershipRequiredForPageCompleteDeletion: this.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,

+ 1 - 1
apps/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -16,7 +16,7 @@ export const AuditLogDisableMode: FC = () => {
               <h1 className="text-center">{t('audit_log_management.audit_log')}</h1>
               <h1 className="text-center">{t('audit_log_management.audit_log')}</h1>
               <h3
               <h3
                 // eslint-disable-next-line react/no-danger
                 // eslint-disable-next-line react/no-danger
-                dangerouslySetInnerHTML={{ __html: t('audit_log_management.disable_mode_explain') }}
+                dangerouslySetInnerHTML={{ __html: t('audit_log_management.disable_mode_explanation') }}
               />
               />
             </div>
             </div>
           </div>
           </div>

+ 2 - 2
apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -21,7 +21,7 @@ export const AuditLogSettings: FC = () => {
     <>
     <>
       <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
       <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
       <p className="form-text text-muted">
       <p className="form-text text-muted">
-        {t('admin:audit_log_management.activity_expiration_date_explain')}
+        {t('admin:audit_log_management.activity_expiration_date_explanation')}
       </p>
       </p>
       <p className="alert alert-warning col-6">
       <p className="alert alert-warning col-6">
         <span className="material-symbols-outlined">error</span>
         <span className="material-symbols-outlined">error</span>
@@ -50,7 +50,7 @@ export const AuditLogSettings: FC = () => {
         </a>
         </a>
       </h4>
       </h4>
       <p className="form-text text-muted">
       <p className="form-text text-muted">
-        {t('admin:audit_log_management.available_action_list_explain')}
+        {t('admin:audit_log_management.available_action_list_explanation')}
       </p>
       </p>
       <p className="mt-1">
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>

+ 24 - 3
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -238,14 +238,14 @@ class SecuritySetting extends React.Component {
           </button>
           </button>
         </div>
         </div>
         <p className="form-text text-muted small">
         <p className="form-text text-muted small">
-          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explain`)}
+          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explanation`)}
         </p>
         </p>
       </div>
       </div>
     );
     );
   }
   }
 
 
   renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
   renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
-    const { t } = this.props;
+    const { t, adminGeneralSecurityContainer } = this.props;
 
 
     const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
     const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
 
 
@@ -265,7 +265,28 @@ class SecuritySetting extends React.Component {
           {
           {
             !isRecursiveDeletion(deletionType)
             !isRecursiveDeletion(deletionType)
               ? (
               ? (
-                <>{this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}</>
+                <>
+                  {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                  {currentState === PageDeleteConfigValue.Anyone && deletionType === DeletionType.CompleteDeletion && (
+                    <>
+                      <input
+                        id="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox"
+                        className="form-check-input"
+                        type="checkbox"
+                        checked={adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion}
+                        onChange={() => { adminGeneralSecurityContainer.switchIsAllGroupMembershipRequiredForPageCompleteDeletion() }}
+                      />
+                      <label className="form-check-label" htmlFor="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox">
+                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion')}
+                      </label>
+                      <p
+                        className="form-text text-muted small mt-2"
+                      >
+                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion_explanation')}
+                      </p>
+                    </>
+                  )}
+                </>
               )
               )
               : (
               : (
                 <>
                 <>

+ 4 - 0
apps/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -30,6 +30,10 @@ const ApiErrorMessage = (props) => {
         return (
         return (
           <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.user_not_admin') }</strong>
           <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.user_not_admin') }</strong>
         );
         );
+      case 'complete_deletion_not_allowed_for_user':
+        return (
+          <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.complete_deletion_not_allowed_for_user') }</strong>
+        );
       case 'outdated':
       case 'outdated':
         return (
         return (
           <>
           <>

+ 1 - 0
apps/app/src/server/models/config.ts

@@ -70,6 +70,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageCompleteDeletionAuthority' : undefined,
   'security:pageCompleteDeletionAuthority' : undefined,
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
+  'security:isAllGroupMembershipRequiredForPageCompleteDeletion' : true,
   'security:disableLinkSharing' : false,
   'security:disableLinkSharing' : false,
   'security:user-homepage-deletion:isEnabled': false,
   'security:user-homepage-deletion:isEnabled': false,
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,

+ 9 - 1
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -8,6 +8,7 @@ import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 
 
+import { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import Crowi from '../../crowi';
 import Crowi from '../../crowi';
@@ -119,6 +120,8 @@ const routerFactory = (crowi: Crowi): Router => {
     const Bookmark = crowi.model('Bookmark');
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const pageService: PageService = crowi.pageService!;
     const pageService: PageService = crowi.pageService!;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 
 
     try {
     try {
       const pages = pageIds != null
       const pages = pageIds != null
@@ -140,16 +143,21 @@ const routerFactory = (crowi: Crowi): Router => {
       const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
       const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
 
 
       const isGuestUser = req.user == null;
       const isGuestUser = req.user == null;
+
+      const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
+
       for (const page of pages) {
       for (const page of pages) {
         // construct isIPageInfoForListing
         // construct isIPageInfoForListing
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
 
+        const canDeleteCompletely = pageService.canDeleteCompletely(page, req.user, false, userRelatedGroups); // use normal delete config
+
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           ? basicPageInfo
           // create IPageInfoForListing
           // create IPageInfoForListing
           : {
           : {
             ...basicPageInfo,
             ...basicPageInfo,
-            isAbleToDeleteCompletely: pageService.canDeleteCompletely(page.path, (page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
+            isAbleToDeleteCompletely: canDeleteCompletely,
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;
           } as IPageInfoForListing;

+ 5 - 0
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -357,6 +357,8 @@ module.exports = (crowi) => {
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        isAllGroupMembershipRequiredForPageCompleteDeletion:
+        await configManager.getConfig('crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
@@ -627,6 +629,7 @@ module.exports = (crowi) => {
       'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
       'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
       'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
+      'security:isAllGroupMembershipRequiredForPageCompleteDeletion': req.body.isAllGroupMembershipRequiredForPageCompleteDeletion,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
       'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
       'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
@@ -660,6 +663,8 @@ module.exports = (crowi) => {
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        isAllGroupMembershipRequiredForPageCompleteDeletion:
+        await configManager.getConfig('crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),

+ 4 - 2
apps/app/src/server/routes/page.js

@@ -753,8 +753,10 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       if (isCompletely) {
       if (isCompletely) {
-        if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
-          return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
+        const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(req.user);
+        const canDeleteCompletely = crowi.pageService.canDeleteCompletely(page, req.user, isRecursively, userRelatedGroups);
+        if (!canDeleteCompletely) {
+          return res.json(ApiResponse.error('You cannot delete this page completely', 'complete_deletion_not_allowed_for_user'));
         }
         }
 
 
         if (pagePathUtils.isUsersHomepage(page.path)) {
         if (pagePathUtils.isUsersHomepage(page.path)) {

+ 65 - 16
apps/app/src/server/service/page/index.ts

@@ -21,8 +21,9 @@ import ExternalUserGroupRelation from '~/features/external-user-group/server/mod
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
 import {
-  PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
+  PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation, PageSingleDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
+import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import {
 import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
 } from '~/interfaces/page-operation';
@@ -192,26 +193,66 @@ class PageService implements IPageService {
     return this.pageEvent;
     return this.pageEvent;
   }
   }
 
 
-  canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
+  /**
+   * Check if page can be deleted completely.
+   * Use pageGrantService.getUserRelatedGroups before execution of canDeleteCompletely to get value for userRelatedGroups.
+   * Do NOT use getUserRelatedGrantedGroups inside this method, because canDeleteCompletely should not be async as for now.
+   * The reason for this is because canDeleteCompletely is called in /page-listing/info in a for loop,
+   * and /page-listing/info should not be an execution heavy API.
+   */
+  canDeleteCompletely(
+      page: PageDocument,
+      operator: any | null,
+      isRecursively: boolean,
+      userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean {
+    if (operator == null || isTopPage(page.path) || isUsersTopPage(page.path)) return false;
 
 
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
 
 
+    if (!this.canDeleteCompletelyAsMultiGroupGrantedPage(page, operator, userRelatedGroups)) return false;
+
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
 
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
+  }
+
+  /**
+   * If page is multi-group granted, check if operator is allowed to completely delete the page.
+   * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E5%AE%8C%E5%85%A8%E3%81%AB%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B%E6%93%8D%E4%BD%9C
+   */
+  canDeleteCompletelyAsMultiGroupGrantedPage(page: PageDocument, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+    const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
+    const isAllGroupMembershipRequiredForPageCompleteDeletion = this.crowi.configManager.getConfig(
+      'crowi', 'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
+    );
+
+    const isAdmin = operator?.admin ?? false;
+    const isAuthor = operator?._id == null ? false : operator._id.equals(page.creator);
+    const isAdminOrAuthor = isAdmin || isAuthor;
+
+    if (page.grant === PageGrant.GRANT_USER_GROUP
+      && !isAdminOrAuthor && pageCompleteDeletionAuthority === PageSingleDeleteCompConfigValue.Anyone
+      && isAllGroupMembershipRequiredForPageCompleteDeletion) {
+      const userRelatedGrantedGroups = this.pageGrantService.filterGrantedGroupsByIds(page, userRelatedGroups.map(ug => ug.item._id.toString()));
+      if (userRelatedGrantedGroups.length !== page.grantedGroups.length) {
+        return false;
+      }
+    }
+
+    return true;
   }
   }
 
 
-  canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
+  canDelete(page: PageDocument, operator: any | null, isRecursively: boolean): boolean {
+    if (operator == null || isTopPage(page.path) || isUsersTopPage(page.path)) return false;
 
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
 
 
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
     const [singleAuthority, recursiveAuthority] = prepareDeleteConfigValuesForCalc(pageDeletionAuthority, pageRecursiveDeletionAuthority);
 
 
-    return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
+    return this.canDeleteLogic(page.creator, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
   }
 
 
   canDeleteUserHomepageByConfig(): boolean {
   canDeleteUserHomepageByConfig(): boolean {
@@ -236,16 +277,16 @@ class PageService implements IPageService {
       recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
       recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
   ): boolean {
   ): boolean {
     const isAdmin = operator?.admin ?? false;
     const isAdmin = operator?.admin ?? false;
-    const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
+    const isAuthor = operator?._id == null ? false : operator._id.equals(creatorId);
 
 
     if (isRecursively) {
     if (isRecursively) {
-      return this.compareDeleteConfig(isAdmin, isOperator, recursiveAuthority);
+      return this.compareDeleteConfig(isAdmin, isAuthor, recursiveAuthority);
     }
     }
 
 
-    return this.compareDeleteConfig(isAdmin, isOperator, authority);
+    return this.compareDeleteConfig(isAdmin, isAuthor, authority);
   }
   }
 
 
-  private compareDeleteConfig(isAdmin: boolean, isOperator: boolean, authority: IPageDeleteConfigValueToProcessValidation | null): boolean {
+  private compareDeleteConfig(isAdmin: boolean, isAuthor: boolean, authority: IPageDeleteConfigValueToProcessValidation | null): boolean {
     if (isAdmin) {
     if (isAdmin) {
       return true;
       return true;
     }
     }
@@ -253,7 +294,7 @@ class PageService implements IPageService {
     if (authority === PageDeleteConfigValue.Anyone || authority == null) {
     if (authority === PageDeleteConfigValue.Anyone || authority == null) {
       return true;
       return true;
     }
     }
-    if (authority === PageDeleteConfigValue.AdminAndAuthor && isOperator) {
+    if (authority === PageDeleteConfigValue.AdminAndAuthor && isAuthor) {
       return true;
       return true;
     }
     }
 
 
@@ -283,9 +324,14 @@ class PageService implements IPageService {
       pages: PageDocument[],
       pages: PageDocument[],
       user: IUserHasId,
       user: IUserHasId,
       isRecursively: boolean,
       isRecursively: boolean,
-      canDeleteFunction: (path: string, creatorId: ObjectIdLike, operator: any, isRecursively: boolean) => boolean,
+      canDeleteFunction: (page: PageDocument, operator: any, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
   ): Promise<PageDocument[]> {
   ): Promise<PageDocument[]> {
-    const filteredPages = pages.filter(p => p.isEmpty || canDeleteFunction(p.path, p.creator, user, isRecursively));
+    const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
+    const filteredPages = pages.filter(async(p) => {
+      if (p.isEmpty) return true;
+      const canDelete = canDeleteFunction(p, user, isRecursively, userRelatedGroups);
+      return canDelete;
+    });
 
 
     if (!this.canDeleteUserHomepageByConfig()) {
     if (!this.canDeleteUserHomepageByConfig()) {
       return filteredPages.filter(p => !isUsersHomepage(p.path));
       return filteredPages.filter(p => !isUsersHomepage(p.path));
@@ -376,8 +422,11 @@ class PageService implements IPageService {
       const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
       const notEmptyClosestAncestor = await Page.findNonEmptyClosestAncestor(page.path);
       creatorId = notEmptyClosestAncestor.creator;
       creatorId = notEmptyClosestAncestor.creator;
     }
     }
-    const isDeletable = this.canDelete(page.path, creatorId, user, false);
-    const isAbleToDeleteCompletely = this.canDeleteCompletely(page.path, creatorId, user, false); // use normal delete config
+
+    const userRelatedGroups = await this.pageGrantService.getUserRelatedGroups(user);
+
+    const isDeletable = this.canDelete(page, user, false);
+    const isAbleToDeleteCompletely = this.canDeleteCompletely(page, user, false, userRelatedGroups); // use normal delete config
 
 
     return {
     return {
       data: page,
       data: page,

+ 184 - 48
apps/app/test/integration/service/page.test.js

@@ -1,7 +1,12 @@
 /* eslint-disable no-unused-vars */
 /* eslint-disable no-unused-vars */
+import { GroupType } from '@growi/core';
 import { advanceTo } from 'jest-date-mock';
 import { advanceTo } from 'jest-date-mock';
 
 
+import { PageSingleDeleteCompConfigValue, PageRecursiveDeleteCompConfigValue } from '~/interfaces/page-delete-config';
 import Tag from '~/server/models/tag';
 import Tag from '~/server/models/tag';
+import UserGroup from '~/server/models/user-group';
+import UserGroupRelation from '~/server/models/user-group-relation';
+
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
@@ -12,6 +17,7 @@ let rootPage;
 let dummyUser1;
 let dummyUser1;
 let testUser1;
 let testUser1;
 let testUser2;
 let testUser2;
+let testUser3;
 let parentTag;
 let parentTag;
 let childTag;
 let childTag;
 
 
@@ -39,6 +45,7 @@ let parentForDelete2;
 
 
 let childForDelete;
 let childForDelete;
 
 
+let canDeleteCompletelyTestPage;
 let parentForDeleteCompletely;
 let parentForDeleteCompletely;
 
 
 let parentForRevert1;
 let parentForRevert1;
@@ -76,13 +83,47 @@ describe('PageService', () => {
     await User.insertMany([
     await User.insertMany([
       { name: 'someone1', username: 'someone1', email: 'someone1@example.com' },
       { name: 'someone1', username: 'someone1', email: 'someone1@example.com' },
       { name: 'someone2', username: 'someone2', email: 'someone2@example.com' },
       { name: 'someone2', username: 'someone2', email: 'someone2@example.com' },
+      { name: 'someone3', username: 'someone3', email: 'someone3@example.com' },
     ]);
     ]);
 
 
     testUser1 = await User.findOne({ username: 'someone1' });
     testUser1 = await User.findOne({ username: 'someone1' });
     testUser2 = await User.findOne({ username: 'someone2' });
     testUser2 = await User.findOne({ username: 'someone2' });
+    testUser3 = await User.findOne({ username: 'someone3' });
 
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
 
 
+    await UserGroup.insertMany([
+      {
+        name: 'userGroupForCanDeleteCompletelyTest1',
+        parent: null,
+      },
+      {
+        name: 'userGroupForCanDeleteCompletelyTest2',
+        parent: null,
+      },
+    ]);
+    const userGroupForCanDeleteCompletelyTest1 = await UserGroup.findOne({ name: 'userGroupForCanDeleteCompletelyTest1' });
+    const userGroupForCanDeleteCompletelyTest2 = await UserGroup.findOne({ name: 'userGroupForCanDeleteCompletelyTest2' });
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest1._id,
+        relatedUser: testUser1._id,
+      },
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest2._id,
+        relatedUser: testUser2._id,
+      },
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest1._id,
+        relatedUser: testUser3._id,
+      },
+      {
+        relatedGroup: userGroupForCanDeleteCompletelyTest2._id,
+        relatedUser: testUser3._id,
+      },
+    ]);
+
     rootPage = await Page.findOne({ path: '/' });
     rootPage = await Page.findOne({ path: '/' });
 
 
     await Page.insertMany([
     await Page.insertMany([
@@ -170,6 +211,16 @@ describe('PageService', () => {
         creator: testUser1,
         creator: testUser1,
         lastUpdateUser: testUser1,
         lastUpdateUser: testUser1,
       },
       },
+      {
+        path: '/canDeleteCompletelyTestPage',
+        grant: Page.GRANT_USER_GROUP,
+        creator: testUser2,
+        grantedGroups: [
+          { item: userGroupForCanDeleteCompletelyTest1._id, type: GroupType.userGroup },
+          { item: userGroupForCanDeleteCompletelyTest2._id, type: GroupType.userGroup },
+        ],
+        lastUpdateUser: testUser1,
+      },
       {
       {
         path: '/parentForDuplicate',
         path: '/parentForDuplicate',
         grant: Page.GRANT_PUBLIC,
         grant: Page.GRANT_PUBLIC,
@@ -255,6 +306,7 @@ describe('PageService', () => {
     parentForDelete1 = await Page.findOne({ path: '/parentForDelete1' });
     parentForDelete1 = await Page.findOne({ path: '/parentForDelete1' });
     parentForDelete2 = await Page.findOne({ path: '/parentForDelete2' });
     parentForDelete2 = await Page.findOne({ path: '/parentForDelete2' });
 
 
+    canDeleteCompletelyTestPage = await Page.findOne({ path: '/canDeleteCompletelyTestPage' });
     parentForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely' });
     parentForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely' });
     parentForRevert1 = await Page.findOne({ path: '/trash/parentForRevert1' });
     parentForRevert1 = await Page.findOne({ path: '/trash/parentForRevert1' });
     parentForRevert2 = await Page.findOne({ path: '/trash/parentForRevert2' });
     parentForRevert2 = await Page.findOne({ path: '/trash/parentForRevert2' });
@@ -703,67 +755,151 @@ describe('PageService', () => {
   });
   });
 
 
   describe('delete page completely', () => {
   describe('delete page completely', () => {
-    let pageEventSpy;
-    let deleteCompletelyOperationSpy;
-    let deleteCompletelyDescendantsWithStreamSpy;
-
-    let deleteManyBookmarkSpy;
-    let deleteManyCommentSpy;
-    let deleteManyPageTagRelationSpy;
-    let deleteManyShareLinkSpy;
-    let deleteManyRevisionSpy;
-    let deleteManyPageSpy;
-    let removeAllAttachmentsSpy;
+    describe('canDeleteCompletely', () => {
+      describe(`when user is not admin or author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is not related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is true`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': true,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is not deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          expect(isDeleteable).toBe(false);
+        });
+      });
 
 
-    beforeEach(async() => {
-      pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
-      deleteCompletelyOperationSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyOperation');
-      deleteCompletelyDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyDescendantsWithStream').mockImplementation();
-
-      deleteManyBookmarkSpy = jest.spyOn(Bookmark, 'deleteMany').mockImplementation();
-      deleteManyCommentSpy = jest.spyOn(Comment, 'deleteMany').mockImplementation();
-      deleteManyPageTagRelationSpy = jest.spyOn(PageTagRelation, 'deleteMany').mockImplementation();
-      deleteManyShareLinkSpy = jest.spyOn(ShareLink, 'deleteMany').mockImplementation();
-      deleteManyRevisionSpy = jest.spyOn(Revision, 'deleteMany').mockImplementation();
-      deleteManyPageSpy = jest.spyOn(Page, 'deleteMany').mockImplementation();
-      removeAllAttachmentsSpy = jest.spyOn(crowi.attachmentService, 'removeAllAttachments').mockImplementation();
-    });
+      describe(`when user is not admin or author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is true`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': true,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is not deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser3);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser3, false, userRelatedGroups);
+          expect(isDeleteable).toBe(true);
+        });
+      });
 
 
-    test('deleteCompletelyOperation', async() => {
-      await crowi.pageService.deleteCompletelyOperation([parentForDeleteCompletely._id], [parentForDeleteCompletely.path], { });
+      describe(`when user is not admin or author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is not related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is false`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': false,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser1);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser1, false, userRelatedGroups);
+          expect(isDeleteable).toBe(true);
+        });
+      });
 
 
-      expect(deleteManyBookmarkSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
-      expect(deleteManyPageSpy).toHaveBeenCalledWith({ _id: { $in: [parentForDeleteCompletely._id] } });
-      expect(removeAllAttachmentsSpy).toHaveBeenCalled();
+      describe(`when user is author,
+        pageCompleteDeletionAuthority is 'anyone',
+        user is not related to all granted groups,
+        and isAllGroupMembershipRequiredForPageCompleteDeletion is true`, () => {
+        beforeEach(async() => {
+          const config = {
+            'security:isAllGroupMembershipRequiredForPageCompleteDeletion': false,
+            'security:pageCompleteDeletionAuthority': PageSingleDeleteCompConfigValue.Anyone,
+            'security:pageRecursiveCompleteDeletionAuthority': PageRecursiveDeleteCompConfigValue.Anyone,
+          };
+          await crowi.configManager.updateConfigsInTheSameNamespace('crowi', config);
+        });
+
+        test('is deletable', async() => {
+          const userRelatedGroups = await crowi.pageGrantService.getUserRelatedGroups(testUser2);
+          const isDeleteable = crowi.pageService.canDeleteCompletely(canDeleteCompletelyTestPage, testUser2, false, userRelatedGroups);
+          expect(isDeleteable).toBe(true);
+        });
+      });
     });
     });
 
 
-    test('delete completely without options', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, false, false, {
-        ip: '::ffff:127.0.0.1',
-        endpoint: '/_api/v3/pages/deletecompletely',
+    describe('actual delete process', () => {
+      let pageEventSpy;
+      let deleteCompletelyOperationSpy;
+      let deleteCompletelyDescendantsWithStreamSpy;
+
+      let deleteManyBookmarkSpy;
+      let deleteManyCommentSpy;
+      let deleteManyPageTagRelationSpy;
+      let deleteManyShareLinkSpy;
+      let deleteManyRevisionSpy;
+      let deleteManyPageSpy;
+      let removeAllAttachmentsSpy;
+
+      beforeEach(async() => {
+        pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit');
+        deleteCompletelyOperationSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyOperation');
+        deleteCompletelyDescendantsWithStreamSpy = jest.spyOn(crowi.pageService, 'deleteCompletelyDescendantsWithStream').mockImplementation();
+
+        deleteManyBookmarkSpy = jest.spyOn(Bookmark, 'deleteMany').mockImplementation();
+        deleteManyCommentSpy = jest.spyOn(Comment, 'deleteMany').mockImplementation();
+        deleteManyPageTagRelationSpy = jest.spyOn(PageTagRelation, 'deleteMany').mockImplementation();
+        deleteManyShareLinkSpy = jest.spyOn(ShareLink, 'deleteMany').mockImplementation();
+        deleteManyRevisionSpy = jest.spyOn(Revision, 'deleteMany').mockImplementation();
+        deleteManyPageSpy = jest.spyOn(Page, 'deleteMany').mockImplementation();
+        removeAllAttachmentsSpy = jest.spyOn(crowi.attachmentService, 'removeAllAttachments').mockImplementation();
       });
       });
 
 
-      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
-      expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
+      test('deleteCompletelyOperation', async() => {
+        await crowi.pageService.deleteCompletelyOperation([parentForDeleteCompletely._id], [parentForDeleteCompletely.path], { });
 
 
-      expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
-    });
+        expect(deleteManyBookmarkSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyCommentSpy).toHaveBeenCalledWith({ page: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyPageTagRelationSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyShareLinkSpy).toHaveBeenCalledWith({ relatedPage: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyRevisionSpy).toHaveBeenCalledWith({ pageId: { $in: [parentForDeleteCompletely._id] } });
+        expect(deleteManyPageSpy).toHaveBeenCalledWith({ _id: { $in: [parentForDeleteCompletely._id] } });
+        expect(removeAllAttachmentsSpy).toHaveBeenCalled();
+      });
 
 
+      test('delete completely without options', async() => {
+        await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, false, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/deletecompletely',
+        });
 
 
-    test('delete completely with isRecursively', async() => {
-      await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true, false, {
-        ip: '::ffff:127.0.0.1',
-        endpoint: '/_api/v3/pages/deletecompletely',
+        expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+        expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
+
+        expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
       });
       });
 
 
-      expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
-      expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
 
 
-      expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
+      test('delete completely with isRecursively', async() => {
+        await crowi.pageService.deleteCompletely(parentForDeleteCompletely, testUser2, { }, true, false, {
+          ip: '::ffff:127.0.0.1',
+          endpoint: '/_api/v3/pages/deletecompletely',
+        });
+
+        expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
+        expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
+
+        expect(pageEventSpy).toHaveBeenCalledWith('deleteCompletely', parentForDeleteCompletely, testUser2);
+      });
     });
     });
   });
   });