Browse Source

Merge branch 'imprv/test-code-rename-non-public' into imprv/test-code-duplicates

yohei0125 4 years ago
parent
commit
9d897e383a
100 changed files with 2312 additions and 727 deletions
  1. 3 2
      .devcontainer/docker-compose.yml
  2. 2 0
      .github/workflows/ci-app-prod.yml
  3. 1 1
      .github/workflows/reusable-app-prod.yml
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 2 2
      packages/app/.env.development
  7. 1 0
      packages/app/config/logger/config.dev.js
  8. 14 13
      packages/app/package.json
  9. 22 6
      packages/app/resource/locales/en_US/admin/admin.json
  10. 19 5
      packages/app/resource/locales/en_US/translation.json
  11. 22 6
      packages/app/resource/locales/ja_JP/admin/admin.json
  12. 20 6
      packages/app/resource/locales/ja_JP/translation.json
  13. 23 7
      packages/app/resource/locales/zh_CN/admin/admin.json
  14. 32 18
      packages/app/resource/locales/zh_CN/translation.json
  15. 12 1
      packages/app/src/client/services/AdminAppContainer.js
  16. 42 3
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  17. 2 0
      packages/app/src/client/services/AdminHomeContainer.js
  18. 16 1
      packages/app/src/client/services/user-ui-settings.ts
  19. 18 0
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  20. 29 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  21. 25 14
      packages/app/src/components/Admin/App/ConfirmModal.tsx
  22. 80 0
      packages/app/src/components/Admin/App/MaintenanceMode.tsx
  23. 8 3
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  24. 96 48
      packages/app/src/components/Admin/Security/SecuritySetting.jsx
  25. 3 0
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  26. 54 7
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  27. 38 13
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  28. 43 5
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  29. 28 11
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  30. 71 11
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  31. 18 9
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  32. 1 1
      packages/app/src/components/Navbar/AuthorInfo.jsx
  33. 1 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  34. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  35. 6 5
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  36. 22 3
      packages/app/src/components/PageCreateModal.jsx
  37. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  38. 29 4
      packages/app/src/components/PageRenameModal.tsx
  39. 25 26
      packages/app/src/components/Sidebar.tsx
  40. 10 12
      packages/app/src/components/Sidebar/PageTree.tsx
  41. 62 31
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  42. 113 75
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  43. 2 2
      packages/app/src/components/Sidebar/RecentChanges.tsx
  44. 3 3
      packages/app/src/components/Sidebar/SidebarContents.tsx
  45. 13 11
      packages/app/src/components/Sidebar/SidebarNav.tsx
  46. 0 168
      packages/app/src/components/StickyStretchableScroller.jsx
  47. 125 0
      packages/app/src/components/StickyStretchableScroller.tsx
  48. 2 3
      packages/app/src/components/TableOfContents.jsx
  49. 37 0
      packages/app/src/interfaces/page-delete-config.ts
  50. 0 4
      packages/app/src/interfaces/page-listing-results.ts
  51. 10 2
      packages/app/src/interfaces/user-group-response.ts
  52. 59 0
      packages/app/src/migrations/20220311011114-convert-page-delete-config.js
  53. 2 0
      packages/app/src/server/crowi/index.js
  54. 2 2
      packages/app/src/server/middlewares/login-required.js
  55. 27 0
      packages/app/src/server/middlewares/unavailable-when-maintenance-mode.ts
  56. 4 0
      packages/app/src/server/models/config.ts
  57. 80 21
      packages/app/src/server/models/page.ts
  58. 1 1
      packages/app/src/server/routes/admin.js
  59. 1 0
      packages/app/src/server/routes/apiv3/admin-home.js
  60. 50 0
      packages/app/src/server/routes/apiv3/app-settings.js
  61. 5 0
      packages/app/src/server/routes/apiv3/import.js
  62. 12 11
      packages/app/src/server/routes/apiv3/index.js
  63. 1 1
      packages/app/src/server/routes/apiv3/page-listing.ts
  64. 2 17
      packages/app/src/server/routes/apiv3/pages.js
  65. 11 3
      packages/app/src/server/routes/apiv3/security-setting.js
  66. 98 7
      packages/app/src/server/routes/apiv3/user-group.js
  67. 57 32
      packages/app/src/server/routes/index.js
  68. 7 3
      packages/app/src/server/routes/page.js
  69. 12 0
      packages/app/src/server/service/app.ts
  70. 6 0
      packages/app/src/server/service/config-loader.ts
  71. 4 2
      packages/app/src/server/service/page-operation.ts
  72. 58 9
      packages/app/src/server/service/page.ts
  73. 1 1
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  74. 2 2
      packages/app/src/server/views/forgot-password.html
  75. 56 0
      packages/app/src/server/views/maintenance-mode.html
  76. 3 3
      packages/app/src/stores/context.tsx
  77. 14 3
      packages/app/src/stores/ui.tsx
  78. 19 5
      packages/app/src/stores/user-group.tsx
  79. 2 2
      packages/app/src/styles/_mixins.scss
  80. 3 0
      packages/app/src/styles/_override-simplebar.scss
  81. 1 1
      packages/app/src/styles/_page-tree.scss
  82. 3 4
      packages/app/src/styles/_sidebar.scss
  83. 3 0
      packages/app/src/styles/_vendor.scss
  84. 3 0
      packages/app/src/styles/style-app.scss
  85. 16 2
      packages/app/src/styles/theme/_apply-colors-dark.scss
  86. 16 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  87. 0 12
      packages/app/src/styles/theme/_apply-colors.scss
  88. 1 2
      packages/app/src/styles/theme/christmas.scss
  89. 1 1
      packages/app/src/styles/theme/default.scss
  90. 1 2
      packages/app/src/styles/theme/future.scss
  91. 4 2
      packages/app/src/styles/theme/island.scss
  92. 1 2
      packages/app/src/styles/theme/kibela.scss
  93. 2 10
      packages/app/src/styles/theme/mixins/_list-group.scss
  94. 1 1
      packages/app/test/cypress/integration/1-install/install.spec.ts
  95. 38 0
      packages/app/test/cypress/integration/2-basic-features/open-page-delete-modal.spec.ts
  96. 10 0
      packages/app/test/cypress/support/index.ts
  97. 4 4
      packages/app/test/integration/middlewares/login-required.test.js
  98. 394 3
      packages/app/test/integration/models/v5.page.test.js
  99. 4 4
      packages/app/test/integration/service/v5.non-public-page.test.ts
  100. 1 2
      packages/app/tsconfig.build.server.json

+ 3 - 2
.devcontainer/docker-compose.yml

@@ -48,7 +48,7 @@ services:
       context: ../../growi-docker-compose/elasticsearch
       dockerfile: ./Dockerfile
       args:
-        - version=6.8.22
+        - version=7.16.1
     container_name: elasticsearch
     restart: unless-stopped
     ports:
@@ -56,6 +56,7 @@ services:
     environment:
       - bootstrap.memory_lock=true
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
+      - LOG4J_FORMAT_MSG_NO_LOOKUPS=true # CVE-2021-44228 mitigation for Elasticsearch <= 6.8.20/7.16.0
     ulimits:
       memlock:
         soft: -1
@@ -66,7 +67,7 @@ services:
 
   #need to adjust kibana version based on elasticsearch version
   kibana:
-    image: docker.elastic.co/kibana/kibana:6.8.22
+    image: docker.elastic.co/kibana/kibana:7.17.1
     restart: unless-stopped
     environment:
       ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'

+ 2 - 0
.github/workflows/ci-app-prod.yml

@@ -5,6 +5,8 @@ on:
     branches:
       - master
   pull_request:
+    branches:
+        - master
     types: [opened, reopened, synchronize]
 
 jobs:

+ 1 - 1
.github/workflows/reusable-app-prod.yml

@@ -185,7 +185,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4', '5', '6']
+        spec-group: ['1', '2', '3']
 
     services:
       mongodb:

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.0-RC.8",
+  "version": "5.0.0-RC.9",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.0-RC.8",
+  "version": "5.0.0-RC.9",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 2 - 2
packages/app/.env.development

@@ -12,10 +12,10 @@ MATHJAX=1
 MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"
+USE_ELASTICSEARCH_V6=false
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
 ELASTICSEARCH_REQUEST_TIMEOUT=15000
-#ELASTICSEARCH_REJECT_UNAUTHORIZED=false
-#USE_ELASTICSEARCH_V6=true
+ELASTICSEARCH_REJECT_UNAUTHORIZED=true
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 OGP_URI="http://ogp:8088"

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -36,6 +36,7 @@ module.exports = {
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  // 'growi:cli:ItemsTree': 'debug',
   'growi:searchResultList': 'debug',
 
 };

+ 14 - 13
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0-RC.8",
+  "version": "5.0.0-RC.9",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -58,13 +58,15 @@
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0-RC.8",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.8",
-    "@growi/plugin-lsx": "^5.0.0-RC.8",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.8",
-    "@growi/slack": "^5.0.0-RC.8",
+    "@growi/codemirror-textlint": "^5.0.0-RC.9",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.9",
+    "@growi/plugin-lsx": "^5.0.0-RC.9",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.9",
+    "@growi/slack": "^5.0.0-RC.9",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -90,8 +92,6 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
     "entities": "^2.0.0",
     "esa-node": "^0.2.2",
@@ -102,7 +102,7 @@
     "express-mongo-sanitize": "^2.1.0",
     "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
-    "express-validator": "^6.1.1",
+    "express-validator": "^6.14.0",
     "express-webpack-assets": "^0.1.0",
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
@@ -150,13 +150,13 @@
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
-    "swagger-jsdoc": "^3.4.0",
+    "swagger-jsdoc": "^6.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
-    "validator": "^13.6.0",
+    "validator": "^13.7.0",
     "ws": "^8.3.0",
     "xss": "^1.0.6"
   },
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.8",
+    "@growi/ui": "^5.0.0-RC.9",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -187,8 +187,8 @@
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
-    "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",
@@ -238,6 +238,7 @@
     "sass": "^1.43.4",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
+    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",

+ 22 - 6
packages/app/resource/locales/en_US/admin/admin.json

@@ -22,13 +22,27 @@
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page tree feature is not available yet.",
     "go_to_settings": "Go to settings to enable the feature",
-    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages.",
+    "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
     "migration_note": "Note: You will lose unique constraints from the page paths.",
-    "upgrade_to_v5": "Upgrade to V5",
-    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
-    "start_upgrading": "Start upgrading",
-    "successfully_started": "Succeeded to start migration",
-    "already_upgraded": "You have already completed upgrading"
+    "upgrade_to_v5": "Convert to v5 compatibility",
+    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during the conversion.",
+    "start_upgrading": "Start converting to v5 compatibility",
+    "successfully_started": "Succeeded to start the conversion",
+    "already_upgraded": "You have already completed the conversion to v5 compatibility"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "Maintenance Mode",
+    "under_maintenance_mode": "Under Maintenance Mode",
+    "failed_to_start_maintenance_mode": "Failed to start maintenance mode",
+    "failed_to_end_maintenance_mode": "Failed to end maintenance mode",
+    "successfully_started_maintenance_mode": "Succussfully started maintenance mode",
+    "successfully_ended_maintenance_mode": "Succussfully ended maintenance mode",
+    "warning_message_to_start": "You will NOT able to access other than admin settings page. General users will NOT able to access to any contents until maintenance mode ends manually.",
+    "warning_message_to_end": "Please make sure that \"data importing\" or \"upgrading to v5\" is already done or not. If not, it is highly recommended to keep maintenance mode on.",
+    "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.",
+    "start_maintenance_mode": "Start maintenance mode",
+    "end_maintenance_mode": "End maintenance mode",
+    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"Security Settings\" > \"Maintenance Mode\"."
   },
   "app_setting": {
     "site_name": "Site name",
@@ -458,6 +472,8 @@
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
+    "parent_group": "Parent Group",
+    "select_parent_group": "Select Parent Group",
     "add_modal": {
       "add_user": "Add a user to the created group",
       "search_option": "Search option",

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

@@ -111,7 +111,7 @@
   "Create under": "Create page under below:",
   "Wiki Management Home Page": "Wiki Management Home Page",
   "App Settings": "App Settings",
-  "V5 Page Migration": "V5 Page Migration",
+  "V5 Page Migration": "Convert To V5 Compatibility",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "See more detail on <a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
   "Site URL settings": "Site URL settings",
@@ -393,7 +393,7 @@
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
     "outdated": "Page is updated someone and now outdated.",
-    "user_not_admin": "Only admin user can delete completely"
+    "user_not_admin": "Only admin user can delete"
   },
   "page_history": {
     "revision_list": "Revision list",
@@ -419,7 +419,7 @@
     },
     "help": {
       "redirect": "Redirect to new page if someone accesses under this path",
-      "metadata": "Remains last update user and updated date as is",
+      "metadata": "Last update user and updated date will remain the same",
       "recursive": "Move/Rename children of under this path recursively"
     }
   },
@@ -668,8 +668,15 @@
     "page_listing_2": "Page listing/searching<br>restricted by User group",
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
     "page_access_and_delete_rights": "Page access / Delete rights",
-    "complete_deletion": "Restrict complete deletion of pages",
-    "complete_deletion_explain": "Restricts users who can completely delete pages.",
+    "deletion": "Restrict trashing of a selected single page",
+    "deletion_explain": "Restricts users who can trash a selected single page.",
+    "complete_deletion": "Restrict complete deletion of a selected single page",
+    "complete_deletion_explain": "Restricts users who can completely delete a selected single page.",
+    "recursive_deletion": "Restrict trashing of pages including descendants",
+    "recursive_deletion_explain": "Restricts users who can trash pages including descendants.",
+    "recursive_complete_deletion": "Restrict complete deletion of pages including descendants",
+    "recursive_complete_deletion_explain": "Restricts users who can completely delete pages including descendants.",
+    "inherit": "Inherit(Use the same setting as for a single page)",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
     "anyone": "Anyone",
@@ -991,6 +998,13 @@
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },
+  "maintenance_mode":{
+    "maintenance_mode": "Maintenance Mode",
+    "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",
+    "admin_page": "Admin Page",
+    "login": "Login",
+    "logout": "Logout"
+  },
   "pagetree": {
     "private_legacy_pages": "Private Legacy Pages",
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",

+ 22 - 6
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -22,13 +22,27 @@
   "v5_page_migration": {
     "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
     "go_to_settings": "設定する",
-    "migration_desc": "公開されているページに古いスキーマのものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページのスキーマをアップグレードしてください。",
+    "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
     "migration_note": "注意: ページパスからユニーク制約が失われます。",
-    "upgrade_to_v5": "V5 にアップグレード",
-    "modal_migration_warning": "管理者はユーザーに、マイグレーション中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
-    "start_upgrading": "アップグレードを開始",
-    "successfully_started": "正常にマイグレーションが開始されました",
-    "already_upgraded": "アップグレードは既に完了しています"
+    "upgrade_to_v5": "v5 互換形式 へ変換",
+    "modal_migration_warning": "管理者はユーザーに、v5 互換形式への変換中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
+    "start_upgrading": "v5 互換形式への変換を開始",
+    "successfully_started": "正常に v5 互換形式への変換が開始されました",
+    "already_upgraded": "v5 互換形式への変換は既に完了しています"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "メンテナンスモード",
+    "under_maintenance_mode": "メンテナンスモード中",
+    "failed_to_start_maintenance_mode": "メンテナンスモードを開始できませんでした",
+    "failed_to_end_maintenance_mode": "メンテナンスモードを終了できませんでした",
+    "successfully_started_maintenance_mode": "メンテナンスモードを開始しました",
+    "successfully_ended_maintenance_mode": "メンテナンスモードを終了しました",
+    "warning_message_to_start": "メンテナンスモード中は管理画面にしかアクセスできなくなり、一般ユーザーは全ての操作が不能になります。",
+    "warning_message_to_end": "「データのインポート」および「V5 へのアップグレード」が進行中の場合は、処理が終了するまでメンテナンスモードを終了しないようにすることを推奨します。",
+    "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。",
+    "start_maintenance_mode": "メンテナンスモードを開始する",
+    "end_maintenance_mode": "メンテナンスモードを終了する",
+    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「セキュリティ設定」>「メンテナンスモード」から操作してください。"
   },
   "app_setting": {
     "site_name": "サイト名",
@@ -457,6 +471,8 @@
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",
+    "parent_group": "親グループ",
+    "select_parent_group": "親グループを選択",
     "add_modal": {
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",

+ 20 - 6
packages/app/resource/locales/ja_JP/translation.json

@@ -111,7 +111,7 @@
   "Create under": "ページを以下に作成",
   "Wiki Management Home Page": "Wiki管理トップ",
   "App Settings": "アプリ設定",
-  "V5 Page Migration": "V5 ページマイグレーション",
+  "V5 Page Migration": "V5 互換形式 への変換",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
   "See_more_detail_on_new_schema": "詳しくは<a href='#'>{{url}}</a><i class='icon-share-alt'></i>を参照ください。",
   "Site URL settings": "サイトURL設定",
@@ -392,7 +392,7 @@
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
-    "user_not_admin": "権限のあるユーザーのみが完全削除できます"
+    "user_not_admin": "権限のあるユーザーのみが削除できます"
   },
   "page_history": {
     "revision_list": "更新履歴",
@@ -413,12 +413,12 @@
       "Rename this page only": "このページのみを移動/名前変更",
       "Force rename all child pages": "全ての配下のページを移動/名前変更する",
       "Other options": "その他のオプション",
-      "Do not update metadata": "不更新元数据",
+      "Do not update metadata": "メタデータを更新しない",
       "Redirect": "リダイレクトする"
     },
     "help": {
       "redirect": "アクセスされた際に自動的に新しいページにジャンプします",
-      "metadata": "Remains last update user and updated date as is",
+      "metadata": "最終更新ユーザー、最終更新日を更新せず維持します",
       "recursive": "配下のページも移動/名前変更します"
     }
   },
@@ -667,8 +667,15 @@
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_access_and_delete_rights": "ページの閲覧・削除権限",
-    "complete_deletion": "ページの完全削除",
-    "complete_deletion_explain": "ページを完全に削除できるユーザーを制限します。",
+    "deletion": "ページをゴミ箱に入れる(単体のみの操作)",
+    "deletion_explain": "ページをゴミ箱に入れることができるユーザーを制限します。",
+    "complete_deletion": "ページを完全削除する(単体のみの操作)",
+    "complete_deletion_explain": "ページを完全削除することができるユーザーを制限します。",
+    "recursive_deletion": "ページをゴミ箱に入れる(子孫を含む操作)",
+    "recursive_deletion_explain": "子孫を含めたページをゴミ箱に入れることができるユーザーを制限します。",
+    "recursive_complete_deletion": "ページを完全削除する(子孫を含む操作)",
+    "recursive_complete_deletion_explain": "子孫を含めたページを完全削除することができるユーザーを制限します。",
+    "inherit": "単体のみと同じ",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
@@ -983,6 +990,13 @@
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
+  "maintenance_mode":{
+    "maintenance_mode": "メンテナンスモード",
+    "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",
+    "admin_page": "管理画面へ",
+    "login": "ログイン",
+    "logout": "ログアウト"
+  },
   "pagetree": {
     "private_legacy_pages": "旧形式のプライベートページ",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",

+ 23 - 7
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -22,13 +22,27 @@
   "v5_page_migration": {
     "page_tree_not_avaliable": "Page Tree 功能不可用",
     "go_to_settings": "进入设置,启用该功能",
-    "migration_desc": "Some of the public pages have the old schema. To take advantage of new features such as page trees and easy renaming, please upgrade the schema of all your pages. ",
-    "migration_note": "Note: You will lose unique constraints from the page paths.",
-    "upgrade_to_v5": "Upgrade to V5",
-    "modal_migration_warning": "This process may take long. It is highly recommended that administrators tell users not to create, modify, or delete pages during migration.",
-    "start_upgrading": "Start upgrading",
-    "successfully_started": "Succeeded to start migration",
-    "already_upgraded": "You have already completed upgrading"
+    "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
+    "migration_note": "注意:你将失去页面路径的唯一约束。",
+    "upgrade_to_v5": "转换为v5兼容性",
+    "modal_migration_warning": "这个过程可能需要很长时间。强烈建议管理员告诉用户在转换期间不要创建、修改或删除页面。",
+    "start_upgrading": "开始转换为v5兼容性",
+    "successfully_started": "成功开始转换",
+    "already_upgraded": "你已经完成了向v5兼容性的转换"
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "维护模式",
+    "under_maintenance_mode": "在维护模式下",
+    "failed_to_start_maintenance_mode": "启动维护模式失败",
+    "failed_to_end_maintenance_mode": "结束维护模式失败",
+    "successfully_started_maintenance_mode": "成功地启动了维护模式",
+    "successfully_ended_maintenance_mode": "成功地结束了维护模式",
+    "warning_message_to_start": "你将无法访问管理员设置以外的页面。普通用户将无法访问任何内容,直到维护模式手动结束。",
+    "warning_message_to_end": "如果 \"数据导入 \"和 \"升级到V5 \"正在进行中,建议在该过程完成之前不要退出维护模式。",
+    "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。",
+    "start_maintenance_mode": "启动维护模式",
+    "end_maintenance_mode": "结束维护模式",
+    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"安全设置\">\"维护模式\"。"
   },
   "app_setting": {
     "site_name": "网站名称 ",
@@ -467,6 +481,8 @@
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
+    "parent_group": "父母组",
+    "select_parent_group": "选择父组",
     "add_modal": {
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",

+ 32 - 18
packages/app/resource/locales/zh_CN/translation.json

@@ -119,7 +119,7 @@
 	"Create under": "Create page under below:",
 	"Wiki Management Home Page": "Wiki管理首页",
 	"App Settings": "系统设置",
-  "V5 Page Migration": "V5 Page Migration",
+  "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "更多详情请见<a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
 	"Site URL settings": "主页URL设置",
@@ -371,7 +371,7 @@
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "新建页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
-		"user_not_admin": "仅管理员用户可以完全删除"
+		"user_not_admin": "仅管理员用户可以删除"
   },
   "page_history": {
     "revision_list": "修订清单",
@@ -392,12 +392,12 @@
       "Rename this page only": "仅重命名此页面",
       "Force rename all child pages": "强制重命名所有子页面 ",
       "Other options": "其他选项",
-      "Update metadata": "更新元数据",
+      "Do not update metadata": "不更新元数据",
       "Redirect": "重定向"
 		},
 		"help": {
       "redirect": "Redirect to new page if someone accesses <code>%s</code>",
-      "metadata": "Update last update user and updated date",
+      "metadata": "Remains last update user and updated date as is",
       "recursive": "Move/Rename children of under <code>%s</code> recursively"
 		}
 	},
@@ -626,8 +626,15 @@
 		"page_listing_2": "页面列表/搜索<br>受用户组限制",
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
     "page_access_and_delete_rights": "页面访问/删除权限",
-		"complete_deletion": "限制完全删除页面",
-		"complete_deletion_explain": "限制可以完全删除页面的用户。",
+    "deletion": "限制捣毁一个选定的单一页面",
+    "deletion_explain": "限制用户对选定的单一页面进行垃圾处理。",
+    "complete_deletion": "限制完全删除一个选定的单页",
+    "complete_deletion_explain": "限制可以完全删除所选单页的用户。",
+    "recursive_deletion": "限制捣毁包括子孙在内的网页",
+    "recursive_deletion_explain": "限制用户可以捣毁包括子孙在内的页面。",
+    "recursive_complete_deletion": "限制完全删除包括子孙在内的页面",
+    "recursive_complete_deletion_explain": "限制可以完全删除页面的用户,包括子孙。",
+    "inherit": "继承(使用与单页相同的设置)。",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
@@ -912,19 +919,19 @@
     }
 	},
   "private_legacy_pages": {
-    "bulk_operation": "Bulk operation",
-    "convert_all_selected_pages": "Convert all to new v5 compatible format",
-    "alert_title": "You are viewing old v4 compatible private pages.",
-    "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
-    "nopages_title": "Congratulations. Ready to use GROWI v5!",
-    "nopages_desc1": "Now all the pages you can manage seem to be in v5 compatible format.",
-    "detail_info": "See the detail information from <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Upgrading GROWI to v5.0.x <i class='icon-share-alt'></i></a>.",
+    "bulk_operation": "批量操作",
+    "convert_all_selected_pages": "全部转换为新的v5兼容格式",
+    "alert_title": "你正在查看旧的v4兼容的私人网页。",
+    "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
+    "nopages_title": "恭喜你。准备使用GROWI v5!",
+    "nopages_desc1": "现在你能管理的所有页面似乎都是v5兼容的格式。",
+    "detail_info": "请参见 <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>升级GROWI到v5.0.x <i class='icon-share-alt'></i></a>.的详细内容。",
     "modal": {
-      "title": "Convert to new v5 compatible format",
-      "converting_pages": "Converting pages",
-      "convert_recursively_label": "Convert child pages recursively.",
-      "convert_recursively_desc": "Convert pages under this path recursively.",
-      "button_label": "Convert"
+      "title": "转换为新的v5兼容格式",
+      "converting_pages": "转换页面",
+      "convert_recursively_label": "递归地转换子页面。",
+      "convert_recursively_desc": "递归地转换该路径下的页面。",
+      "button_label": "转换"
     }
   },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
@@ -993,6 +1000,13 @@
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   },
+  "maintenance_mode":{
+    "maintenance_mode": "维护模式",
+    "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",
+    "admin_page": "管理员页",
+    "login": "登录",
+    "logout": "登出"
+  },
   "pagetree": {
     "private_legacy_pages": "私人遗留页面",
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",

+ 12 - 1
packages/app/src/client/services/AdminAppContainer.js

@@ -58,6 +58,8 @@ export default class AdminAppContainer extends Container {
       s3ReferenceFileWithRelayMode: false,
 
       isEnabledPlugins: true,
+
+      isMaintenanceMode: false,
     };
 
   }
@@ -116,6 +118,7 @@ export default class AdminAppContainer extends Container {
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
+      isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
 
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
@@ -454,9 +457,17 @@ export default class AdminAppContainer extends Container {
    * @memberOf AdminAppContainer
    */
   async v5PageMigrationHandler() {
-    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
+    const response = await this.appContainer.apiv3.post('/app-settings/v5-schema-migration');
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
   }
 
+  async startMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: true });
+  }
+
+  async endMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: false });
+  }
+
 }

+ 42 - 3
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -1,5 +1,9 @@
 import { Container } from 'unstated';
 
+import {
+  PageSingleDeleteConfigValue, PageSingleDeleteCompConfigValue,
+  PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
+} from '~/interfaces/page-delete-config';
 import { toastError } from '../util/apiNotification';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
@@ -22,7 +26,10 @@ export default class AdminGeneralSecurityContainer extends Container {
       wikiMode: '',
       // set dummy value tile for using suspense
       currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
-      currentPageCompleteDeletionAuthority: 'adminOnly',
+      currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
+      currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
+      currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
+      currentPageRecursiveCompleteDeletionAuthority: PageRecursiveDeleteCompConfigValue.Inherit,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       appSiteUrl: appContainer.config.crowi.url || '',
@@ -42,6 +49,11 @@ export default class AdminGeneralSecurityContainer extends Container {
       shareLinksActivePage: 1,
     };
 
+    this.changePageDeletionAuthority = this.changePageDeletionAuthority.bind(this);
+    this.changePageCompleteDeletionAuthority = this.changePageCompleteDeletionAuthority.bind(this);
+    this.changePageRecursiveDeletionAuthority = this.changePageRecursiveDeletionAuthority.bind(this);
+    this.changePageRecursiveCompleteDeletionAuthority = this.changePageRecursiveCompleteDeletionAuthority.bind(this);
+
   }
 
   async retrieveSecurityData() {
@@ -50,7 +62,10 @@ export default class AdminGeneralSecurityContainer extends Container {
     const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
+      currentPageDeletionAuthority: generalSetting.pageDeletionAuthority,
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority: generalSetting.pageRecursiveDeletionAuthority,
+      currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       sessionMaxAge: generalSetting.sessionMaxAge,
@@ -104,11 +119,32 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentRestrictGuestMode: restrictGuestModeLabel });
   }
 
+  /**
+   * Change pageDeletionAuthority
+   */
+  changePageDeletionAuthority(val) {
+    this.setState({ currentPageDeletionAuthority: val });
+  }
+
   /**
    * Change pageCompleteDeletionAuthority
    */
-  changePageCompleteDeletionAuthority(pageCompleteDeletionAuthorityLabel) {
-    this.setState({ currentPageCompleteDeletionAuthority: pageCompleteDeletionAuthorityLabel });
+  changePageCompleteDeletionAuthority(val) {
+    this.setState({ currentPageCompleteDeletionAuthority: val });
+  }
+
+  /**
+   * Change pageRecursiveDeletionAuthority
+   */
+  changePageRecursiveDeletionAuthority(val) {
+    this.setState({ currentPageRecursiveDeletionAuthority: val });
+  }
+
+  /**
+   * Change pageRecursiveCompleteDeletionAuthority
+   */
+  changePageRecursiveCompleteDeletionAuthority(val) {
+    this.setState({ currentPageRecursiveCompleteDeletionAuthority: val });
   }
 
   /**
@@ -135,7 +171,10 @@ export default class AdminGeneralSecurityContainer extends Container {
     let requestParams = {
       sessionMaxAge: this.state.sessionMaxAge,
       restrictGuestMode: this.state.currentRestrictGuestMode,
+      pageDeletionAuthority: this.state.currentPageDeletionAuthority,
       pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
+      pageRecursiveDeletionAuthority: this.state.currentPageRecursiveDeletionAuthority,
+      pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
     };

+ 2 - 0
packages/app/src/client/services/AdminHomeContainer.js

@@ -32,6 +32,7 @@ export default class AdminHomeContainer extends Container {
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: null,
       isV5Compatible: null,
+      isMaintenanceMode: null,
     };
 
   }
@@ -64,6 +65,7 @@ export default class AdminHomeContainer extends Container {
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         isV5Compatible: adminHomeParams.isV5Compatible,
+        isMaintenanceMode: adminHomeParams.isMaintenanceMode,
       }));
     }
     catch (err) {

+ 16 - 1
packages/app/src/client/services/user-ui-settings.ts

@@ -5,6 +5,7 @@ import { debounce } from 'throttle-debounce';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import { useIsGuestUser } from '~/stores/context';
 
 let settingsForBulk: Partial<IUserUISettings> = {};
 const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> => {
@@ -18,7 +19,8 @@ const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> =>
 
 const _putUserUISettingsInBulkDebounced = debounce(1500, false, _putUserUISettingsInBulk);
 
-export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+type ScheduleToPutFunction = (settings: Partial<IUserUISettings>) => Promise<AxiosResponse<IUserUISettings>>;
+const scheduleToPut: ScheduleToPutFunction = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
   settingsForBulk = {
     ...settingsForBulk,
     ...settings,
@@ -26,3 +28,16 @@ export const scheduleToPutUserUISettings = (settings: Partial<IUserUISettings>):
 
   return _putUserUISettingsInBulkDebounced();
 };
+
+type UserUISettingsUtil = {
+  scheduleToPut: ScheduleToPutFunction | (() => void),
+}
+export const useUserUISettings = (): UserUISettingsUtil => {
+  const { data: isGuestUser } = useIsGuestUser();
+
+  return {
+    scheduleToPut: isGuestUser
+      ? () => {}
+      : scheduleToPut,
+  };
+};

+ 18 - 0
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -38,6 +38,24 @@ const AdminHome = (props) => {
 
   return (
     <div data-testid="admin-home">
+      {
+        // Alert message will be displayed in case that the GROWI is under maintenance
+        adminHomeContainer.state.isMaintenanceMode && (
+          <div className="alert alert-danger alert-link" role="alert">
+            <h3 className="alert-heading">
+              {t('maintenance_mode.maintenance_mode')}
+            </h3>
+            <p>
+              {t('maintenance_mode.description')}
+            </p>
+            <hr />
+            <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+              <i className="fa fa-link ml-1" aria-hidden="true"></i>
+              <strong>{t('maintenance_mode.end_maintenance_mode')}</strong>
+            </a>
+          </div>
+        )
+      }
       {
       // Alert message will be displayed in case that V5 migration has not been compleated
         (migrationStatus != null && !migrationStatus.isV5Compatible)

+ 29 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -1,4 +1,4 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 
@@ -9,6 +9,7 @@ import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
 import V5PageMigration from './V5PageMigration';
+import MaintenanceMode from './MaintenanceMode';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
@@ -20,6 +21,24 @@ class AppSettingsPageContents extends React.Component {
 
     return (
       <div data-testid="admin-app-settings">
+        {
+          // Alert message will be displayed in case that the GROWI is under maintenance
+          adminAppContainer.state.isMaintenanceMode && (
+            <div className="alert alert-danger alert-link" role="alert">
+              <h3 className="alert-heading">
+                {t('admin:maintenance_mode.maintenance_mode')}
+              </h3>
+              <p>
+                {t('admin:maintenance_mode.description')}
+              </p>
+              <hr />
+              <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+                <i className="fa fa-fw fa-arrow-down ml-1" aria-hidden="true"></i>
+                <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+              </a>
+            </div>
+          )
+        }
         {
           !isV5Compatible
           && (
@@ -66,7 +85,16 @@ class AppSettingsPageContents extends React.Component {
             <PluginSetting />
           </div>
         </div>
+
+        <div className="row">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>
+            <MaintenanceMode />
+          </div>
+        </div>
+
       </div>
+
     );
   }
 

+ 25 - 14
packages/app/src/components/Admin/App/V5PageMigrationModal.tsx → packages/app/src/components/Admin/App/ConfirmModal.tsx

@@ -3,14 +3,18 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
 
-type V5PageMigrationModalProps = {
+type ConfirmModalProps = {
   isModalOpen: boolean
-  onConfirm?: () => Promise<void>;
-  onCancel?: () => void;
+  warningMessage: TFunctionResult
+  supplymentaryMessage: TFunctionResult | null
+  confirmButtonTitle: TFunctionResult
+  onConfirm?: () => Promise<void>
+  onCancel?: () => void
 };
 
-export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5PageMigrationModalProps) => {
+export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) => {
   const { t } = useTranslation();
 
   const onCancel = () => {
@@ -27,18 +31,25 @@ export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5Pag
 
   return (
     <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
-      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-danger">
         <i className="icon-fw icon-question" />
-        Warning
+        {t('Warning')}
       </ModalHeader>
       <ModalBody>
-        {t('admin:v5_page_migration.modal_migration_warning')}
-        <br />
-        <br />
-        <span className="text-danger">
-          <i className="icon-exclamation icon-fw"></i>
-          {t('admin:v5_page_migration.migration_note')}
-        </span>
+        {props.warningMessage}
+        {
+          props.supplymentaryMessage != null && (
+            <>
+              <br />
+              <br />
+              <span className="text-warning">
+                <i className="icon-exclamation icon-fw"></i>
+                {props.supplymentaryMessage}
+              </span>
+            </>
+          )
+        }
+
       </ModalBody>
       <ModalFooter>
         <button
@@ -53,7 +64,7 @@ export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5Pag
           className="btn btn-outline-primary ml-3"
           onClick={onConfirm}
         >
-          {t('admin:v5_page_migration.start_upgrading')}
+          {props.confirmButtonTitle ?? t('Confirm')}
         </button>
       </ModalFooter>
     </Modal>

+ 80 - 0
packages/app/src/components/Admin/App/MaintenanceMode.tsx

@@ -0,0 +1,80 @@
+import React, { FC, useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { ConfirmModal } from './ConfirmModal';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+
+const logger = loggerFactory('growi:maintenanceMode');
+
+type Props = {
+  adminAppContainer: AdminAppContainer,
+};
+
+const MaintenanceMode: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
+
+  const [isModalOpen, setModalOpen] = useState<boolean>(false);
+  const [isMaintenanceMode, setMaintenanceMode] = useState<boolean | undefined>(adminAppContainer.state.isMaintenanceMode);
+
+  const openModal = () => { setModalOpen(true) };
+  const closeModal = () => { setModalOpen(false) };
+
+  const onConfirmHandler = useCallback(async() => {
+    closeModal();
+
+    try {
+      if (isMaintenanceMode) {
+        await adminAppContainer.endMaintenanceMode();
+        setMaintenanceMode(false);
+      }
+      else {
+        await adminAppContainer.startMaintenanceMode();
+        setMaintenanceMode(true);
+      }
+    }
+    catch (err) {
+      toastError(isMaintenanceMode ? t('admin:maintenance_mode.failed_to_end_maintenance_mode') : t('admin:maintenance_mode.failed_to_start_maintenance_mode'));
+    }
+
+    // eslint-disable-next-line max-len
+    toastSuccess(isMaintenanceMode ? t('admin:maintenance_mode.successfully_ended_maintenance_mode') : t('admin:maintenance_mode.successfully_started_maintenance_mode'));
+  }, [isMaintenanceMode, adminAppContainer, closeModal]);
+
+  return (
+    <div className="mb-5">
+      <ConfirmModal
+        isModalOpen={isModalOpen}
+        warningMessage={isMaintenanceMode ? t('admin:maintenance_mode.warning_message_to_end') : t('admin:maintenance_mode.warning_message_to_start')}
+        // eslint-disable-next-line max-len
+        supplymentaryMessage={isMaintenanceMode ? null : t('admin:maintenance_mode.supplymentary_message_to_start')}
+        confirmButtonTitle={isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+        onConfirm={onConfirmHandler}
+        onCancel={() => closeModal()}
+      />
+      <p className="card well">
+        {t('admin:maintenance_mode.description')}
+        <br />
+        <br />
+        <span className="text-warning">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:maintenance_mode.supplymentary_message_to_start')}
+        </span>
+      </p>
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-success" onClick={() => openModal()}>
+            {isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default withUnstatedContainers(MaintenanceMode, [AdminAppContainer]);

+ 8 - 3
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -1,6 +1,6 @@
 import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { V5PageMigrationModal } from './V5PageMigrationModal';
+import { ConfirmModal } from './ConfirmModal';
 import AdminAppContainer from '../../../client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -31,8 +31,11 @@ const V5PageMigration: FC<Props> = (props: Props) => {
 
   return (
     <>
-      <V5PageMigrationModal
+      <ConfirmModal
         isModalOpen={isV5PageMigrationModalShown}
+        warningMessage={t('admin:v5_page_migration.modal_migration_warning')}
+        supplymentaryMessage={t('admin:v5_page_migration.migration_note')}
+        confirmButtonTitle={t('admin:v5_page_migration.start_upgrading')}
         onConfirm={onConfirm}
         onCancel={() => setIsV5PageMigrationModalShown(false)}
       />
@@ -47,7 +50,9 @@ const V5PageMigration: FC<Props> = (props: Props) => {
       </p>
       <div className="row my-3">
         <div className="mx-auto">
-          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>Upgrade to v5</button>
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>
+            {t('admin:v5_page_migration.upgrade_to_v5')}
+          </button>
         </div>
       </div>
     </>

+ 96 - 48
packages/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -5,16 +5,25 @@ import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
+import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import AppContainer from '~/client/services/AppContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
+// used as the prefix of translation
+const DeletionType = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'complete_deletion',
+  RecursiveDeletion: 'recursive_deletion',
+  RecursiveCompleteDeletion: 'recursive_complete_deletion',
+});
+
 class SecuritySetting extends React.Component {
 
   constructor(props) {
     super(props);
 
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
+    this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
   }
 
   async putSecuritySetting() {
@@ -28,9 +37,83 @@ class SecuritySetting extends React.Component {
     }
   }
 
+  renderPageDeletePermissionDropdown(currentState, setState, deletionType, t) {
+    const isRecursiveDeletion = deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+    return (
+      <div className="row mb-4">
+        <div className="col-md-3 text-md-right mb-2">
+          <strong>{t(`security_setting.${deletionType}`)}</strong>
+        </div>
+        <div className="col-md-6">
+          <div className="dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+              type="button"
+              id="dropdownMenuButton"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              <span className="float-left">
+                {currentState === PageDeleteConfigValue.Inherit && t('security_setting.inherit')}
+                {(currentState === PageDeleteConfigValue.Anyone || currentState == null)
+                    && t('security_setting.anyone')}
+                {currentState === PageDeleteConfigValue.AdminOnly && t('security_setting.admin_only')}
+                {currentState === PageDeleteConfigValue.AdminAndAuthor && t('security_setting.admin_and_author')}
+              </span>
+            </button>
+            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+              {
+                isRecursiveDeletion
+                  ? (
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => { setState(PageDeleteConfigValue.Inherit) }}
+                    >
+                      {t('security_setting.inherit')}
+                    </button>
+                  )
+                  : (
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => { setState(PageDeleteConfigValue.Anyone) }}
+                    >
+                      {t('security_setting.anyone')}
+                    </button>
+                  )
+              }
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setState(PageDeleteConfigValue.AdminOnly) }}
+              >
+                {t('security_setting.admin_only')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setState(PageDeleteConfigValue.AdminAndAuthor) }}
+              >
+                {t('security_setting.admin_and_author')}
+              </button>
+            </div>
+            <p className="form-text text-muted small">
+              {t(`security_setting.${deletionType}_explain`)}
+            </p>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
-    const { currentRestrictGuestMode, currentPageCompleteDeletionAuthority } = adminGeneralSecurityContainer.state;
+    const {
+      currentRestrictGuestMode, currentPageDeletionAuthority, currentPageCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority,
+    } = adminGeneralSecurityContainer.state;
 
     return (
       <React.Fragment>
@@ -142,52 +225,17 @@ class SecuritySetting extends React.Component {
             )}
           </div>
         </div>
-        <div className="row mb-4">
-          <div className="col-md-3 text-md-right mb-2">
-            <strong>{t('security_setting.complete_deletion')}</strong>
-          </div>
-          <div className="col-md-6">
-            <div className="dropdown">
-              <button
-                className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
-                type="button"
-                id="dropdownMenuButton"
-                data-toggle="dropdown"
-                aria-haspopup="true"
-                aria-expanded="true"
-              >
-                <span className="float-left">
-                  {(currentPageCompleteDeletionAuthority === 'anyOne' || currentPageCompleteDeletionAuthority == null)
-                      && t('security_setting.anyone')}
-                  {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
-                  {currentPageCompleteDeletionAuthority === 'adminAndAuthor' && t('security_setting.admin_and_author')}
-                </span>
-              </button>
-              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('anyOne') }}>
-                  {t('security_setting.anyone')}
-                </button>
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminOnly') }}
-                >
-                  {t('security_setting.admin_only')}
-                </button>
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminAndAuthor') }}
-                >
-                  {t('security_setting.admin_and_author')}
-                </button>
-              </div>
-              <p className="form-text text-muted small">
-                {t('security_setting.complete_deletion_explain')}
-              </p>
-            </div>
-          </div>
-        </div>
+
+        {/* Render PageDeletePermissionDropdown */}
+        {
+          [
+            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion],
+            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion],
+            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion],
+            // eslint-disable-next-line max-len
+            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion],
+          ].map(arr => this.renderPageDeletePermissionDropdown(arr[0], arr[1], arr[2], t))
+        }
 
         <h4>{t('security_setting.session')}</h4>
         <div className="form-group row">

+ 3 - 0
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -196,6 +196,9 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         </div>
         <div className="text-danger mt-5">
           {t('admin:user_group_management.delete_modal.desc')}
+
+          {/* TODO 85462: Add a note: "All child groups will disappear */}
+
         </div>
       </ModalBody>
       <ModalFooter>

+ 54 - 7
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -9,6 +9,7 @@ import Xss from '~/services/xss';
 
 type Props = {
   userGroup?: IUserGroupHasId,
+  selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: TFunctionResult;
   onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
 };
@@ -18,14 +19,16 @@ const UserGroupForm: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
-  const { userGroup, submitButtonLabel, onSubmit } = props;
+  const {
+    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+  } = props;
 
   /*
    * State
    */
   const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
   const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
-  const [currentParent, setParent] = useState(userGroup != null ? userGroup.parent : '');
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
 
   /*
    * Function
@@ -38,6 +41,12 @@ const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
+  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+    if (userGroup._id !== selectedParent?._id) {
+      setSelectedParent(userGroup);
+    }
+  }, [selectedParent, setSelectedParent]);
+
   const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
@@ -45,15 +54,15 @@ const UserGroupForm: FC<Props> = (props: Props) => {
       return;
     }
 
-    await onSubmit({ name: currentName, description: currentDescription, parent: currentParent });
-  }, [currentName, currentDescription, currentParent, onSubmit]);
+    await onSubmit({ name: currentName, description: currentDescription, parent: selectedParent?._id });
+  }, [currentName, currentDescription, selectedParent, onSubmit]);
 
   return (
     <form onSubmit={onSubmitHandler}>
 
       <fieldset>
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
-        {/* TODO 85062: improve style */}
+
         {
           userGroup?.createdAt != null && (
             <div className="form-group row">
@@ -62,6 +71,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
             </div>
           )
         }
+
         <div className="form-group row">
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('admin:user_group_management.group_name')}
@@ -78,16 +88,53 @@ const UserGroupForm: FC<Props> = (props: Props) => {
             />
           </div>
         </div>
+
         <div className="form-group row">
           <label htmlFor="description" className="col-md-2 col-form-label">
             {t('Description')}
           </label>
           <div className="col-md-4">
-            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} required />
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
           </div>
         </div>
 
-        {/* TODO 85062: select parent dropdown */}
+        <div className="form-group row">
+          <label htmlFor="parent" className="col-md-2 col-form-label">
+            {t('admin:user_group_management.parent_group')}
+          </label>
+          <div className="dropdown col-md-4">
+            <button
+              type="button"
+              id="dropdownMenuButton"
+              data-toggle="dropdown"
+              className={`
+                btn btn-outline-secondary dropdown-toggle ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+              `}
+            >
+              {selectedParent?.name ?? t('admin:user_group_management.select_parent_group')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+              {
+                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                  <>
+                    {
+                      selectableParentUserGroups.map(userGroup => (
+                        <button
+                          key={userGroup._id}
+                          type="button"
+                          className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
+                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                        >
+                          {userGroup.name}
+                        </button>
+                      ))
+                    }
+                  </>
+                )
+              }
+            </div>
+          </div>
+        </div>
 
         <div className="form-group row">
           <div className="offset-md-2 col-md-10">

+ 38 - 13
packages/app/src/components/Admin/UserGroup/UserGroupCreateModal.tsx → packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,34 +1,40 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, {
+  FC, useState, useEffect, useCallback,
+} from 'react';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
 
+import { Ref } from '~/interfaces/common';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
 
 type Props = {
   userGroup?: IUserGroupHasId,
-  onClickCreateButton?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
+  buttonLabel?: TFunctionResult,
+  onClickButton?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   onHide?: () => Promise<void> | void
 };
 
-const UserGroupCreateModal: FC<Props> = (props: Props) => {
+const UserGroupModal: FC<Props> = (props: Props) => {
   const xss: Xss = (window as CustomWindow).xss;
 
   const { t } = useTranslation();
 
   const {
-    userGroup, onClickCreateButton, isShow, onHide,
+    userGroup, buttonLabel, onClickButton, isShow, onHide,
   } = props;
 
   /*
    * State
    */
-  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
-  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
+  const [currentName, setName] = useState('');
+  const [currentDescription, setDescription] = useState('');
+  const [currentParent, setParent] = useState<Ref<IUserGroup> | null>(null);
 
   /*
    * Function
@@ -41,15 +47,29 @@ const UserGroupCreateModal: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
-  const onClickCreateButtonHandler = useCallback(async(e) => {
+  const onClickButtonHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
-    if (onClickCreateButton == null) {
+    if (onClickButton == null) {
       return;
     }
 
-    await onClickCreateButton({ name: currentName, description: currentDescription });
-  }, [currentName, currentDescription, onClickCreateButton]);
+    await onClickButton({
+      _id: userGroup?._id,
+      name: currentName,
+      description: currentDescription,
+      parent: currentParent,
+    });
+  }, [userGroup, currentName, currentDescription, currentParent, onClickButton]);
+
+  // componentDidMount
+  useEffect(() => {
+    if (userGroup != null) {
+      setName(userGroup.name);
+      setDescription(userGroup.description);
+      setParent(userGroup.parent);
+    }
+  }, [userGroup]);
 
   return (
     <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
@@ -79,12 +99,17 @@ const UserGroupCreateModal: FC<Props> = (props: Props) => {
           </label>
           <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
         </div>
+
+        {/* TODO 90732: Add a drop-down to show selectable parents */}
+
+        {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
+
       </ModalBody>
 
       <ModalFooter>
         <div className="form-group">
-          <button type="button" className="btn btn-primary" onClick={onClickCreateButtonHandler}>
-            {t('Create')}
+          <button type="button" className="btn btn-primary" onClick={onClickButtonHandler}>
+            {buttonLabel}
           </button>
         </div>
       </ModalFooter>
@@ -92,4 +117,4 @@ const UserGroupCreateModal: FC<Props> = (props: Props) => {
   );
 };
 
-export default UserGroupCreateModal;
+export default UserGroupModal;

+ 43 - 5
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -2,14 +2,14 @@ import React, { FC, useState, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import UserGroupTable from './UserGroupTable';
-import UserGroupCreateModal from './UserGroupCreateModal';
+import UserGroupModal from './UserGroupModal';
 import UserGroupDeleteModal from './UserGroupDeleteModal';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import Xss from '~/services/xss';
 import { CustomWindow } from '~/interfaces/global';
-import { apiv3Delete, apiv3Post } from '~/client/util/apiv3-client';
+import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 
@@ -37,6 +37,7 @@ const UserGroupPage: FC = () => {
    */
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
@@ -50,6 +51,16 @@ const UserGroupPage: FC = () => {
     setCreateModalShown(false);
   }, [setCreateModalShown]);
 
+  const showUpdateModal = useCallback((group: IUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
   const syncUserGroupAndRelations = useCallback(async() => {
     try {
       await mutateUserGroups();
@@ -90,6 +101,20 @@ const UserGroupPage: FC = () => {
     }
   }, [t, mutateUserGroups]);
 
+  const updateUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+      await mutateUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateUserGroups]);
+
   const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
     try {
       const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
@@ -114,7 +139,7 @@ const UserGroupPage: FC = () => {
     <div data-testid="admin-user-groups">
       {
         isAclEnabled ? (
-          <div className="mb-2">
+          <div className="mb-3">
             <button type="button" className="btn btn-outline-secondary" onClick={showCreateModal}>
               {t('admin:user_group_management.create_group')}
             </button>
@@ -123,19 +148,32 @@ const UserGroupPage: FC = () => {
           t('admin:user_group_management.deny_create_group')
         )
       }
-      <UserGroupCreateModal
-        onClickCreateButton={createUserGroup}
+
+      <UserGroupModal
+        buttonLabel={t('Create')}
+        onClickButton={createUserGroup}
         isShow={isCreateModalShown}
         onHide={hideCreateModal}
       />
+
+      <UserGroupModal
+        userGroup={selectedUserGroup}
+        buttonLabel={t('Update')}
+        onClickButton={updateUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+      />
+
       <UserGroupTable
         headerLabel={t('admin:user_group_management.group_list')}
         userGroups={userGroups}
         childUserGroups={childUserGroups}
         isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
         onDelete={showDeleteModal}
         userGroupRelations={userGroupRelations}
       />
+
       <UserGroupDeleteModal
         userGroups={userGroups}
         deleteUserGroup={selectedUserGroup}

+ 28 - 11
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -15,6 +15,7 @@ type Props = {
   userGroupRelations: IUserGroupRelation[],
   childUserGroups: IUserGroupHasId[],
   isAclEnabled: boolean,
+  onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
 };
 
@@ -65,22 +66,38 @@ const UserGroupTable: FC<Props> = (props: Props) => {
   /*
    * Function
    */
-  const onClickDelete = useCallback((e) => { // no preventDefault
-    if (props.onDelete == null) {
-      return;
-    }
-
+  const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
-    const group = props.userGroups.find((group) => {
+    return props.userGroups.find((group) => {
       return group._id === groupId;
     });
+  };
+
+  const onClickEdit = (e) => {
+    if (props.onEdit == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    props.onEdit(userGroup);
+  };
+
+  const onClickDelete = (e) => { // no preventDefault
+    if (props.onDelete == null) {
+      return;
+    }
 
-    if (group == null) {
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
       return;
     }
 
-    props.onDelete(group);
-  }, [props]);
+    props.onDelete(userGroup);
+  };
 
   /*
    * useEffect
@@ -159,9 +176,9 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                           <i className="icon-settings"></i>
                         </button>
                         <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
-                          <a className="dropdown-item" href={`/admin/user-group-detail/${group._id}`}>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
-                          </a>
+                          </button>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>

+ 71 - 11
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 
 import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupTable from '../UserGroup/UserGroupTable';
-import UserGroupCreateModal from '../UserGroup/UserGroupCreateModal';
+import UserGroupModal from '../UserGroup/UserGroupModal';
 import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
@@ -21,7 +21,8 @@ import {
   IUserGroup, IUserGroupHasId,
 } from '~/interfaces/user';
 import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxSelectableUserGroups, useSWRxAncestorUserGroups,
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList,
+  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
 
@@ -39,6 +40,7 @@ const UserGroupDetailPage: FC = () => {
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
+  const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
   /*
@@ -54,9 +56,10 @@ const UserGroupDetailPage: FC = () => {
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
-  const { data: selectableUserGroups, mutate: mutateSelectableUserGroups } = useSWRxSelectableUserGroups(userGroup._id);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(userGroup._id);
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(userGroup._id);
 
-  const { data: ancestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
 
   const { data: isAclEnabled } = useIsAclEnabled();
 
@@ -77,17 +80,27 @@ const UserGroupDetailPage: FC = () => {
     setSearchType(searchType);
   }, []);
 
-  const updateUserGroup = useCallback(async(param: Partial<IUserGroup>) => {
+  const updateUserGroup = useCallback(async(UserGroupData: Partial<IUserGroup>) => {
     try {
-      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, param);
+      const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: UserGroupData.name,
+        description: UserGroupData.description,
+        parentId: UserGroupData.parent,
+      });
       const { userGroup: newUserGroup } = res.data;
       setUserGroup(newUserGroup);
+
+      // mutate
+      mutateAncestorUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, userGroup._id, setUserGroup]);
+  }, [t, userGroup._id, setUserGroup, mutateAncestorUserGroups]);
 
   const fetchApplicableUsers = useCallback(async(searchWord) => {
     const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
@@ -113,6 +126,31 @@ const UserGroupDetailPage: FC = () => {
     mutateUserGroupRelations();
   }, [userGroup, mutateUserGroupRelations]);
 
+  const showUpdateModal = useCallback((group: IUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
+  const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: userGroupData.parent,
+      });
+      mutateChildUserGroups();
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateChildUserGroups]);
+
   const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
     try {
       await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
@@ -121,8 +159,12 @@ const UserGroupDetailPage: FC = () => {
         parentId: userGroup._id,
         forceUpdateParents: false,
       });
-      mutateSelectableUserGroups();
+
+      // mutate
       mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     catch (err) {
@@ -145,7 +187,12 @@ const UserGroupDetailPage: FC = () => {
         description: userGroupData.description,
         parentId: userGroup._id,
       });
+
+      // mutate
       mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+      mutateSelectableParentUserGroups();
+
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
     }
     catch (err) {
@@ -214,6 +261,7 @@ const UserGroupDetailPage: FC = () => {
       <div className="mt-4 form-box">
         <UserGroupForm
           userGroup={userGroup}
+          selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           onSubmit={updateUserGroup}
         />
@@ -224,12 +272,22 @@ const UserGroupDetailPage: FC = () => {
 
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
-        selectableUserGroups={selectableUserGroups}
+        selectableUserGroups={selectableChildUserGroups}
         onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
         onClickCreateUserGroupButtonHandler={showCreateModal}
       />
-      <UserGroupCreateModal
-        onClickCreateButton={createChildUserGroup}
+
+      <UserGroupModal
+        userGroup={selectedUserGroup}
+        buttonLabel={t('Update')}
+        onClickButton={updateChildUserGroup}
+        isShow={isUpdateModalShown}
+        onHide={hideUpdateModal}
+      />
+
+      <UserGroupModal
+        buttonLabel={t('Create')}
+        onClickButton={createChildUserGroup}
         isShow={isCreateModalShown}
         onHide={hideCreateModal}
       />
@@ -238,9 +296,11 @@ const UserGroupDetailPage: FC = () => {
         userGroups={childUserGroups}
         childUserGroups={grandChildUserGroups}
         isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
       />
+
       <UserGroupDeleteModal
         userGroups={childUserGroups}
         deleteUserGroup={selectedUserGroup}

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

@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useEffect } from 'react';
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
@@ -40,6 +40,7 @@ type CommonProps = {
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
+  isInstantRename?: boolean,
 }
 
 
@@ -55,7 +56,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
     onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems,
+    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
   } = props;
 
 
@@ -151,7 +152,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
           <DropdownItem onClick={renameItemClickedHandler}>
             <i className="icon-fw  icon-action-redo"></i>
-            {t('Move/Rename')}
+            {t(isInstantRename ? 'Rename' : 'Move/Rename')}
           </DropdownItem>
         ) }
 
@@ -179,6 +180,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
               className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
+              data-testid="open-page-delete-modal-btn"
             >
               <i className="icon-fw icon-trash"></i>
               {t('Delete')}
@@ -212,12 +214,19 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
+  const [shouldFetch, setShouldFetch] = useState(fetchOnInit ?? false);
 
-  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
-  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
-  const { data: fetchedPageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(shouldMutate ? pageId : null);
+  // update shouldFetch (and will never be false)
+  useEffect(() => {
+    if (shouldFetch) {
+      return;
+    }
+    if (!isIPageInfoForOperation(presetPageInfo) && isOpen) {
+      setShouldFetch(true);
+    }
+  }, [isOpen, presetPageInfo, shouldFetch]);
 
   // mutate after handle event
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
@@ -225,10 +234,10 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
       await onClickBookmarkMenuItem(_pageId, _newValue);
     }
 
-    if (shouldMutate) {
+    if (shouldFetch) {
       mutatePageInfo();
     }
-  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
 
   const isLoading = shouldFetch && fetchedPageInfo == null;
 

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -54,7 +54,7 @@ const AuthorInfo = (props) => {
       </div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date">
+        <div className="text-muted text-date" data-hide-in-vrt>
           {renderParsedDate()}
         </div>
       </div>

+ 1 - 1
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -256,7 +256,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
           ) }
         </div>
-        <div className={className}>
+        <div className={`${className} ${isCompactMode ? '' : 'mt-2'}`}>
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -85,7 +85,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
       {/* Right side */}
       <div className="d-flex">
 
-        <div className="d-flex flex-column" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
+        <div className="d-flex flex-column py-md-2" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
           { Controls && <Controls></Controls> }
         </div>
 

+ 6 - 5
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -7,7 +7,7 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
 
-import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { useUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -39,6 +39,7 @@ const PersonalDropdown = (props) => {
 
   const { data: isPreferDrawerMode, mutate: mutatePreferDrawerMode } = usePreferDrawerModeByUser();
   const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
+  const { scheduleToPut } = useUserUISettings();
 
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
@@ -54,13 +55,13 @@ const PersonalDropdown = (props) => {
 
   const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
     mutatePreferDrawerMode(bool);
-    scheduleToPutUserUISettings({ preferDrawerModeByUser: bool });
-  }, [mutatePreferDrawerMode]);
+    scheduleToPut({ preferDrawerModeByUser: bool });
+  }, [mutatePreferDrawerMode, scheduleToPut]);
 
   const preferDrawerModeOnEditSwitchModifiedHandler = useCallback((bool) => {
     mutatePreferDrawerModeOnEdit(bool);
-    scheduleToPutUserUISettings({ preferDrawerModeOnEditByUser: bool });
-  }, [mutatePreferDrawerModeOnEdit]);
+    scheduleToPut({ preferDrawerModeOnEditByUser: bool });
+  }, [mutatePreferDrawerModeOnEdit, scheduleToPut]);
 
   const followOsCheckboxModifiedHandler = (bool) => {
     if (bool) {

+ 22 - 3
packages/app/src/components/PageCreateModal.jsx

@@ -1,8 +1,11 @@
 
-import React, { useEffect, useState } from 'react';
+import React, {
+  useEffect, useState, useMemo,
+} from 'react';
 import PropTypes from 'prop-types';
 
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import { debounce } from 'throttle-debounce';
 
 import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
@@ -19,7 +22,7 @@ import { usePageCreateModal } from '~/stores/modal';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 const {
-  userPageRoot, isCreatablePage, generateEditorPath,
+  userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
 } = pagePathUtils;
 
 const PageCreateModal = (props) => {
@@ -39,12 +42,25 @@ const PageCreateModal = (props) => {
   const [todayInput2, setTodayInput2] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
+  const [isMatchedWithUserHomePagePath, setIsMatchedWithUserHomePagePath] = useState(false);
 
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   useEffect(() => {
     setPageNameInput(isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/');
   }, [pathname]);
 
+  const checkIsUsersHomePageDebounce = useMemo(() => {
+    const checkIsUsersHomePage = () => {
+      setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
+    };
+
+    return debounce(1000, checkIsUsersHomePage);
+  }, [pageNameInput]);
+
+  useEffect(() => {
+    checkIsUsersHomePageDebounce(pageNameInput);
+  }, [checkIsUsersHomePageDebounce, pageNameInput]);
+
   function transitBySubmitEvent(e, transitHandler) {
     // prevent page transition by submit
     e.preventDefault();
@@ -189,7 +205,6 @@ const PageCreateModal = (props) => {
           <h3 className="grw-modal-head pb-2">{t('Create under')}</h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
-
             <div className="flex-fill">
               {isReachable
                 ? (
@@ -221,12 +236,16 @@ const PageCreateModal = (props) => {
                 data-testid="btn-create-page-under-below"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
                 onClick={createInputPage}
+                disabled={isMatchedWithUserHomePagePath}
               >
                 <i className="icon-fw icon-doc"></i>{t('Create')}
               </button>
             </div>
 
           </div>
+          { isMatchedWithUserHomePagePath && (
+            <p className="text-danger mt-2">Error: Cannot create page under /user page directory.</p>
+          ) }
 
         </fieldset>
       </div>

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

@@ -222,7 +222,7 @@ const PageDeleteModal: FC = () => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
       <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
         { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }

+ 29 - 4
packages/app/src/components/PageRenameModal.tsx

@@ -9,6 +9,7 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { debounce } from 'throttle-debounce';
+import { pagePathUtils } from '@growi/core';
 import { usePageRenameModal } from '~/stores/modal';
 import { toastError } from '~/client/util/apiNotification';
 
@@ -29,6 +30,7 @@ const isV5Compatible = (meta: unknown): boolean => {
 const PageRenameModal = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const { isUsersHomePage } = pagePathUtils;
   const { data: siteUrl } = useSiteUrl();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
 
@@ -53,6 +55,7 @@ const PageRenameModal = (): JSX.Element => {
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
   const [expandOtherOptions, setExpandOtherOptions] = useState(false);
   const [subordinatedError] = useState(null);
+  const [isMatchedWithUserHomePagePath, setIsMatchedWithUserHomePagePath] = useState(false);
 
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
@@ -135,11 +138,21 @@ const PageRenameModal = (): JSX.Element => {
     return debounce(1000, checkExistPaths);
   }, [checkExistPaths]);
 
+  const checkIsUsersHomePageDebounce = useMemo(() => {
+    const checkIsPagePathRenameable = () => {
+      setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
+    };
+
+    return debounce(1000, checkIsPagePathRenameable);
+  }, [isUsersHomePage, pageNameInput]);
+
   useEffect(() => {
     if (page != null && pageNameInput !== page.data.path) {
       checkExistPathsDebounce(page.data.path, pageNameInput);
+      checkIsUsersHomePageDebounce(pageNameInput);
     }
-  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page]);
+  }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
+
 
   /**
    * change pageNameInput
@@ -176,9 +189,18 @@ const PageRenameModal = (): JSX.Element => {
   const { path } = page.data;
   const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
 
-  const submitButtonDisabled = isV5Compatible(page.meta)
-    ? existingPaths.length !== 0 // v5 data
-    : !isRenameRecursively; // v4 data
+  let submitButtonDisabled = false;
+
+  if (isMatchedWithUserHomePagePath) {
+    submitButtonDisabled = true;
+  }
+  else if (isV5Compatible(page.meta)) {
+    submitButtonDisabled = existingPaths.length !== 0; // v5 data
+  }
+  else {
+    submitButtonDisabled = !isRenameRecursively; // v4 data
+  }
+
 
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
@@ -212,6 +234,9 @@ const PageRenameModal = (): JSX.Element => {
         { isTargetPageDuplicate && (
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
+        { isMatchedWithUserHomePagePath && (
+          <p className="text-danger">Error: Cannot move to directory under /user page.</p>
+        ) }
 
         { !isV5Compatible(page.meta) && (
           <>

+ 25 - 26
packages/app/src/components/Sidebar.tsx

@@ -2,13 +2,14 @@ import React, {
   FC, useCallback, useEffect, useRef, useState,
 } from 'react';
 
-import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { useUserUISettings } from '~/client/services/user-ui-settings';
 import {
   useDrawerMode, useDrawerOpened,
   useSidebarCollapsed,
   useCurrentSidebarContents,
   useCurrentProductNavWidth,
   useSidebarResizeDisabled,
+  useSidebarScrollerRef,
 } from '~/stores/ui';
 
 import DrawerToggler from './Navbar/DrawerToggler';
@@ -16,7 +17,7 @@ import DrawerToggler from './Navbar/DrawerToggler';
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarContents from './Sidebar/SidebarContents';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
-import StickyStretchableScroller from './StickyStretchableScroller';
+import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
@@ -28,6 +29,8 @@ const GlobalNavigation = () => {
   const { data: currentContents } = useCurrentSidebarContents();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
 
+  const { scheduleToPut } = useUserUISettings();
+
   const itemSelectedHandler = useCallback((selectedContents) => {
     if (isDrawerMode) {
       return;
@@ -42,39 +45,33 @@ const GlobalNavigation = () => {
     }
 
     mutateSidebarCollapsed(newValue, false);
-    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
+    scheduleToPut({ isSidebarCollapsed: newValue });
 
-  }, [currentContents, isCollapsed, isDrawerMode, mutateSidebarCollapsed]);
+  }, [currentContents, isCollapsed, isDrawerMode, mutateSidebarCollapsed, scheduleToPut]);
 
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
 };
 
 const SidebarContentsWrapper = () => {
-  const [resetKey, setResetKey] = useState(0);
-
-  const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
+  const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
   const calcViewHeight = useCallback(() => {
-    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
-    return scrollTargetElem != null
-      ? window.innerHeight - scrollTargetElem?.getBoundingClientRect().top
+    const elem = document.querySelector('#grw-sidebar-contents-wrapper');
+    return elem != null
+      ? window.innerHeight - elem?.getBoundingClientRect().top
       : window.innerHeight;
   }, []);
 
   return (
     <>
-      <StickyStretchableScroller
-        scrollTargetSelector={scrollTargetSelector}
-        contentsElemSelector="#grw-sidebar-content-container"
-        stickyElemSelector=".grw-sidebar"
-        calcViewHeightFunc={calcViewHeight}
-        resetKey={resetKey}
-      />
-
-      <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
-        <div id="grw-sidebar-content-container" className="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
+      <div id="grw-sidebar-contents-wrapper" style={{ minHeight: '100%' }}>
+        <StickyStretchableScroller
+          simplebarRef={mutateSidebarScroller}
+          stickyElemSelector=".grw-sidebar"
+          calcViewHeight={calcViewHeight}
+        >
           <SidebarContents />
-        </div>
+        </StickyStretchableScroller>
       </div>
 
       <DrawerToggler iconClass="icon-arrow-left" />
@@ -93,6 +90,8 @@ const Sidebar: FC<Props> = (props: Props) => {
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
 
+  const { scheduleToPut } = useUserUISettings();
+
   const [isTransitionEnabled, setTransitionEnabled] = useState(false);
 
   const [isHover, setHover] = useState(false);
@@ -170,8 +169,8 @@ const Sidebar: FC<Props> = (props: Props) => {
   const toggleNavigationBtnClickHandler = useCallback(() => {
     const newValue = !isCollapsed;
     mutateSidebarCollapsed(newValue, false);
-    scheduleToPutUserUISettings({ isSidebarCollapsed: newValue });
-  }, [isCollapsed, mutateSidebarCollapsed]);
+    scheduleToPut({ isSidebarCollapsed: newValue });
+  }, [isCollapsed, mutateSidebarCollapsed, scheduleToPut]);
 
   useEffect(() => {
     if (isCollapsed) {
@@ -204,18 +203,18 @@ const Sidebar: FC<Props> = (props: Props) => {
       // force collapsed
       mutateSidebarCollapsed(true);
       mutateProductNavWidth(sidebarMinWidth, false);
-      scheduleToPutUserUISettings({ isSidebarCollapsed: true, currentProductNavWidth: sidebarMinWidth });
+      scheduleToPut({ isSidebarCollapsed: true, currentProductNavWidth: sidebarMinWidth });
     }
     else {
       const newWidth = resizableContainer.current.clientWidth;
       mutateSidebarCollapsed(false);
       mutateProductNavWidth(newWidth, false);
-      scheduleToPutUserUISettings({ isSidebarCollapsed: false, currentProductNavWidth: newWidth });
+      scheduleToPut({ isSidebarCollapsed: false, currentProductNavWidth: newWidth });
     }
 
     resizableContainer.current.classList.remove('dragging');
 
-  }, [mutateProductNavWidth, mutateSidebarCollapsed]);
+  }, [mutateProductNavWidth, mutateSidebarCollapsed, scheduleToPut]);
 
   const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
     if (!isResizableByDrag) {

+ 10 - 12
packages/app/src/components/Sidebar/PageTree.tsx

@@ -16,10 +16,10 @@ const PageTree: FC = memo(() => {
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
-  const { data: notFoundTargetPathOrIdData } = useNotFoundTargetPathOrId();
+  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
-  const targetPathOrId = targetId || notFoundTargetPathOrIdData?.notFoundTargetPathOrId;
+  const targetPathOrId = targetId || notFoundTargetPathOrId;
 
   if (migrationStatus == null) {
     return (
@@ -27,8 +27,8 @@ const PageTree: FC = memo(() => {
         <div className="grw-sidebar-content-header p-3">
           <h3 className="mb-0">{t('Page Tree')}</h3>
         </div>
-        <div className="mt-5 mx-2 text-center">
-          <h3 className="text-gray">Page Tree now loading...</h3>
+        <div className="text-muted text-center mt-3">
+          <i className="fa fa-lg fa-spinner fa-pulse mr-1"></i>
         </div>
       </>
     );
@@ -65,14 +65,12 @@ const PageTree: FC = memo(() => {
         <h3 className="mb-0">{t('Page Tree')}</h3>
       </div>
 
-      <div className="grw-sidebar-content-body">
-        <ItemsTree
-          isEnableActions={!isGuestUser}
-          targetPath={path}
-          targetPathOrId={targetPathOrId}
-          targetAndAncestorsData={targetAndAncestorsData}
-        />
-      </div>
+      <ItemsTree
+        isEnableActions={!isGuestUser}
+        targetPath={path}
+        targetPathOrId={targetPathOrId}
+        targetAndAncestorsData={targetAndAncestorsData}
+      />
 
       {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top p-3 w-100">

+ 62 - 31
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -36,7 +36,6 @@ interface ItemProps {
   isEnableActions: boolean
   itemNode: ItemNode
   targetPathOrId?: string
-  isScrolled: boolean,
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
   onRenamed?(): void
@@ -82,7 +81,7 @@ const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string
  * @param printLog
  * @returns
  */
-const canMoveUnderNewParent = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
+const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
   if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
     if (printLog) {
       logger.warn('Any of page, page.path or droppedPage.path is null');
@@ -91,7 +90,7 @@ const canMoveUnderNewParent = (fromPage?: Partial<IPageHasId>, newParentPage?: P
   }
 
   const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
-  return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved);
+  return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
 };
 
 
@@ -123,6 +122,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [shouldHide, setShouldHide] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const [isRenaming, setRenaming] = useState(false);
+  const [isCreating, setCreating] = useState(false);
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
@@ -152,8 +153,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     type: 'PAGE_TREE',
     item: { page },
     canDrag: () => {
-      const isDraggable = !pagePathUtils.isUserPage(page.path || '/');
-      return isDraggable;
+      if (page.path == null) {
+        return false;
+      }
+      return !pagePathUtils.isUsersProtectedPages(page.path);
     },
     end: (item, monitor) => {
       // in order to set d-none to dropped Item
@@ -171,7 +174,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const pageItemDropHandler = async(item: ItemNode) => {
     const { page: droppedPage } = item;
 
-    if (!canMoveUnderNewParent(droppedPage, page, true)) {
+    if (!isDroppable(droppedPage, page, true)) {
       return;
     }
 
@@ -223,7 +226,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     },
     canDrop: (item) => {
       const { page: droppedPage } = item;
-      return canMoveUnderNewParent(droppedPage, page);
+      return isDroppable(droppedPage, page);
     },
     collect: monitor => ({
       isOver: monitor.isOver(),
@@ -240,7 +243,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   const onClickPlusButton = useCallback(() => {
     setNewPageInputShown(true);
-  }, []);
+
+    if (hasDescendants) {
+      setIsOpen(true);
+    }
+  }, [hasDescendants]);
 
   const duplicateMenuItemClickHandler = useCallback((): void => {
     if (onClickDuplicateMenuItem == null) {
@@ -273,6 +280,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     try {
       setRenameInputShown(false);
+      setRenaming(true);
       await apiv3Put('/pages/rename', {
         pageId: page._id,
         revisionId: page.revision,
@@ -289,6 +297,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       setRenameInputShown(true);
       toastError(err);
     }
+    finally {
+      setTimeout(() => {
+        setRenaming(false);
+      }, 1000);
+    }
   };
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
@@ -315,7 +328,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
     const parentPath = pathUtils.addTrailingSlash(page.path as string);
-    const newPagePath = `${parentPath}${inputText}`;
+    const newPagePath = nodePath.resolve(parentPath, inputText);
     const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
 
     if (!isCreatable) {
@@ -325,11 +338,13 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     let initBody = '';
     if (isEnabledAttachTitleHeader) {
-      const pageTitle = pathUtils.addHeadingSlash(nodePath.basename(newPagePath));
+      const pageTitle = nodePath.basename(newPagePath);
       initBody = pathUtils.attachTitleHeader(pageTitle);
     }
 
     try {
+      setCreating(true);
+
       await apiv3Post('/pages/', {
         path: newPagePath,
         body: initBody,
@@ -337,12 +352,21 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         grantUserGroupId: page.grantedGroup,
         createFromPageTree: true,
       });
+
       mutateChildren();
+
+      if (!hasDescendants) {
+        setIsOpen(true);
+      }
+
       toastSuccess(t('successfully_saved_the_page'));
     }
     catch (err) {
       toastError(err);
     }
+    finally {
+      setCreating(false);
+    }
   };
 
   const inputValidator = (title: string | null): AlertInfo | null => {
@@ -364,12 +388,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   };
 
 
-  useEffect(() => {
-    if (!props.isScrolled && page.isTarget) {
-      document.dispatchEvent(new CustomEvent('targetItemRendered'));
-    }
-  }, [props.isScrolled, page.isTarget]);
-
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
@@ -404,14 +422,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     >
       <li
         ref={(c) => { drag(c); drop(c) }}
-        className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
-        id={page.isTarget ? 'grw-pagetree-is-target' : `grw-pagetree-list-${page._id}`}
+        className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center
+        ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
+        id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
       >
         <div className="grw-triangle-container d-flex justify-content-center">
           {hasDescendants && (
             <button
               type="button"
-              className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
+              className={`grw-pagetree-triangle-btn btn ${isOpen ? 'grw-pagetree-open' : ''}`}
               onClick={onClickLoadChildren}
             >
               <div className="d-flex justify-content-center">
@@ -431,9 +450,14 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             />
           )
           : (
-            <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-              <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
-            </a>
+            <>
+              { isRenaming && (
+                <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
+              )}
+              <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
+                <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+              </a>
+            </>
           )}
         {descendantCount > 0 && !isRenameInputShown && (
           <div className="grw-pagetree-count-wrapper">
@@ -448,19 +472,22 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            isInstantRename
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
               <i className="icon-options fa fa-rotate-90 p-1"></i>
             </DropdownToggle>
           </PageItemControl>
-          <button
-            type="button"
-            className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-            onClick={onClickPlusButton}
-          >
-            <i className="icon-plus d-block p-0" />
-          </button>
+          {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
+            <button
+              type="button"
+              className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+              onClick={onClickPlusButton}
+            >
+              <i className="icon-plus d-block p-0" />
+            </button>
+          )}
         </div>
       </li>
 
@@ -473,19 +500,23 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         />
       )}
       {
-        isOpen && hasChildren() && currentChildren.map(node => (
+        isOpen && hasChildren() && currentChildren.map((node, index) => (
           <div key={node.page._id} className="grw-pagetree-item-children">
             <Item
               isEnableActions={isEnableActions}
               itemNode={node}
               isOpen={false}
-              isScrolled={props.isScrolled}
               targetPathOrId={targetPathOrId}
               isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
               onRenamed={onRenamed}
               onClickDuplicateMenuItem={onClickDuplicateMenuItem}
               onClickDeleteMenuItem={onClickDeleteMenuItem}
             />
+            { isCreating && (currentChildren.length - 1 === index) && (
+              <div className="text-muted text-center">
+                <i className="fa fa-spinner fa-pulse mr-1"></i>
+              </div>
+            )}
           </div>
         ))
       }

+ 113 - 75
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,8 +1,14 @@
-import React, { FC, useEffect, useState } from 'react';
+import React, {
+  useEffect, useRef, useState, useMemo, useCallback,
+} from 'react';
 import { useTranslation } from 'react-i18next';
 
+import { debounce } from 'throttle-debounce';
+
+import loggerFactory from '~/utils/logger';
+
 import { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
-import { TargetAndAncestors } from '~/interfaces/page-listing-results';
+import { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { IPageHasId, IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { SocketEventName, UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
@@ -10,17 +16,17 @@ import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { useGlobalSocket } from '~/stores/websocket';
-import { usePageTreeDescCountMap } from '~/stores/ui';
+import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 
+const logger = loggerFactory('growi:cli:ItemsTree');
 
 /*
  * Utility to generate initial node
@@ -61,6 +67,20 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   return rootNode;
 };
 
+// user defined typeguard to assert the arg is not null
+type RenderingCondition = {
+  ancestorsChildrenResult: AncestorsChildrenResult | undefined,
+  rootPageResult: RootPageResult | undefined,
+}
+type SecondStageRenderingCondition = {
+  ancestorsChildrenResult: AncestorsChildrenResult,
+  rootPageResult: RootPageResult,
+}
+const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondStageRenderingCondition): condition is SecondStageRenderingCondition => {
+  return condition.ancestorsChildrenResult != null && condition.rootPageResult != null;
+};
+
+
 type ItemsTreeProps = {
   isEnableActions: boolean
   targetPath: string
@@ -68,86 +88,41 @@ type ItemsTreeProps = {
   targetAndAncestorsData?: TargetAndAncestors
 }
 
-const renderByInitialNode = (
-    initialNode: ItemNode,
-    isEnableActions: boolean,
-    isScrolled: boolean,
-    targetPathOrId?: string,
-    isEnabledAttachTitleHeader?: boolean,
-    onRenamed?: () => void,
-    onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
-): JSX.Element => {
-
-  return (
-    <ul className="grw-pagetree list-group p-3">
-      <Item
-        key={initialNode.page.path}
-        targetPathOrId={targetPathOrId}
-        itemNode={initialNode}
-        isOpen
-        isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
-        isEnableActions={isEnableActions}
-        onRenamed={onRenamed}
-        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-        onClickDeleteMenuItem={onClickDeleteMenuItem}
-        isScrolled={isScrolled}
-      />
-    </ul>
-  );
-};
-
-// --- Auto scroll related vars and util ---
-
-const SCROLL_OFFSET_TOP = window.innerHeight / 2;
-
-const scrollTargetItem = () => {
-  const scrollElement = document.getElementById('grw-sidebar-contents-scroll-target');
-  const target = document.getElementById('grw-pagetree-is-target');
-  if (scrollElement != null && target != null) {
-    smoothScrollIntoView(target, SCROLL_OFFSET_TOP, scrollElement);
-  }
-};
-// --- end ---
-
-
 /*
  * ItemsTree
  */
-const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
+const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
     targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
   } = props;
 
   const { t } = useTranslation();
 
-  const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
-  const { data: rootPageData, error: error2 } = useSWRxRootPage();
+  const { data: ancestorsChildrenResult, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
+  const { data: rootPageResult, error: error2 } = useSWRxRootPage();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const [isScrolled, setIsScrolled] = useState(false);
+  const { data: sidebarScrollerRef } = useSidebarScrollerRef();
 
   const { data: socket } = useGlobalSocket();
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
 
-
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
 
-  const scrollItem = () => {
-    scrollTargetItem();
-    setIsScrolled(true);
-  };
+  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
 
-  useEffect(() => {
-    document.addEventListener('targetItemRendered', scrollItem);
-    return () => {
-      document.removeEventListener('targetItemRendered', scrollItem);
+  const rootElemRef = useRef(null);
+
+  const renderingCondition = useMemo(() => {
+    return {
+      ancestorsChildrenResult,
+      rootPageResult,
     };
-  }, []);
+  }, [ancestorsChildrenResult, rootPageResult]);
 
   useEffect(() => {
     if (socket == null) {
@@ -207,35 +182,98 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   };
 
+  // ***************************  Scroll on init ***************************
+  const scrollOnInit = useCallback(() => {
+    const scrollTargetElement = document.getElementById('grw-pagetree-current-page-item');
+
+    if (sidebarScrollerRef?.current == null || scrollTargetElement == null) {
+      return;
+    }
+
+    logger.debug('scrollOnInit has invoked');
+
+    const scrollElement = sidebarScrollerRef.current.getScrollElement();
+
+    // NOTE: could not use scrollIntoView
+    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
+
+    // calculate the center point
+    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
+    scrollElement.scrollTo({ top: scrollTop });
+
+    setIsInitialScrollCompleted(true);
+  }, [sidebarScrollerRef]);
+
+  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
+
+  useEffect(() => {
+    if (!isSecondStageRenderingCondition(renderingCondition) || isInitialScrollCompleted) {
+      return;
+    }
+
+    const rootElement = rootElemRef.current as HTMLElement | null;
+    if (rootElement == null) {
+      return;
+    }
+
+    const observerCallback = (mutationRecords: MutationRecord[]) => {
+      mutationRecords.forEach(() => scrollOnInitDebounced());
+    };
+
+    const observer = new MutationObserver(observerCallback);
+    observer.observe(rootElement, { childList: true, subtree: true });
+
+    // first call for the situation that all rendering is complete at this point
+    scrollOnInitDebounced();
+
+    return () => {
+      observer.disconnect();
+    };
+  }, [isInitialScrollCompleted, renderingCondition, scrollOnInitDebounced]);
+  // *******************************  end  *******************************
+
   if (error1 != null || error2 != null) {
     // TODO: improve message
     toastError('Error occurred while fetching pages to render PageTree');
-    return null;
+    return <></>;
   }
 
+  let initialItemNode;
   /*
-   * Render completely
+   * Render second stage
    */
-  if (ancestorsChildrenData != null && rootPageData != null) {
-    const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(
-      // eslint-disable-next-line max-len
-      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+  if (isSecondStageRenderingCondition(renderingCondition)) {
+    initialItemNode = generateInitialNodeAfterResponse(
+      renderingCondition.ancestorsChildrenResult.ancestorsChildren,
+      new ItemNode(renderingCondition.rootPageResult.rootPage),
     );
   }
-
   /*
    * Before swr response comes back
    */
-  if (targetAndAncestorsData != null) {
-    const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(
-      // eslint-disable-next-line max-len
-      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+  else if (targetAndAncestorsData != null) {
+    initialItemNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
+  }
+
+  if (initialItemNode != null) {
+    return (
+      <ul className="grw-pagetree list-group p-3" ref={rootElemRef}>
+        <Item
+          key={initialItemNode.page.path}
+          targetPathOrId={targetPathOrId}
+          itemNode={initialItemNode}
+          isOpen
+          isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
+          isEnableActions={isEnableActions}
+          onRenamed={onRenamed}
+          onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+          onClickDeleteMenuItem={onClickDeleteMenuItem}
+        />
+      </ul>
     );
   }
 
-  return null;
+  return <></>;
 };
 
 export default ItemsTree;

+ 2 - 2
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -121,7 +121,7 @@ SmallPageItem.propTypes = {
 };
 
 
-const RecentChanges: FC<void> = () => {
+const RecentChanges = (): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: pages, mutate } = useSWRxRecentlyUpdated();
@@ -165,7 +165,7 @@ const RecentChanges: FC<void> = () => {
           </div>
         </div>
       </div>
-      <div className="grw-sidebar-content-body grw-recent-changes p-3">
+      <div className="grw-recent-changes p-3">
         <ul className="list-group list-group-flush">
           {(pages || []).map(page => (isRecentChangesSidebarSmall
             ? <SmallPageItem key={page._id} page={page} />

+ 3 - 3
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -18,14 +18,14 @@ const SidebarContents: FC<Props> = (props: Props) => {
     case SidebarContentsType.RECENT:
       Contents = RecentChanges;
       break;
-    case SidebarContentsType.TREE:
-      Contents = PageTree;
+    case SidebarContentsType.CUSTOM:
+      Contents = CustomSidebar;
       break;
     case SidebarContentsType.TAG:
       Contents = Tag;
       break;
     default:
-      Contents = CustomSidebar;
+      Contents = PageTree;
   }
 
   return (

+ 13 - 11
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -1,8 +1,8 @@
 import React, { FC, memo, useCallback } from 'react';
 
-import { scheduleToPutUserUISettings } from '~/client/services/user-ui-settings';
+import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser, useIsSharedUser } from '~/stores/context';
+import { useCurrentUser, useIsGuestUser } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 
@@ -19,6 +19,7 @@ const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
   } = props;
 
   const { data: currentContents, mutate } = useCurrentSidebarContents();
+  const { scheduleToPut } = useUserUISettings();
 
   const isSelected = contents === currentContents;
 
@@ -28,8 +29,9 @@ const PrimaryItem: FC<PrimaryItemProps> = (props: PrimaryItemProps) => {
     }
 
     mutate(contents, false);
-    scheduleToPutUserUISettings({ currentSidebarContents: contents });
-  }, [contents, mutate, onItemSelected]);
+
+    scheduleToPut({ currentSidebarContents: contents });
+  }, [contents, mutate, onItemSelected, scheduleToPut]);
 
   const labelForTestId = label.toLowerCase().replace(' ', '-');
 
@@ -69,28 +71,28 @@ type Props = {
 
 const SidebarNav: FC<Props> = (props: Props) => {
 
-  const { data: isSharedUser } = useIsSharedUser();
   const { data: currentUser } = useCurrentUser();
 
   const isAdmin = currentUser?.admin;
-  const isLoggedIn = currentUser != null;
 
   const { onItemSelected } = props;
 
   return (
     <div className="grw-sidebar-nav">
       <div className="grw-sidebar-nav-primary-container">
-        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
-        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
-        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />}
+        {/* eslint-disable max-len */}
+        <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />
+        <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />
+        <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
-        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} /> }
+        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
+        {/* eslint-enable max-len */}
       </div>
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
-        {isLoggedIn && <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />}
+        <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" />
         <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
       </div>

+ 0 - 168
packages/app/src/components/StickyStretchableScroller.jsx

@@ -1,168 +0,0 @@
-import React, { useEffect, useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { debounce } from 'throttle-debounce';
-import StickyEvents from 'sticky-events';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:cli:StickyStretchableScroller');
-
-
-/**
- * USAGE:
- *
-  const calcViewHeight = useCallback(() => {
-    const containerElem = document.querySelector('#sticky-elem');
-    const containerTop = containerElem.getBoundingClientRect().top;
-
-    // stretch to the bottom of window
-    return window.innerHeight - containerTop;
-  });
-
-  return (
-    <StickyStretchableScroller
-      contentsElemSelector="#long-contents-elem"
-      stickyElemSelector="#sticky-elem"
-      calcViewHeightFunc={calcViewHeight}
-    >
-      <div id="scroll-elem">
-        ...
-      </div>
-    </StickyStretchableScroller>
-  );
-
-  or
-
-  return (
-    <StickyStretchableScroller
-      scrollTargetId="scroll-elem"
-      contentsElemSelector="#long-contents-elem"
-      stickyElemSelector="#sticky-elem"
-      calcViewHeightFunc={calcViewHeight}
-    />
-  );
- */
-const StickyStretchableScroller = (props) => {
-
-  let { scrollTargetSelector } = props;
-  const {
-    children, contentsElemSelector, stickyElemSelector,
-    calcViewHeightFunc, calcContentsHeightFunc,
-    resetKey,
-  } = props;
-
-  if (scrollTargetSelector == null && children == null) {
-    throw new Error('Either of scrollTargetSelector or children is required');
-  }
-
-  if (scrollTargetSelector == null) {
-    scrollTargetSelector = `#${children.props.id}`;
-  }
-
-  /**
-   * Reset scrollbar
-   */
-  const resetScrollbar = useCallback(() => {
-    const contentsElem = document.querySelector(contentsElemSelector);
-    if (contentsElem == null) {
-      return;
-    }
-
-    const viewHeight = calcViewHeightFunc != null
-      ? calcViewHeightFunc()
-      : 'auto';
-    const contentsHeight = calcContentsHeightFunc != null
-      ? calcContentsHeightFunc(contentsElem)
-      : contentsElem.getBoundingClientRect().height;
-
-    logger.debug(`[${scrollTargetSelector}] viewHeight`, viewHeight);
-    logger.debug(`[${scrollTargetSelector}] contentsHeight`, contentsHeight);
-
-    const isScrollEnabled = viewHeight === 'auto' || (viewHeight < contentsHeight);
-
-    $(scrollTargetSelector).slimScroll({
-      color: '#666',
-      railColor: '#999',
-      railVisible: true,
-      position: 'right',
-      height: isScrollEnabled ? viewHeight : contentsHeight,
-      wheelStep: 10,
-      allowPageScroll: true,
-    });
-
-    // destroy
-    if (!isScrollEnabled) {
-      $(scrollTargetSelector).slimScroll({ destroy: true });
-    }
-
-  }, [contentsElemSelector, calcViewHeightFunc, calcContentsHeightFunc, scrollTargetSelector]);
-
-  const resetScrollbarDebounced = debounce(100, resetScrollbar);
-
-
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-    setTimeout(resetScrollbar, 100);
-  }, [resetScrollbar]);
-
-  // setup effect by sticky event
-  useEffect(() => {
-    if (stickyElemSelector == null) {
-      return;
-    }
-
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyElemSelector, stickyChangeHandler]);
-
-  // setup effect by resizing event
-  useEffect(() => {
-    const resizeHandler = (event) => {
-      resetScrollbarDebounced();
-    };
-
-    window.addEventListener('resize', resizeHandler);
-
-    // return clean up handler
-    return () => {
-      window.removeEventListener('resize', resizeHandler);
-    };
-  }, [resetScrollbarDebounced]);
-
-  // setup effect on init
-  useEffect(() => {
-    if (resetKey != null) {
-      resetScrollbarDebounced();
-    }
-  }, [resetKey, resetScrollbarDebounced]);
-
-  return (
-    <>
-      { children }
-    </>
-  );
-};
-
-StickyStretchableScroller.propTypes = {
-  contentsElemSelector: PropTypes.string.isRequired,
-
-  children: PropTypes.node,
-  scrollTargetSelector: PropTypes.string,
-  stickyElemSelector: PropTypes.string,
-
-  resetKey: PropTypes.any,
-
-  calcViewHeightFunc: PropTypes.func,
-  calcContentsHeightFunc: PropTypes.func,
-};
-
-export default StickyStretchableScroller;

+ 125 - 0
packages/app/src/components/StickyStretchableScroller.tsx

@@ -0,0 +1,125 @@
+import React, {
+  useEffect, useCallback, ReactNode, useRef, useState, useMemo, RefObject,
+} from 'react';
+
+import { debounce } from 'throttle-debounce';
+import StickyEvents from 'sticky-events';
+import SimpleBar from 'simplebar-react';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:cli:StickyStretchableScroller');
+
+
+export type StickyStretchableScrollerProps = {
+  stickyElemSelector: string,
+  simplebarRef?: (ref: RefObject<SimpleBar>) => void,
+  calcViewHeight?: (scrollElement: HTMLElement) => number,
+  children?: ReactNode,
+}
+
+/**
+ * USAGE:
+ *
+  const calcViewHeight = useCallback(() => {
+    const containerElem = document.querySelector('#sticky-elem');
+    const containerTop = containerElem.getBoundingClientRect().top;
+
+    // stretch to the bottom of window
+    return window.innerHeight - containerTop;
+  });
+
+  return (
+    <StickyStretchableScroller
+      stickyElemSelector="#sticky-elem"
+      calcViewHeight={calcViewHeight}
+    >
+      <div>
+        ...
+      </div>
+    </StickyStretchableScroller>
+  );
+ */
+export const StickyStretchableScroller = (props: StickyStretchableScrollerProps): JSX.Element => {
+
+  const {
+    children, stickyElemSelector, calcViewHeight, simplebarRef: setSimplebarRef,
+  } = props;
+
+  const simplebarRef = useRef<SimpleBar>(null);
+  const [simplebarMaxHeight, setSimplebarMaxHeight] = useState<number|undefined>();
+
+  /**
+   * Reset scrollbar
+   */
+  const resetScrollbar = useCallback(() => {
+    if (simplebarRef.current == null || calcViewHeight == null) {
+      return;
+    }
+
+    const scrollElement = simplebarRef.current.getScrollElement();
+    const newHeight = calcViewHeight(scrollElement);
+
+    logger.debug('Set new height to simplebar', newHeight);
+
+    // set new height
+    setSimplebarMaxHeight(newHeight);
+    // reculculate
+    simplebarRef.current.recalculate();
+  }, [calcViewHeight]);
+
+  const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
+
+  const stickyChangeHandler = useCallback(() => {
+    logger.debug('StickyEvents.CHANGE detected');
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
+    stickyEvents.enableEvents();
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyElemSelector, stickyChangeHandler]);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = () => {
+      resetScrollbarDebounced();
+    };
+
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [resetScrollbarDebounced]);
+
+  // setup effect on init
+  useEffect(() => {
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // update ref
+  useEffect(() => {
+    if (setSimplebarRef != null) {
+      setSimplebarRef(simplebarRef);
+    }
+  }, [setSimplebarRef]);
+
+  return (
+    <SimpleBar style={{ maxHeight: simplebarMaxHeight }} ref={simplebarRef}>
+      { children }
+    </SimpleBar>
+  );
+};

+ 2 - 3
packages/app/src/components/TableOfContents.jsx

@@ -10,7 +10,7 @@ import { blinkElem } from '~/client/util/blink-section-header';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
-import StickyStretchableScroller from './StickyStretchableScroller';
+import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
@@ -56,9 +56,8 @@ const TableOfContents = (props) => {
 
   return (
     <StickyStretchableScroller
-      contentsElemSelector=".revision-toc .markdownIt-TOC"
       stickyElemSelector=".grw-side-contents-sticky-container"
-      calcViewHeightFunc={calcViewHeight}
+      calcViewHeight={calcViewHeight}
     >
       { tocHtml !== ''
         ? (

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

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

+ 0 - 4
packages/app/src/interfaces/page-listing-results.ts

@@ -23,10 +23,6 @@ export interface TargetAndAncestors {
 }
 
 
-export interface NotFoundTargetPathOrId {
-  notFoundTargetPathOrId: string
-}
-
 export interface IsNotFoundPermalink {
   isNotFoundPermalink: boolean
 }

+ 10 - 2
packages/app/src/interfaces/user-group-response.ts

@@ -1,6 +1,10 @@
 import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
 import { IPageHasId } from './page';
 
+export type UserGroupResult = {
+  userGroup: IUserGroupHasId,
+}
+
 export type UserGroupListResult = {
   userGroups: IUserGroupHasId[],
 };
@@ -18,8 +22,12 @@ export type UserGroupPagesResult = {
   pages: IPageHasId[],
 }
 
-export type SelectableUserGroupsResult = {
-  selectableUserGroups: IUserGroupHasId[],
+export type SelectableParentUserGroupsResult = {
+  selectableParentGroups: IUserGroupHasId[],
+}
+
+export type SelectableUserChildGroupsResult = {
+  selectableChildGroups: IUserGroupHasId[],
 }
 
 export type AncestorUserGroupsResult = {

+ 59 - 0
packages/app/src/migrations/20220311011114-convert-page-delete-config.js

@@ -0,0 +1,59 @@
+import mongoose from 'mongoose';
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+
+import ConfigModel from '~/server/models/config';
+import {
+  PageRecursiveDeleteConfigValue, PageRecursiveDeleteCompConfigValue,
+} from '~/interfaces/page-delete-config';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:convert-page-delete-config');
+
+
+module.exports = {
+  async up(db, client) {
+    mongoose.connect(getMongoUri(), mongoOptions);
+    const Config = getModelSafely('Config') || ConfigModel;
+
+    const oldConfig = await Config.findOne({
+      ns: 'crowi',
+      key: 'security:pageCompleteDeletionAuthority',
+    });
+
+    const oldValue = oldConfig?.value ?? '"anyOne"';
+
+    try {
+
+      await Config.insertMany(
+        [
+          {
+            ns: 'crowi',
+            key: 'security:pageDeletionAuthority',
+            value: oldValue,
+          },
+          {
+            ns: 'crowi',
+            key: 'security:pageRecursiveDeletionAuthority',
+            value: `"${PageRecursiveDeleteConfigValue.Inherit}"`,
+          },
+          {
+            ns: 'crowi',
+            key: 'security:pageRecursiveCompleteDeletionAuthority',
+            value: `"${PageRecursiveDeleteCompConfigValue.Inherit}"`,
+          },
+        ],
+      );
+    }
+    catch (err) {
+      logger.error('Failed to migrate page delete configs', err);
+      throw err;
+    }
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    logger.info('Migration down has successfully applied');
+  },
+};

+ 2 - 0
packages/app/src/server/crowi/index.js

@@ -682,6 +682,8 @@ Crowi.prototype.setupPageService = async function() {
   }
   if (this.pageOperationService == null) {
     this.pageOperationService = new PageOperationService(this);
+    // TODO: Remove this code when resuming feature is implemented
+    await this.pageOperationService.init();
   }
 };
 

+ 2 - 2
packages/app/src/server/middlewares/login-required.js

@@ -44,8 +44,8 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
     }
 
     // is api path
-    const path = req.path || '';
-    if (path.match(/^\/_api\/.+$/)) {
+    const baseUrl = req.baseUrl || '';
+    if (baseUrl.match(/^\/_api\/.+$/)) {
       if (fallback != null) {
         return fallback(req, res, next);
       }

+ 27 - 0
packages/app/src/server/middlewares/unavailable-when-maintenance-mode.ts

@@ -0,0 +1,27 @@
+import { NextFunction, Request, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middlewares:unavailable-when-maintenance-mode');
+
+export const generateUnavailableWhenMaintenanceModeMiddleware = crowi => async(req: Request, res: Response, next: NextFunction): Promise<void> => {
+  const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+
+  if (!isMaintenanceMode) {
+    next();
+    return;
+  }
+
+  res.render('maintenance-mode');
+};
+
+export const generateUnavailableWhenMaintenanceModeMiddlewareForApi = crowi => async(req: Request, res: Response, next: NextFunction): Promise<void> => {
+  const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+
+  if (!isMaintenanceMode) {
+    next();
+    return;
+  }
+
+  res.status(503).json({ error: 'GROWI is under maintenance.' });
+};

+ 4 - 0
packages/app/src/server/models/config.ts

@@ -59,7 +59,11 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 
   'security:list-policy:hideRestrictedByOwner' : false,
   'security:list-policy:hideRestrictedByGroup' : false,
+  // DEPRECATED: 'security:pageCompleteDeletionAuthority' : undefined,
+  'security:pageDeletionAuthority' : undefined,
   'security:pageCompleteDeletionAuthority' : undefined,
+  'security:pageRecursiveDeletionAuthority' : undefined,
+  'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:disableLinkSharing' : false,
 
   'security:passport-local:isEnabled' : true,

+ 80 - 21
packages/app/src/server/models/page.ts

@@ -16,6 +16,7 @@ import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } fr
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { PageRedirectModel } from './page-redirect';
 
+const { addTrailingSlash } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 const logger = loggerFactory('growi:models:page');
@@ -163,7 +164,7 @@ class PageQueryBuilder {
     }
 
     const pathNormalized = pathUtils.normalizePath(path);
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+    const pathWithTrailingSlash = addTrailingSlash(path);
 
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
 
@@ -188,7 +189,7 @@ class PageQueryBuilder {
       return this;
     }
 
-    const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
+    const pathWithTrailingSlash = addTrailingSlash(path);
 
     const startsPattern = escapeStringRegexp(pathWithTrailingSlash);
 
@@ -988,7 +989,7 @@ export default (crowi: Crowi): any => {
      * update empty page if exists, if not, create a new page
      */
     let page;
-    if (emptyPage != null) {
+    if (emptyPage != null && grant !== GRANT_RESTRICTED) {
       page = emptyPage;
       const descendantCount = await this.recountDescendantCount(page._id);
 
@@ -999,23 +1000,19 @@ export default (crowi: Crowi): any => {
       page = new Page();
     }
 
-    let parentId: IObjectId | string | null = null;
-    const parent = await Page.getParentAndFillAncestors(path, user);
-    if (!isTopPage(path)) {
-      parentId = parent._id;
-    }
-
     page.path = path;
     page.creator = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
 
     // set parent to null when GRANT_RESTRICTED
-    if (grant === GRANT_RESTRICTED) {
+    const isGrantRestricted = grant === GRANT_RESTRICTED;
+    if (isTopPage(path) || isGrantRestricted) {
       page.parent = null;
     }
     else {
-      page.parent = parentId;
+      const parent = await Page.getParentAndFillAncestors(path, user);
+      page.parent = parent._id;
     }
 
     page.applyScope(user, grant, grantUserGroupId);
@@ -1048,14 +1045,22 @@ export default (crowi: Crowi): any => {
     return savedPage;
   };
 
+  const shouldUseUpdatePageV4 = (grant:number, isV5Compatible:boolean, isOnTree:boolean): boolean => {
+    const isRestricted = grant === GRANT_RESTRICTED;
+    return !isRestricted && (!isV5Compatible || !isOnTree);
+  };
+
   schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
-    if (crowi.configManager == null || crowi.pageGrantService == null) {
+    if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
     }
 
-    const isPageMigrated = pageData.parent != null;
+    const wasOnTree = pageData.parent != null || isTopPage(pageData.path);
+    const exParent = pageData.parent;
     const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    if (!isV5Compatible || !isPageMigrated) {
+
+    const shouldUseV4Process = shouldUseUpdatePageV4(pageData.grant, isV5Compatible, wasOnTree);
+    if (shouldUseV4Process) {
       // v4 compatible process
       return this.updatePageV4(pageData, body, previousBody, user, options);
     }
@@ -1065,16 +1070,12 @@ export default (crowi: Crowi): any => {
     const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const grantedUserIds = pageData.grantedUserIds || [user._id];
+    const shouldBeOnTree = grant !== GRANT_RESTRICTED;
+    const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
 
     const newPageData = pageData;
 
-    if (grant === GRANT_RESTRICTED) {
-      newPageData.parent = null;
-    }
-    else {
-      /*
-       * UserGroup & Owner validation
-       */
+    if (shouldBeOnTree) {
       let isGrantNormalized = false;
       try {
         const shouldCheckDescendants = true;
@@ -1088,6 +1089,24 @@ export default (crowi: Crowi): any => {
       if (!isGrantNormalized) {
         throw Error('The selected grant or grantedGroup is not assignable to this page.');
       }
+
+      if (!wasOnTree) {
+        const newParent = await this.getParentAndFillAncestors(newPageData.path, user);
+        newPageData.parent = newParent._id;
+      }
+    }
+    else {
+      if (wasOnTree && isChildrenExist) {
+        // Update children's parent with new parent
+        const newParentForChildren = await this.createEmptyPage(pageData.path, pageData.parent, pageData.descendantCount);
+        await this.updateMany(
+          { parent: pageData._id },
+          { parent: newParentForChildren._id },
+        );
+      }
+
+      newPageData.parent = null;
+      newPageData.descendantCount = 0;
     }
 
     newPageData.applyScope(user, grant, grantUserGroupId);
@@ -1104,6 +1123,46 @@ export default (crowi: Crowi): any => {
 
     pageEvent.emit('update', savedPage, user);
 
+    // Update ex children's parent
+    if (!wasOnTree && shouldBeOnTree) {
+      const emptyPageAtSamePath = await this.findOne({ path: pageData.path, isEmpty: true }); // this page is necessary to find children
+
+      if (isChildrenExist) {
+        if (emptyPageAtSamePath != null) {
+          // Update children's parent with new parent
+          await this.updateMany(
+            { parent: emptyPageAtSamePath._id },
+            { parent: savedPage._id },
+          );
+        }
+      }
+
+      await this.findOneAndDelete({ path: pageData.path, isEmpty: true }); // delete here
+    }
+
+    // Sub operation
+    // 1. Update descendantCount
+    const shouldPlusDescCount = !wasOnTree && shouldBeOnTree;
+    const shouldMinusDescCount = wasOnTree && !shouldBeOnTree;
+    if (shouldPlusDescCount) {
+      await crowi.pageService.updateDescendantCountOfAncestors(newPageData._id, 1, false);
+      const newDescendantCount = await this.recountDescendantCount(newPageData._id);
+      await this.updateOne({ _id: newPageData._id }, { descendantCount: newDescendantCount });
+    }
+    else if (shouldMinusDescCount) {
+      // Update from parent. Parent is null if newPageData.grant is RESTRECTED.
+      if (newPageData.grant === GRANT_RESTRICTED) {
+        await crowi.pageService.updateDescendantCountOfAncestors(exParent, -1, true);
+      }
+    }
+
+    // 2. Delete unnecessary empty pages
+
+    const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
+    if (shouldRemoveLeafEmpPages) {
+      await this.removeLeafEmptyPagesRecursively(exParent);
+    }
+
     return savedPage;
   };
 

+ 1 - 1
packages/app/src/server/routes/admin.js

@@ -284,7 +284,7 @@ module.exports = function(crowi, app) {
   // グループ詳細
   actions.userGroup.detail = async function(req, res) {
     const userGroupId = req.params.id;
-    const userGroup = await UserGroup.findOne({ _id: userGroupId });
+    const userGroup = await UserGroup.findOne({ _id: userGroupId }).populate('parent');
 
     if (userGroup == null) {
       logger.error('no userGroup is exists. ', userGroupId);

+ 1 - 0
packages/app/src/server/routes/apiv3/admin-home.js

@@ -74,6 +74,7 @@ module.exports = (crowi) => {
       installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
+      isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),
     };
 
     return res.apiv3({ adminHomeParams });

+ 50 - 0
packages/app/src/server/routes/apiv3/app-settings.js

@@ -197,6 +197,9 @@ module.exports = (crowi) => {
     pluginSetting: [
       body('isEnabledPlugins').isBoolean(),
     ],
+    maintenanceMode: [
+      body('flag').isBoolean(),
+    ],
   };
 
   /**
@@ -262,6 +265,7 @@ module.exports = (crowi) => {
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
 
       isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
+      isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),
     };
     return res.apiv3({ appSettingsParams });
 
@@ -694,5 +698,51 @@ module.exports = (crowi) => {
 
   });
 
+  router.post('/v5-schema-migration', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+    if (!isMaintenanceMode) {
+      return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
+    }
+
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+
+    try {
+      if (!isV5Compatible) {
+        // This method throws and emit socketIo event when error occurs
+        crowi.pageService.normalizeAllPublicPages();
+      }
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+    }
+
+    return res.apiv3({ isV5Compatible });
+  });
+
+  // eslint-disable-next-line max-len
+  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+    const { flag } = req.body;
+
+    try {
+      if (flag) {
+        await crowi.appService.startMaintenanceMode();
+      }
+      else {
+        await crowi.appService.endMaintenanceMode();
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      if (flag) {
+        res.apiv3Err(new ErrorV3('Failed to start maintenance mode', 'failed_to_start_maintenance_mode'), 500);
+      }
+      else {
+        res.apiv3Err(new ErrorV3('Failed to end maintenance mode', 'failed_to_end_maintenance_mode'), 500);
+      }
+    }
+
+    res.apiv3({ flag });
+  });
+
   return router;
 };

+ 5 - 0
packages/app/src/server/routes/apiv3/import.js

@@ -218,6 +218,11 @@ module.exports = (crowi) => {
       }
     }
 
+    const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+    if (!isMaintenanceMode) {
+      return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
+    }
+
 
     const zipFile = importService.getFile(fileName);
 

+ 12 - 11
packages/app/src/server/routes/apiv3/index.js

@@ -9,6 +9,7 @@ const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-un
 const express = require('express');
 
 const router = express.Router();
+const routerForAdmin = express.Router();
 
 module.exports = (crowi) => {
 
@@ -18,16 +19,16 @@ module.exports = (crowi) => {
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
   // admin
-  router.use('/admin-home', require('./admin-home')(crowi));
-  router.use('/markdown-setting', require('./markdown-setting')(crowi));
-  router.use('/app-settings', require('./app-settings')(crowi));
-  router.use('/customize-setting', require('./customize-setting')(crowi));
-  router.use('/notification-setting', require('./notification-setting')(crowi));
-  router.use('/users', require('./users')(crowi));
-  router.use('/user-groups', require('./user-group')(crowi));
-  router.use('/export', require('./export')(crowi));
-  router.use('/import', require('./import')(crowi));
-  router.use('/search', require('./search')(crowi));
+  routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
+  routerForAdmin.use('/markdown-setting', require('./markdown-setting')(crowi));
+  routerForAdmin.use('/app-settings', require('./app-settings')(crowi));
+  routerForAdmin.use('/customize-setting', require('./customize-setting')(crowi));
+  routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));
+  routerForAdmin.use('/users', require('./users')(crowi));
+  routerForAdmin.use('/user-groups', require('./user-group')(crowi));
+  routerForAdmin.use('/export', require('./export')(crowi));
+  routerForAdmin.use('/import', require('./import')(crowi));
+  routerForAdmin.use('/search', require('./search')(crowi));
 
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
@@ -74,5 +75,5 @@ module.exports = (crowi) => {
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
-  return router;
+  return [router, routerForAdmin];
 };

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

@@ -139,7 +139,7 @@ export default (crowi: Crowi): Router => {
           // create IPageInfoForListing
           : {
             ...basicPageInfo,
-            isAbleToDeleteCompletely: pageService.canDeleteCompletely((page.creator as IUserHasId)?._id, req.user),
+            isAbleToDeleteCompletely: pageService.canDeleteCompletely((page.creator as IUserHasId)?._id, req.user, false), // use normal delete config
             bookmarkCount: bookmarkCountMap != null ? bookmarkCountMap[page._id] : undefined,
             revisionShortBody: shortBodiesMap != null ? shortBodiesMap[page._id] : undefined,
           } as IPageInfoForListing;

+ 2 - 17
packages/app/src/server/routes/apiv3/pages.js

@@ -760,13 +760,14 @@ module.exports = (crowi) => {
      * Delete Completely
      */
     if (isCompletely) {
-      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user);
+      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
     }
     /*
      * Trash
      */
     else {
       pagesCanBeDeleted = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
+      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDelete(pagesToDelete, req.user, isRecursively);
     }
 
     if (pagesCanBeDeleted.length === 0) {
@@ -781,22 +782,6 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
 
-  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    try {
-      if (!isV5Compatible) {
-        // this method throws and emit socketIo event when error occurs
-        crowi.pageService.normalizeAllPublicPages(); // not await
-      }
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
-    }
-
-    return res.apiv3({ isV5Compatible });
-  });
-
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;

+ 11 - 3
packages/app/src/server/routes/apiv3/security-setting.js

@@ -1,6 +1,7 @@
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
@@ -18,9 +19,7 @@ const validator = {
     body('restrictGuestMode').if(value => value != null).isString().isIn([
       'Deny', 'Readonly',
     ]),
-    body('pageCompleteDeletionAuthority').if(value => value != null).isString().isIn([
-      'anyOne', 'adminOnly', 'adminAndAuthor',
-    ]),
+    body('pageCompleteDeletionAuthority').if(value => value != null).isString().isIn(Object.values(PageDeleteConfigValue)),
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
   ],
@@ -368,7 +367,10 @@ module.exports = (crowi) => {
     const securityParams = {
       generalSetting: {
         restrictGuestMode: await crowi.configManager.getConfig('crowi', 'security:restrictGuestMode'),
+        pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
         pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
+        pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
+        pageRecursiveCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         wikiMode: await crowi.configManager.getConfig('crowi', 'security:wikiMode'),
@@ -586,7 +588,10 @@ module.exports = (crowi) => {
     const updateData = {
       'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
       'security:restrictGuestMode': req.body.restrictGuestMode,
+      'security:pageDeletionAuthority': req.body.pageDeletionAuthority,
       'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
+      'security:pageRecursiveDeletionAuthority': req.body.pageRecursiveDeletionAuthority,
+      'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
     };
@@ -600,7 +605,10 @@ module.exports = (crowi) => {
       const securitySettingParams = {
         sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
         restrictGuestMode: await crowi.configManager.getConfig('crowi', 'security:restrictGuestMode'),
+        pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
         pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
+        pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
+        pageRecursiveCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
       };

+ 98 - 7
packages/app/src/server/routes/apiv3/user-group.js

@@ -245,12 +245,12 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /selectable-groups:
+   *    /selectable-parent-groups:
    *      get:
    *        tags: [UserGroup]
-   *        operationId: getSelectableGroups
-   *        summary: /selectable-groups
-   *        description: Get selectable user groups.
+   *        operationId: getSelectableParentGroups
+   *        summary: /selectable-parent-groups
+   *        description: Get selectable parent UserGroups
    *        parameters:
    *          - name: groupId
    *            in: query
@@ -271,7 +271,56 @@ module.exports = (crowi) => {
    *                        type: object
    *                      description: userGroup objects
    */
-  router.get('/selectable-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+  router.get('/selectable-parent-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+
+      const descendantGroups = await UserGroup.findGroupsWithDescendantsRecursively([userGroup], []);
+      const descendantGroupIds = descendantGroups.map(userGroups => userGroups._id.toString());
+
+      const selectableParentGroups = await UserGroup.find({ _id: { $nin: [groupId, ...descendantGroupIds] } });
+      return res.apiv3({ selectableParentGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-search-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /selectable-child-groups:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getSelectableChildGroups
+   *        summary: /selectable-child-groups
+   *        description: Get selectable child UserGroups
+   *        parameters:
+   *          - name: groupId
+   *            in: query
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroups are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroup objects
+   */
+  router.get('/selectable-child-groups', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
     const { groupId } = req.query;
 
     try {
@@ -283,8 +332,8 @@ module.exports = (crowi) => {
       ]);
 
       const excludeUserGroupIds = [userGroup, ...ancestorGroups, ...descendantGroups].map(userGroups => userGroups._id.toString());
-      const selectableUserGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
-      return res.apiv3({ selectableUserGroups });
+      const selectableChildGroups = await UserGroup.find({ _id: { $nin: excludeUserGroupIds } });
+      return res.apiv3({ selectableChildGroups });
     }
     catch (err) {
       const msg = 'Error occurred while searching user groups';
@@ -293,6 +342,48 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /user-groups/{id}:
+   *      get:
+   *        tags: [UserGroup]
+   *        operationId: getUserGroupFromGroupId
+   *        summary: /user-groups/{id}
+   *        description: Get UserGroup from Group ID
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *             type: string
+   *        responses:
+   *          200:
+   *            description: userGroup are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroup:
+   *                      type: object
+   *                      description: userGroup object
+   */
+  router.get('/:id', loginRequiredStrictly, adminRequired, validator.selectableGroups, async(req, res) => {
+    const { id: groupId } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(groupId);
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred while getting user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-groups-get-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 57 - 32
packages/app/src/server/routes/index.js

@@ -3,6 +3,9 @@ import express from 'express';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
+import {
+  generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
+} from '../middlewares/unavailable-when-maintenance-mode';
 
 import * as loginFormValidator from '../middlewares/login-form-validator';
 import * as registerFormValidator from '../middlewares/register-form-validator';
@@ -52,15 +55,21 @@ module.exports = function(crowi, app) {
   const hackmd = require('./hackmd')(crowi, app);
   const ogp = require('./ogp')(crowi);
 
+  const unavailableWhenMaintenanceMode = generateUnavailableWhenMaintenanceModeMiddleware(crowi);
+  const unavailableWhenMaintenanceModeForApi = generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
+
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  // API v3
+  const [apiV3Router, apiV3AdminRouter] = require('./apiv3')(crowi);
+
   app.use('/api-docs', require('./apiv3/docs')(crowi));
-  app.use('/_api/v3', require('./apiv3')(crowi));
 
-  app.get('/'                         , applicationInstalled, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
+  // API v3 for admin
+  app.use('/_api/v3', apiV3AdminRouter);
+
+  app.get('/'                         , applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
@@ -142,6 +151,51 @@ module.exports = function(crowi, app) {
 
   app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
 
+  /*
+   * Routes below are unavailable when maintenance mode
+   */
+
+  // API v3
+  app.use('/_api/v3', unavailableWhenMaintenanceModeForApi, apiV3Router);
+
+  const apiV1Router = express.Router();
+
+  apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
+
+  apiV1Router.get('/check_username'           , user.api.checkUsername);
+  apiV1Router.get('/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
+
+  // HTTP RPC Styled API (に徐々に移行していいこうと思う)
+  apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
+  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
+  apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
+  apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
+  apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
+  // allow posting to guests because the client doesn't know whether the user logged in
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , csrf, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , csrf, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
+  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, 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, tag.api.update);
+  apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
+  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
+  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, comment.api.remove);
+  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
+  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);
+  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
+  apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
+
+  // API v1
+  app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
+
+  app.use(unavailableWhenMaintenanceMode);
+
+  app.get('/tags'                     , loginRequired, tag.showPage);
+
   app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
   // my in-app-notifications
@@ -156,35 +210,6 @@ module.exports = function(crowi, app) {
   app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);
 
   app.get('/_search'                            , loginRequired, injectUserUISettings, search.searchPage);
-  app.get('/_api/search'                        , accessTokenParser , loginRequired , search.api.search);
-
-  app.get('/_api/check_username'           , user.api.checkUsername);
-  app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
-
-  // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
-  app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
-  app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
-  app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
-  // allow posting to guests because the client doesn't know whether the user logged in
-  app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
-  app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
-  app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
-  app.get('/tags'                     , loginRequired, tag.showPage);
-  app.get('/_api/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
-  app.get('/_api/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  app.post('/_api/tags.update'        , accessTokenParser, loginRequiredStrictly, tag.api.update);
-  app.get('/_api/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
-  app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
-  app.post('/_api/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);
-  app.post('/_api/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, comment.api.remove);
-  app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
-  app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
-  app.post('/_api/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);
-  app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
-  app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
   app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
   app.get('/trash/$'                  , loginRequired, (req, res) => res.redirect('/trash'));

+ 7 - 3
packages/app/src/server/routes/page.js

@@ -1187,8 +1187,8 @@ module.exports = function(crowi, app) {
 
     try {
       if (isCompletely) {
-        if (!crowi.pageService.canDeleteCompletely(page.creator, req.user)) {
-          return res.json(ApiResponse.error('You can not delete completely', 'user_not_admin'));
+        if (!crowi.pageService.canDeleteCompletely(page.creator, req.user, isRecursively)) {
+          return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively);
       }
@@ -1203,6 +1203,10 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
         }
 
+        if (!crowi.pageService.canDelete(page.creator, req.user, isRecursively)) {
+          return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
+        }
+
         await crowi.pageService.deletePage(page, req.user, options, isRecursively);
       }
     }
@@ -1258,7 +1262,7 @@ module.exports = function(crowi, app) {
     }
     catch (err) {
       logger.error('Error occured while get setting', err);
-      return res.json(ApiResponse.error('Failed to revert deleted page.'));
+      return res.json(ApiResponse.error(err));
     }
 
     const result = {};

+ 12 - 0
packages/app/src/server/service/app.ts

@@ -119,4 +119,16 @@ export default class AppService implements S2sMessageHandlable {
     this.crowi.setupGlobalErrorHandlers();
   }
 
+  isMaintenanceMode(): boolean {
+    return this.configManager.getConfig('crowi', 'app:isMaintenanceMode');
+  }
+
+  async startMaintenanceMode(): Promise<void> {
+    await this.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isMaintenanceMode': true });
+  }
+
+  async endMaintenanceMode(): Promise<void> {
+    await this.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isMaintenanceMode': false });
+  }
+
 }

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -181,6 +181,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: undefined,
   },
+  IS_MAINTENANCE_MODE: {
+    ns:      'crowi',
+    key:     'app:isMaintenanceMode',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   AUTO_INSTALL_ADMIN_USERNAME: {
     ns:      'crowi',
     key:     'autoInstall:adminUsername',

+ 4 - 2
packages/app/src/server/service/page-operation.ts

@@ -10,9 +10,11 @@ class PageOperationService {
 
   constructor(crowi) {
     this.crowi = crowi;
+  }
 
-    // TODO: Remove this code when resuming feature is implemented
-    PageOperation.deleteMany({});
+  // TODO: Remove this code when resuming feature is implemented
+  async init():Promise<void> {
+    await PageOperation.deleteMany({});
   }
 
   /**

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

@@ -22,6 +22,9 @@ import { IUserHasId } from '~/interfaces/user';
 import { Ref } from '~/interfaces/common';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
+import {
+  PageDeleteConfigValue, PageDeleteConfigValueToProcessValidation,
+} from '~/interfaces/page-delete-config';
 import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
 import ActivityDefine from '../util/activityDefine';
 
@@ -208,24 +211,70 @@ class PageService {
     });
   }
 
-  canDeleteCompletely(creatorId, operator) {
+  canDeleteCompletely(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
-    if (operator.admin) {
+    const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
+
+    const recursiveAuthority = this.calcRecursiveDeleteConfigValue(pageCompleteDeletionAuthority, pageRecursiveCompleteDeletionAuthority);
+
+    return this.canDeleteLogic(creatorId, operator, isRecursively, pageCompleteDeletionAuthority, recursiveAuthority);
+  }
+
+  canDelete(creatorId: ObjectIdLike, operator, isRecursively: boolean): boolean {
+    const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
+    const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
+
+    const recursiveAuthority = this.calcRecursiveDeleteConfigValue(pageDeletionAuthority, pageRecursiveDeletionAuthority);
+
+    return this.canDeleteLogic(creatorId, operator, isRecursively, pageDeletionAuthority, recursiveAuthority);
+  }
+
+  private calcRecursiveDeleteConfigValue(confForSingle, confForRecursive) {
+    if (confForRecursive === PageDeleteConfigValue.Inherit) {
+      return confForSingle;
+    }
+
+    return confForRecursive;
+  }
+
+  private canDeleteLogic(
+      creatorId: ObjectIdLike,
+      operator,
+      isRecursively: boolean,
+      authority: PageDeleteConfigValueToProcessValidation | null,
+      recursiveAuthority: PageDeleteConfigValueToProcessValidation | null,
+  ): boolean {
+    const isAdmin = operator.admin;
+    const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
+
+    if (isRecursively) {
+      return this.compareDeleteConfig(isAdmin, isOperator, recursiveAuthority);
+    }
+
+    return this.compareDeleteConfig(isAdmin, isOperator, authority);
+  }
+
+  private compareDeleteConfig(isAdmin: boolean, isOperator: boolean, authority: PageDeleteConfigValueToProcessValidation | null): boolean {
+    if (isAdmin) {
       return true;
     }
-    if (pageCompleteDeletionAuthority === 'anyOne' || pageCompleteDeletionAuthority == null) {
+
+    if (authority === PageDeleteConfigValue.Anyone || authority == null) {
       return true;
     }
-    if (pageCompleteDeletionAuthority === 'adminAndAuthor') {
-      const operatorId = operator?._id;
-      return (operatorId != null && operatorId.equals(creatorId));
+    if (authority === PageDeleteConfigValue.AdminAndAuthor && isOperator) {
+      return true;
     }
 
     return false;
   }
 
-  filterPagesByCanDeleteCompletely(pages, user) {
-    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user));
+  filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean) {
+    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.creator, user, isRecursively));
+  }
+
+  filterPagesByCanDelete(pages, user, isRecursively: boolean) {
+    return pages.filter(p => p.isEmpty || this.canDelete(p.creator, user, isRecursively));
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -279,7 +328,7 @@ class PageService {
 
     const isBookmarked: boolean = (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
     const isLiked: boolean = page.isLiked(user);
-    const isAbleToDeleteCompletely: boolean = this.canDeleteCompletely((page.creator as IUserHasId)?._id, user);
+    const isAbleToDeleteCompletely: boolean = this.canDeleteCompletely((page.creator as IUserHasId)?._id, user, false); // use normal delete config
 
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, pageId);
 

+ 1 - 1
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -299,7 +299,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
       throw error;
     }
     finally {
-      logger.warn('Normalize indices anyway.');
+      logger.info('Normalize indices.');
       await this.normalizeIndices();
     }
 

+ 2 - 2
packages/app/src/server/views/forgot-password.html

@@ -32,8 +32,8 @@
           <div class="col-md-6 mt-5">
             <div class="text-center">
               <h1><i class="icon-lock large"></i></h1>
-              <h2 class="text-center">{{ t('forgot_password.forgot_password') }}</h2>
-              <p>{{ t('forgot_password.password_reset_request_desc') }}</p>
+              <h1 class="text-center">{{ t('forgot_password.forgot_password') }}</h1>
+              <h3>{{ t('forgot_password.password_reset_request_desc') }}</h3>
               <div id="password-reset-request-form"></div>
             </div>
           </div>

+ 56 - 0
packages/app/src/server/views/maintenance-mode.html

@@ -0,0 +1,56 @@
+{% extends './layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('maintenance_mode.maintenance_mode')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+{% block layout_main %}
+<div id="main" class="main">
+  <div id="content-main" class="content-main container-lg">
+    <div class="container">
+      <div class="row justify-content-md-center">
+        <div class="col-md-6 mt-5">
+          <div class="text-center">
+            <h1><i class="icon-exclamation large"></i></h1>
+            <h1 class="text-center">{{ t('maintenance_mode.maintenance_mode') }}</h1>
+            <h3>{{ t('maintenance_mode.growi_is_under_maintenance') }}</h3>
+            <hr />
+            <div class="text-left">
+              <p>
+                <i class="icon-arrow-right"></i>
+                <a href="/admin">{{ t('maintenance_mode.admin_page') }}</a>
+              </p>
+              {% if not user %}
+                <p>
+                  <i class="icon-arrow-right"></i>
+                  <a href="/login">{{ t('maintenance_mode.login') }}</a>
+                </p>
+              {% else %}
+                <p>
+                  <i class="icon-arrow-right"></i>
+                  <a href="/logout">{{ t('maintenance_mode.logout') }}</a>
+                </p>
+              {% endif %}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+{% endblock %}

+ 3 - 3
packages/app/src/stores/context.tsx

@@ -7,7 +7,7 @@ import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
-import { TargetAndAncestors, NotFoundTargetPathOrId, IsNotFoundPermalink } from '../interfaces/page-listing-results';
+import { TargetAndAncestors, IsNotFoundPermalink } from '../interfaces/page-listing-results';
 
 type Nullable<T> = T | null;
 
@@ -132,8 +132,8 @@ export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResp
   return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
 };
 
-export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetPathOrId>): SWRResponse<Nullable<NotFoundTargetPathOrId>, Error> => {
-  return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
+export const useNotFoundTargetPathOrId = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('notFoundTargetPathOrId', initialData);
 };
 
 export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {

+ 14 - 3
packages/app/src/stores/ui.tsx

@@ -1,12 +1,14 @@
-import useSWR, {
+import { RefObject } from 'react';
+import {
   useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
+import SimpleBar from 'simplebar-react';
+
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import { pagePathUtils } from '@growi/core';
 
-import { RefObject } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -38,6 +40,15 @@ export const EditorMode = {
 export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
 
 
+/** **********************************************************
+ *                     Storing RefObjects
+ *********************************************************** */
+
+export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRResponse<RefObject<SimpleBar>, Error> => {
+  return useStaticSWR<RefObject<SimpleBar>, Error>('sidebarScrollerRef', initialData);
+};
+
+
 /** **********************************************************
  *                          SWR Hooks
  *                      for switching UI
@@ -212,7 +223,7 @@ export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean,
 };
 
 export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
-  return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.RECENT });
+  return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
 };
 
 export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {

+ 19 - 5
packages/app/src/stores/user-group.tsx

@@ -6,9 +6,16 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
-  UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupPagesResult, SelectableUserGroupsResult, AncestorUserGroupsResult,
+  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult,
+  UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 
+export const useSWRxUserGroup = (groupId: string | undefined): SWRResponse<IUserGroupHasId, Error> => {
+  return useSWRImmutable(
+    groupId != null ? [`/user-groups/${groupId}`] : null,
+    endpoint => apiv3Get<UserGroupResult>(endpoint).then(result => result.data.userGroup),
+  );
+};
 
 export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable<IUserGroupHasId[], Error>(
@@ -60,16 +67,23 @@ export const useSWRxUserGroupPages = (groupId: string | undefined, limit: number
   );
 };
 
-export const useSWRxSelectableUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxSelectableParentUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/user-groups/selectable-parent-groups', groupId] : null,
+    endpoint => apiv3Get<SelectableParentUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableParentGroups),
+  );
+};
+
+export const useSWRxSelectableChildUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
-    groupId != null ? ['/user-groups/selectable-groups'] : null,
-    endpoint => apiv3Get<SelectableUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableUserGroups),
+    groupId != null ? ['/user-groups/selectable-child-groups', groupId] : null,
+    endpoint => apiv3Get<SelectableUserChildGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableChildGroups),
   );
 };
 
 export const useSWRxAncestorUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
-    groupId != null ? ['/user-groups/ancestors'] : null,
+    groupId != null ? ['/user-groups/ancestors', groupId] : null,
     endpoint => apiv3Get<AncestorUserGroupsResult>(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),
   );
 };

+ 2 - 2
packages/app/src/styles/_mixins.scss

@@ -206,7 +206,7 @@
   }
   @include hover() {
     svg {
-      fill: $value;
+      fill: $color-hover;
     }
   }
   &.disabled,
@@ -219,7 +219,7 @@
   &:not(:disabled):not(.disabled).active,
   .show > &.dropdown-toggle {
     svg {
-      fill: $value;
+      fill: $color-hover;
     }
   }
 }

+ 3 - 0
packages/app/src/styles/_override-simplebar.scss

@@ -0,0 +1,3 @@
+.simplebar-scrollbar::before {
+  background-color: #666;
+}

+ 1 - 1
packages/app/src/styles/_page-tree.scss

@@ -26,7 +26,7 @@ $grw-pagetree-item-padding-left: 10px;
       }
     }
 
-    .grw-pagetree-button {
+    .grw-pagetree-triangle-btn {
       background-color: transparent;
       transition: all 0.2s ease-out;
       transform: rotate(0deg);

+ 3 - 4
packages/app/src/styles/_sidebar.scss

@@ -74,13 +74,12 @@
             flex-direction: column;
             width: 100%;
             height: 100%;
-            overflow: hidden auto;
+            overflow: hidden;
           }
         }
       }
 
-      .grw-sidebar-content-container {
-        position: relative;
+      .simplebar-mask {
         z-index: 110; // greater than the value of .grw-navigation-draggable to fix https://redmine.weseek.co.jp/issues/86678
       }
     }
@@ -89,7 +88,7 @@
       top: 0px;
       bottom: 0px;
       left: 100%;
-      z-index: 100; // greater than the value of slimScrollBar
+      z-index: 10; // greater than the value of SimpleBar
       width: 0;
       .grw-navigation-draggable-hitarea {
         position: relative;

+ 3 - 0
packages/app/src/styles/_vendor.scss

@@ -23,3 +23,6 @@
 
 // import Handsontable styles
 @import '~handsontable/dist/handsontable.full.css';
+
+// import SimpleBar styles
+@import '~simplebar/dist/simplebar.min.css';

+ 3 - 0
packages/app/src/styles/style-app.scss

@@ -16,6 +16,9 @@
 // override react-bootstrap-typeahead styles
 @import 'override-rbt';
 
+// override simplebar-react styles
+@import 'override-simplebar';
+
 // atoms
 @import 'atoms/buttons';
 @import 'atoms/code';

+ 16 - 2
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -281,9 +281,11 @@ ul.pagination {
       $color-list-hover,
       $bgcolor-list-hover,
       $color-list-active,
-      lighten($bgcolor-list-hover, 5%),
-      $gray-500
+      lighten($bgcolor-list-hover, 5%)
     );
+    .grw-pagetree-triangle-btn {
+      @include button-outline-svg-icon-variant($secondary, $gray-200);
+    }
     .grw-pagetree-count {
       color: $gray-400;
       background: $gray-700;
@@ -296,6 +298,18 @@ ul.pagination {
   }
 }
 
+.btn.btn-page-item-control {
+  @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
+  @include hover() {
+    background-color: $gray-600;
+  }
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: $gray-200;
+  }
+  box-shadow: none !important;
+}
+
 /*
  * Popover
  */

+ 16 - 2
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -176,9 +176,11 @@ $border-color: $border-color-global;
       $color-list-hover,
       $bgcolor-list-hover,
       $color-list-active,
-      $bgcolor-list-active,
-      $gray-400
+      $bgcolor-list-active
     );
+    .grw-pagetree-triangle-btn {
+      @include button-outline-svg-icon-variant($gray-400, $primary);
+    }
     .grw-pagetree-count {
       color: $gray-500;
       background: $gray-200;
@@ -191,6 +193,18 @@ $border-color: $border-color-global;
   }
 }
 
+.btn.btn-page-item-control {
+  @include button-outline-variant($gray-500, $primary, lighten($primary, 52%), transparent);
+  @include hover() {
+    background-color: lighten($primary, 58%);
+  }
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: $primary;
+  }
+  box-shadow: none !important;
+}
+
 /*
  * GROWI page list
  */

+ 0 - 12
packages/app/src/styles/theme/_apply-colors.scss

@@ -710,18 +710,6 @@ mark.rbt-highlight-text {
   }
 }
 
-// Page Management Dropdown icon
-.btn-page-item-control {
-  color: $gray-500;
-  &:hover,
-  &:focus {
-    background-color: rgba($color-link, 0.15);
-  }
-  &:active {
-    background-color: rgba($color-link, 0.2);
-  }
-}
-
 /*
   Slack Integration
 */

+ 1 - 2
packages/app/src/styles/theme/christmas.scss

@@ -194,8 +194,7 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        $bgcolor-list-hover,
-        $gray-400
+        $bgcolor-list-hover
       );
     }
   }

+ 1 - 1
packages/app/src/styles/theme/default.scss

@@ -172,7 +172,7 @@ html[dark] {
   $color-resize-button-hover: white;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
-  $bgcolor-sidebar-context: #111d2f;
+  $bgcolor-sidebar-context: lighten($bgcolor-global, 10%);
   $color-sidebar-context: $color-global;
   // Sidebar list group
   $bgcolor-sidebar-list-group: #1c2a3e; // optional

+ 1 - 2
packages/app/src/styles/theme/future.scss

@@ -122,8 +122,7 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        lighten($bgcolor-list-hover, 5%),
-        $gray-600
+        lighten($bgcolor-list-hover, 5%)
       );
     }
   }

+ 4 - 2
packages/app/src/styles/theme/island.scss

@@ -131,9 +131,11 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        lighten($bgcolor-list-hover, 5%),
-        $gray-400
+        lighten($bgcolor-list-hover, 5%)
       );
+      .grw-pagetree-triangle-btn {
+        @include button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
+      }
     }
   }
 }

+ 1 - 2
packages/app/src/styles/theme/kibela.scss

@@ -122,8 +122,7 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        lighten($bgcolor-list-active, 55%),
-        $gray-400
+        lighten($bgcolor-list-active, 55%)
       );
     }
   }

+ 2 - 10
packages/app/src/styles/theme/mixins/_list-group.scss

@@ -24,8 +24,7 @@
   $color-hover: $color,
   $bgcolor-hover: $bgcolor,
   $color-active: $color,
-  $bgcolor-active: $bgcolor,
-  $button-color
+  $bgcolor-active: $bgcolor
 ) {
   .grw-pagetree-is-over {
     background: $bgcolor-hover;
@@ -35,16 +34,9 @@
     background-color: transparent;
     border-color: $border-color-global;
 
-    &.grw-pagetree-is-target {
+    &.grw-pagetree-current-page-item {
       background: $bgcolor-hover;
     }
-    .grw-pagetree-button {
-      &:not(:hover) {
-        svg {
-          fill: $button-color;
-        }
-      }
-    }
 
     &.list-group-item-action {
       &:hover {

+ 1 - 1
packages/app/test/cypress/integration/1-install/install.spec.ts

@@ -53,7 +53,7 @@ context('Installing', () => {
     cy.getByTestid('btnSubmit').click();
 
     cy.screenshot(`${ssPrefix}-installed`, {
-      blackout: ['.grw-sidebar-content-container'],
+      blackout: ['#grw-sidebar-contents-wrapper'],
     });
   });
 

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

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

+ 10 - 0
packages/app/test/cypress/support/index.ts

@@ -20,6 +20,16 @@ import './screenshot'
 // Alternatively you can use CommonJS syntax:
 // require('./commands')
 
+// Ignore 'ResizeObserver loop limit exceeded' exception
+// https://github.com/cypress-io/cypress/issues/8418
+const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
+Cypress.on('uncaught:exception', (err) => {
+    /* returning false here prevents Cypress from failing the test */
+    if (resizeObserverLoopErrRe.test(err.message)) {
+        return false
+    }
+})
+
 declare global {
   // eslint-disable-next-line @typescript-eslint/no-namespace
   namespace Cypress {

+ 4 - 4
packages/app/test/integration/middlewares/login-required.test.js

@@ -98,8 +98,8 @@ describe('loginRequired', () => {
       isGuestAllowedToReadSpy = jest.spyOn(crowi.aclService, 'isGuestAllowedToRead');
     });
 
-    test('send status 403 when \'req.path\' starts with \'_api\'', () => {
-      req.path = '/_api/someapi';
+    test('send status 403 when \'req.baseUrl\' starts with \'_api\'', () => {
+      req.baseUrl = '/_api/someapi';
 
       const result = loginRequiredStrictly(req, res, next);
 
@@ -113,7 +113,7 @@ describe('loginRequired', () => {
     });
 
     test('redirect to \'/login\' when the user does not loggedin', () => {
-      req.path = '/path/that/requires/loggedin';
+      req.baseUrl = '/path/that/requires/loggedin';
 
       const result = loginRequiredStrictly(req, res, next);
 
@@ -174,7 +174,7 @@ describe('loginRequired', () => {
     test('redirect to \'/login\' when user.status is \'STATUS_DELETED\'', () => {
       const User = crowi.model('User');
 
-      req.path = '/path/that/requires/loggedin';
+      req.baseUrl = '/path/that/requires/loggedin';
       req.user = {
         _id: 'user id',
         status: User.STATUS_DELETED,

+ 394 - 3
packages/app/test/integration/models/v5.page.test.js

@@ -37,11 +37,20 @@ describe('Page', () => {
 
     rootPage = await Page.findOne({ path: '/' });
 
-    const createPageId1 = new mongoose.Types.ObjectId();
+    const pageIdCreate1 = new mongoose.Types.ObjectId();
+    const pageIdCreate2 = new mongoose.Types.ObjectId();
+    const pageIdCreate3 = new mongoose.Types.ObjectId();
+    const pageIdCreate4 = new mongoose.Types.ObjectId();
 
+    /**
+     * create
+     * mc_ => model create
+     * emp => empty => page with isEmpty: true
+     * pub => public => GRANT_PUBLIC
+     */
     await Page.insertMany([
       {
-        _id: createPageId1,
+        _id: pageIdCreate1,
         path: '/v5_empty_create_4',
         grant: Page.GRANT_PUBLIC,
         parent: rootPage._id,
@@ -52,7 +61,199 @@ describe('Page', () => {
         grant: Page.GRANT_PUBLIC,
         creator: dummyUser1,
         lastUpdateUser: dummyUser1._id,
-        parent: createPageId1,
+        parent: pageIdCreate1,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdCreate2,
+        path: '/mc4_top/mc1_emp',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: rootPage._id,
+        isEmpty: true,
+      },
+      {
+        path: '/mc4_top/mc1_emp/mc2_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdCreate2,
+        isEmpty: false,
+      },
+      {
+        path: '/mc5_top/mc3_awl',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdCreate3,
+        path: '/mc4_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdCreate4,
+        path: '/mc5_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+    ]);
+
+    /**
+     * update
+     * mup_ => model update
+     * emp => empty => page with isEmpty: true
+     * pub => public => GRANT_PUBLIC
+     * awl => Anyone with the link => GRANT_RESTRICTED
+     */
+    const pageIdUpd1 = new mongoose.Types.ObjectId();
+    const pageIdUpd2 = new mongoose.Types.ObjectId();
+    const pageIdUpd3 = new mongoose.Types.ObjectId();
+    const pageIdUpd4 = new mongoose.Types.ObjectId();
+    const pageIdUpd5 = new mongoose.Types.ObjectId();
+    const pageIdUpd6 = new mongoose.Types.ObjectId();
+    const pageIdUpd7 = new mongoose.Types.ObjectId();
+    const pageIdUpd8 = new mongoose.Types.ObjectId();
+    const pageIdUpd9 = new mongoose.Types.ObjectId();
+    const pageIdUpd10 = new mongoose.Types.ObjectId();
+    const pageIdUpd11 = new mongoose.Types.ObjectId();
+    const pageIdUpd12 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdUpd1,
+        path: '/mup13_top/mup1_emp',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdUpd8._id,
+        isEmpty: true,
+      },
+      {
+        _id: pageIdUpd2,
+        path: '/mup13_top/mup1_emp/mup2_pub',
+        grant: Page.GRANT_PUBLIC,
+        parent: pageIdUpd1._id,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdUpd3,
+        path: '/mup14_top/mup6_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd9,
+        isEmpty: false,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup14_top/mup6_pub/mup7_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd3,
+        isEmpty: false,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd4,
+        path: '/mup15_top/mup8_pub',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd10._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdUpd5,
+        path: '/mup16_top/mup9_pub/mup10_pub/mup11_awl',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        _id: pageIdUpd6,
+        path: '/mup17_top/mup12_emp',
+        isEmpty: true,
+        parent: pageIdUpd12._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdUpd7,
+        path: '/mup17_top/mup12_emp',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        path: '/mup17_top/mup12_emp/mup18_pub',
+        isEmpty: false,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        parent: pageIdUpd6._id,
+      },
+      {
+        _id: pageIdUpd8,
+        path: '/mup13_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 2,
+      },
+      {
+        _id: pageIdUpd9,
+        path: '/mup14_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 2,
+      },
+      {
+        _id: pageIdUpd10,
+        path: '/mup15_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        _id: pageIdUpd11,
+        path: '/mup16_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd12,
+        path: '/mup17_top',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
       },
     ]);
 
@@ -91,5 +292,195 @@ describe('Page', () => {
       expect(grandchildPage.parent).toStrictEqual(childPage._id);
     });
 
+    describe('Creating a page using existing path', () => {
+      test('with grant RESTRICTED should only create the page and change nothing else', async() => {
+        const pathT = '/mc4_top';
+        const path1 = '/mc4_top/mc1_emp';
+        const path2 = '/mc4_top/mc1_emp/mc2_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const page2 = await Page.findOne({ path: path2 });
+        const page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+        expect(page3).toBeNull();
+
+        // use existing path
+        await Page.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const _page2 = await Page.findOne({ path: path2 });
+        const _page3 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_pageT.descendantCount).toBe(1);
+      });
+    });
+    describe('Creating a page under a page with grant RESTRICTED', () => {
+      test('will create a new empty page with the same path as the grant RESTRECTED page and become a parent', async() => {
+        const pathT = '/mc5_top';
+        const path1 = '/mc5_top/mc3_awl';
+        const pathN = '/mc5_top/mc3_awl/mc4_pub'; // used to create
+        const pageT = await Page.findOne({ path: pathT });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeNull();
+
+        await Page.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const _page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC, isEmpty: true });
+        const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_PUBLIC }); // newly crated
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_pageN).toBeTruthy();
+        expect(_pageN.parent).toStrictEqual(_page2._id);
+        expect(_pageT.descendantCount).toStrictEqual(1);
+      });
+    });
+
+  });
+
+  describe('update', () => {
+
+    describe('Changing grant from PUBLIC to RESTRICTED of', () => {
+      test('an only-child page will delete its empty parent page', async() => {
+        const pathT = '/mup13_top';
+        const path1 = '/mup13_top/mup1_emp';
+        const path2 = '/mup13_top/mup1_emp/mup2_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 2 });
+        const page1 = await Page.findOne({ path: path1, isEmpty: true });
+        const page2 = await Page.findOne({ path: path2, grant: Page.GRANT_PUBLIC });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+
+        const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupId: null };
+        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1 });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_RESTRICTED });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeNull();
+        expect(_page2).toBeTruthy();
+        expect(_pageT.descendantCount).toBe(1);
+      });
+      test('a page that has children will create an empty page with the same path and it becomes a new parent', async() => {
+        const pathT = '/mup14_top';
+        const path1 = '/mup14_top/mup6_pub';
+        const path2 = '/mup14_top/mup6_pub/mup7_pub';
+        const top = await Page.findOne({ path: pathT, descendantCount: 2 });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const page2 = await Page.findOne({ path: path2, grant: Page.GRANT_PUBLIC });
+        expect(top).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+
+        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const _top = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const _page2 = await Page.findOne({ path: path2 });
+        const _pageN = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_pageN).toBeTruthy();
+
+        expect(_page1.parent).toBeNull();
+        expect(_page2.parent).toStrictEqual(_pageN._id);
+        expect(_pageN.parent).toStrictEqual(top._id);
+        expect(_pageN.isEmpty).toBe(true);
+        expect(_pageN.descendantCount).toBe(1);
+        expect(_top.descendantCount).toBe(1);
+      });
+      test('of a leaf page will NOT have an empty page with the same path', async() => {
+        const pathT = '/mup15_top';
+        const path1 = '/mup15_top/mup8_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
+        const page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const count = await Page.count({ path: path1 });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(count).toBe(1);
+
+        await Page.updatePage(page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
+        const _pageNotExist = await Page.findOne({ path: path1, isEmpty: true });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeTruthy();
+        expect(_pageNotExist).toBeNull();
+        expect(_pageT.descendantCount).toBe(0);
+      });
+    });
+    describe('Changing grant from RESTRICTED to PUBLIC of', () => {
+      test('a page will create ancestors if they do not exist', async() => {
+        const pathT = '/mup16_top';
+        const path1 = '/mup16_top/mup9_pub';
+        const path2 = '/mup16_top/mup9_pub/mup10_pub';
+        const path3 = '/mup16_top/mup9_pub/mup10_pub/mup11_awl';
+        const top = await Page.findOne({ path: pathT });
+        const page1 = await Page.findOne({ path: path1 });
+        const page2 = await Page.findOne({ path: path2 });
+        const page3 = await Page.findOne({ path: path3, grant: Page.GRANT_RESTRICTED });
+        expect(top).toBeTruthy();
+        expect(page3).toBeTruthy();
+        expect(page1).toBeNull();
+        expect(page2).toBeNull();
+
+        await Page.updatePage(page3, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, isEmpty: true });
+        const _page2 = await Page.findOne({ path: path2, isEmpty: true });
+        const _page3 = await Page.findOne({ path: path3, grant: Page.GRANT_PUBLIC });
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_page1.parent).toStrictEqual(top._id);
+        expect(_page2.parent).toStrictEqual(_page1._id);
+        expect(_page3.parent).toStrictEqual(_page2._id);
+        expect(_pageT.descendantCount).toBe(1);
+      });
+      test('a page will replace an empty page with the same path if any', async() => {
+        const pathT = '/mup17_top';
+        const path1 = '/mup17_top/mup12_emp';
+        const path2 = '/mup17_top/mup12_emp/mup18_pub';
+        const pageT = await Page.findOne({ path: pathT, descendantCount: 1 });
+        const page1 = await Page.findOne({ path: path1, isEmpty: true });
+        const page2 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED, isEmpty: false });
+        const page3 = await Page.findOne({ path: path2 });
+        expect(pageT).toBeTruthy();
+        expect(page1).toBeTruthy();
+        expect(page2).toBeTruthy();
+        expect(page3).toBeTruthy();
+
+        await Page.updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_PUBLIC });
+
+        const _pageT = await Page.findOne({ path: pathT });
+        const _page1 = await Page.findOne({ path: path1, isEmpty: true }); // should be replaced
+        const _page2 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const _page3 = await Page.findOne({ path: path2 });
+        expect(_pageT).toBeTruthy();
+        expect(_page1).toBeNull();
+        expect(_page2).toBeTruthy();
+        expect(_page3).toBeTruthy();
+        expect(_page2.grant).toBe(Page.GRANT_PUBLIC);
+        expect(_page2.parent).toStrictEqual(_pageT._id);
+        expect(_page3.parent).toStrictEqual(_page2._id);
+        expect(_pageT.descendantCount).toBe(2);
+      });
+    });
+
   });
 });

+ 4 - 4
packages/app/test/integration/service/v5.non-public-page.test.ts

@@ -851,8 +851,8 @@ describe('PageService page operations with non-public pages', () => {
       const beforeRevertPath1 = '/trash/np_revert5';
       const beforeRevertPath2 = '/trash/np_revert5/middle/np_revert6';
       const beforeRevertPath3 = '/trash/np_revert5/middle';
-      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
-      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
+      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grantedGroup: groupIdA });
+      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grantedGroup: groupIdB });
       const nonExistantPage3 = await Page.findOne({ path: beforeRevertPath3 }); // not exist
       const revision1 = await Revision.findOne({ pageId: trashedPage1._id });
       const revision2 = await Revision.findOne({ pageId: trashedPage2._id });
@@ -881,8 +881,8 @@ describe('PageService page operations with non-public pages', () => {
       expect(revertedPage2.status).toBe(Page.STATUS_PUBLISHED);
       expect(newlyCreatedPage.status).toBe(Page.STATUS_PUBLISHED);
 
-      expect(revertedPage1.grant).toBe(Page.GRANT_USER_GROUP);
-      expect(revertedPage1.grant).toBe(Page.GRANT_USER_GROUP);
+      expect(revertedPage1.grantedGroup).toStrictEqual(groupIdA);
+      expect(revertedPage2.grantedGroup).toStrictEqual(groupIdB);
       expect(newlyCreatedPage.grant).toBe(Page.GRANT_PUBLIC);
 
     });

+ 1 - 2
packages/app/tsconfig.build.server.json

@@ -5,9 +5,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
     "baseUrl": ".",
     "paths": {
       "~/*": ["./src/*"],

Some files were not shown because too many files changed in this diff