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

Merge branch 'master' of https://github.com/weseek/growi into imprv/91283-91052-security-setting-ui

Shun Miyazawa 4 лет назад
Родитель
Сommit
439494d0c2
81 измененных файлов с 859 добавлено и 476 удалено
  1. 6 6
      .github/workflows/ci-app.yml
  2. 6 6
      .github/workflows/ci-slackbot-proxy.yml
  3. 1 1
      .github/workflows/codeql-analysis.yml
  4. 2 2
      .github/workflows/draft-release.yml
  5. 2 2
      .github/workflows/list-unhealthy-branches.yml
  6. 1 1
      .github/workflows/release-rc.yml
  7. 4 4
      .github/workflows/release-slackbot-proxy.yml
  8. 6 6
      .github/workflows/release.yml
  9. 5 5
      .github/workflows/reusable-app-prod.yml
  10. 2 2
      .github/workflows/reusable-app-reg-suit.yml
  11. 1 1
      lerna.json
  12. 1 1
      package.json
  13. 7 7
      packages/app/package.json
  14. 12 2
      packages/app/resource/locales/en_US/admin/admin.json
  15. 1 1
      packages/app/resource/locales/en_US/sandbox.md
  16. 2 0
      packages/app/resource/locales/en_US/translation.json
  17. 12 2
      packages/app/resource/locales/ja_JP/admin/admin.json
  18. 1 1
      packages/app/resource/locales/ja_JP/sandbox.md
  19. 2 0
      packages/app/resource/locales/ja_JP/translation.json
  20. 12 2
      packages/app/resource/locales/zh_CN/admin/admin.json
  21. 1 1
      packages/app/resource/locales/zh_CN/sandbox.md
  22. 3 1
      packages/app/resource/locales/zh_CN/translation.json
  23. 2 1
      packages/app/src/client/services/ContextExtractor.tsx
  24. 6 6
      packages/app/src/client/util/GrowiRenderer.js
  25. 0 1
      packages/app/src/client/util/interceptor/detach-code-blocks.js
  26. 3 3
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  27. 93 3
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  28. 0 1
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  29. 13 13
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  30. 13 15
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  31. 0 1
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  32. 92 0
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  33. 90 58
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  34. 1 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  35. 3 3
      packages/app/src/components/MyDraftList/Draft.jsx
  36. 5 2
      packages/app/src/components/Navbar/GlobalSearch.tsx
  37. 1 4
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  38. 8 5
      packages/app/src/components/Page.jsx
  39. 14 6
      packages/app/src/components/Page/RevisionLoader.jsx
  40. 7 4
      packages/app/src/components/Page/RevisionRenderer.jsx
  41. 0 110
      packages/app/src/components/Page/TagsInput.jsx
  42. 86 0
      packages/app/src/components/Page/TagsInput.tsx
  43. 3 3
      packages/app/src/components/PageComment/Comment.jsx
  44. 3 3
      packages/app/src/components/PageComment/CommentEditor.jsx
  45. 9 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  46. 0 123
      packages/app/src/components/PageEditor/Preview.jsx
  47. 112 0
      packages/app/src/components/PageEditor/Preview.tsx
  48. 3 2
      packages/app/src/components/PageEditor/PreviewWithSuspense.jsx
  49. 16 2
      packages/app/src/components/PageList/PageListItemL.tsx
  50. 1 0
      packages/app/src/components/PageTimeline.jsx
  51. 1 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  52. 1 2
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  53. 1 0
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  54. 1 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  55. 2 2
      packages/app/src/interfaces/search.ts
  56. 5 0
      packages/app/src/interfaces/tag.ts
  57. 1 1
      packages/app/src/interfaces/user.ts
  58. 13 1
      packages/app/src/interfaces/websocket.ts
  59. 1 1
      packages/app/src/server/routes/apiv3/user-group.js
  60. 1 1
      packages/app/src/server/routes/search.js
  61. 19 6
      packages/app/src/server/service/page.ts
  62. 28 9
      packages/app/src/server/service/search.ts
  63. 16 3
      packages/app/src/server/service/user-group.ts
  64. 34 0
      packages/app/src/stores/modal.tsx
  65. 10 7
      packages/app/src/stores/ui.tsx
  66. 24 0
      packages/app/src/stores/websocket.tsx
  67. 1 1
      packages/app/src/styles/_on-edit.scss
  68. 1 1
      packages/app/src/styles/_page-tree.scss
  69. 1 1
      packages/app/src/styles/theme/_apply-colors.scss
  70. 1 1
      packages/codemirror-textlint/package.json
  71. 1 1
      packages/core/package.json
  72. 1 1
      packages/plugin-attachment-refs/package.json
  73. 1 1
      packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  74. 1 1
      packages/plugin-lsx/package.json
  75. 1 1
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  76. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  77. 4 3
      packages/plugin-pukiwiki-like-linker/src/resource/js/util/PreProcessor/PukiwikiLikeLinker.js
  78. 1 1
      packages/slack/package.json
  79. 1 1
      packages/slackbot-proxy/package.json
  80. 1 1
      packages/ui/package.json
  81. 10 0
      yarn.lock

+ 6 - 6
.github/workflows/ci-app.yml

@@ -17,9 +17,9 @@ jobs:
         node-version: [16.x]
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-node@v2
+      - uses: actions/setup-node@v3
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
@@ -71,9 +71,9 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-node@v2
+      - uses: actions/setup-node@v3
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
@@ -131,9 +131,9 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-node@v2
+      - uses: actions/setup-node@v3
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'

+ 6 - 6
.github/workflows/ci-slackbot-proxy.yml

@@ -18,9 +18,9 @@ jobs:
         node-version: [16.x]
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'
@@ -76,9 +76,9 @@ jobs:
           MYSQL_DATABASE: growi-slackbot-proxy
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'
@@ -141,9 +141,9 @@ jobs:
           MYSQL_DATABASE: growi-slackbot-proxy
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'

+ 1 - 1
.github/workflows/codeql-analysis.yml

@@ -35,7 +35,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v2
+      uses: actions/checkout@v3
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL

+ 2 - 2
.github/workflows/draft-release.yml

@@ -16,7 +16,7 @@ jobs:
       RELEASE_DRAFT_BODY: ${{ steps.release-drafter.outputs.body }}
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
       - name: Retrieve information from package.json
         uses: myrotvorets/info-from-package-json-action@1.1.0
@@ -40,7 +40,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
 

+ 2 - 2
.github/workflows/list-unhealthy-branches.yml

@@ -10,11 +10,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         fetch-depth: 0
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
 

+ 1 - 1
.github/workflows/release-rc.yml

@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         lfs: true
 

+ 4 - 4
.github/workflows/release-slackbot-proxy.yml

@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
@@ -88,7 +88,7 @@ jobs:
         VERBOSE : true
 
     - name: Update Docker Hub Description
-      uses: peter-evans/dockerhub-description@v2
+      uses: peter-evans/dockerhub-description@v3
       with:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
@@ -102,11 +102,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
         cache: 'yarn'

+ 6 - 6
.github/workflows/release.yml

@@ -18,11 +18,11 @@ jobs:
       RELEASED_VERSION: ${{ steps.package-json.outputs.packageVersion }}
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
         cache: 'yarn'
@@ -79,11 +79,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: '16'
         cache: 'yarn'
@@ -131,7 +131,7 @@ jobs:
         flavor: [default, nocdn]
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
         lfs: true
@@ -197,7 +197,7 @@ jobs:
         mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
     - name: Update Docker Hub Description
-      uses: peter-evans/dockerhub-description@v2
+      uses: peter-evans/dockerhub-description@v3
       with:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

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

@@ -23,9 +23,9 @@ jobs:
       PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -103,9 +103,9 @@ jobs:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -200,7 +200,7 @@ jobs:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
     - name: Get yarn cache dir
       id: yarn-cache-dir

+ 2 - 2
.github/workflows/reusable-app-reg-suit.yml

@@ -45,12 +45,12 @@ jobs:
       EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
       with:
         ref: ${{ inputs.checkout-ref }}
         fetch-depth: 0
 
-    - uses: actions/setup-node@v2
+    - uses: actions/setup-node@v3
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0-RC.11",
-    "@growi/plugin-attachment-refs": "^5.0.0-RC.11",
-    "@growi/plugin-lsx": "^5.0.0-RC.11",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.11",
-    "@growi/slack": "^5.0.0-RC.11",
+    "@growi/codemirror-textlint": "^5.0.0-RC.12",
+    "@growi/plugin-attachment-refs": "^5.0.0-RC.12",
+    "@growi/plugin-lsx": "^5.0.0-RC.12",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.0-RC.12",
+    "@growi/slack": "^5.0.0-RC.12",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0-RC.11",
+    "@growi/ui": "^5.0.0-RC.12",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 12 - 2
packages/app/resource/locales/en_US/admin/admin.json

@@ -28,7 +28,10 @@
     "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"
+    "already_upgraded": "You have already completed the conversion to v5 compatibility",
+    "header_upgrading_progress": "Upgrade Progress",
+    "migration_succeeded": "Your upgrade has been successfully completed! Exit maintenance mode and GROWI can be used.",
+    "migration_failed": "Upgrade failed. Please refer to the GROWI docs for information on what to do in the event of failure."
   },
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
@@ -476,6 +479,7 @@
     "select_parent_group": "Select Parent Group",
     "release_parent_group": "Release parent group",
     "add_modal": {
+      "description": "The added user will also be added to all parent groups.",
       "add_user": "Add a user to the created group",
       "search_option": "Search option",
       "enable_option": "Enable {{option}}",
@@ -486,7 +490,6 @@
     "group_list": "Group list",
     "child_group_list": "Child group list",
     "back_to_list": "Go back to group list",
-    "back_to_ancestors_group": "Go back to ancestors group",
     "basic_info": "Basic info",
     "user_list": "User list",
     "created_group": "Group was created",
@@ -502,6 +505,13 @@
       "publish_pages": "Publish all",
       "delete_pages": "Delete all",
       "transfer_pages": "Transfer to another group"
+    },
+    "update_parent_confirm_modal": {
+      "header": "The parent of the group will be changed",
+      "caution_change_parent": "This operation will change the parent of the group \"{{groupName}}\".",
+      "danger_message": "Note that this affects the permissions to view all pages associated with this group.",
+      "force_update_parents_label": "Forcibly add missing users",
+      "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
   }
 }

+ 1 - 1
packages/app/resource/locales/en_US/sandbox.md

@@ -256,7 +256,7 @@ Both the page description and link address can be displayed on the page.
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 ```
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 # :pencil: Lists

+ 2 - 0
packages/app/resource/locales/en_US/translation.json

@@ -169,6 +169,8 @@
   "Link sharing is disabled": "Link sharing is disabled",
   "successfully_saved_the_page": "Successfully saved the page",
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
+  "not_allowed_to_see_this_page": "You cannot see this page",
+  "Confirm": "Confirm",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",

+ 12 - 2
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -28,7 +28,10 @@
     "modal_migration_warning": "管理者はユーザーに、v5 互換形式への変換中はページを作成・変更・削除しないように伝えることを強くお勧めします。",
     "start_upgrading": "v5 互換形式への変換を開始",
     "successfully_started": "正常に v5 互換形式への変換が開始されました",
-    "already_upgraded": "v5 互換形式への変換は既に完了しています"
+    "already_upgraded": "v5 互換形式への変換は既に完了しています",
+    "header_upgrading_progress": "アップグレード進行度",
+    "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
+    "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
   },
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
@@ -475,6 +478,7 @@
     "select_parent_group": "親グループを選択",
     "release_parent_group": "親グループの解除",
     "add_modal": {
+      "description": "追加したユーザーは、親グループにも追加されます。",
       "add_user": "グループへのユーザー追加",
       "search_option": "検索オプション",
       "enable_option": "{{option}}を有効にする",
@@ -485,7 +489,6 @@
     "group_list": "グループ一覧",
     "child_group_list": "子グループ一覧",
     "back_to_list": "グループ一覧に戻る",
-    "back_to_ancestors_group": "祖先グループに戻る",
     "basic_info": "基本情報",
     "user_list": "ユーザー一覧",
     "created_group": "グループを作成しました",
@@ -501,6 +504,13 @@
       "publish_pages": "全て公開する",
       "delete_pages": "全て削除する",
       "transfer_pages": "全て他のグループに移譲する"
+    },
+    "update_parent_confirm_modal": {
+      "header": "グループの親が変更されます",
+      "caution_change_parent": "この操作はグループ \"{{groupName}}\" の親を変更します。",
+      "danger_message": "このグループに関連する全てのページの閲覧権限に影響があることに注意してください。",
+      "force_update_parents_label": "強制的に足りないユーザーを追加する",
+      "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
   }
 }

+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox.md

@@ -255,7 +255,7 @@ ___
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 ```
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 
 # :pencil: Lists

+ 2 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -171,6 +171,8 @@
   "Link sharing is disabled": "リンクのシェアは無効化されています",
   "successfully_saved_the_page": "ページが正常に保存されました",
   "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
+  "not_allowed_to_see_this_page": "このページは閲覧できません",
+  "Confirm": "確認",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",

+ 12 - 2
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -28,7 +28,10 @@
     "modal_migration_warning": "这个过程可能需要很长时间。强烈建议管理员告诉用户在转换期间不要创建、修改或删除页面。",
     "start_upgrading": "开始转换为v5兼容性",
     "successfully_started": "成功开始转换",
-    "already_upgraded": "你已经完成了向v5兼容性的转换"
+    "already_upgraded": "你已经完成了向v5兼容性的转换",
+    "header_upgrading_progress": "升级进度",
+    "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
+    "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
   },
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
@@ -485,6 +488,7 @@
     "select_parent_group": "选择父组",
     "release_parent_group": "Release parent group",
     "add_modal": {
+      "description": "添加的用户也将被添加到所有的父组。",
       "add_user": "将用户添加到创建的组",
       "search_option": "搜索选项",
       "enable_option": "启用{{option}",
@@ -495,7 +499,6 @@
     "group_list": "组列表",
     "child_group_list": "儿童组名单",
     "back_to_list": "返回组列表",
-    "back_to_ancestors_group": "返回到祖先组",
     "basic_info": "基本信息",
     "user_list": "用户列表",
     "created_group": "已创建组",
@@ -511,6 +514,13 @@
       "publish_pages": "全部发布",
       "delete_pages": "全部删除",
       "transfer_pages": "转移到另一组"
+    },
+    "update_parent_confirm_modal": {
+      "header": "该组的父组被改变",
+      "caution_change_parent": "该操作改变了组的父级,即 \"{{groupName}}\" 。",
+      "danger_message": "注意,查看与该组相关的所有页面的权限会受到影响。",
+      "force_update_parents_label": "强行添加失踪的用户",
+      "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
     }
   }
 }

+ 1 - 1
packages/app/resource/locales/zh_CN/sandbox.md

@@ -256,7 +256,7 @@ Both the page description and link address can be displayed on the page.
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 ```
 
-[[../Bootstrap4]]  
+[[./Bootstrap4]]  
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 # :pencil: Lists

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

@@ -177,12 +177,14 @@
   "Link sharing is disabled": "你不允许分享该链接",
   "successfully_saved_the_page": "成功地保存了该页面",
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
+  "not_allowed_to_see_this_page": "你不能看到这个页面",
+  "Confirm": "确定",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
     "title_required": "标题是必需的。",
-    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
+    "slashed_are_not_yet_supported": "目前还不支持包含斜线的标题"
   },
   "not_found_page": {
     "Create Page": "创建页面",

+ 2 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -15,7 +15,7 @@ import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
-import { useSetupGlobalSocket } from '~/stores/websocket';
+import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -161,6 +161,7 @@ const ContextExtractorOnce: FC = () => {
 
   // Global Socket
   useSetupGlobalSocket();
+  useSetupGlobalAdminSocket();
 
   return null;
 };

+ 6 - 6
packages/app/src/client/util/GrowiRenderer.js

@@ -135,29 +135,29 @@ export default class GrowiRenderer {
     }
   }
 
-  preProcess(markdown) {
+  preProcess(markdown, context) {
     let processed = markdown;
     for (let i = 0; i < this.preProcessors.length; i++) {
       if (!this.preProcessors[i].process) {
         continue;
       }
-      processed = this.preProcessors[i].process(processed);
+      processed = this.preProcessors[i].process(processed, context);
     }
 
     return processed;
   }
 
-  process(markdown) {
-    return this.md.render(markdown);
+  process(markdown, context) {
+    return this.md.render(markdown, context);
   }
 
-  postProcess(html) {
+  postProcess(html, context) {
     let processed = html;
     for (let i = 0; i < this.postProcessors.length; i++) {
       if (!this.postProcessors[i].process) {
         continue;
       }
-      processed = this.postProcessors[i].process(processed);
+      processed = this.postProcessors[i].process(processed, context);
     }
 
     return processed;

+ 0 - 1
packages/app/src/client/util/interceptor/detach-code-blocks.js

@@ -47,7 +47,6 @@ export class DetachCodeBlockInterceptor extends BasicInterceptor {
 
     const context = Object.assign(args[0]); // clone
     const targetKey = this.getTargetKey(contextName);
-    const currentPagePath = context.currentPagePath; // eslint-disable-line no-unused-vars
 
     context.dcbContextMap = {};
 

+ 3 - 3
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -66,15 +66,15 @@
         interceptorManager.process('preRender', context)
           .then(() => { return interceptorManager.process('prePreProcess', context) })
           .then(() => {
-            context.markdown = growiRenderer.preProcess(context.markdown);
+            context.markdown = growiRenderer.preProcess(context.markdown, context);
           })
           .then(() => { return interceptorManager.process('postPreProcess', context) })
           .then(() => {
-            context.parsedHTML = growiRenderer.process(context.markdown);
+            context.parsedHTML = growiRenderer.process(context.markdown, context);
           })
           .then(() => { return interceptorManager.process('prePostProcess', context) })
           .then(() => {
-            context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+            context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
           })
           .then(() => { return interceptorManager.process('postPostProcess', context) })
           .then(() => { return interceptorManager.process('preRenderHtml', context) })

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

@@ -1,19 +1,75 @@
-import React, { FC, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 import { useTranslation } from 'react-i18next';
 import { ConfirmModal } from './ConfirmModal';
 import AdminAppContainer from '../../../client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+import { useGlobalAdminSocket } from '~/stores/websocket';
+import LabeledProgressBar from '../Common/LabeledProgressBar';
+import {
+  SocketEventName, PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
+} from '~/interfaces/websocket';
 
 type Props = {
   adminAppContainer: typeof AdminAppContainer & { v5PageMigrationHandler: () => Promise<{ isV5Compatible: boolean }> },
 }
 
 const V5PageMigration: FC<Props> = (props: Props) => {
+  // Modal
   const [isV5PageMigrationModalShown, setIsV5PageMigrationModalShown] = useState(false);
-  const { adminAppContainer } = props;
+  // Progress bar
+  const [isInProgress, setProgressing] = useState<boolean | undefined>(undefined); // use false as ended
+  const [total, setTotal] = useState<number>(0);
+  const [skip, setSkip] = useState<number>(0);
+  const [current, setCurrent] = useState<number>(0);
+  const [isSucceeded, setSucceeded] = useState<boolean | undefined>(undefined);
+
+  const { data: adminSocket } = useGlobalAdminSocket();
   const { t } = useTranslation();
 
+  const { adminAppContainer } = props;
+
+  /*
+   * Local components
+   */
+  const renderResultMessage = useCallback((isSucceeded: boolean) => {
+    return (
+      <>
+        {
+          isSucceeded
+            ? <p className="text-success p-1">{t('admin:v5_page_migration.migration_succeeded')}</p>
+            : <p className="text-danger p-1">{t('admin:v5_page_migration.migration_failed')}</p>
+        }
+      </>
+    );
+  }, [t]);
+
+  const renderProgressBar = () => {
+    if (isInProgress == null) {
+      return <></>;
+    }
+
+    return (
+      <>
+        {
+          isSucceeded != null && renderResultMessage(isSucceeded)
+        }
+        <LabeledProgressBar
+          header={t('admin:v5_page_migration.header_upgrading_progress')}
+          currentCount={current}
+          errorsCount={skip}
+          totalCount={total}
+          isInProgress={isInProgress}
+        />
+      </>
+    );
+  };
+
+  /*
+   * Functions
+   */
   const onConfirm = async() => {
     setIsV5PageMigrationModalShown(false);
     try {
@@ -29,6 +85,39 @@ const V5PageMigration: FC<Props> = (props: Props) => {
     }
   };
 
+  /*
+   * Use Effect
+   */
+  // Setup Admin Socket
+  useEffect(() => {
+    adminSocket?.once(SocketEventName.PMStarted, (data: PMStartedData) => {
+      setProgressing(true);
+      setTotal(data.total);
+    });
+
+    adminSocket?.on(SocketEventName.PMMigrating, (data: PMMigratingData) => {
+      setProgressing(true);
+      setCurrent(data.count);
+    });
+
+    adminSocket?.on(SocketEventName.PMErrorCount, (data: PMErrorCountData) => {
+      setProgressing(true);
+      setSkip(data.skip);
+    });
+
+    adminSocket?.once(SocketEventName.PMEnded, (data: PMEndedData) => {
+      setProgressing(false);
+      setSucceeded(data.isSucceeded);
+    });
+
+    return () => {
+      adminSocket?.off(SocketEventName.PMStarted);
+      adminSocket?.off(SocketEventName.PMMigrating);
+      adminSocket?.off(SocketEventName.PMErrorCount);
+      adminSocket?.off(SocketEventName.PMEnded);
+    };
+  }, [adminSocket]);
+
   return (
     <>
       <ConfirmModal
@@ -48,9 +137,10 @@ const V5PageMigration: FC<Props> = (props: Props) => {
           {t('admin:v5_page_migration.migration_note')}
         </span>
       </p>
+      {renderProgressBar()}
       <div className="row my-3">
         <div className="mx-auto">
-          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)}>
+          <button type="button" className="btn btn-warning" onClick={() => setIsV5PageMigrationModalShown(true)} disabled={isInProgress != null}>
             {t('admin:v5_page_migration.upgrade_to_v5')}
           </button>
         </div>

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

@@ -23,7 +23,6 @@ type Props = {
   deleteUserGroup?: IUserGroupHasId,
   onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
   isShow: boolean,
-  onShow?: (group: IUserGroupHasId) => Promise<void> | void,
   onHide?: () => Promise<void> | void,
 };
 

+ 13 - 13
packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -5,26 +5,26 @@ import { IUserGroupHasId } from '~/interfaces/user';
 
 type Props = {
   selectableUserGroups?: IUserGroupHasId[]
-  onClickAddExistingUserGroupButtonHandler?(userGroup: IUserGroupHasId | null): void
-  onClickCreateUserGroupButtonHandler?(): void
+  onClickAddExistingUserGroupButton?(userGroup: IUserGroupHasId | null): void
+  onClickCreateUserGroupButton?(): void
 };
 
 const UserGroupDropdown: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
 
-  const { selectableUserGroups, onClickAddExistingUserGroupButtonHandler, onClickCreateUserGroupButtonHandler } = props;
+  const { selectableUserGroups, onClickAddExistingUserGroupButton, onClickCreateUserGroupButton } = props;
 
-  const onClickAddExistingUserGroupButton = useCallback((userGroup: IUserGroupHasId) => {
-    if (onClickAddExistingUserGroupButtonHandler != null) {
-      onClickAddExistingUserGroupButtonHandler(userGroup);
+  const onClickAddExistingUserGroupButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+    if (onClickAddExistingUserGroupButton != null) {
+      onClickAddExistingUserGroupButton(userGroup);
     }
-  }, [onClickAddExistingUserGroupButtonHandler]);
+  }, [onClickAddExistingUserGroupButton]);
 
-  const onClickCreateUserGroupButton = useCallback(() => {
-    if (onClickCreateUserGroupButtonHandler != null) {
-      onClickCreateUserGroupButtonHandler();
+  const onClickCreateUserGroupButtonHandler = useCallback(() => {
+    if (onClickCreateUserGroupButton != null) {
+      onClickCreateUserGroupButton();
     }
-  }, [onClickCreateUserGroupButtonHandler]);
+  }, [onClickCreateUserGroupButton]);
 
   return (
     <>
@@ -44,7 +44,7 @@ const UserGroupDropdown: FC<Props> = (props: Props) => {
                       key={userGroup._id}
                       type="button"
                       className="dropdown-item"
-                      onClick={() => onClickAddExistingUserGroupButton(userGroup)}
+                      onClick={() => onClickAddExistingUserGroupButtonHandler(userGroup)}
                     >
                       {userGroup.name}
                     </button>
@@ -58,7 +58,7 @@ const UserGroupDropdown: FC<Props> = (props: Props) => {
           <button
             className="dropdown-item"
             type="button"
-            onClick={() => onClickCreateUserGroupButton()}
+            onClick={() => onClickCreateUserGroupButtonHandler()}
           >{t('admin:user_group_management.create_group')}
           </button>
         </div>

+ 13 - 15
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -3,15 +3,15 @@ import { useTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 
-import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { IUserGroupHasId } from '~/interfaces/user';
 import { CustomWindow } from '~/interfaces/global';
 import Xss from '~/services/xss';
 
 type Props = {
-  userGroup?: IUserGroupHasId,
+  userGroup: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: TFunctionResult;
-  onSubmit?: (userGroupData: Partial<IUserGroup>) => Promise<IUserGroupHasId | void>
+  onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
 };
 
 const UserGroupForm: FC<Props> = (props: Props) => {
@@ -47,18 +47,16 @@ const UserGroupForm: FC<Props> = (props: Props) => {
     }
   }, [selectedParent, setSelectedParent]);
 
-  const onSubmitHandler = useCallback(async(e) => {
-    e.preventDefault(); // no reload
-
-    if (onSubmit == null) {
-      return;
-    }
-
-    await onSubmit({ name: currentName, description: currentDescription, parent: selectedParent?._id });
-  }, [currentName, currentDescription, selectedParent, onSubmit]);
-
   return (
-    <form onSubmit={onSubmitHandler}>
+    <form onSubmit={(e) => {
+      e.preventDefault();
+      onSubmit?.(props.userGroup, {
+        name: currentName,
+        description: currentDescription,
+        parent: selectedParent,
+      });
+    }}
+    >
 
       <fieldset>
         <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
@@ -108,7 +106,7 @@ const UserGroupForm: FC<Props> = (props: Props) => {
               id="dropdownMenuButton"
               data-toggle="dropdown"
               className={`
-                btn btn-outline-secondary dropdown-toggle ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+                btn btn-outline-secondary dropdown-toggle mb-3 ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
               `}
             >
               {selectedParent?.name ?? t('admin:user_group_management.select_parent_group')}

+ 0 - 1
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -189,7 +189,6 @@ const UserGroupPage: FC = () => {
         deleteUserGroup={selectedUserGroup}
         onDelete={deleteUserGroupById}
         isShow={isDeleteModalShown}
-        onShow={showDeleteModal}
         onHide={hideDeleteModal}
       />
     </div>

+ 92 - 0
packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -0,0 +1,92 @@
+import React, { FC, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
+
+
+const UpdateParentConfirmModal: FC = () => {
+  const { t } = useTranslation();
+
+  const [isForceUpdate, setForceUpdate] = useState(false);
+
+  const { data: modalStatus, close: closeModal } = useUpdateUserGroupConfirmModal();
+
+  if (modalStatus == null) {
+    closeModal();
+    return <></>;
+  }
+
+  const {
+    isOpened, targetGroup, updateData, onConfirm,
+  } = modalStatus;
+
+  return (
+    <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
+      <ModalHeader tag="h4" toggle={closeModal} className="bg-warning text-light">
+        <i className="icon icon-warning"></i> {t('admin:user_group_management.update_parent_confirm_modal.header')}
+      </ModalHeader>
+      {
+        targetGroup != null && updateData != null && updateData?.parent !== undefined ? (
+          <>
+            <ModalBody>
+              <div className="mb-2">
+                <span className="font-weight-bold">{t('admin:user_group_management.group_name')}</span> : &quot;{targetGroup.name}&quot;
+                <hr />
+                {t('admin:user_group_management.update_parent_confirm_modal.caution_change_parent', { groupName: targetGroup.name })}
+              </div>
+              <div className="text-danger mb-3">
+                <i className="icon-exclamation"></i>
+                {t('admin:user_group_management.update_parent_confirm_modal.danger_message')}
+              </div>
+
+              <div className="custom-control custom-checkbox custom-checkbox-primary pl-5">
+                <input
+                  className="custom-control-input"
+                  name="forceUpdateParents"
+                  id="forceUpdateParents"
+                  type="checkbox"
+                  checked={isForceUpdate}
+                  onChange={() => setForceUpdate(!isForceUpdate)}
+                />
+                <label className="custom-control-label" htmlFor="forceUpdateParents">
+                  {t('admin:user_group_management.update_parent_confirm_modal.force_update_parents_label')}
+                  <p className="form-text text-muted mt-0">{t('admin:user_group_management.update_parent_confirm_modal.force_update_parents_description')}</p>
+                </label>
+              </div>
+            </ModalBody>
+            <ModalFooter>
+              <button
+                type="button"
+                className="btn btn-warning"
+                onClick={() => {
+                  onConfirm?.(targetGroup, updateData, isForceUpdate);
+                  closeModal();
+                }}
+              >
+                {t('Confirm')}
+              </button>
+            </ModalFooter>
+          </>
+        ) : (
+          <>
+            <ModalBody>
+              <div>
+                <span className="text-error">Something went wrong. Please try again.</span>
+              </div>
+            </ModalBody>
+            <ModalFooter>
+              <button type="button" onClick={() => closeModal()} className="btn btn-sm btn-secondary">
+                {t('Cancel')}
+              </button>
+            </ModalFooter>
+          </>
+        )
+      }
+    </Modal>
+  );
+};
+
+export default UpdateParentConfirmModal;

+ 90 - 58
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -7,6 +7,7 @@ import UserGroupForm from '../UserGroup/UserGroupForm';
 import UserGroupTable from '../UserGroup/UserGroupTable';
 import UserGroupModal from '../UserGroup/UserGroupModal';
 import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
+import UpdateParentConfirmModal from './UpdateParentConfirmModal';
 import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
 import UserGroupUserTable from './UserGroupUserTable';
 import UserGroupUserModal from './UserGroupUserModal';
@@ -25,6 +26,7 @@ import {
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 import { useIsAclEnabled } from '~/stores/context';
+import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 
 const UserGroupDetailPage: FC = () => {
   const { t } = useTranslation();
@@ -33,7 +35,7 @@ const UserGroupDetailPage: FC = () => {
   /*
    * State (from AdminUserGroupDetailContainer)
    */
-  const [userGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
+  const [currentUserGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
   const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
   const [searchType, setSearchType] = useState<string>('partial');
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -46,9 +48,9 @@ const UserGroupDetailPage: FC = () => {
   /*
    * Fetch
    */
-  const { data: userGroupPages } = useSWRxUserGroupPages(userGroup._id, 10, 0);
+  const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroup._id, 10, 0);
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([userGroup._id], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([currentUserGroup._id], true);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
@@ -56,13 +58,15 @@ const UserGroupDetailPage: FC = () => {
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(userGroup._id);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(userGroup._id);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroup._id);
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroup._id);
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(userGroup._id);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroup._id);
 
   const { data: isAclEnabled } = useIsAclEnabled();
 
+  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
+
   /*
    * Function
    */
@@ -80,30 +84,66 @@ const UserGroupDetailPage: FC = () => {
     setSearchType(searchType);
   }, []);
 
-  const updateUserGroup = useCallback(async(UserGroupData: Partial<IUserGroup>) => {
-    try {
-      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);
+  const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
+    if (update.parent == null) {
+      throw Error('"parent" attr must not be null');
+    }
 
-      // mutate
-      mutateAncestorUserGroups();
-      mutateSelectableChildUserGroups();
-      mutateSelectableParentUserGroups();
+    const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
+    const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+      name: update.name,
+      description: update.description,
+      parentId,
+      forceUpdateParents,
+    });
+    const { userGroup: updatedUserGroup } = res.data;
+
+    setUserGroup(updatedUserGroup);
+
+    // mutate
+    mutateAncestorUserGroups();
+    mutateSelectableChildUserGroups();
+    mutateSelectableParentUserGroups();
+  }, [setUserGroup, mutateAncestorUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+
+  const onSubmitUpdateGroup = useCallback(
+    async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
+      try {
+        await updateUserGroup(targetGroup, userGroupData, forceUpdateParents);
+        toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+      }
+      catch {
+        toastError(t('toaster.update_failed', { target: t('UserGroup') }));
+      }
+    },
+    [t, updateUserGroup],
+  );
 
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
+    if (userGroupData?.parent === undefined || typeof userGroupData?.parent === 'string') {
+      toastError(t('Something went wrong. Please try again.'));
+      return;
     }
-    catch (err) {
-      toastError(err);
+
+    const prevParentId = typeof targetGroup.parent === 'string' ? targetGroup.parent : (targetGroup.parent?._id || null);
+    const newParentId = typeof userGroupData.parent?._id === 'string' ? userGroupData.parent?._id : null;
+
+    const shouldShowConfirmModal = prevParentId !== newParentId;
+
+    if (shouldShowConfirmModal) { // show confirm modal before submiting
+      await openUpdateParentConfirmModal(
+        targetGroup,
+        userGroupData,
+        onSubmitUpdateGroup,
+      );
     }
-  }, [t, userGroup._id, setUserGroup, mutateAncestorUserGroups]);
+    else { // directly submit
+      await onSubmitUpdateGroup(targetGroup, userGroupData, false);
+    }
+  }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
 
   const fetchApplicableUsers = useCallback(async(searchWord) => {
-    const res = await apiv3Get(`/user-groups/${userGroup._id}/unrelated-users`, {
+    const res = await apiv3Get(`/user-groups/${currentUserGroup._id}/unrelated-users`, {
       searchWord,
       searchType,
       isAlsoMailSearched,
@@ -117,14 +157,14 @@ const UserGroupDetailPage: FC = () => {
 
   // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
-    await apiv3Post(`/user-groups/${userGroup._id}/users/${username}`);
+    await apiv3Post(`/user-groups/${currentUserGroup._id}/users/${username}`);
     mutateUserGroupRelations();
-  }, [userGroup, mutateUserGroupRelations]);
+  }, [currentUserGroup, mutateUserGroupRelations]);
 
   const removeUserByUsername = useCallback(async(username: string) => {
-    await apiv3Delete(`/user-groups/${userGroup._id}/users/${username}`);
+    await apiv3Delete(`/user-groups/${currentUserGroup._id}/users/${username}`);
     mutateUserGroupRelations();
-  }, [userGroup, mutateUserGroupRelations]);
+  }, [currentUserGroup, mutateUserGroupRelations]);
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
@@ -156,26 +196,16 @@ const UserGroupDetailPage: FC = () => {
     }
   }, [t, mutateChildUserGroups, hideUpdateModal]);
 
-  const onClickAddChildButtonHandler = async(selectedUserGroup: IUserGroupHasId) => {
-    try {
-      await apiv3Put(`/user-groups/${selectedUserGroup._id}`, {
-        name: selectedUserGroup.name,
-        description: selectedUserGroup.description,
-        parentId: userGroup._id,
-        forceUpdateParents: false,
-      });
-
-      // mutate
-      mutateChildUserGroups();
-      mutateSelectableChildUserGroups();
-      mutateSelectableParentUserGroups();
-
-      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  };
+  const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
+    // show confirm modal before submiting
+    await openUpdateParentConfirmModal(
+      selectedChild,
+      {
+        parent: currentUserGroup._id,
+      },
+      onSubmitUpdateGroup,
+    );
+  }, [openUpdateParentConfirmModal, onSubmitUpdateGroup, currentUserGroup]);
 
   const showCreateModal = useCallback(() => {
     setCreateModalShown(true);
@@ -190,7 +220,7 @@ const UserGroupDetailPage: FC = () => {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         description: userGroupData.description,
-        parentId: userGroup._id,
+        parentId: currentUserGroup._id,
       });
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
@@ -205,7 +235,7 @@ const UserGroupDetailPage: FC = () => {
     catch (err) {
       toastError(err);
     }
-  }, [t, userGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
+  }, [t, currentUserGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
 
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
@@ -240,7 +270,7 @@ const UserGroupDetailPage: FC = () => {
   /*
    * Dependencies
    */
-  if (userGroup == null) {
+  if (currentUserGroup == null) {
     return <></>;
   }
 
@@ -252,8 +282,9 @@ const UserGroupDetailPage: FC = () => {
           {
             ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
               ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
-                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === userGroup._id ? 'active' : ''}`} aria-current="page">
-                  { ancestorUserGroup._id === userGroup._id ? (
+                // eslint-disable-next-line max-len
+                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroup._id ? 'active' : ''}`} aria-current="page">
+                  { ancestorUserGroup._id === currentUserGroup._id ? (
                     <>{ancestorUserGroup.name}</>
                   ) : (
                     <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
@@ -267,10 +298,10 @@ const UserGroupDetailPage: FC = () => {
 
       <div className="mt-4 form-box">
         <UserGroupForm
-          userGroup={userGroup}
+          userGroup={currentUserGroup}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
-          onSubmit={updateUserGroup}
+          onSubmit={onClickSubmitForm}
         />
       </div>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
@@ -280,8 +311,8 @@ const UserGroupDetailPage: FC = () => {
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
         selectableUserGroups={selectableChildUserGroups}
-        onClickAddExistingUserGroupButtonHandler={onClickAddChildButtonHandler}
-        onClickCreateUserGroupButtonHandler={showCreateModal}
+        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+        onClickCreateUserGroupButton={showCreateModal}
       />
 
       <UserGroupModal
@@ -299,6 +330,8 @@ const UserGroupDetailPage: FC = () => {
         onHide={hideCreateModal}
       />
 
+      <UpdateParentConfirmModal />
+
       <UserGroupTable
         userGroups={childUserGroups}
         childUserGroups={grandChildUserGroups}
@@ -313,7 +346,6 @@ const UserGroupDetailPage: FC = () => {
         deleteUserGroup={selectedUserGroup}
         onDelete={deleteChildUserGroupById}
         isShow={isDeleteModalShown}
-        onShow={showDeleteModal}
         onHide={hideDeleteModal}
       />
 

+ 1 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -23,6 +23,7 @@ class UserGroupUserModal extends React.Component {
           {t('admin:user_group_management.add_modal.add_user') }
         </ModalHeader>
         <ModalBody>
+          <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
           <div className="p-3">
             <UserGroupUserFormByInput />
           </div>

+ 3 - 3
packages/app/src/components/MyDraftList/Draft.jsx

@@ -63,16 +63,16 @@ class Draft extends React.Component {
     const interceptorManager = this.props.appContainer.interceptorManager;
     await interceptorManager.process('prePreProcess', context)
       .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown, context);
         context.parsedHTML = parsedHTML;
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => {

+ 5 - 2
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -13,6 +13,7 @@ import { IPageWithMeta } from '~/interfaces/page';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import SearchForm from '../SearchForm';
+import { useCurrentPagePath } from '~/stores/context';
 
 
 type Props = {
@@ -33,6 +34,8 @@ const GlobalSearch: FC<Props> = (props: Props) => {
   const [isScopeChildren, setScopeChildren] = useState<boolean>(appContainer.getConfig().isSearchScopeChildrenAsDefault);
   const [isFocused, setFocused] = useState<boolean>(false);
 
+  const { data: currentPagePath } = useCurrentPagePath();
+
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
 
@@ -51,12 +54,12 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     // construct search query
     let q = text;
     if (isScopeChildren) {
-      q += ` prefix:${window.location.pathname}`;
+      q += ` prefix:${currentPagePath ?? window.location.pathname}`;
     }
     url.searchParams.append('q', q);
 
     window.location.href = url.href;
-  }, [isScopeChildren, text]);
+  }, [currentPagePath, isScopeChildren, text]);
 
   const scopeLabel = isScopeChildren
     ? t('header_search_box.label.This tree')

+ 1 - 4
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -44,10 +44,7 @@ const PersonalDropdown = (props) => {
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
 
-    const context = {
-      user,
-      currentPagePath: decodeURIComponent(window.location.pathname),
-    };
+    const context = {};
     interceptorManager.process('logout', context);
 
     window.location.href = '/logout';

+ 8 - 5
packages/app/src/components/Page.jsx

@@ -24,7 +24,7 @@ import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 import { useIsSlackEnabled } from '~/stores/editor';
-import { useSlackChannels } from '~/stores/context';
+import { useCurrentPagePath, useSlackChannels } from '~/stores/context';
 
 const logger = loggerFactory('growi:Page');
 
@@ -143,7 +143,7 @@ class Page extends React.Component {
   }
 
   render() {
-    const { appContainer, pageContainer } = this.props;
+    const { appContainer, pageContainer, pagePath } = this.props;
     const { isMobile } = appContainer;
     const isLoggedIn = appContainer.currentUser != null;
     const { markdown, revisionId } = pageContainer.state;
@@ -152,7 +152,7 @@ class Page extends React.Component {
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
 
         { revisionId != null && (
-          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
+          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} pagePath={pagePath} />
         )}
 
         { isLoggedIn && (
@@ -170,11 +170,12 @@ class Page extends React.Component {
 }
 
 Page.propTypes = {
+  // TODO: remove this when omitting unstated is completed
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
-  // TODO: remove this when omitting unstated is completed
+  pagePath: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
@@ -184,6 +185,7 @@ Page.propTypes = {
 };
 
 const PageWrapper = (props) => {
+  const { data: currentPagePath } = useCurrentPagePath();
   const { data: editorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannels } = useSlackChannels();
@@ -191,13 +193,14 @@ const PageWrapper = (props) => {
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
 
-  if (editorMode == null) {
+  if (currentPagePath == null || editorMode == null) {
     return null;
   }
 
   return (
     <Page
       {...props}
+      pagePath={currentPagePath}
       editorMode={editorMode}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannels}

+ 14 - 6
packages/app/src/components/Page/RevisionLoader.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
 import { Waypoint } from 'react-waypoint';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -20,7 +21,7 @@ class LegacyRevisionLoader extends React.Component {
     this.logger = loggerFactory('growi:Page:RevisionLoader');
 
     this.state = {
-      markdown: '',
+      markdown: null,
       isLoading: false,
       isLoaded: false,
       errors: null,
@@ -49,7 +50,7 @@ class LegacyRevisionLoader extends React.Component {
       const res = await this.props.appContainer.apiv3Get(`/revisions/${revisionId}`, { pageId });
 
       this.setState({
-        markdown: res.data.revision.body,
+        markdown: res.data?.revision?.body,
         errors: null,
       });
 
@@ -94,18 +95,23 @@ class LegacyRevisionLoader extends React.Component {
     }
 
     // ----- after load -----
+    const isForbidden = this.state.errors != null && this.state.errors[0].code === 'forbidden-page';
     let markdown = this.state.markdown;
-    if (this.state.errors != null) {
+    if (isForbidden) {
+      markdown = `<i class="icon-exclamation p-1"></i>${this.props.t('not_allowed_to_see_this_page')}`;
+    }
+    else if (this.state.errors != null) {
       const errorMessages = this.state.errors.map((error) => {
-        return `<span class="text-muted"><em>${error.message}</em></span>`;
+        return `<i class="icon-exclamation p-1"></i><span class="text-muted"><em>${error.message}</em></span>`;
       });
-      markdown = errorMessages.join('');
+      markdown = errorMessages.join('\n');
     }
 
     return (
       <RevisionRenderer
         growiRenderer={this.props.growiRenderer}
         markdown={markdown}
+        pagePath={this.props.pagePath}
         highlightKeywords={this.props.highlightKeywords}
       />
     );
@@ -116,13 +122,15 @@ class LegacyRevisionLoader extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const LegacyRevisionLoaderWrapper = withUnstatedContainers(LegacyRevisionLoader, [AppContainer]);
+const LegacyRevisionLoaderWrapper = withTranslation()(withUnstatedContainers(LegacyRevisionLoader, [AppContainer]));
 
 LegacyRevisionLoader.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  t: PropTypes.func.isRequired,
 
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageId: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   onRevisionLoaded: PropTypes.func,

+ 7 - 4
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -28,7 +28,8 @@ class LegacyRevisionRenderer extends React.PureComponent {
   initCurrentRenderingContext() {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
-      currentPagePath: decodeURIComponent(window.location.pathname),
+      pagePath: this.props.pagePath,
+      currentPathname: decodeURIComponent(window.location.pathname),
     };
   }
 
@@ -133,11 +134,11 @@ class LegacyRevisionRenderer extends React.PureComponent {
 
     await interceptorManager.process('preRender', context);
     await interceptorManager.process('prePreProcess', context);
-    context.markdown = growiRenderer.preProcess(context.markdown);
+    context.markdown = growiRenderer.preProcess(context.markdown, context);
     await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = growiRenderer.process(context.markdown);
+    context.parsedHTML = growiRenderer.process(context.markdown, context);
     await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
 
     const isMarkdownEmpty = context.markdown.trim().length === 0;
     if (highlightKeywords != null && highlightKeywords.length > 0 && !isMarkdownEmpty) {
@@ -169,6 +170,7 @@ LegacyRevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
 };
@@ -187,6 +189,7 @@ const RevisionRenderer = (props) => {
 RevisionRenderer.propTypes = {
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.arrayOf(PropTypes.string),
   additionalClassName: PropTypes.string,
 };

+ 0 - 110
packages/app/src/components/Page/TagsInput.jsx

@@ -1,110 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class TagsInput
- * @extends {React.Component}
- */
-
-class TagsInput extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      resultTags: [],
-      isLoading: false,
-      selected: this.props.tags,
-      defaultPageTags: this.props.tags,
-    };
-
-    this.handleChange = this.handleChange.bind(this);
-    this.handleSearch = this.handleSearch.bind(this);
-    this.handleSelect = this.handleSelect.bind(this);
-  }
-
-  componentDidMount() {
-    this.typeahead.getInstance().focus();
-  }
-
-  handleChange(selected) {
-    // send tags to TagLabel Component when user add tag to form everytime
-    this.setState({ selected }, () => {
-      this.props.onTagsUpdated(this.state.selected);
-    });
-  }
-
-  async handleSearch(query) {
-    this.setState({ isLoading: true });
-    const res = await this.props.appContainer.apiGet('/tags.search', { q: query });
-    res.tags.unshift(query); // selectable new tag whose name equals query
-    this.setState({
-      resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
-      isLoading: false,
-    });
-  }
-
-  handleSelect(e) {
-    if (e.keyCode === 32) { // '32' means ASCII code of 'space'
-      e.preventDefault();
-      const instance = this.typeahead.getInstance();
-      const { initialItem } = instance.state;
-
-      if (initialItem) {
-        instance._handleMenuItemSelect(initialItem, e);
-      }
-    }
-  }
-
-  render() {
-    return (
-      <div className="tag-typeahead">
-        <AsyncTypeahead
-          id="tag-typeahead-asynctypeahead"
-          ref={(typeahead) => { this.typeahead = typeahead }}
-          caseSensitive={false}
-          defaultSelected={this.state.defaultPageTags}
-          isLoading={this.state.isLoading}
-          minLength={1}
-          multiple
-          newSelectionPrefix=""
-          onChange={this.handleChange}
-          onSearch={this.handleSearch}
-          onKeyDown={this.handleSelect}
-          options={this.state.resultTags} // Search result (Some tag names)
-          placeholder="tag name"
-          selectHintOnEnter
-          autoFocus={this.props.autoFocus}
-        />
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const TagsInputWrapper = withUnstatedContainers(TagsInput, [AppContainer]);
-
-TagsInput.propTypes = {
-  appContainer:  PropTypes.instanceOf(AppContainer).isRequired,
-
-  tags:          PropTypes.array.isRequired,
-  onTagsUpdated: PropTypes.func.isRequired,
-  autoFocus:     PropTypes.bool,
-};
-
-TagsInput.defaultProps = {
-  autoFocus:     false,
-};
-
-export default TagsInputWrapper;

+ 86 - 0
packages/app/src/components/Page/TagsInput.tsx

@@ -0,0 +1,86 @@
+import React, {
+  FC, useRef, useState, useCallback,
+} from 'react';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+
+import { apiGet } from '~/client/util/apiv1-client';
+import { toastError } from '~/client/util/apiNotification';
+import { ITagsSearchApiv1Result } from '~/interfaces/tag';
+
+type TypeaheadInstance = {
+  _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
+  state: {
+    initialItem: string,
+  },
+}
+
+type Props = {
+  tags: string[],
+  autoFocus: boolean,
+  onTagsUpdated: (tags: string[]) => void,
+}
+
+const TagsInput: FC<Props> = (props: Props) => {
+  const tagsInputRef = useRef<TypeaheadInstance>(null);
+
+  const [resultTags, setResultTags] = useState<string[]>([]);
+  const [isLoading, setLoading] = useState(false);
+
+  const changeHandler = useCallback((selected: string[]) => {
+    if (props.onTagsUpdated != null) {
+      props.onTagsUpdated(selected);
+    }
+  }, [props]);
+
+  const searchHandler = useCallback(async(query: string) => {
+    setLoading(true);
+    try {
+      // TODO: 91698 SWRize
+      const res = await apiGet('/tags.search', { q: query }) as ITagsSearchApiv1Result;
+      res.tags.unshift(query);
+      setResultTags(Array.from(new Set(res.tags)));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
+    if (event.key === ' ') {
+      event.preventDefault();
+
+      const initialItem = tagsInputRef?.current?.state?.initialItem;
+      const handleMenuItemSelect = tagsInputRef?.current?._handleMenuItemSelect;
+
+      if (initialItem != null && handleMenuItemSelect != null) {
+        handleMenuItemSelect(initialItem, event);
+      }
+    }
+  }, []);
+
+  return (
+    <div className="tag-typeahead">
+      <AsyncTypeahead
+        id="tag-typeahead-asynctypeahead"
+        ref={tagsInputRef}
+        caseSensitive={false}
+        defaultSelected={props.tags ?? []}
+        isLoading={isLoading}
+        minLength={1}
+        multiple
+        newSelectionPrefix=""
+        onChange={changeHandler}
+        onSearch={searchHandler}
+        onKeyDown={keyDownHandler}
+        options={resultTags} // Search result (Some tag names)
+        placeholder="tag name"
+        autoFocus={props.autoFocus}
+      />
+    </div>
+  );
+};
+
+export default TagsInput;

+ 3 - 3
packages/app/src/components/PageComment/Comment.jsx

@@ -135,11 +135,11 @@ class Comment extends React.PureComponent {
 
     await interceptorManager.process('preRenderComment', context);
     await interceptorManager.process('prePreProcess', context);
-    context.markdown = await growiRenderer.preProcess(context.markdown);
+    context.markdown = await growiRenderer.preProcess(context.markdown, context);
     await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = await growiRenderer.process(context.markdown);
+    context.parsedHTML = await growiRenderer.process(context.markdown, context);
     await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML);
+    context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML, context);
     await interceptorManager.process('postPostProcess', context);
     await interceptorManager.process('preRenderCommentHtml', context);
     this.setState({ html: context.parsedHTML });

+ 3 - 3
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -231,16 +231,16 @@ class CommentEditor extends React.Component {
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown, context);
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown, context);
         context.parsedHTML = parsedHTML;
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })

+ 9 - 2
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -38,6 +38,7 @@ class LinkEditModal extends React.PureComponent {
       labelInputValue: '',
       linkerType: Linker.types.markdownLink,
       markdown: null,
+      pagePath: null,
       previewError: '',
       permalink: '',
       isPreviewOpen: false,
@@ -153,6 +154,7 @@ class LinkEditModal extends React.PureComponent {
     const { t } = this.props;
     const path = this.state.linkInputValue;
     let markdown = null;
+    let pagePath = null;
     let permalink = '';
     let previewError = '';
 
@@ -165,6 +167,7 @@ class LinkEditModal extends React.PureComponent {
         const { data } = await this.props.appContainer.apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
         const { page } = data;
         markdown = page.revision.body;
+        pagePath = page.path;
         permalink = page.id;
       }
       catch (err) {
@@ -174,7 +177,9 @@ class LinkEditModal extends React.PureComponent {
     else {
       previewError = t('link_edit.page_not_found_in_preview', { path });
     }
-    this.setState({ markdown, previewError, permalink });
+    this.setState({
+      markdown, pagePath, previewError, permalink,
+    });
   }
 
   renderLinkPreview() {
@@ -278,6 +283,8 @@ class LinkEditModal extends React.PureComponent {
 
   renderLinkAndLabelForm() {
     const { t } = this.props;
+    const { pagePath } = this.state;
+
     return (
       <>
         <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
@@ -301,7 +308,7 @@ class LinkEditModal extends React.PureComponent {
                 </button>
                 <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
                   <PopoverBody>
-                    <PreviewWithSuspense setMarkdown={this.setMarkdown} markdown={this.state.markdown} error={this.state.previewError} />
+                    <PreviewWithSuspense setMarkdown={this.setMarkdown} markdown={this.state.markdown} pagePath={pagePath} error={this.state.previewError} />
                   </PopoverBody>
                 </Popover>
               </div>

+ 0 - 123
packages/app/src/components/PageEditor/Preview.jsx

@@ -1,123 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Subscribe } from 'unstated';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import RevisionBody from '../Page/RevisionBody';
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-
-/**
- * Wrapper component for Page/RevisionBody
- */
-class Preview extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      html: '',
-    };
-
-    // get renderer
-    this.growiRenderer = props.appContainer.getRenderer('editor');
-  }
-
-  componentDidMount() {
-    this.initCurrentRenderingContext();
-    this.renderPreview();
-  }
-
-  componentDidUpdate(prevProps) {
-    const { markdown: prevMarkdown } = prevProps;
-    const { markdown } = this.props;
-
-    // render only when props.markdown is updated
-    if (markdown !== prevMarkdown) {
-      this.initCurrentRenderingContext();
-      this.renderPreview();
-      return;
-    }
-
-    const { interceptorManager } = this.props.appContainer;
-
-    interceptorManager.process('postRenderPreviewHtml', this.currentRenderingContext);
-  }
-
-  initCurrentRenderingContext() {
-    this.currentRenderingContext = {
-      markdown: this.props.markdown,
-      currentPagePath: decodeURIComponent(window.location.pathname),
-    };
-  }
-
-  async renderPreview() {
-    const { appContainer } = this.props;
-    const { growiRenderer } = this;
-
-    const { interceptorManager } = appContainer;
-    const context = this.currentRenderingContext;
-
-    await interceptorManager.process('preRenderPreview', context);
-    await interceptorManager.process('prePreProcess', context);
-    context.markdown = growiRenderer.preProcess(context.markdown);
-    await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = growiRenderer.process(context.markdown);
-    await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-    await interceptorManager.process('postPostProcess', context);
-    await interceptorManager.process('preRenderPreviewHtml', context);
-
-    this.setState({ html: context.parsedHTML });
-  }
-
-  render() {
-    return (
-      <Subscribe to={[EditorContainer]}>
-        { editorContainer => (
-          // eslint-disable-next-line arrow-body-style
-          <div
-            className="page-editor-preview-body"
-            ref={(elm) => {
-              this.previewElement = elm;
-              if (this.props.inputRef != null) {
-                this.props.inputRef(elm);
-              }
-            }}
-            onScroll={(event) => {
-              if (this.props.onScroll != null) {
-                this.props.onScroll(event.target.scrollTop);
-              }
-            }}
-          >
-            <RevisionBody
-              {...this.props}
-              html={this.state.html}
-              renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
-            />
-          </div>
-        )}
-      </Subscribe>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
-
-Preview.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  markdown: PropTypes.string,
-  inputRef: PropTypes.func,
-  isMathJaxEnabled: PropTypes.bool,
-  renderMathJaxOnInit: PropTypes.bool,
-  onScroll: PropTypes.func,
-};
-
-export default PreviewWrapper;

+ 112 - 0
packages/app/src/components/PageEditor/Preview.tsx

@@ -0,0 +1,112 @@
+import React, {
+  UIEventHandler, useCallback, useEffect, useMemo, useState,
+} from 'react';
+
+import { Subscribe } from 'unstated';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import RevisionBody from '../Page/RevisionBody';
+
+import AppContainer from '~/client/services/AppContainer';
+import EditorContainer from '~/client/services/EditorContainer';
+
+
+type Props = {
+  appContainer: AppContainer,
+  editorContainer: EditorContainer,
+
+  markdown?: string,
+  pagePath?: string,
+  inputRef?: React.RefObject<HTMLDivElement>,
+  isMathJaxEnabled?: boolean,
+  renderMathJaxOnInit?: boolean,
+  onScroll?: UIEventHandler<HTMLDivElement>,
+}
+
+
+const Preview = (props: Props): JSX.Element => {
+
+  const {
+    appContainer,
+    markdown, pagePath,
+    inputRef,
+    onScroll,
+  } = props;
+
+  const [html, setHtml] = useState('');
+
+  const { interceptorManager } = appContainer;
+  const growiRenderer = props.appContainer.getRenderer('editor');
+
+  const context = useMemo(() => {
+    return {
+      markdown,
+      pagePath,
+      currentPathname: decodeURIComponent(window.location.pathname),
+      parsedHTML: null,
+    };
+  }, [markdown, pagePath]);
+
+  const renderPreview = useCallback(async() => {
+    if (interceptorManager != null) {
+      await interceptorManager.process('preRenderPreview', context);
+      await interceptorManager.process('prePreProcess', context);
+      context.markdown = growiRenderer.preProcess(context.markdown, context);
+      await interceptorManager.process('postPreProcess', context);
+      context.parsedHTML = growiRenderer.process(context.markdown, context);
+      await interceptorManager.process('prePostProcess', context);
+      context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+      await interceptorManager.process('postPostProcess', context);
+      await interceptorManager.process('preRenderPreviewHtml', context);
+    }
+
+    setHtml(context.parsedHTML ?? '');
+  }, [interceptorManager, context, growiRenderer]);
+
+  useEffect(() => {
+    if (markdown == null) {
+      setHtml('');
+    }
+
+    renderPreview();
+  }, [markdown, renderPreview]);
+
+  useEffect(() => {
+    if (html == null) {
+      return;
+    }
+
+    if (interceptorManager != null) {
+      interceptorManager.process('postRenderPreviewHtml', {
+        ...context,
+        parsedHTML: html,
+      });
+    }
+  }, [context, html, interceptorManager]);
+
+  return (
+    <Subscribe to={[EditorContainer]}>
+      { editorContainer => (
+        <div
+          className="page-editor-preview-body"
+          ref={inputRef}
+          onScroll={onScroll}
+        >
+          <RevisionBody
+            {...props}
+            html={html}
+            renderMathJaxInRealtime={editorContainer.state.previewOptions.renderMathJaxInRealtime}
+          />
+        </div>
+      ) }
+    </Subscribe>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
+
+export default PreviewWrapper;

+ 3 - 2
packages/app/src/components/PageEditor/PreviewWithSuspense.jsx

@@ -5,7 +5,7 @@ import Preview from './Preview';
 import { withLoadingSppiner } from '../SuspenseUtils';
 
 function PagePreview(props) {
-  if (props.markdown == null) {
+  if (props.markdown == null || props.pagePath == null) {
     if (props.error !== '') {
       return props.error;
     }
@@ -16,7 +16,7 @@ function PagePreview(props) {
 
   return (
     <div className="linkedit-preview">
-      <Preview markdown={props.markdown} />
+      <Preview markdown={props.markdown} pagePath={props.pagePath} />
     </div>
   );
 }
@@ -24,6 +24,7 @@ function PagePreview(props) {
 PagePreview.propTypes = {
   setMarkdown: PropTypes.func,
   markdown: PropTypes.string,
+  pagePath: PropTypes.string,
   error: PropTypes.string,
 };
 

+ 16 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -3,6 +3,7 @@ import React, {
   ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
 } from 'react';
 
+import { useTranslation } from 'react-i18next';
 import { CustomInput } from 'reactstrap';
 
 import Clamp from 'react-multiline-clamp';
@@ -55,6 +56,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
+  const { t } = useTranslation();
+
   const inputRef = useRef<HTMLInputElement>(null);
 
   // publish ISelectable methods
@@ -139,7 +142,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   // background color of list item changes when class "active" exists under 'list-group-item'
   const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
 
-  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath.length > 0;
+  const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
 
   let likerCount;
   if (isSelected && isIPageInfoForEntity(pageInfo)) {
@@ -157,6 +160,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     bookmarkCount = pageMeta?.bookmarkCount;
   }
 
+  const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
+  const canRenderRevisionSnippet = revisionShortBody != null;
+
   return (
     <li
       key={pageData._id}
@@ -239,13 +245,21 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
-                { elasticSearchResult != null && elasticSearchResult?.snippet.length > 0 && (
+                { elasticSearchResult != null && elasticSearchResult.snippet != null && (
                   // eslint-disable-next-line react/no-danger
                   <div dangerouslySetInnerHTML={{ __html: elasticSearchResult.snippet }}></div>
                 ) }
                 { revisionShortBody != null && (
                   <div>{revisionShortBody}</div>
                 ) }
+                {
+                  !canRenderESSnippet && !canRenderRevisionSnippet && (
+                    <>
+                      <i className="icon-exclamation p-1"></i>
+                      {t('not_allowed_to_see_this_page')}
+                    </>
+                  )
+                }
               </Clamp>
             </div>
           </div>

+ 1 - 0
packages/app/src/components/PageTimeline.jsx

@@ -84,6 +84,7 @@ class PageTimeline extends React.Component {
                     lazy
                     growiRenderer={this.growiRenderer}
                     pageId={page._id}
+                    pagePath={page.path}
                     revisionId={page.revision}
                   />
                 </div>

+ 1 - 1
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -37,7 +37,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
   const { t } = useTranslation();
 
   const pageIdsWithNoSnippet = pages
-    .filter(page => (page.meta?.elasticSearchResult?.snippet.length ?? 0) === 0)
+    .filter(page => (page.meta?.elasticSearchResult?.snippet?.length ?? 0) === 0)
     .map(page => page.data._id);
 
   const { data: isGuestUser } = useIsGuestUser();

+ 1 - 2
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -181,8 +181,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                   <div className="page-list px-md-4">
                     <SearchResultList
                       ref={searchResultListRef}
-                      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                      pages={pages!}
+                      pages={pages}
                       selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
                       onPageSelected={page => setSelectedPageWithMeta(page)}

+ 1 - 0
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -62,6 +62,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
             <RevisionRenderer
               growiRenderer={renderer}
               markdown={markdown}
+              pagePath="/Sidebar"
               additionalClassName="grw-custom-sidebar-content"
             />
           </div>

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

@@ -101,7 +101,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
     <>
-      <span className="grw-pagetree-count px-0 badge badge-pill badge-light">
+      <span className="grw-pagetree-count px-2 badge badge-pill badge-light">
         {props.descendantCount}
       </span>
     </>

+ 2 - 2
packages/app/src/interfaces/search.ts

@@ -3,8 +3,8 @@ import { IPageWithMeta } from './page';
 export type IPageSearchMeta = {
   bookmarkCount?: number,
   elasticSearchResult?: {
-    snippet: string;
-    highlightedPath: string;
+    snippet?: string | null;
+    highlightedPath?: string | null;
     isHtmlInPath: boolean;
   };
 }

+ 5 - 0
packages/app/src/interfaces/tag.ts

@@ -2,3 +2,8 @@ export type ITag = {
   name: string,
   createdAt: Date;
 }
+
+export type ITagsSearchApiv1Result = {
+  ok: boolean,
+  tags: string[]
+}

+ 1 - 1
packages/app/src/interfaces/user.ts

@@ -20,7 +20,7 @@ export type IUserGroup = {
   name: string;
   createdAt: Date;
   description: string;
-  parent: Ref<IUserGroup> | null;
+  parent: Ref<IUserGroupHasId> | null;
 }
 
 export type IUserHasId = IUser & HasObjectId;

+ 13 - 1
packages/app/src/interfaces/websocket.ts

@@ -1,5 +1,12 @@
 export const SocketEventName = {
-  UpdateDescCount: 'UpdateDsecCount',
+  // Update descendantCount
+  UpdateDescCount: 'UpdateDescCount',
+
+  // Public migration
+  PMStarted: 'PublicMigrationStarted',
+  PMMigrating: 'PublicMigrationMigrating',
+  PMErrorCount: 'PublicMigrationErrorCount',
+  PMEnded: 'PublicMigrationEnded',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
@@ -10,3 +17,8 @@ type DescendantCount = number;
  */
 export type UpdateDescCountRawData = Record<PageId, DescendantCount>;
 export type UpdateDescCountData = Map<PageId, DescendantCount>;
+
+export type PMStartedData = { total: number };
+export type PMMigratingData = { count: number };
+export type PMErrorCountData = { skip: number };
+export type PMEndedData = { isSucceeded: boolean };

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

@@ -47,7 +47,7 @@ module.exports = (crowi) => {
       body('parentId', 'ParentId must be a string').optional().isString(),
     ],
     update: [
-      body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+      body('name', 'Group name must be a string').optional().trim().isString(),
       body('description', 'Group description must be a string').optional().isString(),
       body('parentId', 'parentId must be a string').optional().isString(),
       body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),

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

@@ -156,7 +156,7 @@ module.exports = function(crowi, app) {
 
     let result;
     try {
-      result = await searchService.formatSearchResult(searchResult, delegatorName);
+      result = await searchService.formatSearchResult(searchResult, delegatorName, user, userGroups);
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 19 - 6
packages/app/src/server/service/page.ts

@@ -2557,13 +2557,14 @@ class PageService {
     return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user);
   }
 
-  // TODO: use websocket to show progress
   private async _normalizeParentRecursively(
-      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user,
+      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
   ): Promise<void> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
+    const socket = this.crowi.socketIoService.getAdminSocket();
+
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
 
@@ -2617,6 +2618,9 @@ class PageService {
 
     // Limit pages to get
     const total = await Page.countDocuments(mergedFilter);
+    if (isFirst) {
+      socket.emit(SocketEventName.PMStarted, { total });
+    }
     if (total > PAGES_LIMIT) {
       baseAggregation = baseAggregation.limit(Math.floor(total * 0.3));
     }
@@ -2624,8 +2628,9 @@ class PageService {
     const pagesStream = await baseAggregation.cursor({ batchSize: BATCH_SIZE });
     const batchStream = createBatchStream(BATCH_SIZE);
 
-    let countPages = 0;
     let shouldContinue = true;
+    let nextCount = count;
+    let nextSkiped = skiped;
 
     const migratePagesStream = new Writable({
       objectMode: true,
@@ -2710,12 +2715,17 @@ class PageService {
         try {
           const res = await Page.bulkWrite(updateManyOperations);
 
-          countPages += res.result.nModified;
-          logger.info(`Page migration processing: (count=${countPages})`);
+          nextCount += res.result.nModified;
+          nextSkiped += res.result.writeErrors.length;
+          logger.info(`Page migration processing: (migratedPages=${res.result.nModified})`);
+
+          socket.emit(SocketEventName.PMMigrating, { count: nextCount });
+          socket.emit(SocketEventName.PMErrorCount, { skip: nextSkiped });
 
           // Throw if any error is found
           if (res.result.writeErrors.length > 0) {
             logger.error('Failed to migrate some pages', res.result.writeErrors);
+            socket.emit(SocketEventName.PMEnded, { isSucceeded: false });
             throw Error('Failed to migrate some pages');
           }
 
@@ -2723,6 +2733,7 @@ class PageService {
           if (res.result.nModified === 0 && res.result.nMatched === 0) {
             shouldContinue = false;
             logger.error('Migration is unable to continue', 'parentPaths:', parentPaths, 'bulkWriteResult:', res);
+            socket.emit(SocketEventName.PMEnded, { isSucceeded: false });
           }
         }
         catch (err) {
@@ -2744,9 +2755,11 @@ class PageService {
     await streamToPromise(migratePagesStream);
 
     if (await Page.exists(mergedFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user);
+      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, nextCount, nextSkiped, false);
     }
 
+    // End
+    socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
   }
 
   private async _v5NormalizeIndex() {

+ 28 - 9
packages/app/src/server/service/search.ts

@@ -1,4 +1,5 @@
 import xss from 'xss';
+import mongoose from 'mongoose';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { IPageWithMeta } from '~/interfaces/page';
@@ -351,7 +352,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
   /**
    * formatting result
    */
-  async formatSearchResult(searchResult: ISearchResult<any>, delegatorName): Promise<IFormattedSearchResult> {
+  async formatSearchResult(searchResult: ISearchResult<any>, delegatorName: SearchDelegatorName, user, userGroups): Promise<IFormattedSearchResult> {
     if (!this.checkIsFormattable(searchResult, delegatorName)) {
       const data: IPageWithMeta<IPageSearchMeta>[] = searchResult.data.map((page) => {
         return {
@@ -398,21 +399,17 @@ class SearchService implements SearchQueryParser, SearchResolver {
         pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
       }
 
-      // const data = searchResult.data.find((data) => {
-      //   return pageData.id === data._id;
-      // });
-
       // increment elasticSearchResult
       let elasticSearchResult;
       const highlightData = data._highlight;
       if (highlightData != null) {
-        const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
-        const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+        const snippet = this.canShowSnippet(pageData, user, userGroups) ? highlightData['body.en'] || highlightData['body.ja'] : null;
+        const pathMatch = highlightData['path.en'] || highlightData['path.ja'];
         const isHtmlInPath = highlightData['path.en'] != null || highlightData['path.ja'] != null;
 
         elasticSearchResult = {
-          snippet: filterXss.process(snippet),
-          highlightedPath: filterXss.process(pathMatch),
+          snippet: typeof snippet === 'string' ? filterXss.process(snippet) : null,
+          highlightedPath: typeof pathMatch === 'string' ? filterXss.process(pathMatch) : null,
           isHtmlInPath,
         };
       }
@@ -430,6 +427,28 @@ class SearchService implements SearchQueryParser, SearchResolver {
     return result;
   }
 
+  canShowSnippet(pageData, user, userGroups): boolean {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const testGrant = pageData.grant;
+    const testGrantedUser = pageData.grantedUsers?.[0];
+    const testGrantedGroup = pageData.grantedGroup;
+
+    if (testGrant === Page.GRANT_RESTRICTED) {
+      return false;
+    }
+
+    if (testGrant === Page.GRANT_OWNER) {
+      return user._id.toString() === testGrantedUser.toString();
+    }
+
+    if (testGrant === Page.GRANT_USER_GROUP) {
+      return userGroups.map(d => d.toString()).include(testGrantedGroup.toString());
+    }
+
+    return true;
+  }
+
 }
 
 export default SearchService;

+ 16 - 3
packages/app/src/server/service/user-group.ts

@@ -27,7 +27,7 @@ class UserGroupService {
 
   // TODO 85062: write test code
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
-  async updateGroup(id, name: string, description: string, parentId?: string, forceUpdateParents = false) {
+  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false) {
     const userGroup = await UserGroup.findById(id);
     if (userGroup == null) {
       throw new Error('The group does not exist');
@@ -39,19 +39,32 @@ class UserGroupService {
       throw new Error('The group name is already taken');
     }
 
-    userGroup.name = name;
-    userGroup.description = description;
+    if (name != null) {
+      userGroup.name = name;
+    }
+    if (description != null) {
+      userGroup.description = description;
+    }
 
     // return when not update parent
     if (userGroup.parent === parentId) {
       return userGroup.save();
     }
+
+    /*
+     * Update parent
+     */
+    if (parentId === undefined) { // undefined will be ignored
+      return userGroup.save();
+    }
+
     // set parent to null and return when parentId is null
     if (parentId == null) {
       userGroup.parent = null;
       return userGroup.save();
     }
 
+
     const parent = await UserGroup.findById(parentId);
 
     if (parent == null) { // it should not be null

+ 34 - 0
packages/app/src/stores/modal.tsx

@@ -4,6 +4,7 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
+import { IUserGroupHasId } from '~/interfaces/user';
 
 
 /*
@@ -330,3 +331,36 @@ export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatu
     },
   };
 };
+
+/*
+ * UpdateUserGroupConfirmModal
+ */
+type UpdateUserGroupConfirmModalStatus = {
+  isOpened: boolean,
+  targetGroup?: IUserGroupHasId,
+  updateData?: Partial<IUserGroupHasId>,
+  onConfirm?: (targetGroup: IUserGroupHasId, updateData: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => any,
+}
+
+type UpdateUserGroupConfirmModalUtils = {
+  open(targetGroup: IUserGroupHasId, updateData: Partial<IUserGroupHasId>, onConfirm?: (...args: any[]) => any): Promise<void>,
+  close(): Promise<void>,
+}
+
+export const useUpdateUserGroupConfirmModal = (): SWRResponse<UpdateUserGroupConfirmModalStatus, Error> & UpdateUserGroupConfirmModalUtils => {
+
+  const initialStatus: UpdateUserGroupConfirmModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<UpdateUserGroupConfirmModalStatus, Error>('updateParentConfirmModal', undefined, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    async open(targetGroup: IUserGroupHasId, updateData: Partial<IUserGroupHasId>, onConfirm?: (...args: any[]) => any) {
+      await swrResponse.mutate({
+        isOpened: true, targetGroup, updateData, onConfirm,
+      });
+    },
+    async close() {
+      await swrResponse.mutate({ isOpened: false });
+    },
+  };
+};

+ 10 - 7
packages/app/src/stores/ui.tsx

@@ -68,28 +68,28 @@ export const useIsMobile = (): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>(key, undefined, configuration);
 };
 
-const updateBodyClassesByEditorMode = (newEditorMode: EditorMode) => {
+const updateBodyClassesByEditorMode = (newEditorMode: EditorMode, isSidebar = false) => {
   switch (newEditorMode) {
     case EditorMode.View:
       $('body').removeClass('on-edit');
       $('body').removeClass('builtin-editor');
       $('body').removeClass('hackmd');
-      $('body').removeClass('pathname-sidebar');
+      $('body').removeClass('editing-sidebar');
       break;
     case EditorMode.Editor:
       $('body').addClass('on-edit');
       $('body').addClass('builtin-editor');
       $('body').removeClass('hackmd');
       // editing /Sidebar
-      if (window.location.pathname === '/Sidebar') {
-        $('body').addClass('pathname-sidebar');
+      if (isSidebar) {
+        $('body').addClass('editing-sidebar');
       }
       break;
     case EditorMode.HackMD:
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').removeClass('builtin-editor');
-      $('body').removeClass('pathname-sidebar');
+      $('body').removeClass('editing-sidebar');
       break;
   }
 };
@@ -133,6 +133,9 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   const isEditable = !isLoading && _isEditable;
   const initialData = isEditable ? editorModeByHash : EditorMode.View;
 
+  const { data: currentPagePath } = useCurrentPagePath();
+  const isSidebar = currentPagePath === '/Sidebar';
+
   const swrResponse = useSWRImmutable(
     isLoading ? null : ['editorMode', isEditable],
     null,
@@ -142,7 +145,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
   // initial updating
   if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
     if (isEditable) {
-      updateBodyClassesByEditorMode(swrResponse.data);
+      updateBodyClassesByEditorMode(swrResponse.data, isSidebar);
     }
     isEditorModeLoaded = true;
   }
@@ -155,7 +158,7 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
       if (!isEditable) {
         return Promise.resolve(EditorMode.View); // fixed if not editable
       }
-      updateBodyClassesByEditorMode(editorMode);
+      updateBodyClassesByEditorMode(editorMode, isSidebar);
       updateHashByEditorMode(editorMode);
       return swrResponse.mutate(editorMode, shouldRevalidate);
     },

+ 24 - 0
packages/app/src/stores/websocket.tsx

@@ -9,6 +9,12 @@ const logger = loggerFactory('growi:stores:ui');
 export const GLOBAL_SOCKET_NS = '/';
 export const GLOBAL_SOCKET_KEY = 'globalSocket';
 
+export const GLOBAL_ADMIN_SOCKET_NS = '/admin';
+export const GLOBAL_ADMIN_SOCKET_KEY = 'globalAdminSocket';
+
+/*
+ * Global Socket
+ */
 export const useSetupGlobalSocket = (): SWRResponse<Socket, Error> => {
   const socket = io(GLOBAL_SOCKET_NS, {
     transports: ['websocket'],
@@ -23,3 +29,21 @@ export const useSetupGlobalSocket = (): SWRResponse<Socket, Error> => {
 export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
   return useStaticSWR(GLOBAL_SOCKET_KEY);
 };
+
+/*
+ * Global Admin Socket
+ */
+export const useSetupGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
+  const socket = io(GLOBAL_ADMIN_SOCKET_NS, {
+    transports: ['websocket'],
+  });
+
+  socket.on('error', (err) => { logger.error(err) });
+  socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+
+  return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY, socket);
+};
+
+export const useGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
+  return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY);
+};

+ 1 - 1
packages/app/src/styles/_on-edit.scss

@@ -246,7 +246,7 @@ body.on-edit {
   // .builtin-editor .tab-pane#edit
 
   // editing /Sidebar
-  &.pathname-sidebar {
+  &.editing-sidebar {
     .page-editor-preview-body {
       width: 320px;
       padding-top: 0;

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

@@ -50,7 +50,7 @@ $grw-pagetree-item-padding-left: 10px;
       }
 
       .grw-pagetree-count {
-        width: 26px;
+        width: auto;
         padding: 0.1rem 0;
         font-size: 12px;
       }

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

@@ -538,7 +538,7 @@ body.on-edit {
 /*
  * Preview for editing /Sidebar
  */
-body.pathname-sidebar {
+body.editing-sidebar {
   .page-editor-preview-body {
     color: $color-sidebar-context;
     background-color: $bgcolor-sidebar-context;

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js

@@ -42,7 +42,7 @@ export default class RefsPostRenderInterceptor extends BasicInterceptor {
         const refsContext = (tagContext.method === 'gallery')
           ? new GalleryContext(tagContext || {})
           : new RefsContext(tagContext || {});
-        refsContext.fromPagePath = context.currentPagePath;
+        refsContext.fromPagePath = context.pagePath ?? context.currentPathname;
 
         this.renderReactDom(refsContext, elem);
       }

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -43,7 +43,7 @@ export class LsxPostRenderInterceptor extends BasicInterceptor {
       if (elem) {
         // instanciate LsxContext from context
         const lsxContext = new LsxContext(context.lsxContextMap[domId] || {});
-        lsxContext.fromPagePath = context.currentPagePath;
+        lsxContext.fromPagePath = context.pagePath ?? context.currentPathname;
 
         this.renderReactDOM(lsxContext, elem, isPreview);
       }

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 4 - 3
packages/plugin-pukiwiki-like-linker/src/resource/js/util/PreProcessor/PukiwikiLikeLinker.js

@@ -2,14 +2,15 @@ const path = require('path');
 
 export default class PukiwikiLikeLinker {
 
-  process(markdown) {
+  process(markdown, context) {
+    const currentPath = context.pagePath ?? context.currentPathname;
 
     return markdown
       // see: https://regex101.com/r/k2dwz3/3
       .replace(/\[\[(([^(\]\])]+)>)?(.+?)\]\]/g, (all, group1, group2, group3) => {
         // create url
-        // use 'group3' as is if starts from 'http(s)', otherwise join to 'window.location.pathname'
-        const url = (group3.match(/^(\/|https?:\/\/)/)) ? group3 : path.join(window.location.pathname, group3);
+        // use 'group3' as is if starts from 'http(s)', otherwise join to currentPath
+        const url = (group3.match(/^(\/|https?:\/\/)/)) ? group3 : path.join(currentPath, group3);
         // determine alias string
         // if 'group2' is undefined, use group3
         const alias = group2 || group3;

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 1 - 1
packages/slackbot-proxy/package.json

@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.0-RC.11",
+    "@growi/slack": "^5.0.0-RC.12",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.0-RC.11",
+  "version": "5.0.0-RC.12",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

+ 10 - 0
yarn.lock

@@ -11981,6 +11981,16 @@ jquery.cookie@~1.4.1:
   resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
   integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
 
+js-base64@^2.1.8:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.2.tgz#313b6274dda718f714d00b3330bbae6e38e90209"
+  integrity sha512-Vg8czh0Q7sFBSUMWWArX/miJeBWYBPpdU/3M/DKSaekLMqrqVPaedp+5mZhie/r0lgrcaYBfwXatEew6gwgiQQ==
+
+js-base64@^2.1.9:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4"
+  integrity sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==
+
 js-tokens@^3.0.0:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"