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

Merge branch 'dev/7.0.x' into 126524-139354-support-keybind

reiji-h 2 лет назад
Родитель
Сommit
9e18f7e2ae
100 измененных файлов с 2293 добавлено и 1470 удалено
  1. 0 0
      apps/app/_obsolete/src/components/Navbar/GlobalSearch.module.scss
  2. 1 1
      apps/app/_obsolete/src/components/Navbar/GlobalSearch.tsx
  3. 1 2
      apps/app/_obsolete/src/components/PageEditor/MarkdownLinkUtil.js
  4. 2 2
      apps/app/_obsolete/src/components/PageEditorByHackmd.tsx
  5. 9 7
      apps/app/public/static/locales/en_US/admin.json
  6. 6 3
      apps/app/public/static/locales/en_US/translation.json
  7. 9 7
      apps/app/public/static/locales/ja_JP/admin.json
  8. 6 3
      apps/app/public/static/locales/ja_JP/translation.json
  9. 9 7
      apps/app/public/static/locales/zh_CN/admin.json
  10. 6 3
      apps/app/public/static/locales/zh_CN/translation.json
  11. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  12. 36 2
      apps/app/src/client/services/use-create-page-and-transit.tsx
  13. 1 1
      apps/app/src/client/services/use-on-template-button-clicked.ts
  14. 1 1
      apps/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx
  15. 2 2
      apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  16. 24 3
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  17. 11 2
      apps/app/src/components/Common/ClosableTextInput.tsx
  18. 24 12
      apps/app/src/components/Common/PagePathHierarchicalLink/CollapsedParentsDropdown.tsx
  19. 8 10
      apps/app/src/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  20. 1 1
      apps/app/src/components/Layout/SearchResultLayout.tsx
  21. 2 3
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  22. 62 30
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  23. 5 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  24. 4 0
      apps/app/src/components/PageComment/CommentEditor.module.scss
  25. 27 9
      apps/app/src/components/PageDuplicateModal.tsx
  26. 2 3
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  27. 2 2
      apps/app/src/components/PageEditor/PageEditor.tsx
  28. 4 6
      apps/app/src/components/PageHeader/PageHeader.tsx
  29. 86 69
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  30. 78 18
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  31. 0 76
      apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx
  32. 7 5
      apps/app/src/components/PageHeader/page-header-utils.ts
  33. 3 2
      apps/app/src/components/PageHistory/RevisionDiff.tsx
  34. 4 0
      apps/app/src/components/PageManagement/ApiErrorMessage.jsx
  35. 11 14
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  36. 3 2
      apps/app/src/components/PageTags/PageTags.tsx
  37. 2 2
      apps/app/src/components/SavePageControls.tsx
  38. 40 18
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  39. 1 1
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  40. 1 1
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  41. 4 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  42. 0 4
      apps/app/src/components/Sidebar/SidebarNav/SidebarNav.module.scss
  43. 1 1
      apps/app/src/components/Skeleton.tsx
  44. 0 1
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  45. 1 1
      apps/app/src/interfaces/editor-settings.ts
  46. 1 1
      apps/app/src/interfaces/page-operation.ts
  47. 1 0
      apps/app/src/interfaces/page-tag-relation.ts
  48. 1 1
      apps/app/src/interfaces/page.ts
  49. 1 1
      apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js
  50. 28 0
      apps/app/src/migrations/20231223155127-non-null-granted-groups.js
  51. 12 7
      apps/app/src/pages/[[...path]].page.tsx
  52. 10 5
      apps/app/src/server/crowi/index.js
  53. 1 1
      apps/app/src/server/events/activity.ts
  54. 6 3
      apps/app/src/server/models/GlobalNotificationSetting.ts
  55. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  56. 5 2
      apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  57. 19 0
      apps/app/src/server/models/GlobalNotificationSetting/consts.ts
  58. 1 0
      apps/app/src/server/models/config.ts
  59. 5 1
      apps/app/src/server/models/index.ts
  60. 1 1
      apps/app/src/server/models/interfaces/page-operation.ts
  61. 1 25
      apps/app/src/server/models/obsolete-page.js
  62. 0 180
      apps/app/src/server/models/page-tag-relation.js
  63. 208 0
      apps/app/src/server/models/page-tag-relation.ts
  64. 8 4
      apps/app/src/server/models/page.ts
  65. 3 4
      apps/app/src/server/models/tag.ts
  66. 1 1
      apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts
  67. 7 6
      apps/app/src/server/routes/apiv3/notification-setting.js
  68. 13 6
      apps/app/src/server/routes/apiv3/page-listing.ts
  69. 234 0
      apps/app/src/server/routes/apiv3/page/cteate-page.ts
  70. 17 15
      apps/app/src/server/routes/apiv3/page/index.js
  71. 21 201
      apps/app/src/server/routes/apiv3/pages/index.js
  72. 5 0
      apps/app/src/server/routes/apiv3/security-settings/index.js
  73. 2 1
      apps/app/src/server/routes/comment.js
  74. 0 2
      apps/app/src/server/routes/index.js
  75. 11 257
      apps/app/src/server/routes/page.js
  76. 3 3
      apps/app/src/server/routes/tag.js
  77. 10 10
      apps/app/src/server/service/global-notification/global-notification-mail.js
  78. 15 17
      apps/app/src/server/service/global-notification/global-notification-slack.js
  79. 147 16
      apps/app/src/server/service/page-grant.ts
  80. 2 2
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  81. 269 135
      apps/app/src/server/service/page/index.ts
  82. 11 1
      apps/app/src/server/service/page/page-service.ts
  83. 11 14
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  84. 2 3
      apps/app/src/server/service/user-notification/index.ts
  85. 3 32
      apps/app/src/stores/modal.tsx
  86. 10 3
      apps/app/src/stores/page.tsx
  87. 0 4
      apps/app/src/stores/ui.tsx
  88. 18 6
      apps/app/src/styles/organisms/_wiki.scss
  89. 124 31
      apps/app/test/integration/models/v5.page.test.js
  90. 108 13
      apps/app/test/integration/service/page-grant.test.js
  91. 185 50
      apps/app/test/integration/service/page.test.js
  92. 177 15
      apps/app/test/integration/service/v5.non-public-page.test.ts
  93. 4 6
      apps/app/test/integration/service/v5.page.test.ts
  94. 11 12
      apps/app/test/integration/service/v5.public-page.test.ts
  95. 3 3
      apps/app/test/integration/setup-crowi.ts
  96. 1 0
      packages/core/scss/bootstrap/_variables.scss
  97. 0 5
      packages/core/src/interfaces/page.ts
  98. 26 13
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  99. 2 2
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx
  100. 6 8
      packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

+ 0 - 0
apps/app/src/components/Navbar/GlobalSearch.module.scss → apps/app/_obsolete/src/components/Navbar/GlobalSearch.module.scss


+ 1 - 1
apps/app/src/components/Navbar/GlobalSearch.tsx → apps/app/_obsolete/src/components/Navbar/GlobalSearch.tsx

@@ -17,7 +17,7 @@ import {
 import { useCurrentPagePath } from '~/stores/page';
 import { useGlobalSearchFormRef } from '~/stores/ui';
 
-import SearchForm from '../SearchForm';
+import SearchForm from '../../../../src/components/SearchForm';
 
 import styles from './GlobalSearch.module.scss';
 

+ 1 - 2
apps/app/src/components/PageEditor/MarkdownLinkUtil.js → apps/app/_obsolete/src/components/PageEditor/MarkdownLinkUtil.js

@@ -1,5 +1,4 @@
-import Linker from '~/client/models/Linker';
-
+import Linker from '@growi/editor/src/services/link-util/Linker';
 /**
  * Utility for markdown link
  */

+ 2 - 2
apps/app/_obsolete/src/components/PageEditorByHackmd.tsx

@@ -98,7 +98,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (grantData == null) {
       return;
     }
-    const grantedGroups = grantData.grantedGroups?.map((group) => {
+    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
       return { item: group.id, type: group.type };
     });
     const optionsToSave = {
@@ -106,7 +106,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       pageTags: pageTags ?? [],
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);

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

@@ -38,10 +38,12 @@
     "page_delete": "Page Delete",
     "page_delete_completely": "Page Delete Completely",
     "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)",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
@@ -857,12 +859,12 @@
     "return": "Return",
     "clear": "Clear",
     "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>.",
     "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",
-    "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": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/en_US/translation.json

@@ -328,7 +328,8 @@
     "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "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": {
     "revision_list": "Revision list",
@@ -389,10 +390,12 @@
       "Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",

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

@@ -47,10 +47,12 @@
     "page_delete": "ゴミ箱に入れる",
     "page_delete_completely": "完全に削除する",
     "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": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
@@ -867,12 +869,12 @@
     "return": "戻る",
     "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
-    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "activity_expiration_date_explanation": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explanation": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "disable_mode_explanation": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
     "docs_url": {
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -361,7 +361,8 @@
     "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが削除できます",
-    "single_deletion_empty_pages": "空ページの単体削除はできません"
+    "single_deletion_empty_pages": "空ページの単体削除はできません",
+    "complete_deletion_not_allowed_for_user": "ページを完全に削除する権限がありません"
   },
   "page_history": {
     "revision_list": "更新履歴",
@@ -422,10 +423,12 @@
       "Current page name": "現在のページ名",
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
-      "Same page already exists": "同じページがすでに存在します"
+      "Same page already exists": "同じページがすでに存在します",
+      "Only duplicate user related resources": "ユーザに関連のあるリソースのみを複製する"
     },
     "help": {
-      "recursive": "配下のページも複製します"
+      "recursive": "配下のページも複製します",
+      "only_user_related_resources": "ユーザが閲覧可能なページのみを複製します。また、閲覧権限が「特定グループのみ」で設定されている場合、複製後のページにはユーザが所属するグループのみを閲覧可能なグループとして設定します。"
     }
   },
   "duplicated_pages": "{{fromPath}} を複製しました",

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

@@ -47,10 +47,12 @@
     "page_delete": "删除",
     "page_delete_completely": "彻底删除",
     "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": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
@@ -866,12 +868,12 @@
     "return": "返回",
     "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
-    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "activity_expiration_date_explanation": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
+    "available_action_list_explanation": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "disable_mode_explanation": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
     "docs_url": {
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }

+ 6 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -318,7 +318,8 @@
 		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以删除",
-    "single_deletion_empty_pages": "空的页面不能被单一删除"
+    "single_deletion_empty_pages": "空的页面不能被单一删除",
+    "complete_deletion_not_allowed_for_user": "您无权永久删除该页面"
   },
   "page_history": {
     "revision_list": "修订清单",
@@ -379,10 +380,12 @@
 			"Current page name": "Current page name",
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
-      "Same page already exists": "Same page already exists"
+      "Same page already exists": "Same page already exists",
+      "Only duplicate user related resources": "Only duplicate user related resources"
     },
     "help": {
-      "recursive": "Duplicate children of under this path recursively"
+      "recursive": "Duplicate children of under this path recursively",
+      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
     }
   },
   "duplicated_pages": "{{fromPath}} 已重复",

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

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

+ 36 - 2
apps/app/src/components/Navbar/hooks.tsx → apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import { useRouter } from 'next/router';
 
 import { createPage } from '~/client/services/page-operation';
-import { useIsNotFound } from '~/stores/page';
+import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -46,14 +46,27 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
   const router = useRouter();
 
   const { data: isNotFound } = useIsNotFound();
+  const { data: currentPage, isLoading } = useSWRxCurrentPage();
   const { mutate: mutateEditorMode } = useEditorMode();
 
+  // const {
+  //   path: currentPagePath,
+  //   grant: currentPageGrant,
+  //   grantedGroups: currentPageGrantedGroups,
+  // } = currentPage ?? {};
+
   return useCallback(async(pagePath, opts = {}) => {
+    if (isLoading) {
+      return;
+    }
+
     const {
       onCreationStart, onCreated, onAborted, onError, onTerminated,
     } = opts;
 
     if (isNotFound == null || !isNotFound || pagePath == null) {
+      mutateEditorMode(EditorMode.Editor);
+
       onAborted?.();
       onTerminated?.();
       return;
@@ -62,6 +75,27 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
     try {
       onCreationStart?.();
 
+      /**
+       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
+       * since the new page path is not generated on the client side.
+       * need shouldGeneratePath flag.
+       */
+      // const shouldCreateUnderRoot = currentPagePath == null || currentPageGrant == null;
+      // const parentPath = shouldCreateUnderRoot
+      //   ? '/'
+      //   : currentPagePath;
+
+      // const params = {
+      //   isSlackEnabled: false,
+      //   slackChannels: '',
+      //   grant: shouldCreateUnderRoot ? 1 : currentPageGrant,
+      //   grantUserGroupIds: shouldCreateUnderRoot ? undefined : currentPageGrantedGroups,
+      //   shouldGeneratePath: true,
+      // };
+
+      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
+      // const response = await createPage(parentPath, '', params);
+
       const params = {
         isSlackEnabled: false,
         slackChannels: '',
@@ -85,5 +119,5 @@ export const useCreatePageAndTransit = (): CreatePageAndTransit => {
       onTerminated?.();
     }
 
-  }, [isNotFound, mutateEditorMode, router]);
+  }, [isLoading, isNotFound, mutateEditorMode, router]);
 };

+ 1 - 1
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -4,7 +4,7 @@ import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useRouter } from 'next/router';
 
 import { createPage, exist } from '~/client/services/page-operation';
-import { LabelType } from '~/interfaces/template';
+import type { LabelType } from '~/interfaces/template';
 
 export const useOnTemplateButtonClicked = (
     currentPagePath?: string,

+ 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>
               <h3
                 // 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>

+ 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>
       <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 className="alert alert-warning col-6">
         <span className="material-symbols-outlined">error</span>
@@ -50,7 +50,7 @@ export const AuditLogSettings: FC = () => {
         </a>
       </h4>
       <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 className="mt-1">
         <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>
         </div>
         <p className="form-text text-muted small">
-          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explain`)}
+          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explanation`)}
         </p>
       </div>
     );
   }
 
   renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
-    const { t } = this.props;
+    const { t, adminGeneralSecurityContainer } = this.props;
 
     const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
 
@@ -265,7 +265,28 @@ class SecuritySetting extends React.Component {
           {
             !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>
+                    </>
+                  )}
+                </>
               )
               : (
                 <>

+ 11 - 2
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -1,16 +1,19 @@
+import type { FC } from 'react';
 import React, {
-  FC, memo, useEffect, useRef, useState,
+  memo, useEffect, useRef, useState,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { AlertInfo, AlertType, inputValidator } from '~/client/util/input-validator';
+import type { AlertInfo } from '~/client/util/input-validator';
+import { AlertType, inputValidator } from '~/client/util/input-validator';
 
 type ClosableTextInputProps = {
   value?: string
   placeholder?: string
   validationTarget?: string,
   onPressEnter?(inputText: string | null): void
+  onPressEscape?: () => void
   onClickOutside?(): void
   handleInputChange?: (string) => void
 }
@@ -66,6 +69,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         }
         onPressEnter();
         break;
+      case 'Escape':
+        if (isComposing) {
+          return;
+        }
+        props.onPressEscape?.();
+        break;
       default:
         break;
     }

+ 24 - 12
apps/app/src/components/Common/PagePathHierarchicalLink/CollapsedParentsDropdown.tsx

@@ -1,12 +1,25 @@
+import { useMemo } from 'react';
+
+import Link from 'next/link';
 import {
   DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown,
 } from 'reactstrap';
 
-import LinkedPagePath from '~/models/linked-page-path';
-
+import type LinkedPagePath from '~/models/linked-page-path';
 
 import styles from './CollapsedParentsDropdown.module.scss';
 
+const getAncestorPathAndPathNames = (linkedPagePath: LinkedPagePath) => {
+  const pathAndPathName: Array<{ path: string, pathName: string }> = [];
+  let currentLinkedPagePath = linkedPagePath;
+
+  while (currentLinkedPagePath.parent != null) {
+    pathAndPathName.unshift({ path: currentLinkedPagePath.path, pathName: currentLinkedPagePath.pathName });
+    currentLinkedPagePath = currentLinkedPagePath.parent;
+  }
+
+  return pathAndPathName;
+};
 
 type Props = {
   linkedPagePath: LinkedPagePath,
@@ -15,20 +28,19 @@ type Props = {
 export const CollapsedParentsDropdown = (props: Props): JSX.Element => {
   const { linkedPagePath } = props;
 
+  const ancestorPathAndPathNames = useMemo(() => getAncestorPathAndPathNames(linkedPagePath), [linkedPagePath]);
+
   return (
     <UncontrolledDropdown className="d-inline-block">
       <DropdownToggle color="transparent">...</DropdownToggle>
       <DropdownMenu className={`dropdown-menu ${styles['collapsed-parents-dropdown-menu']}`} container="body">
-        {/* TODO: generate DropdownItems */}
-        <DropdownItem>
-          <a role="menuitem">foo</a>
-        </DropdownItem>
-        <DropdownItem>
-          <a role="menuitem">bar</a>
-        </DropdownItem>
-        <DropdownItem>
-          <a role="menuitem">baz</a>
-        </DropdownItem>
+        {ancestorPathAndPathNames.map(data => (
+          <DropdownItem key={data.path}>
+            <Link href={data.path} legacyBehavior>
+              <a role="menuitem">{data.pathName}</a>
+            </Link>
+          </DropdownItem>
+        ))}
       </DropdownMenu>
     </UncontrolledDropdown>
   );

+ 8 - 10
apps/app/src/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx

@@ -1,11 +1,12 @@
 import { useEffect } from 'react';
 
+import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsEditable } from '~/stores/context';
-import { useGlobalSearchFormRef } from '~/stores/ui';
+
 
 const FocusToGlobalSearch = (props) => {
   const { data: isEditable } = useIsEditable();
-  const { data: globalSearchFormRef } = useGlobalSearchFormRef();
+  const { data: searchModalData, open: openSearchModal } = useSearchModal();
 
   // setup effect
   useEffect(() => {
@@ -13,16 +14,13 @@ const FocusToGlobalSearch = (props) => {
       return;
     }
 
-    // ignore when dom that has 'modal in' classes exists
-    if (document.getElementsByClassName('modal in').length > 0) {
-      return;
+    if (!searchModalData.isOpened) {
+      openSearchModal();
+      // remove this
+      props.onDeleteRender();
     }
 
-    globalSearchFormRef.current.focus();
-
-    // remove this
-    props.onDeleteRender();
-  }, [globalSearchFormRef, isEditable, props]);
+  }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
 
   return null;
 };

+ 1 - 1
apps/app/src/components/Layout/SearchResultLayout.tsx

@@ -1,4 +1,4 @@
-import React, { ReactNode } from 'react';
+import React, { type ReactNode } from 'react';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 

+ 2 - 3
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { toastError } from '~/client/util/toastr';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useCreatePageAndTransit } from './hooks';
+import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -76,12 +76,11 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
       path,
       {
         onCreationStart: () => { setIsCreating(true) },
-        onAborted: () => { mutateEditorMode(EditorMode.Editor) },
         onError: () => { toastError(t('toaster.create_failed', { target: path })) },
         onTerminated: () => { setIsCreating(false) },
       },
     );
-  }, [createPageAndTransit, path, mutateEditorMode, t]);
+  }, [createPageAndTransit, path, t]);
 
   return (
     <>

+ 62 - 30
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -31,7 +31,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
 
   const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
-  const [selectedGroup, setSelectedGroup] = useState<PopulatedGrantedGroup | undefined>(undefined);
+  const [selectedGroups, setSelectedGroups] = useState<PopulatedGrantedGroup[]>([]);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -42,14 +42,23 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   useEffect(() => {
     if (isOpen) {
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
-      setSelectedGroup(undefined);
+      setSelectedGroups([]);
       setShowModalAlert(false);
     }
   }, [isOpen]);
 
+  const groupListItemClickHandler = (group: PopulatedGrantedGroup) => {
+    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
+      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
+    }
+    else {
+      setSelectedGroups([...selectedGroups, group]);
+    }
+  };
+
   const submit = async() => {
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
       setShowModalAlert(true);
       return;
     }
@@ -59,7 +68,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroups: selectedGroup?.item._id != null ? [{ item: selectedGroup?.item._id, type: selectedGroup.type }] : null,
+        userRelatedGrantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
+          return { item: g.item._id, type: g.type };
+        }) : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -88,10 +99,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
+      if (grantData.userRelatedGrantedGroups == null || grantData.userRelatedGrantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.userRelatedGrantedGroups.map(g => g.name).join(', ')})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -180,31 +191,17 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   <button
                     type="button"
                     className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
-                    data-toggle="dropdown"
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                    onClick={() => setIsGroupSelectModalShown(true)}
                   >
                     <span className="float-start ms-2">
                       {
-                        selectedGroup == null
+                        selectedGroups.length === 0
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.item.name
+                          : selectedGroups.map(g => g.item.name).join(', ')
                       }
                     </span>
                   </button>
-                  <div className="dropdown-menu">
-                    {
-                      applicableGroups != null && applicableGroups.map(g => (
-                        <button
-                          key={g.item._id}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => setSelectedGroup(g)}
-                        >
-                          {g.item.name}
-                        </button>
-                      ))
-                    }
-                  </div>
                 </div>
               </div>
               {
@@ -227,12 +224,47 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close}>
-      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-        { t('fix_page_grant.modal.title') }
-      </ModalHeader>
-      {renderModalBodyAndFooter()}
-    </Modal>
+    <>
+      <Modal size="lg" isOpen={isOpen} toggle={close}>
+        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+          { t('fix_page_grant.modal.title') }
+        </ModalHeader>
+        {renderModalBodyAndFooter()}
+      </Modal>
+      {applicableGroups != null && (
+        <Modal
+          isOpen={isGroupSelectModalShown}
+          toggle={() => setIsGroupSelectModalShown(false)}
+        >
+          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
+            {t('user_group.select_group')}
+          </ModalHeader>
+          <ModalBody>
+            <>
+              { applicableGroups.map((group) => {
+                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
+                const activeClass = groupIsGranted ? 'active' : '';
+
+                return (
+                  <button
+                    className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+                    type="button"
+                    key={group.item._id}
+                    onClick={() => groupListItemClickHandler(group)}
+                  >
+                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+                    <h5 className="d-inline-block ml-3">{group.item.name}</h5>
+                    {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
+                    {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+                  </button>
+                );
+              }) }
+              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
+            </>
+          </ModalBody>
+        </Modal>
+      )}
+    </>
   );
 };
 

+ 5 - 1
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -14,6 +14,10 @@ export const PageGrantAlert = (): JSX.Element => {
     return <></>;
   }
 
+  const populatedGrantedGroups = () => {
+    return pageData.grantedGroups.filter(group => isPopulated(group.item));
+  };
+
   const renderAlertContent = () => {
     const getGrantLabel = () => {
       if (pageData.grant === 2) {
@@ -35,7 +39,7 @@ export const PageGrantAlert = (): JSX.Element => {
           <>
             <i className="icon-fw icon-organization"></i>
             <strong>{
-              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
+              populatedGrantedGroups().map(g => g.item.name).join(', ')
             }
             </strong>
           </>

+ 4 - 0
apps/app/src/components/PageComment/CommentEditor.module.scss

@@ -4,6 +4,10 @@
 
 // display cheatsheet for comment form only
 .comment-editor-styles :global {
+  .cm-editor {
+    height: 300px !important;
+  }
+
   .comment-form {
     position: relative;
     margin-top: 1em;

+ 27 - 9
apps/app/src/components/PageDuplicateModal.tsx

@@ -37,6 +37,7 @@ const PageDuplicateModal = (): JSX.Element => {
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
+  const [onlyDuplicateUserRelatedResources, setOnlyDuplicateUserRelatedResources] = useState(false);
 
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
@@ -114,7 +115,9 @@ const PageDuplicateModal = (): JSX.Element => {
 
     const { pageId, path } = page;
     try {
-      const { data } = await apiv3Post('/pages/duplicate', { pageId, pageNameInput, isRecursively: isDuplicateRecursively });
+      const { data } = await apiv3Post('/pages/duplicate', {
+        pageId, pageNameInput, isRecursively: isDuplicateRecursively, onlyDuplicateUserRelatedResources,
+      });
       const onDuplicated = duplicateModalData?.opts?.onDuplicated;
       const fromPath = path;
       const toPath = data.page.path;
@@ -127,7 +130,7 @@ const PageDuplicateModal = (): JSX.Element => {
     catch (err) {
       setErrs(err);
     }
-  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput]);
+  }, [closeDuplicateModal, duplicateModalData?.opts?.onDuplicated, isDuplicateRecursively, page, pageNameInput, onlyDuplicateUserRelatedResources]);
 
   useEffect(() => {
     if (isOpened) {
@@ -193,7 +196,7 @@ const PageDuplicateModal = (): JSX.Element => {
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
 
-        <div className="form-check form-check-warning mb-3">
+        <div className="form-check form-check-warning">
           <input
             className="form-check-input"
             name="recursively"
@@ -204,7 +207,7 @@ const PageDuplicateModal = (): JSX.Element => {
           />
           <label className="form-label form-check-label" htmlFor="cbDuplicateRecursively">
             { t('modal_duplicate.label.Recursively') }
-            <p className="form-text text-muted mt-0">{ t('modal_duplicate.help.recursive') }</p>
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
           </label>
 
           <div>
@@ -220,15 +223,30 @@ const PageDuplicateModal = (): JSX.Element => {
                 />
                 <label className="form-label form-check-label" htmlFor="cbDuplicatewithoutExistRecursively">
                   { t('modal_duplicate.label.Duplicate without exist path') }
+                  <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
                 </label>
               </div>
             )}
           </div>
-          <div>
-            {isDuplicateRecursively && existingPaths.length !== 0 && (
-              <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
-            ) }
-          </div>
+        </div>
+
+        <div className="form-check form-check-warning mb-3">
+          <input
+            className="form-check-input"
+            id="cbOnlyDuplicateUserRelatedResources"
+            type="checkbox"
+            checked={onlyDuplicateUserRelatedResources}
+            onChange={() => setOnlyDuplicateUserRelatedResources(!onlyDuplicateUserRelatedResources)}
+          />
+          <label className="form-label form-check-label" htmlFor="cbOnlyDuplicateUserRelatedResources">
+            { t('modal_duplicate.label.Only duplicate user related resources') }
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
+          </label>
+        </div>
+        <div>
+          {isDuplicateRecursively && existingPaths.length !== 0 && (
+            <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
+          ) }
         </div>
       </>
     );

+ 2 - 3
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -2,6 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
 
 import path from 'path';
 
+import Linker from '@growi/editor/src/services/link-util/Linker';
+import { useLinkEditModal } from '@growi/editor/src/stores/use-link-edit-modal';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -13,10 +15,7 @@ import {
 } from 'reactstrap';
 import validator from 'validator';
 
-
-import Linker from '~/client/models/Linker';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { useLinkEditModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';

+ 2 - 2
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -220,7 +220,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     if (grantData == null) {
       return;
     }
-    const grantedGroups = grantData.grantedGroups?.map((group) => {
+    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
       return { item: group.id, type: group.type };
     });
     const optionsToSave = {
@@ -228,7 +228,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       // pageTags: pageTags ?? [],
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled]);

+ 4 - 6
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -1,26 +1,24 @@
-import { FC } from 'react';
+import type { FC } from 'react';
 
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 import { PagePathHeader } from './PagePathHeader';
 import { PageTitleHeader } from './PageTitleHeader';
 
+
 export const PageHeader: FC = () => {
-  const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPage } = useSWRxCurrentPage();
 
-  if (currentPage == null || currentPagePath == null) {
+  if (currentPage == null) {
     return <></>;
   }
 
   return (
     <>
       <PagePathHeader
-        currentPagePath={currentPagePath}
         currentPage={currentPage}
       />
       <PageTitleHeader
-        currentPagePath={currentPagePath}
         currentPage={currentPage}
       />
     </>

+ 86 - 69
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -1,84 +1,95 @@
 import {
-  FC, useEffect, useMemo, useState,
+  useMemo, useState, useEffect, useCallback,
 } from 'react';
+import type { FC } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { useTranslation } from 'next-i18next';
 
+import { ValidationTarget } from '~/client/util/input-validator';
 import { usePageSelectModal } from '~/stores/modal';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
+import ClosableTextInput from '../Common/ClosableTextInput';
 import { PagePathNav } from '../Common/PagePathNav';
 import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
 import { usePagePathRenameHandler } from './page-header-utils';
 
-type Props = {
-  currentPagePath: string
+
+export type Props = {
   currentPage: IPagePopulatedToShowRevision
 }
 
 export const PagePathHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { currentPage } = props;
+
+  const currentPagePath = currentPage.path;
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isButtonsShown, setButtonShown] = useState(false);
-  const [inputText, setInputText] = useState('');
+  const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
 
   const { data: editorMode } = useEditorMode();
   const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
 
-  const onRenameFinish = () => {
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const { t } = useTranslation();
+
+  const onRenameFinish = useCallback(() => {
     setRenameInputShown(false);
-  };
+  }, []);
 
-  const onRenameFailure = () => {
+  const onRenameFailure = useCallback(() => {
     setRenameInputShown(true);
-  };
+  }, []);
+
+  const onInputChange = useCallback((inputText: string) => {
+    setEditedPagePath(inputText);
+  }, []);
 
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+  const onPressEnter = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const onPressEscape = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(false);
+  }, [currentPagePath]);
 
-  const isOpened = PageSelectModalData?.isOpened ?? false;
+  const onClickEditButton = useCallback(() => {
+    if (isRenameInputShown) {
+      pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+    }
+    else {
+      setEditedPagePath(currentPagePath);
+      setRenameInputShown(true);
+    }
+  }, [currentPagePath, editedPagePath, isRenameInputShown, pagePathRenameHandler]);
 
+  const isOpened = PageSelectModalData?.isOpened ?? false;
   const isViewMode = editorMode === EditorMode.View;
   const isEditorMode = !isViewMode;
 
   const PagePath = useMemo(() => (
-    <>
-      {currentPagePath != null && (
-        <PagePathNav
-          pageId={currentPage._id}
-          pagePath={currentPagePath}
-          isSingleLineMode={isEditorMode}
-        />
-      )}
-    </>
+    <PagePathNav
+      pageId={currentPage._id}
+      pagePath={currentPagePath}
+      isSingleLineMode={isEditorMode}
+    />
   ), [currentPage._id, currentPagePath, isEditorMode]);
 
-  const handleInputChange = (inputText: string) => {
-    setInputText(inputText);
-  };
-
-  const handleEditButtonClick = () => {
-    if (isRenameInputShown) {
-      pagePathRenameHandler(inputText);
-    }
-    else {
-      setRenameInputShown(true);
-    }
-  };
 
   const buttonStyle = isButtonsShown ? '' : 'd-none';
 
-  const clickOutSideHandler = (e) => {
+  const clickOutSideHandler = useCallback((e) => {
     const container = document.getElementById('page-path-header');
 
     if (container && !container.contains(e.target)) {
       setRenameInputShown(false);
     }
-  };
+  }, []);
 
   useEffect(() => {
     document.addEventListener('click', clickOutSideHandler);
@@ -88,43 +99,49 @@ export const PagePathHeader: FC<Props> = (props) => {
     };
   }, []);
 
+
   return (
-    <>
-      <div
-        id="page-path-header"
-        onMouseLeave={() => setButtonShown(false)}
-      >
-        <div className="row">
-          <div
-            className="col-4"
-            onMouseEnter={() => setButtonShown(true)}
-          >
-            <TextInputForPageTitleAndPath
-              currentPage={currentPage}
-              stateHandler={stateHandler}
-              inputValue={currentPagePath}
-              CustomComponent={PagePath}
-              handleInputChange={handleInputChange}
-            />
-          </div>
-          <div className={`${buttonStyle} col-4 row`}>
-            <div className="col-4">
-              <button type="button" onClick={handleEditButtonClick}>
-                {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
-              </button>
-            </div>
-            <div className="col-4">
-              <button type="button" onClick={openPageSelectModal}>
-                <span className="material-symbols-outlined">account_tree</span>
-              </button>
+    <div
+      id="page-path-header"
+      onMouseLeave={() => setButtonShown(false)}
+    >
+      <div className="row">
+        <div
+          className="col-4"
+          onMouseEnter={() => setButtonShown(true)}
+        >
+          {isRenameInputShown ? (
+            <div className="flex-fill">
+              <ClosableTextInput
+                value={editedPagePath}
+                placeholder={t('Input page name')}
+                onPressEnter={onPressEnter}
+                onPressEscape={onPressEscape}
+                validationTarget={ValidationTarget.PAGE}
+                handleInputChange={onInputChange}
+              />
             </div>
+          ) : (
+            <>{ PagePath }</>
+          )}
+        </div>
+        <div className={`${buttonStyle} col-4 row`}>
+          <div className="col-4">
+            <button type="button" onClick={onClickEditButton}>
+              {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
+            </button>
+          </div>
+          <div className="col-4">
+            <button type="button" onClick={openPageSelectModal}>
+              <span className="material-symbols-outlined">account_tree</span>
+            </button>
           </div>
-          {isOpened
-            && (
-              <PageSelectModal />
-            )}
         </div>
+        {isOpened
+          && (
+            <PageSelectModal />
+          )}
       </div>
-    </>
+    </div>
   );
 };

+ 78 - 18
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,35 +1,95 @@
-import { FC, useState, useMemo } from 'react';
+import type { FC } from 'react';
+import { useState, useCallback } from 'react';
 
 import nodePath from 'path';
 
-import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
 
-import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+import { ValidationTarget } from '~/client/util/input-validator';
 
-type Props = {
-  currentPagePath: string,
-  currentPage: IPagePopulatedToShowRevision;
-}
+import ClosableTextInput from '../Common/ClosableTextInput';
+
+import type { Props } from './PagePathHeader';
+import { usePagePathRenameHandler } from './page-header-utils';
 
 
 export const PageTitleHeader: FC<Props> = (props) => {
-  const { currentPagePath, currentPage } = props;
+  const { currentPage } = props;
+
+  const currentPagePath = currentPage.path;
+
+  const pageTitle = nodePath.basename(currentPagePath) || '/';
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const pageName = nodePath.basename(currentPagePath ?? '') || '/';
+  const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
+  const { t } = useTranslation();
+
+  const editedPageTitle = nodePath.basename(editedPagePath);
+
+  const onRenameFinish = useCallback(() => {
+    setRenameInputShown(false);
+  }, []);
+
+  const onRenameFailure = useCallback(() => {
+    setRenameInputShown(true);
+  }, []);
+
+  const onInputChange = useCallback((inputText: string) => {
+    const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
+    const newPagePath = nodePath.resolve(parentPagePath, inputText);
+
+    setEditedPagePath(newPagePath);
+  }, [currentPage?.path, setEditedPagePath]);
+
+  const onPressEnter = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
+
+  const onPressEscape = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(false);
+  }, [currentPagePath]);
+
+  const onClickButton = useCallback(() => {
+    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
+  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
+
+  const onClickPageTitle = useCallback(() => {
+    setEditedPagePath(currentPagePath);
+    setRenameInputShown(true);
+  }, [currentPagePath]);
 
-  const stateHandler = { isRenameInputShown, setRenameInputShown };
+  const PageTitle = <div onClick={onClickPageTitle}>{pageTitle}</div>;
 
-  const PageTitle = useMemo(() => (<div onClick={() => setRenameInputShown(true)}>{pageName}</div>), [pageName]);
+  const buttonStyle = isRenameInputShown ? '' : 'd-none';
 
   return (
-    <div onBlur={() => setRenameInputShown(false)}>
-      <TextInputForPageTitleAndPath
-        currentPage={currentPage}
-        stateHandler={stateHandler}
-        inputValue={pageName}
-        CustomComponent={PageTitle}
-      />
+    <div className="row">
+      <div className="col-4">
+        {isRenameInputShown ? (
+          <div className="flex-fill">
+            <ClosableTextInput
+              value={editedPageTitle}
+              placeholder={t('Input page name')}
+              onPressEnter={onPressEnter}
+              onPressEscape={onPressEscape}
+              validationTarget={ValidationTarget.PAGE}
+              handleInputChange={onInputChange}
+            />
+          </div>
+        ) : (
+          <>{ PageTitle }</>
+        )}
+      </div>
+      <div className={`col-4 ${buttonStyle}`}>
+        <button type="button" onClick={onClickButton}>
+          <span className="material-symbols-outlined">check_circle</span>
+        </button>
+      </div>
     </div>
   );
 };

+ 0 - 76
apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx

@@ -1,76 +0,0 @@
-import { FC, useCallback } from 'react';
-import type { Dispatch, SetStateAction } from 'react';
-
-import nodePath from 'path';
-
-import type { IPagePopulatedToShowRevision } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-
-import { ValidationTarget } from '~/client/util/input-validator';
-
-import ClosableTextInput from '../Common/ClosableTextInput';
-
-
-import { usePagePathRenameHandler } from './page-header-utils';
-
-
-type StateHandler = {
-  isRenameInputShown: boolean
-  setRenameInputShown: Dispatch<SetStateAction<boolean>>
-}
-
-type Props = {
-  currentPage: IPagePopulatedToShowRevision
-  stateHandler: StateHandler
-  inputValue: string
-  CustomComponent: JSX.Element
-  handleInputChange?: (string) => void
-}
-
-export const TextInputForPageTitleAndPath: FC<Props> = (props) => {
-  const {
-    currentPage, stateHandler, inputValue, CustomComponent, handleInputChange,
-  } = props;
-
-  const { t } = useTranslation();
-
-  const { isRenameInputShown, setRenameInputShown } = stateHandler;
-
-  const onRenameFinish = () => {
-    setRenameInputShown(false);
-  };
-
-  const onRenameFailure = () => {
-    setRenameInputShown(true);
-  };
-
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
-
-  const onPressEnter = useCallback((inputPagePath: string) => {
-
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputPagePath);
-
-    pagePathRenameHandler(newPagePath);
-
-  }, [currentPage.path, pagePathRenameHandler]);
-
-  return (
-    <>
-      {isRenameInputShown ? (
-        <div className="flex-fill">
-          <ClosableTextInput
-            value={inputValue}
-            placeholder={t('Input page name')}
-            onPressEnter={onPressEnter}
-            validationTarget={ValidationTarget.PAGE}
-            handleInputChange={handleInputChange}
-          />
-        </div>
-      ) : (
-        <>{ CustomComponent }</>
-      )}
-    </>
-  );
-};

+ 7 - 5
apps/app/src/components/PageHeader/page-header-utils.ts

@@ -8,16 +8,18 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
 
+type PagePathRenameHandler = (newPagePath: string, onRenameFinish?: () => void, onRenameFailure?: () => void) => Promise<void>
+
 export const usePagePathRenameHandler = (
-    currentPage: IPagePopulatedToShowRevision, onRenameFinish?: () => void, onRenameFailure?: () => void,
-): (newPagePath: string) => Promise<void> => {
+    currentPage: IPagePopulatedToShowRevision,
+): PagePathRenameHandler => {
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { t } = useTranslation();
 
   const currentPagePath = currentPage.path;
 
-  const pagePathRenameHandler = useCallback(async(newPagePath: string) => {
+  const pagePathRenameHandler = useCallback(async(newPagePath, onRenameFinish, onRenameFailure) => {
 
     const onRenamed = (fromPath: string | undefined, toPath: string) => {
       mutatePageTree();
@@ -34,7 +36,6 @@ export const usePagePathRenameHandler = (
     }
 
     try {
-      onRenameFinish?.();
       await apiv3Put('/pages/rename', {
         pageId: currentPage._id,
         revisionId: currentPage.revision._id,
@@ -42,6 +43,7 @@ export const usePagePathRenameHandler = (
       });
 
       onRenamed(currentPage.path, newPagePath);
+      onRenameFinish?.();
 
       toastSuccess(t('renamed_pages', { path: currentPage.path }));
     }
@@ -49,7 +51,7 @@ export const usePagePathRenameHandler = (
       onRenameFailure?.();
       toastError(err);
     }
-  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, onRenameFailure, onRenameFinish, t]);
+  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, t]);
 
   return pagePathRenameHandler;
 };

+ 3 - 2
apps/app/src/components/PageHistory/RevisionDiff.tsx

@@ -3,7 +3,8 @@ import React from 'react';
 import type { IRevisionHasPageId } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { createPatch } from 'diff';
-import { html, Diff2HtmlConfig } from 'diff2html';
+import type { Diff2HtmlConfig } from 'diff2html';
+import { html } from 'diff2html';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import urljoin from 'url-join';
@@ -43,7 +44,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
     drawFileList: false,
   };
 
-  const diffViewHTML = (currentRevision.body && previousRevision.body && revisionDiffOpened) ? html(patch, option) : '';
+  const diffViewHTML = revisionDiffOpened ? html(patch, option) : '';
 
   const diffView = { __html: diffViewHTML };
 

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

@@ -30,6 +30,10 @@ const ApiErrorMessage = (props) => {
         return (
           <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':
         return (
           <>

+ 11 - 14
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { Suspense, useCallback } from 'react';
 
 import { getIdForRef, type IPageHasId, type IPageInfoForOperation } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -37,7 +37,7 @@ type TagsProps = {
 const Tags = (props: TagsProps): JSX.Element => {
   const { pageId, revisionId } = props;
 
-  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId, { suspense: true });
 
   const { data: showTagLabel } = useIsAbleToShowTagLabel();
   const { data: isGuestUser } = useIsGuestUser();
@@ -51,7 +51,7 @@ const Tags = (props: TagsProps): JSX.Element => {
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
   }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
-  if (!showTagLabel) {
+  if (!showTagLabel || tagsInfoData == null) {
     return <></>;
   }
 
@@ -59,16 +59,11 @@ const Tags = (props: TagsProps): JSX.Element => {
 
   return (
     <div className="grw-taglabels-container">
-      { tagsInfoData?.tags != null
-        ? (
-          <PageTags
-            tags={tagsInfoData.tags}
-            isTagLabelsDisabled={isTagLabelsDisabled}
-            onClickEditTagsButton={onClickEditTagsButton}
-          />
-        )
-        : <PageTagsSkeleton />
-      }
+      <PageTags
+        tags={tagsInfoData.tags}
+        isTagLabelsDisabled={isTagLabelsDisabled}
+        onClickEditTagsButton={onClickEditTagsButton}
+      />
     </div>
   );
 };
@@ -97,7 +92,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   return (
     <>
       {/* Tags */}
-      <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      <Suspense fallback={<PageTagsSkeleton />}>
+        <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
+      </Suspense>
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
         {/* Page list */}

+ 3 - 2
apps/app/src/components/PageTags/PageTags.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { Skeleton } from '../Skeleton';
 
@@ -23,7 +24,7 @@ export const PageTags:FC<Props> = (props: Props) => {
   } = props;
 
   if (tags == null) {
-    return <PageTagsSkeleton />;
+    return <></>;
   }
 
   const printNoneClass = tags.length === 0 ? 'd-print-none' : '';

+ 2 - 2
apps/app/src/components/SavePageControls.tsx

@@ -67,7 +67,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     return null;
   }
 
-  const { grant, grantedGroups } = grantData;
+  const { grant, userRelatedGrantedGroups } = grantData;
 
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
@@ -82,7 +82,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
             <GrantSelector
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
-              grantedGroups={grantedGroups}
+              userRelatedGrantedGroups={userRelatedGrantedGroups}
               onUpdateGrant={updateGrantHandler}
             />
           </div>

+ 40 - 18
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -34,7 +34,7 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     name: string,
     type: GroupType,
@@ -51,7 +51,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
   const {
     disabled,
-    grantedGroups,
+    userRelatedGrantedGroups,
     onUpdateGrant,
     grant: currentGrant,
   } = props;
@@ -80,18 +80,23 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroups: undefined });
+      onUpdateGrant({ grant, userRelatedGrantedGroups: undefined });
     }
   }, [onUpdateGrant, showSelectGroupModal]);
 
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type }] });
+      let userRelatedGrantedGroupsCopy = userRelatedGrantedGroups != null ? [...userRelatedGrantedGroups] : [];
+      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
+      if (userRelatedGrantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
+        userRelatedGrantedGroupsCopy.push(grantGroupInfo);
+      }
+      else {
+        userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
+      }
+      onUpdateGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
     }
-
-    // hide modal
-    setIsSelectGroupModalShown(false);
-  }, [onUpdateGrant]);
+  }, [onUpdateGrant, userRelatedGrantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -102,7 +107,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -123,11 +128,19 @@ export const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (grantedGroups != null && grantedGroups.length > 0) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0) {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">{grantedGroups[0].name}</span>
+          <span className="label">
+            {userRelatedGrantedGroups.length > 1
+              ? (
+                <span>
+                  {`${userRelatedGrantedGroups[0].name}... `}
+                  <span className="badge badge-purple">+{userRelatedGrantedGroups.length - 1}</span>
+                </span>
+              ) : userRelatedGrantedGroups[0].name}
+          </span>
         </span>
       );
 
@@ -149,7 +162,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
+  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t]);
 
   /**
    * Render select grantgroup modal.
@@ -180,20 +193,30 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <div className="list-group">
+      <>
         { myUserGroups.map((group) => {
+          const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
+          const activeClass = groupIsGranted ? 'active' : '';
+
           return (
-            <button key={group.item._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
-              <h5 className="d-inline-block">{group.item.name}</h5>
+            <button
+              className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+              type="button"
+              key={group.item._id}
+              onClick={() => groupListItemClickHandler(group)}
+            >
+              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+              <h5 className="d-inline-block ml-3">{group.item.name}</h5>
               {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
         }) }
-      </div>
+        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
+      </>
     );
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
 
   return (
     <>
@@ -202,7 +225,6 @@ export const GrantSelector = (props: Props): JSX.Element => {
       {/* render modal */}
       { !disabled && currentUser != null && (
         <Modal
-          className="select-grant-group"
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
         >

+ 1 - 1
apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -12,7 +12,7 @@ export const DropendToggle = (): JSX.Element => {
   return (
     <DropdownToggle
       color="primary"
-      className={`position-absolute ${moduleClass}`}
+      className={`position-absolute z-1 ${moduleClass}`}
       aria-expanded={false}
     >
       <Hexagon />

+ 1 - 1
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -91,7 +91,7 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const FormerLink = () => (
-    <div className={`${formerLinkClass} small`}>
+    <div className={`${formerLinkClass} ${isSmall ? 'text-truncate' : ''} small`}>
       <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
     </div>
   );

+ 4 - 1
apps/app/src/components/Sidebar/Sidebar.tsx

@@ -6,6 +6,7 @@ import React, {
 import dynamic from 'next/dynamic';
 
 import { SidebarMode } from '~/interfaces/ui';
+import { useIsSearchPage } from '~/stores/context';
 import {
   useDrawerOpened,
   useCollapsedContentsOpened,
@@ -181,6 +182,8 @@ export const Sidebar = (): JSX.Element => {
     isDrawerMode, isCollapsedMode, isDockMode,
   } = useSidebarMode();
 
+  const { data: isSearchPage } = useIsSearchPage();
+
   // css styles
   const grwSidebarClass = styles['grw-sidebar'];
   // eslint-disable-next-line no-nested-ternary
@@ -204,7 +207,7 @@ export const Sidebar = (): JSX.Element => {
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
       ) }
-      { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
+      { sidebarMode != null && !isDockMode() && !isSearchPage && <AppTitleOnSubnavigation /> }
       <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
         <ResizableContainer>
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }

+ 0 - 4
apps/app/src/components/Sidebar/SidebarNav/SidebarNav.module.scss

@@ -3,10 +3,6 @@
 @use '~/styles/variables' as var;
 
 .grw-sidebar-nav :global {
-  // set position and z-index to prevent dropdowns covered by other element
-  position: relative;
-  z-index: bs.$zindex-fixed;
-
   width: var.$grw-sidebar-nav-width;
 
   border-right : 1px solid var(--bs-border-color);

+ 1 - 1
apps/app/src/components/Skeleton.tsx

@@ -12,7 +12,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill && 'rounded-pill'}`}></div>
+      <div className={`grw-skeleton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
     </div>
   );
 };

+ 0 - 1
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx

@@ -149,7 +149,6 @@ export const SyncExecution = ({
       </form>
 
       <Modal
-        className="select-grant-group"
         isOpen={isAlertModalOpen}
         toggle={() => setIsAlertModalOpen(false)}
       >

+ 1 - 1
apps/app/src/interfaces/editor-settings.ts

@@ -1,4 +1,4 @@
-export const DEFAULT_THEME = 'elegant';
+export const DEFAULT_THEME = 'DefaultLight';
 
 const KeyMapMode = {
   default: 'default',

+ 1 - 1
apps/app/src/interfaces/page-operation.ts

@@ -33,6 +33,6 @@ export type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;
   grant: number;
-  // grantUserGroupIds?: IGrantedGroup[];
+  // userRelatedGrantUserGroupIds?: IGrantedGroup[];
   // isSyncRevisionToHackmd?: boolean;
 };

+ 1 - 0
apps/app/src/interfaces/page-tag-relation.ts

@@ -3,4 +3,5 @@ import type { IPage, ITag } from '@growi/core';
 export type IPageTagRelation = {
   relatedPage: IPage,
   relatedTag: ITag,
+  isPageTrashed: boolean,
 }

+ 1 - 1
apps/app/src/interfaces/page.ts

@@ -10,7 +10,7 @@ export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData
 
 export type IPageGrantData = {
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     name: string,
     type: GroupType,

+ 1 - 1
apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:remove-basic-auth-related-config');
+const logger = loggerFactory('growi:granted-group-to-array-of-objects');
 
 module.exports = {
   async up(db, client) {

+ 28 - 0
apps/app/src/migrations/20231223155127-non-null-granted-groups.js

@@ -0,0 +1,28 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:non-null-granted-groups');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+
+    const pageCollection = await db.collection('pages');
+
+    await pageCollection.updateMany(
+      { grantedGroups: { $eq: null } },
+      [
+        {
+          $set: {
+            grantedGroups: [],
+          },
+        },
+      ],
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    // No rollback
+  },
+};

+ 12 - 7
apps/app/src/pages/[[...path]].page.tsx

@@ -3,8 +3,9 @@ import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity } from '@growi/core';
+import { isIPageInfoForEntity, isPopulated } from '@growi/core';
 import type {
+  GroupType,
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
@@ -410,7 +411,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 
   const Page = crowi.model('Page') as PageModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager } = crowi;
+  const { pageService, configManager, pageGrantService } = crowi;
 
   let currentPathname = props.currentPathname;
 
@@ -464,16 +465,20 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     // apply parent page grant, without groups that user isn't related to
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
-      const userRelatedGrantedGroups = await pageService.getUserRelatedGrantedGroups(ancestor, user);
-      props.grantData = {
-        grant: ancestor.grant,
-        grantedGroups: userRelatedGrantedGroups.map((group) => {
+      ancestor.populate('grantedGroups.item');
+      const userRelatedGrantedGroups = (await pageGrantService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
+        if (isPopulated(group.item)) {
           return {
             id: group.item._id,
             name: group.item.name,
             type: group.type,
           };
-        }),
+        }
+        return null;
+      }).filter((info): info is NonNullable<{id: string, name: string, type: GroupType}> => info != null);
+      props.grantData = {
+        grant: ancestor.grant,
+        userRelatedGrantedGroups,
       };
     }
   }

+ 10 - 5
apps/app/src/server/crowi/index.js

@@ -50,6 +50,12 @@ class Crowi {
   /** @type {AppService} */
   appService;
 
+  /** @type {import('../service/page').IPageService} */
+  pageService;
+
+  /** @type UserNotificationService */
+  userNotificationService;
+
   /** @type {FileUploader} */
   fileUploadService;
 
@@ -74,7 +80,6 @@ class Crowi {
     this.mailService = null;
     this.passportService = null;
     this.globalNotificationService = null;
-    this.userNotificationService = null;
     this.xssService = null;
     this.aclService = null;
     this.appService = null;
@@ -86,7 +91,6 @@ class Crowi {
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;
-    this.pageService = null;
     this.syncPageStatusService = null;
     this.cdnResourcesService = new CdnResourcesService();
     this.slackIntegrationService = null;
@@ -715,12 +719,13 @@ Crowi.prototype.setupGrowiPluginService = async function() {
 };
 
 Crowi.prototype.setupPageService = async function() {
-  if (this.pageService == null) {
-    this.pageService = new PageService(this);
-  }
   if (this.pageGrantService == null) {
     this.pageGrantService = new PageGrantService(this);
   }
+  // initialize after pageGrantService since pageService uses pageGrantService in constructor
+  if (this.pageService == null) {
+    this.pageService = new PageService(this);
+  }
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
     await this.pageOperationService.init();

+ 1 - 1
apps/app/src/server/events/activity.ts

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../crowi';
+import type Crowi from '../crowi';
 
 const logger = loggerFactory('growi:events:activity');
 

+ 6 - 3
apps/app/src/server/models/GlobalNotificationSetting.js → apps/app/src/server/models/GlobalNotificationSetting.ts

@@ -2,6 +2,7 @@
 /* eslint-disable no-return-await */
 
 const mongoose = require('mongoose');
+
 const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +11,7 @@ const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
 /**
  * global notifcation event master
  */
-GlobalNotificationSettingSchema.statics.EVENT = {
+export const GlobalNotificationSettingEvent = {
   PAGE_CREATE: 'pageCreate',
   PAGE_EDIT: 'pageEdit',
   PAGE_DELETE: 'pageDelete',
@@ -22,13 +23,15 @@ GlobalNotificationSettingSchema.statics.EVENT = {
 /**
  * global notifcation type master
  */
-GlobalNotificationSettingSchema.statics.TYPE = {
+export const GlobalNotificationSettingType = {
   MAIL: 'mail',
   SLACK: 'slack',
 };
 
-module.exports = function(crowi) {
+const factory = function(crowi) {
   GlobalNotificationSettingClass.crowi = crowi;
   GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
   return mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
 };
+
+export default factory;

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.MAIL,
+    GlobalNotificationSettingType.MAIL,
     new mongoose.Schema({
       toEmail: String,
     }, {

+ 5 - 2
apps/app/src/server/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -1,4 +1,7 @@
-const mongoose = require('mongoose');
+import mongoose from 'mongoose';
+
+import { GlobalNotificationSettingType } from '../GlobalNotificationSetting';
+
 const GlobalNotificationSetting = require('./index');
 
 const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
@@ -10,7 +13,7 @@ module.exports = function(crowi) {
 
   const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
   const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator(
-    GlobalNotificationSetting.schema.statics.TYPE.SLACK,
+    GlobalNotificationSettingType.SLACK,
     new mongoose.Schema({
       slackChannels: String,
     }, {

+ 19 - 0
apps/app/src/server/models/GlobalNotificationSetting/consts.ts

@@ -0,0 +1,19 @@
+/**
+ * global notifcation event master
+ */
+export const GlobalNotificationSettingEvent = {
+  PAGE_CREATE: 'pageCreate',
+  PAGE_EDIT: 'pageEdit',
+  PAGE_DELETE: 'pageDelete',
+  PAGE_MOVE: 'pageMove',
+  PAGE_LIKE: 'pageLike',
+  COMMENT: 'comment',
+};
+
+/**
+ * global notifcation type master
+ */
+export const GlobalNotificationSettingEventType = {
+  MAIL: 'mail',
+  SLACK: 'slack',
+};

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

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

+ 5 - 1
apps/app/src/server/models/index.js → apps/app/src/server/models/index.ts

@@ -1,3 +1,4 @@
+import GlobalNotificationSettingFactory from './GlobalNotificationSetting';
 import Page from './page';
 
 export const modelsDependsOnCrowi = {
@@ -6,7 +7,7 @@ export const modelsDependsOnCrowi = {
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
-  GlobalNotificationSetting: require('./GlobalNotificationSetting'),
+  GlobalNotificationSetting: GlobalNotificationSettingFactory,
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   SlackAppIntegration: require('./slack-app-integration'),
@@ -19,5 +20,8 @@ export * as PageRedirect from './page-redirect';
 export * as ShareLink from './share-link';
 export * as Tag from './tag';
 export * as UserGroup from './user-group';
+export * as PageTagRelation from './page-tag-relation';
 
 export * from './serializers';
+
+export * from './GlobalNotificationSetting';

+ 1 - 1
apps/app/src/server/models/interfaces/page-operation.ts

@@ -23,7 +23,7 @@ export type IUserForResuming = {
 
 export type IOptionsForUpdate = {
   grant?: PageGrant,
-  grantUserGroupIds?: IGrantedGroup[],
+  userRelatedGrantUserGroupIds?: IGrantedGroup[],
   isSyncRevisionToHackmd?: boolean,
   overwriteScopesOfDescendants?: boolean,
 };

+ 1 - 25
apps/app/src/server/models/obsolete-page.js

@@ -639,30 +639,6 @@ export const getPageSchema = (crowi) => {
     return { templateBody, templateTags };
   };
 
-  pageSchema.statics.applyScopesToDescendantsAsyncronously = async function(parentPage, user, isV4 = false) {
-    const builder = new this.PageQueryBuilder(this.find());
-    builder.addConditionToListOnlyDescendants(parentPage.path);
-
-    if (isV4) {
-      builder.addConditionAsRootOrNotOnTree();
-    }
-    else {
-      builder.addConditionAsOnTree();
-    }
-
-    // add grant conditions
-    await addConditionToFilteringByViewerToEdit(builder, user);
-
-    const grant = parentPage.grant;
-
-    await builder.query.updateMany({}, {
-      grant,
-      grantedGroups: grant === PageGrant.GRANT_USER_GROUP ? parentPage.grantedGroups : null,
-      grantedUsers: grant === PageGrant.GRANT_OWNER ? [user._id] : null,
-    });
-
-  };
-
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
     const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
@@ -676,7 +652,7 @@ export const getPageSchema = (crowi) => {
         updateOne: {
           filter: { _id: page._id },
           update: {
-            grantedGroups: null,
+            grantedGroups: [],
             grant: this.GRANT_PUBLIC,
           },
         },

+ 0 - 180
apps/app/src/server/models/page-tag-relation.js

@@ -1,180 +0,0 @@
-import Tag from './tag';
-
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const flatMap = require('array.prototype.flatmap');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedPage: {
-    type: ObjectId,
-    ref: 'Page',
-    required: true,
-    index: true,
-  },
-  relatedTag: {
-    type: ObjectId,
-    ref: 'Tag',
-    required: true,
-    index: true,
-  },
-  isPageTrashed: {
-    type: Boolean,
-    default: false,
-    required: true,
-    index: true,
-  },
-});
-// define unique compound index
-schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * PageTagRelation Class
- *
- * @class PageTagRelation
- */
-class PageTagRelation {
-
-  static async createTagListWithCount(option) {
-    const opt = option || {};
-    const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset;
-    const limit = opt.limit;
-
-    const tags = await this.aggregate()
-      .match({ isPageTrashed: false })
-      .lookup({
-        from: 'tags',
-        localField: 'relatedTag',
-        foreignField: '_id',
-        as: 'tag',
-      })
-      .unwind('$tag')
-      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
-      .sort(sortOpt)
-      .skip(offset)
-      .limit(limit);
-
-    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
-
-    return { data: tags, totalCount };
-  }
-
-  static async findByPageId(pageId, options = {}) {
-    const isAcceptRelatedTagNull = options.nullable || null;
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
-    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
-  }
-
-  static async listTagNamesByPage(pageId) {
-    const relations = await this.findByPageId(pageId);
-    return relations.map((relation) => { return relation.relatedTag.name });
-  }
-
-  /**
-   * @return {object} key: Page._id, value: array of tag names
-   */
-  static async getIdToTagNamesMap(pageIds) {
-    /**
-     * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
-     *
-     * results will be:
-     * [
-     *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
-     *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
-     *   ...
-     * ]
-     */
-    const results = await this.aggregate()
-      .match({ relatedPage: { $in: pageIds } })
-      .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
-
-    if (results.length === 0) {
-      return {};
-    }
-
-    results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
-
-    // extract distinct tag ids
-    const allTagIds = results
-      .flatMap(result => result.tagIds); // map + flatten
-    const distinctTagIds = Array.from(new Set(allTagIds));
-
-    // TODO: set IdToNameMap type by 93933
-    const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
-
-    // convert to map
-    const idToTagNamesMap = {};
-    results.forEach((result) => {
-      const tagNames = result.tagIds
-        .map(tagId => tagIdToNameMap[tagId])
-        .filter(tagName => tagName != null); // filter null object
-
-      idToTagNamesMap[result._id] = tagNames;
-    });
-
-    return idToTagNamesMap;
-  }
-
-  static async updatePageTags(pageId, tags) {
-    if (pageId == null || tags == null) {
-      throw new Error('args \'pageId\' and \'tags\' are required.');
-    }
-
-    // filter empty string
-    // eslint-disable-next-line no-param-reassign
-    tags = tags.filter((tag) => { return tag !== '' });
-
-    // get relations for this page
-    const relations = await this.findByPageId(pageId, { nullable: true });
-
-    const unlinkTagRelationIds = [];
-    const relatedTagNames = [];
-
-    relations.forEach((relation) => {
-      if (relation.relatedTag == null) {
-        unlinkTagRelationIds.push(relation._id);
-      }
-      else {
-        relatedTagNames.push(relation.relatedTag.name);
-        if (!tags.includes(relation.relatedTag.name)) {
-          unlinkTagRelationIds.push(relation._id);
-        }
-      }
-    });
-    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
-    // find or create tags
-    const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
-    const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
-
-    // create relations
-    const bulkCreatePromise = this.insertMany(
-      tagEntities.map((relatedTag) => {
-        return {
-          relatedPage: pageId,
-          relatedTag,
-        };
-      }),
-    );
-
-    return Promise.all([bulkDeletePromise, bulkCreatePromise]);
-  }
-
-}
-
-module.exports = function() {
-  schema.loadClass(PageTagRelation);
-  const model = mongoose.model('PageTagRelation', schema);
-  return model;
-};

+ 208 - 0
apps/app/src/server/models/page-tag-relation.ts

@@ -0,0 +1,208 @@
+import type { ITag } from '@growi/core';
+import type { Document, Model } from 'mongoose';
+import mongoose, { ObjectId } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
+
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import type { IdToNameMap } from './tag';
+import Tag from './tag';
+
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+
+// disable no-return-await for model functions
+/* eslint-disable no-return-await */
+
+const flatMap = require('array.prototype.flatmap');
+
+
+export interface PageTagRelationDocument extends IPageTagRelation, Document {
+}
+
+type CreateTagListWithCountOpts = {
+  sortOpt?: any,
+  offset?: number,
+  limit?: number,
+}
+type CreateTagListWithCountResult = {
+  data: ITag[],
+  totalCount: number
+}
+type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
+
+type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNameMap>;
+
+type UpdatePageTags = (this: PageTagRelationModel, pageId: string, tags: string[]) => Promise<void>
+
+export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
+  createTagListWithCount: CreateTagListWithCount
+  findByPageId(pageId: string, options?: { nullable?: boolean }): Promise<PageTagRelationDocument[]>
+  listTagNamesByPage(pageId: string): Promise<PageTagRelationDocument[]>
+  getIdToTagNamesMap: GetIdToTagNamesMap
+  updatePageTags: UpdatePageTags
+}
+
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
+  relatedPage: {
+    type: ObjectId,
+    ref: 'Page',
+    required: true,
+    index: true,
+  },
+  relatedTag: {
+    type: ObjectId,
+    ref: 'Tag',
+    required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
+  },
+});
+// define unique compound index
+schema.index({ relatedPage: 1, relatedTag: 1 }, { unique: true });
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+const createTagListWithCount: CreateTagListWithCount = async function(this, opts) {
+  const sortOpt = opts?.sortOpt || {};
+  const offset = opts?.offset ?? 0;
+  const limit = opts?.limit;
+
+  let query = this.aggregate()
+    .match({ isPageTrashed: false })
+    .lookup({
+      from: 'tags',
+      localField: 'relatedTag',
+      foreignField: '_id',
+      as: 'tag',
+    })
+    .unwind('$tag')
+    .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+    .sort(sortOpt)
+    .skip(offset);
+
+  if (limit != null) {
+    query = query.limit(limit);
+  }
+
+  const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+  return { data: await query.exec(), totalCount };
+};
+schema.statics.createTagListWithCount = createTagListWithCount;
+
+schema.statics.findByPageId = async function(pageId, options = {}) {
+  const isAcceptRelatedTagNull = options.nullable || null;
+  const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+  return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
+};
+
+schema.statics.listTagNamesByPage = async function(pageId) {
+  const relations = await this.findByPageId(pageId);
+  return relations.map((relation) => { return relation.relatedTag.name });
+};
+
+
+const getIdToTagNamesMap: GetIdToTagNamesMap = async function(this, pageIds) {
+  /**
+   * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
+   *
+   * results will be:
+   * [
+   *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
+   *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
+   *   ...
+   * ]
+   */
+  const results = await this.aggregate<{ _id: ObjectId, tagIds: ObjectIdLike[] }>()
+    .match({ relatedPage: { $in: pageIds } })
+    .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
+
+  if (results.length === 0) {
+    return {};
+  }
+
+  results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
+
+  // extract distinct tag ids
+  const allTagIds = results
+    .flatMap(result => result.tagIds); // map + flatten
+  const distinctTagIds = Array.from(new Set(allTagIds));
+
+  // TODO: set IdToNameMap type by 93933
+  const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
+
+  // convert to map
+  const idToTagNamesMap = {};
+  results.forEach((result) => {
+    const tagNames = result.tagIds
+      .map(tagId => tagIdToNameMap[tagId.toString()])
+      .filter(tagName => tagName != null); // filter null object
+
+    idToTagNamesMap[result._id.toString()] = tagNames;
+  });
+
+  return idToTagNamesMap;
+};
+schema.statics.getIdToTagNamesMap = getIdToTagNamesMap;
+
+const updatePageTags: UpdatePageTags = async function(pageId, tags) {
+  if (pageId == null || tags == null) {
+    throw new Error('args \'pageId\' and \'tags\' are required.');
+  }
+
+  // filter empty string
+  // eslint-disable-next-line no-param-reassign
+  tags = tags.filter((tag) => { return tag !== '' });
+
+  // get relations for this page
+  const relations = await this.findByPageId(pageId, { nullable: true });
+
+  const unlinkTagRelationIds: string[] = [];
+  const relatedTagNames: string[] = [];
+
+  relations.forEach((relation) => {
+    if (relation.relatedTag == null) {
+      unlinkTagRelationIds.push(relation._id);
+    }
+    else {
+      relatedTagNames.push(relation.relatedTag.name);
+      if (!tags.includes(relation.relatedTag.name)) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+    }
+  });
+  const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
+  // find or create tags
+  const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
+  const tagEntities = await Tag.findOrCreateMany(tagsToCreate);
+
+  // create relations
+  const bulkCreatePromise = this.insertMany(
+    tagEntities.map((relatedTag) => {
+      return {
+        relatedPage: pageId,
+        relatedTag,
+      };
+    }),
+  );
+
+  await Promise.all([bulkDeletePromise, bulkCreatePromise]);
+};
+schema.statics.updatePageTags = updatePageTags;
+
+export default getOrCreateModel<PageTagRelationDocument, PageTagRelationModel>('PageTagRelation', schema);

+ 8 - 4
apps/app/src/server/models/page.ts

@@ -8,26 +8,25 @@ import {
   type IGrantedGroup,
   GroupType, type HasObjectId,
 } from '@growi/core';
+import type { ITag } from '@growi/core/dist/interfaces';
 import { isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash, collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
+import type { Model, Document, AnyObject } from 'mongoose';
 import mongoose, {
-  Schema, Model, Document, AnyObject,
+  Schema,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
-import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
@@ -76,6 +75,10 @@ export interface PageModel extends Model<PageDocument> {
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
+  findTemplate(path: string): Promise<{
+    templateBody?: string,
+    templateTags?: string[],
+  }>
 
   PageQueryBuilder: typeof PageQueryBuilder
 
@@ -127,6 +130,7 @@ const schema = new Schema<PageDocument, PageModel>({
       return arr.length === uniqueItemValues.size;
     }, 'grantedGroups contains non unique item'],
     default: [],
+    required: true,
   },
   creator: { type: ObjectId, ref: 'User', index: true },
   lastUpdateUser: { type: ObjectId, ref: 'User' },

+ 3 - 4
apps/app/src/server/models/tag.ts

@@ -1,8 +1,7 @@
-import {
-  Types, Model, Schema,
-} from 'mongoose';
+import type { Types, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 const mongoosePaginate = require('mongoose-paginate-v2');

+ 1 - 1
apps/app/src/server/routes/apiv3/interfaces/apiv3-response.ts

@@ -1,4 +1,4 @@
-import { Response } from 'express';
+import type { Response } from 'express';
 
 export interface ApiV3Response extends Response {
   apiv3(obj?: any, status?: number): any

+ 7 - 6
apps/app/src/server/routes/apiv3/notification-setting.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
+import { GlobalNotificationSettingType } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
@@ -283,11 +284,11 @@ module.exports = (crowi) => {
 
     let notification;
 
-    if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+    if (notifyType === GlobalNotificationSettingType.MAIL) {
       notification = new GlobalNotificationMailSetting(crowi);
       notification.toEmail = toEmail;
     }
-    if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+    if (notifyType === GlobalNotificationSettingType.SLACK) {
       notification = new GlobalNotificationSlackSetting(crowi);
       notification.slackChannels = slackChannels;
     }
@@ -350,8 +351,8 @@ module.exports = (crowi) => {
     } = req.body;
 
     const models = {
-      [GlobalNotificationSetting.TYPE.MAIL]: GlobalNotificationMailSetting,
-      [GlobalNotificationSetting.TYPE.SLACK]: GlobalNotificationSlackSetting,
+      [GlobalNotificationSettingType.MAIL]: GlobalNotificationMailSetting,
+      [GlobalNotificationSettingType.SLACK]: GlobalNotificationSlackSetting,
     };
 
     try {
@@ -368,11 +369,11 @@ module.exports = (crowi) => {
         setting = setting.toObject();
       }
 
-      if (notifyType === GlobalNotificationSetting.TYPE.MAIL) {
+      if (notifyType === GlobalNotificationSettingType.MAIL) {
         setting = GlobalNotificationMailSetting.hydrate(setting);
         setting.toEmail = toEmail;
       }
-      if (notifyType === GlobalNotificationSetting.TYPE.SLACK) {
+      if (notifyType === GlobalNotificationSettingType.SLACK) {
         setting = GlobalNotificationSlackSetting.hydrate(setting);
         setting.slackChannels = slackChannels;
       }

+ 13 - 6
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageInfoForListing, IPageInfo, IUserHasId,
+  IPageInfoForListing, IPageInfo,
 } from '@growi/core';
 import { isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
@@ -8,12 +8,12 @@ import { query, oneOf } from 'express-validator';
 import mongoose from 'mongoose';
 
 
+import { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { PageModel } from '../../models/page';
-import PageService from '../../service/page';
 
 import { ApiV3Response } from './interfaces/apiv3-response';
 
@@ -75,7 +75,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/ancestors-children', accessTokenParser, loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
     try {
       const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
       return res.apiv3({ ancestorsChildren });
@@ -94,7 +94,7 @@ const routerFactory = (crowi: Crowi): Router => {
   router.get('/children', accessTokenParser, loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const { id, path } = req.query;
 
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
 
     try {
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer((id || path)as string, req.user);
@@ -118,7 +118,9 @@ const routerFactory = (crowi: Crowi): Router => {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Bookmark = crowi.model('Bookmark');
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    const pageService: PageService = crowi.pageService!;
+    const pageService = crowi.pageService;
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const pageGrantService: IPageGrantService = crowi.pageGrantService!;
 
     try {
       const pages = pageIds != null
@@ -140,16 +142,21 @@ const routerFactory = (crowi: Crowi): Router => {
       const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> = {};
 
       const isGuestUser = req.user == null;
+
+      const userRelatedGroups = await pageGrantService.getUserRelatedGroups(req.user);
+
       for (const page of pages) {
         // construct isIPageInfoForListing
         const basicPageInfo = pageService.constructBasicPageInfo(page, isGuestUser);
 
+        const canDeleteCompletely = pageService.canDeleteCompletely(page, req.user, false, userRelatedGroups); // use normal delete config
+
         const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           // create IPageInfoForListing
           : {
             ...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,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;

+ 234 - 0
apps/app/src/server/routes/apiv3/page/cteate-page.ts

@@ -0,0 +1,234 @@
+import type {
+  IGrantedGroup,
+  IPage, IUser, IUserHasId, PageGrant,
+} from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+import { addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { body } from 'express-validator';
+import mongoose from 'mongoose';
+
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import type Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import {
+  GlobalNotificationSettingEvent, serializePageSecurely, serializeRevisionSecurely,
+} from '~/server/models';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument, PageModel } from '~/server/models/page';
+import PageTagRelation from '~/server/models/page-tag-relation';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:create-page');
+
+
+async function generateUniquePath(basePath: string, index = 1): Promise<string> {
+  const Page = mongoose.model<IPage>('Page');
+
+  const path = basePath + index;
+  const existingPageId = await Page.exists({ path, isEmpty: false });
+  if (existingPageId != null) {
+    return generateUniquePath(basePath, index + 1);
+  }
+  return path;
+}
+
+type ReqBody = {
+  path: string,
+
+  grant?: PageGrant,
+  grantUserGroupIds?: IGrantedGroup[],
+
+  body?: string,
+  overwriteScopesOfDescendants?: boolean,
+  isSlackEnabled?: boolean,
+  slackChannels?: any,
+  pageTags?: string[],
+  shouldGeneratePath?: boolean,
+}
+
+interface CreatePageRequest extends Request<undefined, ApiV3Response, ReqBody> {
+  user: IUserHasId,
+}
+
+type CreatePageHandlersFactory = (crowi: Crowi) => RequestHandler[];
+
+export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const User = mongoose.model<IUser, { isExistUserByUserPagePath: any }>('User');
+
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const globalNotificationService = crowi.getGlobalNotificationService();
+  const userNotificationService = crowi.getUserNotificationService();
+
+
+  async function saveTagsAction({ createdPage, pageTags }: { createdPage: PageDocument, pageTags: string[] }) {
+    if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
+      return PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    return [];
+  }
+
+  const validator: ValidationChain[] = [
+    body('body').optional().isString()
+      .withMessage('body must be string or undefined'),
+    body('path').exists().not().isEmpty({ ignore_whitespace: true })
+      .withMessage('path is required'),
+    body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
+    body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
+    body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
+    body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
+    body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
+    validator, apiV3FormValidator,
+    async(req: CreatePageRequest, res: ApiV3Response) => {
+      const {
+        body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
+      } = req.body;
+
+      let { path, grant, grantUserGroupIds } = req.body;
+
+      // check whether path starts slash
+      path = addHeadingSlash(path);
+
+      if (shouldGeneratePath) {
+        try {
+          const rootPath = '/';
+          const defaultTitle = '/Untitled';
+          const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
+          path = await generateUniquePath(basePath);
+
+          // if the generated path is not creatable, create the path under the root path
+          if (!isCreatablePage(path)) {
+            path = await generateUniquePath(defaultTitle);
+            // initialize grant data
+            grant = 1;
+            grantUserGroupIds = undefined;
+          }
+        }
+        catch (err) {
+          return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
+        }
+      }
+
+      if (!isCreatablePage(path)) {
+        return res.apiv3Err(`Could not use the path '${path}'`);
+      }
+
+      if (isUserPage(path)) {
+        const isExistUser = await User.isExistUserByUserPagePath(path);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+        }
+      }
+
+      const options: IOptionsForCreate = { overwriteScopesOfDescendants };
+      if (grant != null) {
+        options.grant = grant;
+        options.grantUserGroupIds = grantUserGroupIds;
+      }
+
+      const isNoBodyPage = body === undefined;
+      let initialTags: string[] = [];
+      let initialBody = '';
+      if (isNoBodyPage) {
+        const isEnabledAttachTitleHeader = await configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
+        if (isEnabledAttachTitleHeader) {
+          initialBody += `${attachTitleHeader(path)}\n`;
+        }
+
+        const templateData = await Page.findTemplate(path);
+        if (templateData.templateTags != null) {
+          initialTags = templateData.templateTags;
+        }
+        if (templateData.templateBody != null) {
+          initialBody += `${templateData.templateBody}\n`;
+        }
+      }
+
+      let createdPage;
+      try {
+        createdPage = await crowi.pageService.create(
+          path,
+          body ?? initialBody,
+          req.user,
+          options,
+        );
+      }
+      catch (err) {
+        logger.error('Error occurred while creating a page.', err);
+        return res.apiv3Err(err);
+      }
+
+      const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : (pageTags ?? ['']) });
+
+      const result = {
+        page: serializePageSecurely(createdPage),
+        tags: savedTags,
+        revision: serializeRevisionSecurely(createdPage.revision),
+      };
+
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: createdPage,
+        action: SupportedAction.ACTION_PAGE_CREATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      res.apiv3(result, 201);
+
+      try {
+      // global notification
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, createdPage, req.user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
+
+      // user notification
+      if (isSlackEnabled) {
+        try {
+          const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
+          results.forEach((result) => {
+            if (result.status === 'rejected') {
+              logger.error('Create user notification failed', result.reason);
+            }
+          });
+        }
+        catch (err) {
+          logger.error('Create user notification failed', err);
+        }
+      }
+
+      // create subscription
+      try {
+        await crowi.inAppNotificationService.createSubscription(req.user._id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
+      }
+      catch (err) {
+        logger.error('Failed to create subscription document', err);
+      }
+    },
+  ];
+};

+ 17 - 15
apps/app/src/server/routes/apiv3/page.js → apps/app/src/server/routes/apiv3/page/index.js

@@ -12,8 +12,10 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
+import { GlobalNotificationSettingEvent } from '~/server/models';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
+import { configManager } from '~/server/service/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
@@ -166,16 +168,14 @@ const router = express.Router();
  *            example: 5e07345972560e001761fa63
  */
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  const configManager = crowi.configManager;
-
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { Page, GlobalNotificationSetting } = crowi.models;
+  const { Page } = crowi.models;
   const { pageService, exportService } = crowi;
 
   const activityEvent = crowi.event('activity');
@@ -372,7 +372,7 @@ module.exports = (crowi) => {
     if (isLiked) {
       try {
         // global notification
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_LIKE, page, req.user);
       }
       catch (err) {
         logger.error('Like notification failed', err);
@@ -478,7 +478,8 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(grantedGroups);
+    const userRelatedGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(page, req.user);
+    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(userRelatedGrantedGroups);
     const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
     const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
     const grantedUserGroupData = currentPageUserGroups.map((group) => {
@@ -489,7 +490,7 @@ module.exports = (crowi) => {
     });
     const currentPageGrant = {
       grant,
-      grantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
+      userRelatedGrantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
     };
 
     // page doesn't have parent page
@@ -514,10 +515,11 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
+    const userRelatedParentGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(parentPage, req.user);
     const {
       grantedUserGroups: parentGrantedUserGroupIds,
       grantedExternalUserGroups: parentGrantedExternalUserGroupIds,
-    } = divideByType(parentPage.grantedGroups);
+    } = divideByType(userRelatedParentGrantedGroups);
     const parentPageUserGroups = await UserGroup.find({ _id: { $in: parentGrantedUserGroupIds } });
     const parentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: parentGrantedExternalUserGroupIds } });
     const parentGrantedUserGroupData = parentPageUserGroups.map((group) => {
@@ -528,7 +530,7 @@ module.exports = (crowi) => {
     });
     const parentPageGrant = {
       grant: parentPage.grant,
-      grantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
+      userRelatedGrantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
     };
 
     const grantData = {
@@ -565,10 +567,10 @@ module.exports = (crowi) => {
 
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
-    const { grant, grantedGroups } = req.body;
+    const { grant, userRelatedGrantedGroups } = req.body;
 
     // TODO: remove in https://redmine.weseek.co.jp/issues/136137
-    if (grantedGroups != null && grantedGroups.length > 1) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 1) {
       return res.apiv3Err('Cannot grant multiple groups to page at the moment');
     }
 
@@ -584,7 +586,7 @@ module.exports = (crowi) => {
     let data;
     try {
       const shouldUseV4Process = false;
-      const grantData = { grant, grantedGroups };
+      const grantData = { grant, userRelatedGrantedGroups };
       data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     catch (err) {

+ 21 - 201
apps/app/src/server/routes/apiv3/pages.js → apps/app/src/server/routes/apiv3/pages/index.js

@@ -2,25 +2,27 @@
 import { PageGrant } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
-import { normalizePath, addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
+import { normalizePath, addHeadingSlash } from '@growi/core/dist/utils/path-utils';
+import express from 'express';
+import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { GlobalNotificationSettingEvent } from '~/server/models';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
-import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../../middlewares/exclude-read-only-user';
+import { serializePageSecurely } from '../../../models/serializers/page-serializer';
+import { serializeUserSecurely } from '../../../models/serializers/user-serializer';
+import { isV5ConversionError } from '../../../models/vo/v5-conversion-error';
+import { createPageHandlersFactory } from '../page/cteate-page';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
-const { body } = require('express-validator');
-const { query } = require('express-validator');
-const mongoose = require('mongoose');
-
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
@@ -144,40 +146,21 @@ const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
  */
 
 module.exports = (crowi) => {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequired = require('../../middlewares/login-required')(crowi, true);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../../middlewares/admin-required')(crowi);
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
   const activityEvent = crowi.event('activity');
 
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const userNotificationService = crowi.getUserNotificationService();
-
-  const { serializePageSecurely } = require('../../models/serializers/page-serializer');
-  const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
-  const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const validator = {
-    createPage: [
-      body('body').optional().isString()
-        .withMessage('body must be string or undefined'),
-      body('path').exists().not().isEmpty({ ignore_whitespace: true })
-        .withMessage('path is required'),
-      body('grant').if(value => value != null).isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
-      body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
-      body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
-      body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-      body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
-      body('shouldGeneratePath').optional().isBoolean().withMessage('shouldGeneratePath is must be boolean or undefined'),
-    ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
@@ -222,33 +205,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  async function createPageAction({
-    path, body, user, options,
-  }) {
-    const createdPage = await crowi.pageService.create(path, body, user, options);
-    return createdPage;
-  }
-
-  async function saveTagsAction({ createdPage, pageTags }) {
-    if (pageTags != null) {
-      const tagEvent = crowi.event('tag');
-      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
-      tagEvent.emit('update', createdPage, pageTags);
-      return PageTagRelation.listTagNamesByPage(createdPage.id);
-    }
-
-    return [];
-  }
-
-  async function generateUniquePath(basePath, index = 1) {
-    const path = basePath + index;
-    const existingPageId = await Page.exists({ path, isEmpty: false });
-    if (existingPageId != null) {
-      return generateUniquePath(basePath, index + 1);
-    }
-    return path;
-  }
-
   /**
    * @swagger
    *
@@ -304,137 +260,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
-    const {
-      // body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-      body, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
-    } = req.body;
-
-    let { path, grant, grantUserGroupIds } = req.body;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    // check whether path starts slash
-    path = addHeadingSlash(path);
-
-    if (shouldGeneratePath) {
-      try {
-        const rootPath = '/';
-        const defaultTitle = '/Untitled';
-        const basePath = path === rootPath ? defaultTitle : path + defaultTitle;
-        path = await generateUniquePath(basePath);
-
-        // if the generated path is not creatable, create the path under the root path
-        if (!isCreatablePage(path)) {
-          path = await generateUniquePath(defaultTitle);
-          // initialize grant data
-          grant = 1;
-          grantUserGroupIds = undefined;
-        }
-      }
-      catch (err) {
-        return res.apiv3Err(new ErrorV3('Failed to generate unique path'));
-      }
-    }
-
-    if (!isCreatablePage(path)) {
-      return res.apiv3Err(`Could not use the path '${path}'`);
-    }
-
-    if (isUserPage(path)) {
-      const isExistUser = await User.isExistUserByUserPagePath(path);
-      if (!isExistUser) {
-        return res.apiv3Err("Unable to create a page under a non-existent user's user page");
-      }
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const isNoBodyPage = body === undefined;
-    let initialTags = [];
-    let initialBody = '';
-    if (isNoBodyPage) {
-      const isEnabledAttachTitleHeader = await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader');
-      if (isEnabledAttachTitleHeader) {
-        initialBody += `${attachTitleHeader(path)}\n`;
-      }
-
-      const templateData = await Page.findTemplate(path);
-      if (templateData?.templateTags != null) {
-        initialTags = templateData.templateTags;
-      }
-      if (templateData?.templateBody != null) {
-        initialBody += `${templateData.templateBody}\n`;
-      }
-    }
-
-    let createdPage;
-    try {
-      createdPage = await createPageAction({
-        path, body: isNoBodyPage ? initialBody : body, user: req.user, options,
-      });
-    }
-    catch (err) {
-      logger.error('Error occurred while creating a page.', err);
-      return res.apiv3Err(err);
-    }
-
-    const savedTags = await saveTagsAction({ createdPage, pageTags: isNoBodyPage ? initialTags : pageTags });
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      tags: savedTags,
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: createdPage,
-      action: SupportedAction.ACTION_PAGE_CREATE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    res.apiv3(result, 201);
-
-    try {
-      // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create grobal notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-
-    // create subscription
-    try {
-      await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
-      logger.error('Failed to create subscription document', err);
-    }
-  });
-
+  router.post('/', createPageHandlersFactory(crowi));
 
   /**
    * @swagger
@@ -471,7 +297,6 @@ module.exports = (crowi) => {
         }
       });
 
-      const PageTagRelation = mongoose.model('PageTagRelation');
       const ids = result.pages.map((page) => { return page._id });
       const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
 
@@ -622,7 +447,7 @@ module.exports = (crowi) => {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, renamedPage, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_MOVE, renamedPage, req.user, {
         oldPath: page.path,
       });
     }
@@ -802,7 +627,7 @@ module.exports = (crowi) => {
    */
   router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
     async(req, res) => {
-      const { pageId, isRecursively } = req.body;
+      const { pageId, isRecursively, onlyDuplicateUserRelatedResources } = req.body;
 
       const newPagePath = normalizePath(req.body.pageNameInput);
 
@@ -826,11 +651,6 @@ module.exports = (crowi) => {
 
       const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
-      // TODO: remove in https://redmine.weseek.co.jp/issues/136139
-      if (page.grantedGroups != null && page.grantedGroups.length > 1) {
-        return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-      }
-
       const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
       if (page == null || isEmptyAndNotRecursively) {
         res.code = 'Page is not found';
@@ -838,14 +658,14 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
       }
 
-      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
+      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively, onlyDuplicateUserRelatedResources);
       const result = { page: serializePageSecurely(newParentPage) };
 
       // copy the page since it's used and updated in crowi.pageService.duplicate
       const copyPage = { ...page };
       copyPage.path = newPagePath;
       try {
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
+        await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_CREATE, copyPage, req.user);
       }
       catch (err) {
         logger.error('Create grobal notification failed', err);

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

+ 2 - 1
apps/app/src/server/routes/comment.js

@@ -3,6 +3,7 @@ import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { preNotifyService } from '../service/pre-notify';
 
 /**
@@ -281,7 +282,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.COMMENT, page, req.user, {
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.COMMENT, page, req.user, {
         comment: createdComment,
       });
     }

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -121,7 +121,6 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
@@ -130,7 +129,6 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
   apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
-  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
   apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);

+ 11 - 257
apps/app/src/server/routes/page.js

@@ -5,7 +5,9 @@ import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent } from '../models';
 import { PathAlreadyExistsError } from '../models/errors';
+import PageTagRelation from '../models/page-tag-relation';
 import UpdatePost from '../models/update-post';
 import { preNotifyService } from '../service/pre-notify';
 
@@ -137,12 +139,9 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
-  const { pathUtils, pagePathUtils } = require('@growi/core/dist/utils');
+  const { pagePathUtils } = require('@growi/core/dist/utils');
 
   const Page = crowi.model('Page');
-  const User = crowi.model('User');
-  const PageTagRelation = crowi.model('PageTagRelation');
-  const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const PageRedirect = mongoose.model('PageRedirect');
 
   const ApiResponse = require('../util/apiResponse');
@@ -221,171 +220,6 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.validator = validator;
 
-  /**
-   * @swagger
-   *
-   *    /pages.list:
-   *      get:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: listPages
-   *        summary: /pages.list
-   *        description: Get list of pages
-   *        parameters:
-   *          - in: query
-   *            name: path
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/path'
-   *          - in: query
-   *            name: user
-   *            schema:
-   *              $ref: '#/components/schemas/User/properties/username'
-   *          - in: query
-   *            name: limit
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *          - in: query
-   *            name: offset
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get list of pages.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/Page'
-   *                      description: page list
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /pages.list List pages by user
-   * @apiName ListPage
-   * @apiGroup Page
-   *
-   * @apiParam {String} path
-   * @apiParam {String} user
-   */
-  api.list = async function(req, res) {
-    const username = req.query.user || null;
-    const path = req.query.path || null;
-    const limit = +req.query.limit || 50;
-    const offset = parseInt(req.query.offset) || 0;
-
-    const queryOptions = { offset, limit: limit + 1 };
-
-    // Accepts only one of these
-    if (username === null && path === null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-    if (username !== null && path !== null) {
-      return res.json(ApiResponse.error('Parameter user or path is required.'));
-    }
-
-    try {
-      let result = null;
-      if (path == null) {
-        const user = await User.findUserByUsername(username);
-        if (user === null) {
-          throw new Error('The user not found.');
-        }
-        result = await Page.findListByCreator(user, req.user, queryOptions);
-      }
-      else {
-        result = await Page.findListByStartWith(path, req.user, queryOptions);
-      }
-
-      if (result.pages.length > limit) {
-        result.pages.pop();
-      }
-
-      result.pages.forEach((page) => {
-        if (page.lastUpdateUser != null && page.lastUpdateUser instanceof User) {
-          page.lastUpdateUser = serializeUserSecurely(page.lastUpdateUser);
-        }
-      });
-
-      return res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
-  // TODO If everything that depends on this route, delete it too
-  api.create = async function(req, res) {
-    const body = req.body.body || null;
-    let pagePath = req.body.path || null;
-    const grant = req.body.grant || null;
-    const grantUserGroupIds = req.body.grantUserGroupIds || null;
-    const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
-    const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
-    const slackChannels = req.body.slackChannels || null;
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    if (body === null || pagePath === null) {
-      return res.json(ApiResponse.error('Parameters body and path are required.'));
-    }
-
-    // check whether path starts slash
-    pagePath = pathUtils.addHeadingSlash(pagePath);
-
-    // check page existence
-    const isExist = await Page.count({ path: pagePath }) > 0;
-    if (isExist) {
-      return res.json(ApiResponse.error('Page exists', 'already_exists'));
-    }
-
-    const options = { overwriteScopesOfDescendants };
-    if (grant != null) {
-      options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
-    }
-
-    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
-
-    const result = {
-      page: serializePageSecurely(createdPage),
-      revision: serializeRevisionSecurely(createdPage.revision),
-    };
-    res.json(ApiResponse.success(result));
-
-    // global notification
-    try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create notification failed', err);
-    }
-
-    // user notification
-    if (isSlackEnabled) {
-      try {
-        const results = await userNotificationService.fire(createdPage, req.user, slackChannels, 'create');
-        results.forEach((result) => {
-          if (result.status === 'rejected') {
-            logger.error('Create user notification failed', result.reason);
-          }
-        });
-      }
-      catch (err) {
-        logger.error('Create user notification failed', err);
-      }
-    }
-  };
-
   /**
    * @swagger
    *
@@ -449,16 +283,11 @@ module.exports = function(crowi, app) {
     const pageId = req.body.page_id || null;
     const revisionId = req.body.revision_id || null;
     const grant = req.body.grant || null;
-    const grantUserGroupIds = req.body.grantUserGroupIds || null;
+    const userRelatedGrantUserGroupIds = req.body.userRelatedGrantUserGroupIds || null;
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
 
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136140
-    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
     if (pageId === null || pageBody === null || revisionId === null) {
       return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
     }
@@ -486,7 +315,7 @@ module.exports = function(crowi, app) {
     const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
-      options.grantUserGroupIds = grantUserGroupIds;
+      options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
     }
 
     const previousRevision = await Revision.findById(revisionId);
@@ -507,7 +336,7 @@ module.exports = function(crowi, app) {
 
     // global notification
     try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_EDIT, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_EDIT, page, req.user);
     }
     catch (err) {
       logger.error('Edit notification failed', err);
@@ -758,8 +587,10 @@ module.exports = function(crowi, app) {
 
     try {
       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)) {
@@ -815,7 +646,7 @@ module.exports = function(crowi, app) {
 
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_DELETE, page, req.user);
+      await globalNotificationService.fire(GlobalNotificationSettingEvent.PAGE_DELETE, page, req.user);
     }
     catch (err) {
       logger.error('Delete notification failed', err);
@@ -870,83 +701,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.duplicate:
-   *      post:
-   *        tags: [Pages]
-   *        operationId: duplicatePage
-   *        summary: /pages.duplicate
-   *        description: Duplicate page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  new_path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                required:
-   *                  - page_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to duplicate page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    tags:
-   *                      $ref: '#/components/schemas/Tags'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.duplicate Duplicate page
-   * @apiName DuplicatePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id Page Id.
-   * @apiParam {String} new_path New path name.
-   */
-  api.duplicate = async function(req, res) {
-    const pageId = req.body.page_id;
-    let newPagePath = pathUtils.normalizePath(req.body.new_path);
-
-    const page = await Page.findByIdAndViewer(pageId, req.user);
-
-    if (page == null) {
-      return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-    }
-
-    // TODO: remove in https://redmine.weseek.co.jp/issues/136139
-    if (page.grantedGroups != null && page.grantedGroups.length > 1) {
-      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
-    }
-
-    // check whether path starts slash
-    newPagePath = pathUtils.addHeadingSlash(newPagePath);
-
-    await page.populateDataToShowRevision();
-    const originTags = await page.findRelatedTagsById();
-
-    req.body.path = newPagePath;
-    req.body.body = page.revision.body;
-    req.body.grant = page.grant;
-    req.body.grantedUsers = page.grantedUsers;
-    req.body.grantUserGroupIds = page.grantedGroups;
-    req.body.pageTags = originTags;
-
-    return api.create(req, res);
-  };
-
   /**
    * @api {post} /pages.unlink Remove the redirecting page
    * @apiName UnlinkPage

+ 3 - 3
apps/app/src/server/routes/tag.js

@@ -1,6 +1,9 @@
 import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 
+import PageTagRelation from '../models/page-tag-relation';
+import ApiResponse from '../util/apiResponse';
+
 /**
  * @swagger
  *
@@ -32,9 +35,7 @@ import Tag from '~/server/models/tag';
  */
 module.exports = function(crowi, app) {
 
-  const PageTagRelation = crowi.model('PageTagRelation');
   const activityEvent = crowi.event('activity');
-  const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const api = {};
 
@@ -138,7 +139,6 @@ module.exports = function(crowi, app) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const User = crowi.model('User');
-    const PageTagRelation = crowi.model('PageTagRelation');
     const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;

+ 10 - 10
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -1,8 +1,10 @@
+import nodePath from 'path';
+
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
-const nodePath = require('path');
 
 /**
  * sub service class of GlobalNotificationSetting
@@ -11,8 +13,6 @@ class GlobalNotificationMailService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.MAIL;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
   /**
@@ -29,7 +29,7 @@ class GlobalNotificationMailService {
     const { mailService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, page.path, GlobalNotificationSettingType.MAIL);
 
     const option = this.generateOption(event, page, triggeredBy, vars);
 
@@ -73,19 +73,19 @@ class GlobalNotificationMailService {
     };
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         subject = `#${event} - ${triggeredBy.username} created ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         subject = `#${event} - ${triggeredBy.username} edited ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         subject = `#${event} - ${triggeredBy.username} deleted ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);
@@ -99,11 +99,11 @@ class GlobalNotificationMailService {
         };
         break;
 
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         subject = `#${event} - ${triggeredBy.username} liked ${path} at URL: ${pageUrl}`;
         break;
 
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationMailService.generateOption for event ${event}`);

+ 15 - 17
apps/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,6 +1,7 @@
 import { pagePathUtils } from '@growi/core/dist/utils';
-import loggerFactory from '~/utils/logger';
 
+import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models';
+import loggerFactory from '~/utils/logger';
 
 import {
   prepareSlackMessageForGlobalNotification,
@@ -18,9 +19,6 @@ class GlobalNotificationSlackService {
 
   constructor(crowi) {
     this.crowi = crowi;
-
-    this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
-    this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
 
 
@@ -39,7 +37,7 @@ class GlobalNotificationSlackService {
     const { appService, slackIntegrationService } = this.crowi;
 
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
-    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
+    const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, GlobalNotificationSettingType.SLACK);
 
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
@@ -74,16 +72,16 @@ class GlobalNotificationSlackService {
     let messageBody;
 
     switch (event) {
-      case this.event.PAGE_CREATE:
+      case GlobalNotificationSettingEvent.PAGE_CREATE:
         messageBody = `:bell: ${username} created ${parmaLink}`;
         break;
-      case this.event.PAGE_EDIT:
+      case GlobalNotificationSettingEvent.PAGE_EDIT:
         messageBody = `:bell: ${username} edited ${parmaLink}`;
         break;
-      case this.event.PAGE_DELETE:
+      case GlobalNotificationSettingEvent.PAGE_DELETE:
         messageBody = `:bell: ${username} deleted ${pathLink}`;
         break;
-      case this.event.PAGE_MOVE:
+      case GlobalNotificationSettingEvent.PAGE_MOVE:
         // validate for page move
         if (oldPath == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -91,10 +89,10 @@ class GlobalNotificationSlackService {
         // eslint-disable-next-line no-case-declarations
         messageBody = `:bell: ${username} moved ${oldPath} to ${parmaLink}`;
         break;
-      case this.event.PAGE_LIKE:
+      case GlobalNotificationSettingEvent.PAGE_LIKE:
         messageBody = `:bell: ${username} liked ${parmaLink}`;
         break;
-      case this.event.COMMENT:
+      case GlobalNotificationSettingEvent.COMMENT:
         // validate for comment
         if (comment == null) {
           throw new Error(`invalid vars supplied to GlobalNotificationSlackService.generateOption for event ${event}`);
@@ -128,17 +126,17 @@ class GlobalNotificationSlackService {
     // attachment body is intended for comment or page diff
 
     // switch (event) {
-    //   case this.event.PAGE_CREATE:
+    //   case GlobalNotificationSettingEvent.PAGE_CREATE:
     //     break;
-    //   case this.event.PAGE_EDIT:
+    //   case GlobalNotificationSettingEvent.PAGE_EDIT:
     //     break;
-    //   case this.event.PAGE_DELETE:
+    //   case GlobalNotificationSettingEvent.PAGE_DELETE:
     //     break;
-    //   case this.event.PAGE_MOVE:
+    //   case GlobalNotificationSettingEvent.PAGE_MOVE:
     //     break;
-    //   case this.event.PAGE_LIKE:
+    //   case GlobalNotificationSettingEvent.PAGE_LIKE:
     //     break;
-    //   case this.event.COMMENT:
+    //   case GlobalNotificationSettingEvent.COMMENT:
     //     break;
     //   default:
     //     throw new Error(`unknown global notificaiton event: ${event}`);

+ 147 - 16
apps/app/src/server/service/page-grant.ts

@@ -1,20 +1,19 @@
 import {
   type IGrantedGroup,
-  PageGrant, type PageGrantCanBeOnTree, GroupType,
+  PageGrant, GroupType, getIdForRef, isPopulated,
 } from '@growi/core';
 import {
   pagePathUtils, pathUtils, pageUtils,
 } from '@growi/core/dist/utils';
-import { et } from 'date-fns/locale';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
-import ExternalUserGroup, { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { IRecordApplicableGrant } from '~/interfaces/page-grant';
+import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
-import UserGroup, { UserGroupDocument } from '~/server/models/user-group';
-import { includesObjectIds, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import UserGroup from '~/server/models/user-group';
+import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import UserGroupRelation from '../models/user-group-relation';
@@ -26,7 +25,7 @@ const { isTopPage } = pagePathUtils;
 const LIMIT_FOR_MULTIPLE_PAGE_OP = 20;
 
 type ComparableTarget = {
-  grant: number,
+  grant?: number,
   grantedUserIds?: ObjectIdLike[],
   grantedGroupIds?: IGrantedGroup[],
   applicableUserIds?: ObjectIdLike[],
@@ -78,7 +77,33 @@ type OperatorGrantInfo = {
   userGroupIds: Set<ObjectIdLike>,
 };
 
-class PageGrantService {
+export interface IPageGrantService {
+  isGrantNormalized: (
+    user,
+    targetPath: string,
+    grant?: PageGrant,
+    grantedUserIds?: ObjectIdLike[],
+    grantedGroupIds?: IGrantedGroup[],
+    shouldCheckDescendants?: boolean,
+    includeNotMigratedPages?: boolean,
+    previousGrantedGroupIds?: IGrantedGroup[]
+  ) => Promise<boolean>,
+  separateNormalizableAndNotNormalizablePages: (user, pages) => Promise<[(PageDocument & { _id: any })[], (PageDocument & { _id: any })[]]>,
+  generateUpdateGrantInfoToOverwriteDescendants: (
+    operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
+  ) => Promise<UpdateGrantInfo>,
+  canOverwriteDescendants: (targetPath: string, operator: { _id: ObjectIdLike }, updateGrantInfo: UpdateGrantInfo) => Promise<boolean>,
+  validateGrantChange: (user, previousGrantedGroupIds: IGrantedGroup[], grant?: PageGrant, grantedGroupIds?: IGrantedGroup[]) => Promise<boolean>,
+  validateGrantChangeSyncronously:(
+    userRelatedGroups: PopulatedGrantedGroup[], previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[],
+  ) => boolean,
+  getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
+  getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
+  getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean
+}
+
+class PageGrantService implements IPageGrantService {
 
   crowi!: any;
 
@@ -103,7 +128,10 @@ class PageGrantService {
    * About the rule of validation, see: https://dev.growi.org/61b2cdabaa330ce7d8152844
    * @returns boolean
    */
-  private processValidation(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+  private validateGrant(target: ComparableTarget, ancestor: ComparableAncestor, descendants?: ComparableDescendants): boolean {
+    /*
+     * the page itself
+     */
     this.validateComparableTarget(target);
 
     const Page = mongoose.model('Page') as unknown as PageModel;
@@ -209,12 +237,55 @@ class PageGrantService {
     return true;
   }
 
+  /**
+   * Validate if page grant can be changed from prior grant to specified grant.
+   * Necessary for pages with multiple group grant.
+   * see: https://dev.growi.org/656745fa52eafe1cf1879508#%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE-grant-%E3%81%AE%E6%9B%B4%E6%96%B0
+   * @param user The user who is changing the grant
+   * @param previousGrantedGroups The groups that were granted priorly
+   * @param grant The grant to be changed to
+   * @param grantedGroups The groups to be granted
+   */
+  async validateGrantChange(user, previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[]): Promise<boolean> {
+    const userRelatedGroups = await this.getUserRelatedGroups(user);
+    return this.validateGrantChangeSyncronously(userRelatedGroups, previousGrantedGroups, grant, grantedGroups);
+  }
+
+  /**
+   * Use when you do not want to use validateGrantChange with async/await (e.g inside loops that process a large amount of pages)
+   * Specification of userRelatedGroups is necessary to avoid the cost of fetching userRelatedGroups from DB every time.
+   */
+  validateGrantChangeSyncronously(
+      userRelatedGroups: PopulatedGrantedGroup[],
+      previousGrantedGroups: IGrantedGroup[],
+      grant?: PageGrant,
+      grantedGroups?: IGrantedGroup[],
+  ): boolean {
+    const userRelatedGroupIds = userRelatedGroups.map(g => g.item._id);
+    const userBelongsToAllPreviousGrantedGroups = excludeTestIdsFromTargetIds(
+      previousGrantedGroups.map(g => getIdForRef(g.item)),
+      userRelatedGroupIds,
+    ).length === 0;
+
+    if (!userBelongsToAllPreviousGrantedGroups) {
+      if (grant !== PageGrant.GRANT_USER_GROUP) {
+        return false;
+      }
+      const pageGrantIncludesUserRelatedGroup = hasIntersection(grantedGroups?.map(g => getIdForRef(g.item)) || [], userRelatedGroupIds);
+      if (!pageGrantIncludesUserRelatedGroup) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
   /**
    * Prepare ComparableTarget
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableTarget(
-      grant, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
+      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
   ): Promise<ComparableTarget> {
     if (includeApplicable) {
       const Page = mongoose.model('Page') as unknown as PageModel;
@@ -415,27 +486,51 @@ class PageGrantService {
    * Only v5 schema pages will be used to compare by default (Set includeNotMigratedPages to true to include v4 schema pages as well).
    * When comparing, it will use path regex to collect pages instead of using parent attribute of the Page model. This is reasonable since
    * using the path attribute is safer than using the parent attribute in this case. 2022.02.13 -- Taichi Masuyama
+   * @param user The user responsible for execution
+   * @param targetPath Path of page which grant will be validated
+   * @param grant Type of the grant to be validated
+   * @param grantedUserIds Users of grant to be validated
+   * @param grantedGroupIds Groups of grant to be validated
+   * @param shouldCheckDescendants Whether or not to use descendant grant for validation
+   * @param includeNotMigratedPages Whether or not to use unmigrated pages for validation
+   * @param previousGrantedGroupIds
+   *   Previously granted groups of the page. Specific validation is required when previous grant is multiple group grant.
+   *   Apply when page grant change needs to be validated.
+   *   see: https://dev.growi.org/656745fa52eafe1cf1879508#%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AE-grant-%E3%81%AE%E6%9B%B4%E6%96%B0
    * @returns Promise<boolean>
    */
   async isGrantNormalized(
-      // eslint-disable-next-line max-len
-      user, targetPath: string, grant, grantedUserIds?: ObjectIdLike[], grantedGroupIds?: IGrantedGroup[], shouldCheckDescendants = false, includeNotMigratedPages = false,
+      user,
+      targetPath: string,
+      grant?: PageGrant,
+      grantedUserIds?: ObjectIdLike[],
+      grantedGroupIds?: IGrantedGroup[],
+      shouldCheckDescendants = false,
+      includeNotMigratedPages = false,
+      previousGrantedGroupIds?: IGrantedGroup[],
   ): Promise<boolean> {
     if (isTopPage(targetPath)) {
       return true;
     }
 
+    if (previousGrantedGroupIds != null) {
+      const isGrantChangeable = await this.validateGrantChange(user, previousGrantedGroupIds, grant, grantedGroupIds);
+      if (!isGrantChangeable) {
+        return false;
+      }
+    }
+
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
       const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, false);
-      return this.processValidation(comparableTarget, comparableAncestor);
+      return this.validateGrant(comparableTarget, comparableAncestor);
     }
 
     const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, true);
     const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
-    return this.processValidation(comparableTarget, comparableAncestor, comparableDescendants);
+    return this.validateGrant(comparableTarget, comparableAncestor, comparableDescendants);
   }
 
   /**
@@ -568,7 +663,10 @@ class PageGrantService {
     return data;
   }
 
-  async getUserRelatedGroups(user) {
+  /*
+   * get all groups that user is related to
+   */
+  async getUserRelatedGroups(user): Promise<PopulatedGrantedGroup[]> {
     const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
     const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
     return [
@@ -581,6 +679,38 @@ class PageGrantService {
     ];
   }
 
+  /*
+   * get all groups of Page that user is related to
+   */
+  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const userRelatedGroups = (await this.getUserRelatedGroups(user));
+    return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page);
+  }
+
+  /**
+   * Use when you do not want to use getUserRelatedGrantedGroups with async/await (e.g inside loops that process a large amount of pages)
+   * Specification of userRelatedGroups is necessary to avoid the cost of fetching userRelatedGroups from DB every time.
+   */
+  getUserRelatedGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument): IGrantedGroup[] {
+    const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
+    return page.grantedGroups?.filter((group) => {
+      if (isPopulated(group.item)) {
+        return userRelatedGroupIds.includes(group.item._id.toString());
+      }
+      return userRelatedGroupIds.includes(group.item);
+    }) || [];
+  }
+
+  /**
+   * Check if user is granted access to page
+   */
+  isUserGrantedPageAccess(page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]): boolean {
+    if (page.grant === PageGrant.GRANT_PUBLIC) return true;
+    if (page.grant === PageGrant.GRANT_OWNER) return page.grantedUsers?.includes(user._id.toString()) ?? false;
+    if (page.grant === PageGrant.GRANT_USER_GROUP) return this.getUserRelatedGrantedGroupsSyncronously(userRelatedGroups, page).length > 0;
+    return false;
+  }
+
   /**
    * see: https://dev.growi.org/635a314eac6bcd85cbf359fc
    * @param {string} targetPath
@@ -622,7 +752,7 @@ class PageGrantService {
   }
 
   async generateUpdateGrantInfoToOverwriteDescendants(
-      operator, updateGrant: PageGrantCanBeOnTree, grantGroupIds?: IGrantedGroup[],
+      operator, updateGrant?: PageGrant, grantGroupIds?: IGrantedGroup[],
   ): Promise<UpdateGrantInfo> {
     let updateGrantInfo: UpdateGrantInfo | null = null;
 
@@ -666,6 +796,7 @@ class PageGrantService {
     }
 
     if (updateGrantInfo == null) {
+      // Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
       throw Error('The parameter `updateGrant` must be 1, 4, or 5');
     }
 

+ 2 - 2
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -90,12 +90,12 @@ describe('delete-completely-user-home-by-system test', () => {
     const mockPageEvent = mock<EventEmitter>();
     const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
 
-    const mockPageService: IPageService = {
+    const mockPageService = mock<IPageService>({
       updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
       deleteCompletelyOperation: mockDeleteCompletelyOperation,
       getEventEmitter: () => mockPageEvent,
       deleteMultipleCompletely: mockDeleteMultipleCompletely,
-    };
+    });
 
     it('should call used page service functions', async() => {
       // when

Разница между файлами не показана из-за своего большого размера
+ 269 - 135
apps/app/src/server/service/page/index.ts


+ 11 - 1
apps/app/src/server/service/page/page-service.ts

@@ -1,12 +1,22 @@
 import type EventEmitter from 'events';
 
-import type { IUser } from '@growi/core';
+import type { IPageInfo, IPageInfoForEntity, IUser } from '@growi/core';
+import type { ObjectId } from 'mongoose';
 
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { IOptionsForCreate } from '~/server/models/interfaces/page-operation';
+import type { PageDocument } from '~/server/models/page';
 
 export interface IPageService {
+  create(path: string, body: string, user: IUser, options: IOptionsForCreate): Promise<PageDocument>,
   updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
   deleteCompletelyOperation: (pageIds: string[], pagePaths: string[]) => Promise<void>,
   getEventEmitter: () => EventEmitter,
   deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
+  findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>,
+  shortBodiesMapByPageIds(pageIds?: ObjectId[], user?): Promise<Record<string, string | null>>,
+  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity,
+  canDeleteCompletely(page: PageDocument, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]): boolean,
 }

+ 11 - 14
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -7,18 +7,18 @@ import streamToPromise from 'stream-to-promise';
 
 import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import {
-  ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
-} from '~/interfaces/search';
+import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
+import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SocketEventName } from '~/interfaces/websocket';
+import PageTagRelation from '~/server/models/page-tag-relation';
 import loggerFactory from '~/utils/logger';
 
-import {
+import type {
   SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey,
 } from '../../interfaces/search';
-import { PageModel } from '../../models/page';
+import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
-import { UpdateOrInsertPagesOpts } from '../interfaces/search';
+import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 import ElasticsearchClient from './elasticsearch-client';
@@ -368,13 +368,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       });
     }
 
-    let grantedGroupIds = null;
-    if (page.grantedGroups != null) {
-      grantedGroupIds = page.grantedGroups.map((group) => {
-        const groupId = (group.item._id == null) ? group.item : group.item._id;
-        return groupId.toString();
-      });
-    }
+    let grantedGroupIds = [];
+    grantedGroupIds = page.grantedGroups.map((group) => {
+      const groupId = (group.item._id == null) ? group.item : group.item._id;
+      return groupId.toString();
+    });
 
     return {
       grant: page.grant,
@@ -462,7 +460,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
-    const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 

+ 2 - 3
apps/app/src/server/service/user-notification/index.ts

@@ -31,7 +31,7 @@ export class UserNotificationService {
    * @param {Comment} comment
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  async fire(page, user, slackChannelsStr, mode, option, comment = {}): Promise<PromiseSettledResult<any>[]> {
+  async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: string }, comment = {}): Promise<PromiseSettledResult<any>[]> {
     const {
       appService, slackIntegrationService,
     } = this.crowi;
@@ -43,8 +43,7 @@ export class UserNotificationService {
     // update slackChannels attribute asynchronously
     page.updateSlackChannels(slackChannelsStr);
 
-    const opt = option || {};
-    const previousRevision = opt.previousRevision || '';
+    const { previousRevision } = option ?? {};
 
     // "dev,slacktest" => [dev,slacktest]
     const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);

+ 3 - 32
apps/app/src/stores/modal.tsx

@@ -3,11 +3,11 @@ import { useCallback, useMemo } from 'react';
 import type {
   IAttachmentHasId, IPageToDeleteWithMeta, IPageToRenameWithMeta, IUserGroupHasId,
 } from '@growi/core';
-import { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+
 
-import Linker from '~/client/models/Linker';
 import MarkdownTable from '~/client/models/MarkdownTable';
-import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,
 } from '~/interfaces/ui';
@@ -675,35 +675,6 @@ export const useDeleteAttachmentModal = (): SWRResponse<DeleteAttachmentModalSta
   };
 };
 
-/*
- * LinkEditModal
- */
-type LinkEditModalStatus = {
-  isOpened: boolean,
-  defaultMarkdownLink?: Linker,
-  onSave?: (linkText: string) => void
-}
-
-type LinkEditModalUtils = {
-  open(defaultMarkdownLink: Linker, onSave: (linkText: string) => void): void,
-  close(): void,
-}
-
-export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & LinkEditModalUtils => {
-
-  const initialStatus: LinkEditModalStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<LinkEditModalStatus, Error>('linkEditModal', undefined, { fallbackData: initialStatus });
-
-  return Object.assign(swrResponse, {
-    open: (defaultMarkdownLink: Linker, onSave: (linkText: string) => void) => {
-      swrResponse.mutate({ isOpened: true, defaultMarkdownLink, onSave });
-    },
-    close: () => {
-      swrResponse.mutate({ isOpened: false });
-    },
-  });
-};
-
 /*
 * PageSelectModal
 */

+ 10 - 3
apps/app/src/stores/page.tsx

@@ -125,14 +125,21 @@ export const useSWRxPageByPath = (path?: string, config?: SWRConfiguration): SWR
   );
 };
 
-export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTagsInfo | undefined, Error> => {
+export const useSWRxTagsInfo = (pageId: Nullable<string>, config?: SWRConfiguration): SWRResponse<IPageTagsInfo | null, Error> => {
   const { data: shareLinkId } = useShareLinkId();
 
   const endpoint = `/pages.getPageTag?pageId=${pageId}`;
 
-  return useSWRImmutable(
+  return useSWR(
     shareLinkId == null && pageId != null ? [endpoint, pageId] : null,
-    ([endpoint, pageId]) => apiGet<IPageTagsInfo>(endpoint, { pageId }).then(result => result),
+    ([endpoint, pageId]) => apiGet<IPageTagsInfo>(endpoint, { pageId })
+      .then(result => result)
+      .catch(getPageApiErrorHandler),
+    {
+      ...config,
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
   );
 };
 

+ 0 - 4
apps/app/src/stores/ui.tsx

@@ -352,10 +352,6 @@ export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRRes
   return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
 };
 
-export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
-  return useStaticSWR('globalSearchTypeahead', initialData);
-};
-
 type PageTreeDescCountMapUtils = {
   update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
   getDescCount(pageId?: string): number | null | undefined

+ 18 - 6
apps/app/src/styles/organisms/_wiki.scss

@@ -8,7 +8,7 @@
       margin-right: 0.4em;
       content: '';
       border-left: $width solid;
-      opacity: 0.2;
+      border-left-color: var(--bs-border-color);
     }
   }
 
@@ -41,12 +41,13 @@
   }
 
   h1 {
-    padding: 0.5em 0;
+    padding: 0.3em 0;
     margin-top: 2em;
     font-size: 1.9em;
     line-height: 1.1em;
-    // style
-    border-bottom: solid 1px transparent;
+    border-bottom-color: var(--bs-border-color);
+    border-bottom-style: solid;
+    border-bottom-width: 2px;
   }
 
   h2 {
@@ -54,8 +55,9 @@
     font-size: 1.6em;
     font-weight: bold;
     line-height: 1.225;
-    // style
-    border-bottom: solid 1px transparent;
+    border-bottom-color: var(--bs-border-color);
+    border-bottom-style: solid;
+    border-bottom-width: 1px;
   }
 
   h3 {
@@ -309,6 +311,16 @@
         var(--bs-link-opacity, 1)
       );
     }
+  }
+}
 
+@include bs.color-mode(light) {
+  .wiki {
+    --bs-border-color: var(--bs-gray-300);
+  }
+}
+@include bs.color-mode(dark) {
+  .wiki {
+    --bs-border-color: var(--bs-gray-700);
   }
 }

+ 124 - 31
apps/app/test/integration/models/v5.page.test.js

@@ -1,4 +1,4 @@
-import { PageGrant, GroupType } from '@growi/core';
+import { PageGrant, GroupType, getIdForRef } from '@growi/core';
 import mongoose from 'mongoose';
 
 import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
@@ -16,7 +16,6 @@ describe('Page', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -63,6 +62,8 @@ describe('Page', () => {
   const upodPageIdPublic5 = new mongoose.Types.ObjectId();
   const upodPageIdPublic6 = new mongoose.Types.ObjectId();
 
+  // Since updatePageSubOperation is asyncronously called from updatePageSubOperation,
+  // mock it inside updatePageSubOperation, and later call it independently to await for it's execution.
   const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
     const mockedUpdatePageSubOperation = jest.spyOn(pageService, 'updatePageSubOperation').mockReturnValue(null);
 
@@ -255,7 +256,22 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
-        grantedGroups: null,
+        grantedGroups: [],
+        parent: upodPageIdgAB1,
+      },
+      // grant user A and B with independent groups
+      {
+        path: '/gAB_upod_1/gA_gB_upod_1',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: upodUserGroupIdA, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdA, type: GroupType.externalUserGroup },
+          { item: upodUserGroupIdB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+        ],
         parent: upodPageIdgAB1,
       },
       // case 2
@@ -266,7 +282,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -299,7 +315,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: [upodUserA._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic2,
       },
       // case 3
@@ -310,9 +326,11 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
+      // grant user A and B with a single group
+      // (external group is extra for testing external groups)
       {
         path: '/public_upod_3/gAB_upod_3',
         grant: PageGrant.GRANT_USER_GROUP,
@@ -325,6 +343,21 @@ describe('Page', () => {
         ],
         parent: upodPageIdPublic3,
       },
+      // grant user A and B with independent groups
+      {
+        path: '/public_upod_3/gA_gB_upod_3',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: upodUserA,
+        lastUpdateUser: upodUserA,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: upodUserGroupIdA, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdA, type: GroupType.externalUserGroup },
+          { item: upodUserGroupIdB, type: GroupType.userGroup },
+          { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+        ],
+        parent: upodPageIdPublic3,
+      },
       {
         path: '/public_upod_3/gB_upod_3',
         grant: PageGrant.GRANT_USER_GROUP,
@@ -343,7 +376,7 @@ describe('Page', () => {
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic3,
       },
       // case 4
@@ -354,7 +387,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -389,7 +422,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -410,7 +443,7 @@ describe('Page', () => {
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic5,
       },
       // case 6
@@ -421,7 +454,7 @@ describe('Page', () => {
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -430,7 +463,7 @@ describe('Page', () => {
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic6,
       },
     ]);
@@ -439,8 +472,7 @@ describe('Page', () => {
   // normalize for result comparison
   const normalizeGrantedGroups = (grantedGroups) => {
     return grantedGroups.map((group) => {
-      const itemId = typeof group.item === 'string' ? group.item : group.item._id;
-      return { item: itemId, type: group.type };
+      return { item: getIdForRef(group.item), type: group.type };
     });
   };
 
@@ -455,7 +487,6 @@ describe('Page', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
@@ -996,12 +1027,24 @@ describe('Page', () => {
         parent: rootPage._id,
         descendantCount: 0,
       },
+      {
+        path: '/with_multiple_individual_granted_groups',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroups: [
+          { item: userGroupIdPModelA, type: GroupType.userGroup },
+          { item: userGroupIdPModelB, type: GroupType.userGroup },
+        ],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+      },
     ]);
 
     await createDocumentsToTestUpdatePageOverwritingDescendants();
   });
 
-  describe('update', () => {
+  describe('updatePage with overwriteScopesOfDescendants false', () => {
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
       test('an only-child page will delete its empty parent page', async() => {
         const pathT = '/mup13_top';
@@ -1014,7 +1057,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeTruthy();
 
-        const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupIds: null };
+        const options = { grant: Page.GRANT_RESTRICTED, userRelatedGrantUserGroupIds: null };
         await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
 
         const _pageT = await Page.findOne({ path: pathT });
@@ -1221,7 +1264,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
@@ -1251,7 +1294,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
@@ -1289,7 +1332,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
@@ -1327,7 +1370,7 @@ describe('Page', () => {
           ];
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
@@ -1348,7 +1391,7 @@ describe('Page', () => {
             { item: userGroupIdPModelC, type: GroupType.userGroup },
             { item: externalUserGroupIdPModelC, type: GroupType.externalUserGroup },
           ];
-          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupIds: secondRoundNewGrantedGroups }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, userRelatedGrantUserGroupIds: secondRoundNewGrantedGroups }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
           // undo grantedGroups populate to prevent Page.hydrate error
           _page2.grantedGroups.forEach((group) => {
             group.item = group.item._id;
@@ -1379,7 +1422,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: [
+            userRelatedGrantUserGroupIds: [
               { item: userGroupIdPModelIsolate, type: GroupType.userGroup },
               { item: externalUserGroupIdPModelIsolate, type: GroupType.externalUserGroup },
             ],
@@ -1410,7 +1453,7 @@ describe('Page', () => {
 
           const options = {
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: [
+            userRelatedGrantUserGroupIds: [
               { item: userGroupIdPModelA, type: GroupType.userGroup },
               { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
             ],
@@ -1444,7 +1487,7 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupIds: [{ item: userGroupIdPModelA, type: GroupType.userGroup }] };
+          const options = { grant: Page.GRANT_USER_GROUP, userRelatedGrantUserGroupIds: [{ item: userGroupIdPModelA, type: GroupType.userGroup }] };
           await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
@@ -1457,9 +1500,40 @@ describe('Page', () => {
           expect(page2.grantedGroups.length).toBe(0); // no group should be set
         });
       });
+      describe('update grant of a page from GRANT_USER_GROUP to GRANT_USER_GROUP', () => {
+        test('successfully change the granted groups, with the previous groups wich user is not related to remaining', async() => {
+          // path
+          const path = '/with_multiple_individual_granted_groups';
+          // page
+          const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP });
+          expect(_page).toBeTruthy();
 
-    });
+          const newUserRelatedGrantedGroups = [
+            { item: userGroupIdPModelA, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+          ];
 
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            userRelatedGrantUserGroupIds: newUserRelatedGrantedGroups,
+          };
+          const updatedPage = await updatePage(_page, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
+
+          const page = await Page.findById(_page._id);
+          expect(page).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page._id);
+
+          // check page grant and group
+          expect(page.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page.grantedGroups)).toEqual(expect.arrayContaining([
+            ...newUserRelatedGrantedGroups,
+            // userB group remains, although options does not include it
+            { item: userGroupIdPModelB, type: GroupType.userGroup },
+          ]));
+        });
+      });
+    });
   });
 
 
@@ -1469,14 +1543,17 @@ describe('Page', () => {
       const upodPagegAB = await Page.findOne({ path: '/gAB_upod_1' });
       const upodPagegB = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
       const upodPageonlyB = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+      const upodPagegAgB = await Page.findOne({ path: '/gAB_upod_1/gA_gB_upod_1' });
 
       expect(upodPagegAB).not.toBeNull();
       expect(upodPagegB).not.toBeNull();
       expect(upodPageonlyB).not.toBeNull();
+      expect(upodPagegAgB).not.toBeNull();
 
       expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
+      expect(upodPagegAgB.grant).toBe(PageGrant.GRANT_USER_GROUP);
 
       // Update
       const options = {
@@ -1487,6 +1564,7 @@ describe('Page', () => {
 
       const upodPagegBUpdated = await Page.findOne({ path: '/gAB_upod_1/gB_upod_1' });
       const upodPageonlyBUpdated = await Page.findOne({ path: '/gAB_upod_1/onlyB_upod_1' });
+      const upodPagegAgBUpdated = await Page.findOne({ path: '/gAB_upod_1/gA_gB_upod_1' });
 
       // Changed
       const newGrant = PageGrant.GRANT_PUBLIC;
@@ -1496,8 +1574,10 @@ describe('Page', () => {
       expect(upodPagegBUpdated.grantedGroups).toStrictEqual(upodPagegB.grantedGroups);
       expect(upodPageonlyBUpdated.grant).toBe(PageGrant.GRANT_OWNER);
       expect(upodPageonlyBUpdated.grantedUsers).toStrictEqual(upodPageonlyB.grantedUsers);
+      expect(upodPagegAgBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAgBUpdated.grantedGroups).toStrictEqual(upodPagegAgB.grantedGroups);
     });
-    test('(case 2) it should update all granted descendant pages when all descendant pages are granted by the operator', async() => {
+    test('(case 2) it should update all granted descendant pages when all descendant pages are granted to the operator', async() => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_2' });
       const upodPagegA = await Page.findOne({ path: '/public_upod_2/gA_upod_2' });
       const upodPagegAIsolated = await Page.findOne({ path: '/public_upod_2/gAIsolated_upod_2' });
@@ -1541,23 +1621,26 @@ describe('Page', () => {
     , and all users of descendants belong to the update user group`, async() => {
       const upodPagePublic = await Page.findOne({ path: '/public_upod_3' });
       const upodPagegAB = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegAgB = await Page.findOne({ path: '/public_upod_3/gA_gB_upod_3' });
       const upodPagegB = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
       const upodPageonlyB = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
 
       expect(upodPagePublic).not.toBeNull();
       expect(upodPagegAB).not.toBeNull();
+      expect(upodPagegAgB).not.toBeNull();
       expect(upodPagegB).not.toBeNull();
       expect(upodPageonlyB).not.toBeNull();
 
       expect(upodPagePublic.grant).toBe(PageGrant.GRANT_PUBLIC);
       expect(upodPagegAB.grant).toBe(PageGrant.GRANT_USER_GROUP);
+      expect(upodPagegAgB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPagegB.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPageonlyB.grant).toBe(PageGrant.GRANT_OWNER);
 
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
@@ -1566,6 +1649,7 @@ describe('Page', () => {
       const updatedPage = await updatePage(upodPagePublic, 'newRevisionBody', 'oldRevisionBody', upodUserA, options);
 
       const upodPagegABUpdated = await Page.findOne({ path: '/public_upod_3/gAB_upod_3' });
+      const upodPagegAgBUpdated = await Page.findOne({ path: '/public_upod_3/gA_gB_upod_3' });
       const upodPagegBUpdated = await Page.findOne({ path: '/public_upod_3/gB_upod_3' });
       const upodPageonlyBUpdated = await Page.findOne({ path: '/public_upod_3/onlyB_upod_3' });
 
@@ -1579,6 +1663,15 @@ describe('Page', () => {
       expect(normalizeGrantedGroups(updatedPage.grantedGroups)).toStrictEqual(newGrantedGroups);
       expect(upodPagegABUpdated.grant).toBe(newGrant);
       expect(normalizeGrantedGroups(upodPagegABUpdated.grantedGroups)).toStrictEqual(newGrantedGroups);
+      expect(upodPagegAgBUpdated.grant).toBe(newGrant);
+      // For multi group granted pages, the grant update will only add/remove groups that the user belongs to,
+      // and groups that the user doesn't belong to will stay as it was before the update.
+      expect(normalizeGrantedGroups(upodPagegAgBUpdated.grantedGroups)).toEqual(expect.arrayContaining([
+        ...newGrantedGroups,
+        { item: upodUserGroupIdB, type: GroupType.userGroup },
+        { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
+      ]));
+
       // Not changed
       expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPagegBUpdated.grantedGroups).toStrictEqual(upodPagegB.grantedGroups);
@@ -1603,7 +1696,7 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
@@ -1631,7 +1724,7 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
@@ -1654,7 +1747,7 @@ describe('Page', () => {
       // Update
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],

+ 108 - 13
apps/app/test/integration/service/page-grant.test.js

@@ -33,6 +33,7 @@ describe('PageGrantService', () => {
 
   let groupParent;
   let groupChild;
+  let differentTreeGroup;
 
   let externalGroupParent;
   let externalGroupChild;
@@ -51,10 +52,13 @@ describe('PageGrantService', () => {
   const emptyPagePath2 = '/E2';
   const emptyPagePath3 = '/E3';
 
+  let multipleGroupTreesAndUsersPage;
+
   let pageRootPublic;
   let pageRootGroupParent;
   const pageRootPublicPath = '/Public';
   const pageRootGroupParentPath = '/GroupParent';
+  const pageMultipleGroupTreesAndUsersPath = '/MultipleGroupTreesAndUsers';
 
   const v4PageRootOnlyMePagePath = '/v4OnlyMe';
   const v4PageRootAnyoneWithTheLinkPagePath = '/v4AnyoneWithTheLink';
@@ -102,10 +106,15 @@ describe('PageGrantService', () => {
         name: 'GroupChild',
         parent: userGroupIdParent,
       },
+      {
+        name: 'DifferentTreeGroup',
+        parent: null,
+      },
     ]);
 
     groupParent = await UserGroup.findOne({ name: 'GroupParent' });
     groupChild = await UserGroup.findOne({ name: 'GroupChild' });
+    differentTreeGroup = await UserGroup.findOne({ name: 'DifferentTreeGroup' });
 
     // UserGroupRelations
     await UserGroupRelation.insertMany([
@@ -121,6 +130,10 @@ describe('PageGrantService', () => {
         relatedGroup: groupChild._id,
         relatedUser: user1._id,
       },
+      {
+        relatedGroup: differentTreeGroup._id,
+        relatedUser: user1._id,
+      },
     ]);
 
     await ExternalUserGroup.insertMany([
@@ -186,7 +199,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
       },
       {
@@ -198,8 +211,19 @@ describe('PageGrantService', () => {
         grantedGroups: [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }],
         parent: rootPage._id,
       },
+      {
+        path: pageMultipleGroupTreesAndUsersPath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user1,
+        lastUpdateUser: user1,
+        grantedUsers: null,
+        grantedGroups: [{ item: groupParent._id, type: GroupType.userGroup }, { item: differentTreeGroup._id, type: GroupType.userGroup }],
+        parent: null,
+      },
     ]);
 
+    multipleGroupTreesAndUsersPage = await Page.findOne({ path: pageMultipleGroupTreesAndUsersPath });
+
     await Page.insertMany([
       // Root Page
       {
@@ -304,7 +328,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage1._id,
       },
       {
@@ -313,7 +337,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage2._id,
       },
       {
@@ -340,7 +364,7 @@ describe('PageGrantService', () => {
         creator: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage3._id,
       },
     ]);
@@ -375,7 +399,7 @@ describe('PageGrantService', () => {
       const targetPath = '/NEW';
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -399,7 +423,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageRootPublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -423,7 +447,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE1PublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -435,7 +459,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE2User1Path}/NEW`;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -447,7 +471,7 @@ describe('PageGrantService', () => {
       const targetPath = `${pageE3GroupParentPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -473,7 +497,7 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_PUBLIC;
       const grantedUserIds = null;
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -485,7 +509,7 @@ describe('PageGrantService', () => {
       const targetPath = emptyPagePath2;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -505,11 +529,11 @@ describe('PageGrantService', () => {
       expect(result).toBe(true);
     });
 
-    test('Should return false when Target: owned by UserA, Descendant: public', async() => {
+    test('Should return false when Target: owned by User1, Descendant: public', async() => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_OWNER;
       const grantedUserIds = [user1._id];
-      const grantedGroupIds = null;
+      const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
       const result = await pageGrantService.isGrantNormalized(user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants);
@@ -518,6 +542,77 @@ describe('PageGrantService', () => {
     });
   });
 
+  describe('Test isGrantNormalized method with previousGrantedGroupIds given', () => {
+    test('Should return true when Target: completely owned by User1 (belongs to all groups)', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user1, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(true);
+    });
+
+    test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to public grant', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = null;
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+
+    test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to owner grant', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_OWNER;
+      const grantedUserIds = [user2._id];
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+
+    test('Should return false when Target: partially owned by User2 (belongs to one of the groups), and change to restricted grant', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_RESTRICTED;
+      const grantedUserIds = null;
+      const grantedGroupIds = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+
+    test('Should return false when Target: partially owned by User2, and change to group grant without any groups of user2', async() => {
+      const targetPath = pageMultipleGroupTreesAndUsersPath;
+      const grant = Page.GRANT_USER_GROUP;
+      const grantedUserIds = null;
+      const grantedGroupIds = [{ item: differentTreeGroup._id, type: GroupType.userGroup }];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user2, targetPath, grant, grantedUserIds, grantedGroupIds, shouldCheckDescendants, false, multipleGroupTreesAndUsersPage.grantedGroups,
+      );
+
+      expect(result).toBe(false);
+    });
+  });
 
   describe('Test for calcApplicableGrantData', () => {
     test('Only Public is Applicable in case of top page', async() => {

+ 185 - 50
apps/app/test/integration/service/page.test.js

@@ -1,7 +1,13 @@
 /* eslint-disable no-unused-vars */
+import { GroupType } from '@growi/core';
 import { advanceTo } from 'jest-date-mock';
 
+import { PageSingleDeleteCompConfigValue, PageRecursiveDeleteCompConfigValue } from '~/interfaces/page-delete-config';
+import PageTagRelation from '~/server/models/page-tag-relation';
 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');
 
@@ -12,6 +18,7 @@ let rootPage;
 let dummyUser1;
 let testUser1;
 let testUser2;
+let testUser3;
 let parentTag;
 let childTag;
 
@@ -39,6 +46,7 @@ let parentForDelete2;
 
 let childForDelete;
 
+let canDeleteCompletelyTestPage;
 let parentForDeleteCompletely;
 
 let parentForRevert1;
@@ -55,7 +63,6 @@ describe('PageService', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -68,7 +75,6 @@ describe('PageService', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
@@ -76,13 +82,47 @@ describe('PageService', () => {
     await User.insertMany([
       { name: 'someone1', username: 'someone1', email: 'someone1@example.com' },
       { name: 'someone2', username: 'someone2', email: 'someone2@example.com' },
+      { name: 'someone3', username: 'someone3', email: 'someone3@example.com' },
     ]);
 
     testUser1 = await User.findOne({ username: 'someone1' });
     testUser2 = await User.findOne({ username: 'someone2' });
+    testUser3 = await User.findOne({ username: 'someone3' });
 
     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: '/' });
 
     await Page.insertMany([
@@ -170,6 +210,16 @@ describe('PageService', () => {
         creator: 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',
         grant: Page.GRANT_PUBLIC,
@@ -255,6 +305,7 @@ describe('PageService', () => {
     parentForDelete1 = await Page.findOne({ path: '/parentForDelete1' });
     parentForDelete2 = await Page.findOne({ path: '/parentForDelete2' });
 
+    canDeleteCompletelyTestPage = await Page.findOne({ path: '/canDeleteCompletelyTestPage' });
     parentForDeleteCompletely = await Page.findOne({ path: '/parentForDeleteCompletely' });
     parentForRevert1 = await Page.findOne({ path: '/trash/parentForRevert1' });
     parentForRevert2 = await Page.findOne({ path: '/trash/parentForRevert2' });
@@ -703,67 +754,151 @@ describe('PageService', () => {
   });
 
   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);
+      });
     });
   });
 

+ 177 - 15
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -5,6 +5,8 @@ import mongoose from 'mongoose';
 import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
+import type { IPageTagRelation } from '../../../src/interfaces/page-tag-relation';
+import PageTagRelation from '../../../src/server/models/page-tag-relation';
 import Tag from '../../../src/server/models/tag';
 import UserGroup from '../../../src/server/models/user-group';
 import UserGroupRelation from '../../../src/server/models/user-group-relation';
@@ -29,7 +31,6 @@ describe('PageService page operations with non-public pages', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let xssSpy;
 
   let rootPage;
@@ -57,6 +58,9 @@ describe('PageService page operations with non-public pages', () => {
   const pageIdDuplicate4 = new mongoose.Types.ObjectId();
   const pageIdDuplicate5 = new mongoose.Types.ObjectId();
   const pageIdDuplicate6 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate7 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate8 = new mongoose.Types.ObjectId();
+  const pageIdDuplicate9 = new mongoose.Types.ObjectId();
   // revision id
   const revisionIdDuplicate1 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate2 = new mongoose.Types.ObjectId();
@@ -64,6 +68,9 @@ describe('PageService page operations with non-public pages', () => {
   const revisionIdDuplicate4 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate5 = new mongoose.Types.ObjectId();
   const revisionIdDuplicate6 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate7 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate8 = new mongoose.Types.ObjectId();
+  const revisionIdDuplicate9 = new mongoose.Types.ObjectId();
 
   /**
    * Revert
@@ -115,7 +122,6 @@ describe('PageService page operations with non-public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
 
     /*
      * Common
@@ -507,7 +513,10 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate2,
         path: '/np_duplicate2',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroups: [{ item: groupIdA, type: GroupType.userGroup }, { item: externalGroupIdA, type: GroupType.externalUserGroup }],
+        grantedGroups: [
+          { item: groupIdA, type: GroupType.userGroup },
+          { item: externalGroupIdA, type: GroupType.externalUserGroup },
+        ],
         creator: npDummyUser1._id,
         lastUpdateUser: npDummyUser1._id,
         revision: revisionIdDuplicate2,
@@ -517,7 +526,10 @@ describe('PageService page operations with non-public pages', () => {
         _id: pageIdDuplicate3,
         path: '/np_duplicate2/np_duplicate3',
         grant: Page.GRANT_USER_GROUP,
-        grantedGroups: [{ item: groupIdB, type: GroupType.userGroup }, { item: externalGroupIdB, type: GroupType.externalUserGroup }],
+        grantedGroups: [
+          { item: groupIdB, type: GroupType.userGroup },
+          { item: externalGroupIdB, type: GroupType.externalUserGroup },
+        ],
         creator: npDummyUser2._id,
         lastUpdateUser: npDummyUser2._id,
         revision: revisionIdDuplicate3,
@@ -549,6 +561,44 @@ describe('PageService page operations with non-public pages', () => {
         parent: pageIdDuplicate4,
         revision: revisionIdDuplicate6,
       },
+      {
+        _id: pageIdDuplicate7,
+        path: '/np_duplicate7',
+        grant: Page.GRANT_USER_GROUP,
+        creator: npDummyUser1._id,
+        lastUpdateUser: npDummyUser1._id,
+        parent: rootPage._id,
+        revision: revisionIdDuplicate7,
+        grantedGroups: [
+          { item: groupIdA, type: GroupType.userGroup },
+          { item: externalGroupIdA, type: GroupType.externalUserGroup },
+          { item: groupIdB, type: GroupType.userGroup },
+          { item: externalGroupIdB, type: GroupType.externalUserGroup },
+        ],
+      },
+      {
+        _id: pageIdDuplicate8,
+        path: '/np_duplicate7/np_duplicate8',
+        grant: Page.GRANT_USER_GROUP,
+        creator: npDummyUser3._id,
+        lastUpdateUser: npDummyUser3._id,
+        parent: pageIdDuplicate7,
+        revision: revisionIdDuplicate8,
+        grantedGroups: [
+          { item: groupIdC, type: GroupType.userGroup },
+          { item: externalGroupIdC, type: GroupType.externalUserGroup },
+        ],
+      },
+      {
+        _id: pageIdDuplicate9,
+        path: '/np_duplicate7/np_duplicate9',
+        grant: Page.GRANT_OWNER,
+        creator: npDummyUser2._id,
+        lastUpdateUser: npDummyUser2._id,
+        parent: pageIdDuplicate7,
+        revision: revisionIdDuplicate9,
+        grantedUsers: [npDummyUser2._id],
+      },
     ]);
     await Revision.insertMany([
       {
@@ -593,6 +643,27 @@ describe('PageService page operations with non-public pages', () => {
         pageId: pageIdDuplicate6,
         author: npDummyUser1._id,
       },
+      {
+        _id: revisionIdDuplicate7,
+        body: 'np_duplicate7',
+        format: 'markdown',
+        pageId: pageIdDuplicate7,
+        author: npDummyUser1._id,
+      },
+      {
+        _id: revisionIdDuplicate8,
+        body: 'np_duplicate8',
+        format: 'markdown',
+        pageId: pageIdDuplicate8,
+        author: npDummyUser3._id,
+      },
+      {
+        _id: revisionIdDuplicate9,
+        body: 'np_duplicate9',
+        format: 'markdown',
+        pageId: pageIdDuplicate9,
+        author: npDummyUser2._id,
+      },
     ]);
 
     /**
@@ -1083,10 +1154,10 @@ describe('PageService page operations with non-public pages', () => {
   });
   describe('Duplicate', () => {
 
-    const duplicate = async(page, newPagePath, user, isRecursively) => {
+    const duplicate = async(page, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean) => {
       // mock return value
       const mockedDuplicateRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'duplicateRecursivelyMainOperation').mockReturnValue(null);
-      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively);
+      const duplicatedPage = await crowi.pageService.duplicate(page, newPagePath, user, isRecursively, onlyDuplicateUserRelatedResources);
 
       // retrieve the arguments passed when calling method duplicateRecursivelyMainOperation inside duplicate method
       const argsForDuplicateRecursivelyMainOperation = mockedDuplicateRecursivelyMainOperation.mock.calls[0];
@@ -1108,7 +1179,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(_revision).toBeTruthy();
 
       const newPagePath = '/dup_np_duplicate1';
-      await duplicate(_page, newPagePath, npDummyUser1, false);
+      await duplicate(_page, newPagePath, npDummyUser1, false, false);
 
       const duplicatedPage = await Page.findOne({ path: newPagePath });
       const duplicatedRevision = await Revision.findOne({ pageId: duplicatedPage._id });
@@ -1126,9 +1197,9 @@ describe('PageService page operations with non-public pages', () => {
       const _path1 = '/np_duplicate2';
       const _path2 = '/np_duplicate2/np_duplicate3';
       const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id, grantedGroups: { $elemMatch: { item: groupIdA } } })
-        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdA._id });
+        .populate({ path: 'revision', model: 'Revision' });
       const _page2 = await Page.findOne({ path: _path2, parent: _page1._id, grantedGroups: { $elemMatch: { item: groupIdB } } })
-        .populate({ path: 'revision', model: 'Revision', grantedPage: groupIdB._id });
+        .populate({ path: 'revision', model: 'Revision' });
       const _revision1 = _page1.revision;
       const _revision2 = _page2.revision;
       expect(_page1).toBeTruthy();
@@ -1137,7 +1208,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(_revision2).toBeTruthy();
 
       const newPagePath = '/dup_np_duplicate2';
-      await duplicate(_page1, newPagePath, npDummyUser2, true);
+      await duplicate(_page1, newPagePath, npDummyUser2, true, false);
 
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate2/np_duplicate3' }).populate({ path: 'revision', model: 'Revision' });
@@ -1181,7 +1252,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(baseRevision2).toBeTruthy();
 
       const newPagePath = '/dup_np_duplicate4';
-      await duplicate(_page1, newPagePath, npDummyUser1, true);
+      await duplicate(_page1, newPagePath, npDummyUser1, true, false);
 
       const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
       const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate4/np_duplicate5' }).populate({ path: 'revision', model: 'Revision' });
@@ -1203,6 +1274,97 @@ describe('PageService page operations with non-public pages', () => {
       expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
       expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
     });
+    test('Should duplicate only user related pages and granted groups when onlyDuplicateUserRelatedResources is true', async() => {
+      const _path1 = '/np_duplicate7';
+      const _path2 = '/np_duplicate7/np_duplicate8';
+      const _path3 = '/np_duplicate7/np_duplicate9';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id });
+      const _page3 = await Page.findOne({ path: _path3, parent: _page1._id });
+      const _revision1 = _page1.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_revision1).toBeTruthy();
+
+      const newPagePath = '/dup_np_duplicate7';
+      await duplicate(_page1, newPagePath, npDummyUser1, true, true);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate8' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage3 = await Page.findOne({ path: '/dup_np_duplicate7/np_duplicate9' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeFalsy();
+      expect(duplicatedPage3).toBeFalsy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(normalizeGrantedGroups(duplicatedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedRevision1.body).toBe(_revision1.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+    });
+    test('Should duplicate all pages and granted groups when onlyDuplicateUserRelatedResources is false', async() => {
+      const _path1 = '/np_duplicate7';
+      const _path2 = '/np_duplicate7/np_duplicate8';
+      const _path3 = '/np_duplicate7/np_duplicate9';
+      const _page1 = await Page.findOne({ path: _path1, parent: rootPage._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page2 = await Page.findOne({ path: _path2, parent: _page1._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _page3 = await Page.findOne({ path: _path3, parent: _page1._id })
+        .populate({ path: 'revision', model: 'Revision' });
+      const _revision1 = _page1.revision;
+      const _revision2 = _page2.revision;
+      const _revision3 = _page3.revision;
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_revision1).toBeTruthy();
+      expect(_revision2).toBeTruthy();
+      expect(_revision3).toBeTruthy();
+
+      const newPagePath = '/dup2_np_duplicate7';
+      await duplicate(_page1, newPagePath, npDummyUser1, true, false);
+
+      const duplicatedPage1 = await Page.findOne({ path: newPagePath }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage2 = await Page.findOne({ path: '/dup2_np_duplicate7/np_duplicate8' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedPage3 = await Page.findOne({ path: '/dup2_np_duplicate7/np_duplicate9' }).populate({ path: 'revision', model: 'Revision' });
+      const duplicatedRevision1 = duplicatedPage1.revision;
+      const duplicatedRevision2 = duplicatedPage2.revision;
+      const duplicatedRevision3 = duplicatedPage3.revision;
+      expect(xssSpy).toHaveBeenCalled();
+      expect(duplicatedPage1).toBeTruthy();
+      expect(duplicatedPage2).toBeTruthy();
+      expect(duplicatedPage3).toBeTruthy();
+      expect(duplicatedRevision1).toBeTruthy();
+      expect(duplicatedRevision2).toBeTruthy();
+      expect(duplicatedRevision3).toBeTruthy();
+      expect(normalizeGrantedGroups(duplicatedPage1.grantedGroups)).toStrictEqual([
+        { item: groupIdA, type: GroupType.userGroup },
+        { item: externalGroupIdA, type: GroupType.externalUserGroup },
+        { item: groupIdB, type: GroupType.userGroup },
+        { item: externalGroupIdB, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage1.parent).toStrictEqual(_page1.parent);
+      expect(duplicatedRevision1.body).toBe(_revision1.body);
+      expect(duplicatedRevision1.pageId).toStrictEqual(duplicatedPage1._id);
+      expect(normalizeGrantedGroups(duplicatedPage2.grantedGroups)).toStrictEqual([
+        { item: groupIdC, type: GroupType.userGroup },
+        { item: externalGroupIdC, type: GroupType.externalUserGroup },
+      ]);
+      expect(duplicatedPage2.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision2.body).toBe(_revision2.body);
+      expect(duplicatedRevision2.pageId).toStrictEqual(duplicatedPage2._id);
+      expect(duplicatedPage3.grantedUsers).toStrictEqual([npDummyUser2._id]);
+      expect(duplicatedPage3.parent).toStrictEqual(duplicatedPage1._id);
+      expect(duplicatedRevision3.body).toBe(_revision3.body);
+      expect(duplicatedRevision3.pageId).toStrictEqual(duplicatedPage3._id);
+    });
 
   });
   describe('Delete', () => {
@@ -1422,7 +1584,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert1' });
       const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(deltedPageBeforeRevert).toBeNull();
@@ -1431,7 +1593,7 @@ describe('PageService page operations with non-public pages', () => {
       expect(revertedPage.parent).toBeNull();
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
       expect(revertedPage.grant).toBe(Page.GRANT_RESTRICTED);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
     });
     test('should revert single deleted page with GRANT_USER_GROUP', async() => {
       const beforeRevertPath = '/trash/np_revert2';
@@ -1452,7 +1614,7 @@ describe('PageService page operations with non-public pages', () => {
 
       const revertedPage = await Page.findOne({ path: '/np_revert2' });
       const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: revertedPage._id, relatedTag: tag?._id });
       expect(revertedPage).toBeTruthy();
       expect(pageTagRelation).toBeTruthy();
       expect(trashedPageBR).toBeNull();
@@ -1464,7 +1626,7 @@ describe('PageService page operations with non-public pages', () => {
         { item: groupIdA, type: GroupType.userGroup },
         { item: externalGroupIdA, type: GroupType.externalUserGroup },
       ]);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
     });
     test(`revert multiple pages: only target page should be reverted.
           Non-existant middle page and leaf page with GRANT_RESTRICTED shoud not be reverted`, async() => {

+ 4 - 6
apps/app/test/integration/service/v5.page.test.ts

@@ -11,7 +11,6 @@ describe('Test page service methods', () => {
   let Revision;
   let User;
   let Tag;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -42,7 +41,6 @@ describe('Test page service methods', () => {
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
     Tag = mongoose.model('Tag');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
@@ -370,7 +368,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -399,7 +397,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -428,7 +426,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -457,7 +455,7 @@ describe('Test page service methods', () => {
           status: 'published',
           grant: Page.GRANT_PUBLIC,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },

+ 11 - 12
apps/app/test/integration/service/v5.public-page.test.ts

@@ -1,8 +1,9 @@
 /* eslint-disable no-unused-vars */
-import { advanceTo } from 'jest-date-mock';
 import mongoose from 'mongoose';
 
 import { PageActionType, PageActionStage } from '../../../src/interfaces/page-operation';
+import type { IPageTagRelation } from '../../../src/interfaces/page-tag-relation';
+import PageTagRelation from '../../../src/server/models/page-tag-relation';
 import Tag from '../../../src/server/models/tag';
 import { getInstance } from '../setup-crowi';
 
@@ -15,7 +16,6 @@ describe('PageService page operations with only public pages', () => {
   let Page;
   let Revision;
   let User;
-  let PageTagRelation;
   let Bookmark;
   let Comment;
   let ShareLink;
@@ -49,7 +49,6 @@ describe('PageService page operations with only public pages', () => {
     User = mongoose.model('User');
     Page = mongoose.model('Page');
     Revision = mongoose.model('Revision');
-    PageTagRelation = mongoose.model('PageTagRelation');
     Bookmark = mongoose.model('Bookmark');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
@@ -453,7 +452,7 @@ describe('PageService page operations with only public pages', () => {
           status: 'published',
           grant: 1,
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
@@ -2016,13 +2015,13 @@ describe('PageService page operations with only public pages', () => {
         endpoint: '/_api/v3/pages/delete',
       });
       const page = await Page.findOne({ path: '/v5_PageForDelete6' });
-      const deletedTagRelation1 = await PageTagRelation.findOne({ _id: pageRelation1._id });
-      const deletedTagRelation2 = await PageTagRelation.findOne({ _id: pageRelation2._id });
+      const deletedTagRelation1 = await PageTagRelation.findOne<IPageTagRelation>({ _id: pageRelation1?._id });
+      const deletedTagRelation2 = await PageTagRelation.findOne<IPageTagRelation>({ _id: pageRelation2?._id });
 
       expect(page).toBe(null);
       expect(deletedPage.status).toBe(Page.STATUS_DELETED);
-      expect(deletedTagRelation1.isPageTrashed).toBe(true);
-      expect(deletedTagRelation2.isPageTrashed).toBe(true);
+      expect(deletedTagRelation1?.isPageTrashed).toBe(true);
+      expect(deletedTagRelation2?.isPageTrashed).toBe(true);
     });
   });
   describe('Delete completely', () => {
@@ -2103,7 +2102,7 @@ describe('PageService page operations with only public pages', () => {
       const deletedPages = await Page.find({ _id: { $in: [parentPage._id, childPage._id, grandchildPage._id] } });
       const deletedRevisions = await Revision.find({ pageId: { $in: [parentPage._id, grandchildPage._id] } });
       const tags = await Tag.find({ _id: { $in: [tag1?._id, tag2?._id] } });
-      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1._id, pageTagRelation2._id] } });
+      const deletedPageTagRelations = await PageTagRelation.find({ _id: { $in: [pageTagRelation1?._id, pageTagRelation2?._id] } });
       const deletedBookmarks = await Bookmark.find({ _id: bookmark._id });
       const deletedComments = await Comment.find({ _id: comment._id });
       const deletedPageRedirects = await PageRedirect.find({ _id: { $in: [pageRedirect1._id, pageRedirect2._id] } });
@@ -2115,7 +2114,7 @@ describe('PageService page operations with only public pages', () => {
       expect(deletedRevisions.length).toBe(0);
       // tag should be Truthy
       expect(tags).toBeTruthy();
-      // pageTagRelation should be null
+      // PageTagRelation should be null
       expect(deletedPageTagRelations.length).toBe(0);
       // bookmark should be null
       expect(deletedBookmarks.length).toBe(0);
@@ -2201,12 +2200,12 @@ describe('PageService page operations with only public pages', () => {
         ip: '::ffff:127.0.0.1',
         endpoint: '/_api/v3/pages/revert',
       });
-      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: deletedPage._id, relatedTag: tag?._id });
+      const pageTagRelation = await PageTagRelation.findOne<IPageTagRelation>({ relatedPage: deletedPage._id, relatedTag: tag?._id });
 
       expect(revertedPage.parent).toStrictEqual(rootPage._id);
       expect(revertedPage.path).toBe('/v5_revert1');
       expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
-      expect(pageTagRelation.isPageTrashed).toBe(false);
+      expect(pageTagRelation?.isPageTrashed).toBe(false);
 
     });
 

+ 3 - 3
apps/app/test/integration/setup-crowi.js → apps/app/test/integration/setup-crowi.ts

@@ -1,8 +1,8 @@
 import { Server } from 'http';
 
-import Crowi from '~/server/crowi';
+import Crowi from '../../src/server/crowi';
 
-let _instance = null;
+let _instance: Crowi;
 
 const initCrowi = async(crowi) => {
   await crowi.setupModels();
@@ -27,7 +27,7 @@ const initCrowi = async(crowi) => {
   ]);
 };
 
-export async function getInstance(isNewInstance) {
+export async function getInstance(isNewInstance?: boolean): Promise<Crowi> {
   if (isNewInstance) {
     const crowi = new Crowi();
     await initCrowi(crowi);

+ 1 - 0
packages/core/scss/bootstrap/_variables.scss

@@ -115,6 +115,7 @@ $font-family-base: $font-family-sans-serif;
 // $dropdown-link-disabled-color: $gray-500;
 // $dropdown-header-color: $gray-500;
 // $dropdown-box-shadow: 0 0.5rem 0.7rem rgba(black, 0.1);
+$dropdown-header-padding-y:         0 !default;
 
 //== Popovers
 // $popover-border-radius: $border-radius;

+ 0 - 5
packages/core/src/interfaces/page.ts

@@ -64,11 +64,6 @@ export const PageGrant = {
 type UnionPageGrantKeys = keyof typeof PageGrant;
 export type PageGrant = typeof PageGrant[UnionPageGrantKeys];
 
-/**
- * Neither pages with grant `GRANT_RESTRICTED` nor `GRANT_SPECIFIED` can be on a page tree.
- */
-export type PageGrantCanBeOnTree = typeof PageGrant[Exclude<UnionPageGrantKeys, 'GRANT_RESTRICTED' | 'GRANT_SPECIFIED'>];
-
 export const PageStatus = {
   STATUS_PUBLISHED: 'published',
   STATUS_DELETED: 'deleted',

+ 26 - 13
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -1,18 +1,18 @@
 import {
-  forwardRef, useMemo, useRef, useEffect,
+  forwardRef, useMemo, useRef, useEffect, useState,
 } from 'react';
 
 import { indentUnit } from '@codemirror/language';
-import { Prec } from '@codemirror/state';
+import { Prec, Extension } from '@codemirror/state';
 import { EditorView } from '@codemirror/view';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
 import {
-  useFileDropzone, FileDropzoneOverlay, AllEditorTheme,
+  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme,
 } from '../../services';
 import {
-  getStrFromBol, adjustPasteData,
+  adjustPasteData, getStrFromBol,
 } from '../../services/list-util/markdown-list-util';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
@@ -144,22 +144,35 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [onScroll, codeMirrorEditor]);
 
+
+  const [themeExtension, setThemeExtension] = useState<Extension | undefined>(undefined);
   useEffect(() => {
-    if (editorTheme == null) {
-      return;
-    }
-    if (AllEditorTheme[editorTheme] == null) {
+    const settingTheme = async(name?: EditorTheme) => {
+      setThemeExtension(await getEditorTheme(name ?? 'DefaultLight'));
+    };
+    settingTheme(editorTheme as EditorTheme);
+  }, [codeMirrorEditor, editorTheme, setThemeExtension]);
+
+  useEffect(() => {
+    if (themeExtension == null) {
       return;
     }
-
-    const extension = AllEditorTheme[editorTheme];
-
     // React CodeMirror has default theme which is default prec
     // and extension have to be higher prec here than default theme.
-    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(extension));
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(themeExtension));
+    return cleanupFunction;
+  }, [codeMirrorEditor, themeExtension]);
+
+
+  useEffect(() => {
+
+    const keymap = editorKeymap ?? 'default';
+    const extension = getKeymap(keymap, onSave);
+
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.low(extension));
     return cleanupFunction;
 
-  }, [codeMirrorEditor, editorTheme]);
+  }, [codeMirrorEditor, editorKeymap, onSave]);
 
 
   useEffect(() => {

+ 2 - 2
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsButton.tsx

@@ -16,7 +16,7 @@ export const AttachmentsButton = (props: Props): JSX.Element => {
   if (acceptedFileType === AcceptedUploadFileType.ALL) {
     return (
       <>
-        <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
+        <DropdownItem className="d-flex gap-2 align-items-center" onClick={onFileOpen}>
           <span className="material-symbols-outlined fs-5">attach_file</span>
           Files
         </DropdownItem>
@@ -26,7 +26,7 @@ export const AttachmentsButton = (props: Props): JSX.Element => {
   if (acceptedFileType === AcceptedUploadFileType.IMAGE) {
     return (
       <>
-        <DropdownItem className="d-flex gap-1 align-items-center" onClick={onFileOpen}>
+        <DropdownItem className="d-flex gap-2 align-items-center" onClick={onFileOpen}>
           <span className="material-symbols-outlined fs-5">image</span>
           Images
         </DropdownItem>

+ 6 - 8
packages/editor/src/components/CodeMirrorEditor/Toolbar/AttachmentsDropup.tsx

@@ -5,19 +5,21 @@ import {
   DropdownItem,
 } from 'reactstrap';
 
+import type { GlobalCodeMirrorEditorKey } from '../../../consts';
 import { AcceptedUploadFileType } from '../../../consts/accepted-upload-file-type';
 
 import { AttachmentsButton } from './AttachmentsButton';
-
+import { LinkEditButton } from './LinkEditButton';
 
 type Props = {
+  editorKey: string | GlobalCodeMirrorEditorKey,
   onFileOpen: () => void,
   acceptedFileType: AcceptedUploadFileType,
 }
 
 export const AttachmentsDropup = (props: Props): JSX.Element => {
+  const { onFileOpen, acceptedFileType, editorKey } = props;
 
-  const { onFileOpen, acceptedFileType } = props;
   return (
     <>
       <UncontrolledDropdown direction="up" className="lh-1">
@@ -25,16 +27,12 @@ export const AttachmentsDropup = (props: Props): JSX.Element => {
           <span className="material-symbols-outlined fs-6">add</span>
         </DropdownToggle>
         <DropdownMenu>
-          <DropdownItem className="d-flex gap-1 align-items-center" header>
-            <span className="material-symbols-outlined fs-5">add_circle_outline</span>
+          <DropdownItem className="mt-1" header>
             Attachments
           </DropdownItem>
           <DropdownItem divider />
           <AttachmentsButton onFileOpen={onFileOpen} acceptedFileType={acceptedFileType} />
-          <DropdownItem className="d-flex gap-1 align-items-center">
-            <span className="material-symbols-outlined fs-5">link</span>
-            Link
-          </DropdownItem>
+          <LinkEditButton editorKey={editorKey} />
         </DropdownMenu>
       </UncontrolledDropdown>
     </>

Некоторые файлы не были показаны из-за большого количества измененных файлов