Przeglądaj źródła

Merge branch 'master' into feat/notification

kaori 4 lat temu
rodzic
commit
48ca55c569
100 zmienionych plików z 5330 dodań i 2725 usunięć
  1. 3 0
      .github/git-pr-release-template.erb
  2. 50 0
      .github/release-drafter.yml
  3. 21 72
      .github/workflows/ci-slackbot-proxy.yml
  4. 29 112
      .github/workflows/ci.yml
  5. 62 0
      .github/workflows/draft-release.yml
  6. 46 0
      .github/workflows/pr-to-master.yml
  7. 5 7
      .github/workflows/release-rc.yml
  8. 8 8
      .github/workflows/release-slackbot-proxy.yml
  9. 104 37
      .github/workflows/release.yml
  10. 2603 0
      CHANGELOG.md
  11. 0 2007
      CHANGES.md
  12. 4 0
      bin/github-actions/bump-versions/README.md
  13. 18 0
      bin/github-actions/bump-versions/cli.js
  14. 71 0
      bin/github-actions/bump-versions/flow/bump-versions.js
  15. 16 0
      bin/github-actions/bump-versions/index.js
  16. 54 0
      bin/github-actions/bump-versions/step/printHelp.js
  17. 12 0
      bump-versions.config.js
  18. 2 2
      lerna.json
  19. 3 3
      package.json
  20. 2 4
      packages/app/.env.development
  21. 2 2
      packages/app/bin/github-actions/update-readme.sh
  22. 6 6
      packages/app/config/migrate.js
  23. 0 2
      packages/app/docker/Dockerfile
  24. 5 5
      packages/app/docker/README.md
  25. 12 13
      packages/app/package.json
  26. BIN
      packages/app/public/images/slack-integration/activate-public-dist.png
  27. BIN
      packages/app/public/images/slack-integration/basicinfo-all-checked.png
  28. BIN
      packages/app/public/images/slack-integration/click-add-to-slack.png
  29. 17 1
      packages/app/resource/locales/en_US/admin/admin.json
  30. 8 0
      packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt
  31. 13 0
      packages/app/resource/locales/en_US/notifications/notActiveUser.txt
  32. 10 0
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  33. 19 1
      packages/app/resource/locales/en_US/translation.json
  34. 10 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  35. 13 0
      packages/app/resource/locales/ja_JP/notifications/notActiveUser.txt
  36. 10 0
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  37. 6 0
      packages/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt
  38. 19 1
      packages/app/resource/locales/ja_JP/translation.json
  39. 10 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  40. 6 0
      packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt
  41. 13 0
      packages/app/resource/locales/zh_CN/notifications/notActiveUser.txt
  42. 10 0
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  43. 20 2
      packages/app/resource/locales/zh_CN/translation.json
  44. 34 0
      packages/app/src/client/nologin.jsx
  45. 50 2
      packages/app/src/client/services/AdminHomeContainer.js
  46. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  47. 11 25
      packages/app/src/client/services/PageContainer.js
  48. 0 5
      packages/app/src/client/services/SocketIoContainer.js
  49. 28 0
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  50. 5 2
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  51. 22 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  52. 8 5
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  53. 3 3
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  54. 3 3
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx
  55. 2 0
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  56. 14 2
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  57. 240 91
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  58. 242 0
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  59. 7 4
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  60. 11 5
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  61. 64 46
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  62. 1 1
      packages/app/src/components/EmptyTrashModal.jsx
  63. 7 0
      packages/app/src/components/LoginForm.jsx
  64. 1 1
      packages/app/src/components/PagePathHierarchicalLink.jsx
  65. 96 0
      packages/app/src/components/PasswordResetExecutionForm.jsx
  66. 66 0
      packages/app/src/components/PasswordResetRequestForm.jsx
  67. 1 1
      packages/app/src/components/Sidebar/CustomSidebar.jsx
  68. 138 30
      packages/app/src/components/Sidebar/RecentChanges.jsx
  69. 2 0
      packages/app/src/components/StickyStretchableScroller.jsx
  70. 65 0
      packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  71. 33 0
      packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js
  72. 110 0
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  73. 0 1
      packages/app/src/server/.node-dev.json
  74. 3 1
      packages/app/src/server/console.js
  75. 4 2
      packages/app/src/server/crowi/index.js
  76. 2 1
      packages/app/src/server/middlewares/admin-required.js
  77. 27 0
      packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts
  78. 3 5
      packages/app/src/server/models/page.js
  79. 72 0
      packages/app/src/server/models/password-reset-order.ts
  80. 14 6
      packages/app/src/server/models/slack-app-integration.js
  81. 120 0
      packages/app/src/server/routes/apiv3/forgot-password.js
  82. 2 0
      packages/app/src/server/routes/apiv3/index.js
  83. 1 0
      packages/app/src/server/routes/apiv3/page.js
  84. 76 15
      packages/app/src/server/routes/apiv3/pages.js
  85. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  86. 118 51
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  87. 65 45
      packages/app/src/server/routes/apiv3/slack-integration.js
  88. 21 0
      packages/app/src/server/routes/forgot-password.ts
  89. 2 2
      packages/app/src/server/routes/hackmd.js
  90. 19 0
      packages/app/src/server/routes/index.js
  91. 4 8
      packages/app/src/server/routes/page.js
  92. 41 14
      packages/app/src/server/service/config-loader.ts
  93. 6 10
      packages/app/src/server/service/page.js
  94. 8 4
      packages/app/src/server/service/search.js
  95. 1 1
      packages/app/src/server/service/slack-command-handler/create.js
  96. 2 2
      packages/app/src/server/service/slack-command-handler/search.js
  97. 16 16
      packages/app/src/server/service/slack-command-handler/togetter.js
  98. 28 8
      packages/app/src/server/service/slack-integration.ts
  99. 43 12
      packages/app/src/server/service/socket-io.js
  100. 31 8
      packages/app/src/server/service/system-events/sync-page-status.ts

+ 3 - 0
.github/git-pr-release-template.erb

@@ -0,0 +1,3 @@
+<%= ENV['GIT_PR_RELEASE_TITLE'] %>
+
+<%= ENV['GIT_PR_RELEASE_BODY'] %>

+ 50 - 0
.github/release-drafter.yml

@@ -0,0 +1,50 @@
+categories:
+  - title: 'BREAKING CHANGES'
+    labels:
+      - 'breaking'
+  - title: '💎 Features'
+    labels:
+      - 'feature'
+  - title: '🚀 Improvement'
+    labels:
+      - 'improvement'
+  - title: '🐛 Bug Fixes'
+    labels:
+      - 'bug'
+  - title: '🧰 Maintenance'
+    labels:
+      - 'support'
+      - 'dependencies'
+category-template: '### $TITLE'
+change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
+autolabeler:
+  - label: 'feature'
+    branch:
+      - '/^feat\/.+/'
+  - label: 'improvement'
+    branch:
+      - '/^imprv\/.+/'
+  - label: 'bug'
+    branch:
+      - '/^fix\/.+/'
+    title:
+      - '/^fix/i'
+  - label: 'support'
+    branch:
+      - '/^support\/.+/'
+    title:
+      - '/^ci/i'
+      - '/^docs/i'
+      - '/^test/i'
+  - label: 'exclude from changelog'
+    branch:
+      - '/^chore\/.+/'
+    title:
+      - '/^chore/i'
+
+exclude-labels:
+  - 'exclude from changelog'
+template: |
+  ### Changes
+
+  $CHANGES

+ 21 - 72
.github/workflows/ci-slackbot-proxy.yml

@@ -5,7 +5,8 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - tmp/**
+      - chore/**
+      - support/prepare-v**
 
 jobs:
 
@@ -18,30 +19,14 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: '**/node_modules'
-        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-    - name: Get yarn cache dir
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      id: cache-yarn
-      run: echo "::set-output name=dir::$(yarn cache dir)"
-    - name: Cache/Restore yarn cache
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v2
-      with:
-        path: ${{ steps.cache-yarn.outputs.dir }}
-        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
-    - name: Install dependencies
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: lerna bootstrap
       run: |
         npx lerna bootstrap
     - name: Print dependencies
@@ -49,6 +34,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
+
     - name: yarn lint
       run: |
         yarn lerna run lint --scope @growi/slack --scope @growi/slackbot-proxy
@@ -86,30 +72,14 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: '**/node_modules'
-        key: ${{ runner.OS }}-node_modules_dev-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-    - name: Get yarn cache dir
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      id: cache-yarn
-      run: echo "::set-output name=dir::$(yarn cache dir)"
-    - name: Cache/Restore yarn cache
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v2
-      with:
-        path: ${{ steps.cache-yarn.outputs.dir }}
-        key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
     - name: lerna bootstrap
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
         npx lerna bootstrap
     - name: Print dependencies
@@ -117,6 +87,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
+
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy
       run: |
@@ -129,6 +100,7 @@ jobs:
         TYPEORM_DATABASE: growi-slackbot-proxy
         TYPEORM_USERNAME: root
         TYPEORM_PASSWORD:
+
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       if: failure()
@@ -159,36 +131,13 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Get Date
-      id: date
-      run: |
-        echo ::set-output name=YmdH::$(date '+%Y%m%d%H')
-        echo ::set-output name=Ymd::$(date '+%Y%m%d')
-        echo ::set-output name=Ym::$(date '+%Y%m')
-        echo ::set-output name=Y::$(date '+%Y')
-    - name: Cache/Restore node_modules
-      uses: actions/cache@v2
-      with:
-        path: '**/node_modules'
-        key: ${{ runner.OS }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
-        restore-keys: |
-          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
-          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ym }}
-          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Y }}
-    - name: Get yarn cache dir
-      id: cache-yarn
-      run: echo "::set-output name=dir::$(yarn cache dir)"
-    - name: Cache/Restore yarn cache
-      uses: actions/cache@v2
-      with:
-        path: ${{ steps.cache-yarn.outputs.dir }}
-        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
     - name: lerna bootstrap
       run: |
         npx lerna bootstrap

+ 29 - 112
.github/workflows/ci.yml

@@ -5,7 +5,8 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - tmp/**
+      - chore/**
+      - support/prepare-v**
 
 jobs:
 
@@ -18,30 +19,14 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: '**/node_modules'
-        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-    - name: Get yarn cache dir
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      id: cache-yarn
-      run: echo "::set-output name=dir::$(yarn cache dir)"
-    - name: Cache/Restore yarn cache
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v2
-      with:
-        path: ${{ steps.cache-yarn.outputs.dir }}
-        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
-    - name: Install dependencies
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: lerna bootstrap
       run: |
         npx lerna bootstrap
     - name: Print dependencies
@@ -49,6 +34,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
+
     - name: lerna run lint for plugins
       run: |
         yarn lerna run lint --scope @growi/plugin-*
@@ -86,30 +72,14 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: '**/node_modules'
-        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-    - name: Get yarn cache dir
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      id: cache-yarn
-      run: echo "::set-output name=dir::$(yarn cache dir)"
-    - name: Cache/Restore yarn cache
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v2
-      with:
-        path: ${{ steps.cache-yarn.outputs.dir }}
-        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
-    - name: Install dependencies
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: lerna bootstrap
       run: |
         npx lerna bootstrap
     - name: Print dependencies
@@ -117,6 +87,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
+
     - name: yarn test
       working-directory: ./packages/app
       run: |
@@ -162,46 +133,14 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Cache/Restore node_modules
-      id: cache-dependencies
-      uses: actions/cache@v2
-      with:
-        path: '**/node_modules'
-        key: ${{ runner.OS }}-node_modules_dev-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-    - name: Get Date
-      id: date
-      run: |
-        echo ::set-output name=YmdH::$(date '+%Y%m%d%H')
-        echo ::set-output name=Ymd::$(date '+%Y%m%d')
-        echo ::set-output name=Ym::$(date '+%Y%m')
-        echo ::set-output name=Y::$(date '+%Y')
-    - name: Cache/Restore node_modules/.cache/hard-source
-      uses: actions/cache@v2
-      with:
-        path: node_modules/.cache
-        key: ${{ runner.OS }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
-        restore-keys: |
-          ${{ runner.os }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
-          ${{ runner.os }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.Ym }}
-          ${{ runner.os }}-hard_source_webpack-${{ matrix.node-version }}-${{ steps.date.outputs.Y }}
-    - name: Get yarn cache dir
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      id: cache-yarn
-      run: echo "::set-output name=dir::$(yarn cache dir)"
-    - name: Cache/Restore yarn cache
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
-      uses: actions/cache@v2
-      with:
-        path: ${{ steps.cache-yarn.outputs.dir }}
-        key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-
-    - name: Install dependencies
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: lerna bootstrap
       run: |
         npx lerna bootstrap
     - name: Print dependencies
@@ -209,6 +148,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
+
     - name: yarn dev:ci
       working-directory: ./packages/app
       run: |
@@ -247,37 +187,14 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Get Date
-      id: date
-      run: |
-        echo ::set-output name=YmdH::$(date '+%Y%m%d%H')
-        echo ::set-output name=Ymd::$(date '+%Y%m%d')
-        echo ::set-output name=Ym::$(date '+%Y%m')
-        echo ::set-output name=Y::$(date '+%Y')
-    - name: Cache/Restore node_modules
-      uses: actions/cache@v2
-      with:
-        path: '**/node_modules'
-        key: ${{ runner.OS }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.YmdH }}
-        restore-keys: |
-          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ymd }}
-          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Ym }}
-          ${{ runner.os }}-node_modules_prod-${{ matrix.node-version }}-${{ steps.date.outputs.Y }}
-    - name: Get yarn cache dir
-      id: cache-yarn
-      run: echo "::set-output name=dir::$(yarn cache dir)"
-    - name: Cache/Restore yarn cache
-      uses: actions/cache@v2
-      with:
-        path: ${{ steps.cache-yarn.outputs.dir }}
-        key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
-        restore-keys: |
-          ${{ runner.os }}-yarn-
-    - name: Install dependencies
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: lerna bootstrap
       run: |
         npx lerna bootstrap
     - name: Print dependencies

+ 62 - 0
.github/workflows/draft-release.yml

@@ -0,0 +1,62 @@
+name: Draft Release
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+
+  # Refs: https://github.com/release-drafter/release-drafter
+  update-release-draft:
+    runs-on: ubuntu-latest
+
+    outputs:
+      CURRENT_VERSION: ${{ steps.package-json.outputs.packageVersion }}
+      RELEASE_DRAFT_BODY: ${{ steps.release-drafter.outputs.body }}
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: Retrieve information from package.json
+        uses: myrotvorets/info-from-package-json-action@0.0.2
+        id: package-json
+
+      # Drafts your next Release notes as Pull Requests are merged into "master"
+      - uses: release-drafter/release-drafter@v5
+        id: release-drafter
+        with:
+          name: v${{ steps.package-json.outputs.packageVersion }}
+          tag: v${{ steps.package-json.outputs.packageVersion }}
+          version: ${{ steps.package-json.outputs.packageVersion }}
+          disable-autolabeler: true
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  # Refs: https://github.com/bakunyo/git-pr-release-action
+  update-release-pr:
+    needs: update-release-draft
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+
+      - name: Get release version
+        id: release-version
+        run: |
+          RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
+          echo ::set-output name=RELEASE_VERSION::$RELEASE_VERSION
+
+      - name: Create/Update Pull Request
+        uses: bakunyo/git-pr-release-action@master
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current
+          GIT_PR_RELEASE_BRANCH_STAGING: master
+          GIT_PR_RELEASE_TEMPLATE: .github/git-pr-release-template.erb
+          GIT_PR_RELEASE_TITLE: Release v${{ steps.release-version.outputs.RELEASE_VERSION }}
+          GIT_PR_RELEASE_BODY: ${{ needs.update-release-draft.outputs.RELEASE_DRAFT_BODY }}
+

+ 46 - 0
.github/workflows/pr-to-master.yml

@@ -0,0 +1,46 @@
+name: PR to master
+
+on:
+  pull_request:
+    branches:
+      - master
+    # Only following types are handled by the action, but one can default to all as well
+    types: [opened, reopened, edited, synchronize]
+
+jobs:
+
+  # Refs: https://github.com/release-drafter/release-drafter
+  auto-labeling:
+    runs-on: ubuntu-latest
+
+    if: ${{ !contains(github.event.pull_request.labels.*.name, 'exclude from changelog') }}
+
+    steps:
+      - uses: release-drafter/release-drafter@v5
+        with:
+          disable-releaser: true
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  check-title:
+    runs-on: ubuntu-latest
+
+    if: |
+      (!contains( github.event.pull_request.labels.*.name, 'exclude from changelog' ) &&
+        !startsWith( github.ref, 'refs/heads/chore/' ))
+
+    steps:
+      - uses: amannn/action-semantic-pull-request@v3.4.2
+        with:
+          types: |
+            feat
+            imprv
+            fix
+            support
+            chore
+            ci
+            docs
+            test
+          requireScope: false
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 5 - 7
.github/workflows/release-rc.yml

@@ -14,11 +14,9 @@ jobs:
     steps:
     - uses: actions/checkout@v2
 
-    - name: Setup semver
-      id: semver
-      run: |
-        semver=`npm run version --silent`
-        echo "::set-output name=SEMVER::$semver"
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@0.0.2
+      id: package-json
 
     - name: Docker meta
       id: meta
@@ -26,8 +24,8 @@ jobs:
       with:
         images: weseek/growi,ghcr.io/weseek/growi
         tags: |
-          type=raw,value=${{ steps.semver.outputs.SEMVER }}
-          type=raw,value=${{ steps.semver.outputs.SEMVER }}.{{sha}}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
     - name: Login to docker.io registry
       run: |

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

@@ -1,9 +1,10 @@
 name: Release Docker Image for @growi/slackbot-proxy
 
 on:
-  push:
+  pull_request:
     branches:
       - release/slackbot-proxy/**
+    types: [closed]
 
 jobs:
 
@@ -12,13 +13,12 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
+      with:
+        ref: ${{ github.event.pull_request.base.ref }}
 
-    - name: Setup semver
-      id: semver
-      working-directory: ./packages/slackbot-proxy
-      run: |
-        semver=`npm run version --silent`
-        echo "::set-output name=SEMVER::$semver"
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@0.0.2
+      id: package-json
 
     - name: Docker meta
       id: meta
@@ -27,7 +27,7 @@ jobs:
         images: weseek/growi-slackbot-proxy,ghcr.io/weseek/growi-slackbot-proxy,asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy
         tags: |
           type=raw,value=latest
-          type=raw,value=${{ steps.semver.outputs.SEMVER }}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
 
     - name: Login to docker.io registry
       run: |

+ 104 - 37
.github/workflows/release.yml

@@ -1,57 +1,127 @@
 name: Release
 
 on:
-  push:
+  pull_request:
     branches:
       - release/current
       - release/*.*.*
+    types: [closed]
 
 jobs:
-  github-release:
+  create-github-release:
 
     runs-on: ubuntu-latest
 
+    if: github.event.pull_request.merged == true
+
     outputs:
-      RELEASE_VERSION: ${{ steps.bump-version.outputs.RELEASE_VERSION }}
+      RELEASED_VERSION: ${{ steps.package-json.outputs.packageVersion }}
 
     steps:
     - uses: actions/checkout@v2
+      with:
+        ref: ${{ github.event.pull_request.base.ref }}
 
-    - name: Init Git
+    - uses: actions/setup-node@v2
+      with:
+        node-version: '14'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Install dependencies
       run: |
-        git config --local user.name "GitHub Action"
-        git config --local user.email "info@weseek.co.jp"
-        git remote set-url origin "https://$GITHUB_ACTOR:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY"
+        npx lerna bootstrap
 
-    - name: Bump version
-      id: bump-version
+    - name: Bump versions
       run: |
-        npm --no-git-tag-version version patch
-        export RELEASE_VERSION=`npm run version --silent`
-        sh ./bin/github-actions/update-readme.sh
-        echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV
-        echo ::set-output name=RELEASE_VERSION::$RELEASE_VERSION
+        node ./bin/github-actions/bump-versions -i patch
+        sh ./packages/app/bin/github-actions/update-readme.sh
 
-    - name: Checkout, Commit, Tag and Push
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@0.0.2
+      id: package-json
+
+    - name: Update Changelog
+      uses: stefanzweifel/changelog-updater-action@v1
+      with:
+        latest-version: v${{ steps.package-json.outputs.packageVersion }}
+        release-notes: ${{ github.event.pull_request.body }}
+
+    - name: Update README.md for docker image
+      working-directory: ./packages/app
       run: |
-        TMP_RELEASE_BRANCH=tmp/release-${{ env.RELEASE_VERSION }}
-        git checkout -B $TMP_RELEASE_BRANCH
-        git commit -am "Release v${{ env.RELEASE_VERSION }}"
-        git tag -a v${{ env.RELEASE_VERSION }} -m "v${{ env.RELEASE_VERSION }}"
-        git push --follow-tags origin $TMP_RELEASE_BRANCH
-        git push --delete origin $TMP_RELEASE_BRANCH
-
-    - name: Upload release notes
-      uses: Roang-zero1/github-create-release-action@master
-      with:
-        created_tag: v${{ env.RELEASE_VERSION }}
-        changelog_file: CHANGES.md
+        sh ./bin/github-actions/update-readme.sh
+      env:
+        RELEASED_VERSION: ${{ steps.package-json.outputs.packageVersion }}
+
+    - name: Commit, Tag and Push
+      uses: stefanzweifel/git-auto-commit-action@v4
+      with:
+        branch: ${{ github.event.pull_request.base.ref }}
+        commit_message: Release v${{ steps.package-json.outputs.packageVersion }}
+        tagging_message: v${{ steps.package-json.outputs.packageVersion }}
+
+    - uses: ncipollo/release-action@v1
+      with:
+        body: ${{ github.event.pull_request.body }}
+        tag: v${{ steps.package-json.outputs.packageVersion }}
+        token: ${{ secrets.GITHUB_TOKEN }}
+
+    - name: Delete drafts
+      uses: hugo19941994/delete-draft-releases@v1.0.0
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
 
+  create-pr-for-next-rc:
+    needs: create-github-release
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
+
+    - uses: actions/setup-node@v2
+      with:
+        node-version: '14'
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
+
+    - name: Install dependencies
+      run: |
+        npx lerna bootstrap
+
+    - name: Bump versions for next RC
+      run: |
+        node ./bin/github-actions/bump-versions -i prerelease
+
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@0.0.2
+      id: package-json
+
+    - name: Commit
+      uses: github-actions-x/commit@v2.8
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        commit-message: 'Bump version'
+        name: GitHub Action
+
+    - name: Create PR
+      uses: repo-sync/pull-request@v2
+      with:
+        source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
+        destination_branch: master
+        pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
+        pr_label: exclude from changelog
+        pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+        github_token: ${{ secrets.GITHUB_TOKEN }}
+
+
   build-image:
-    needs: github-release
+    needs: create-github-release
 
     runs-on: ubuntu-latest
 
@@ -61,11 +131,8 @@ jobs:
 
     steps:
     - uses: actions/checkout@v2
-
-    - name: Checkout released tag
-      run: |
-        git fetch --tags
-        git checkout refs/tags/v${{ needs.github-release.outputs.RELEASE_VERSION }}
+      with:
+        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
     - name: Setup suffix
       id: suffix
@@ -82,9 +149,9 @@ jobs:
           suffix=${{ steps.suffix.outputs.SUFFIX }}
         tags: |
           type=raw,value=latest
-          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}
-          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}.{{minor}}
-          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
     - name: Login to docker.io registry
       run: |
@@ -138,7 +205,7 @@ jobs:
       with:
         channel: '#release'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
-        created_tag: 'v${{ needs.github-release.outputs.RELEASE_VERSION }}${{ steps.suffix.outputs.SUFFIX }}'
+        created_tag: 'v${{ needs.create-github-release.outputs.RELEASED_VERSION }}${{ steps.suffix.outputs.SUFFIX }}'
 
     - name: Check whether workspace is clean
       run: |

+ 2603 - 0
CHANGELOG.md

@@ -0,0 +1,2603 @@
+# Changelog
+
+## [Unreleased](https://github.com/weseek/growi/compare/v4.4.2...HEAD)
+
+*Please do not manually update this file. We've automated the process.*
+
+## [v4.4.2](https://github.com/weseek/growi/compare/v4.4.0...v4.4.2) - 2021-09-07
+
+### Changes
+
+- Release v4.4.1 (#4262) @github-actions
+
+### 🐛 Bug Fixes
+
+- fix: Plugin backend's permission (#4271) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Make lerna mode fixed (#4274) @yuki-takei
+- support: Make lerna mode fixed (#4263) @yuki-takei
+
+## v4.4.1 (Missing number)
+
+## [v4.4.0](https://github.com/weseek/growi/compare/v4.3.3...v4.4.0) (Discontinued) - 2021-09-06
+
+### Changes
+
+### BREAKING CHANGES
+
+- Official plugins are now preinstalled
+- It is no longer compatible with previous versions of official bots
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/44x.html](https://docs.growi.org/en/admin-guide/upgrading/44x.html)
+
+### 💎 Features
+
+- feat: Password resetting by users (#4135) @kaoritokashiki
+- feat: Copy bug report btn (#4200) @Mxchaeltrxn
+- Feature: User trigger notification and Global notification are available by new Slack integration
+
+### 🚀 Improvement
+
+- imprv: Add attachment button in editor navbar
+- imprv: Modified with proxy app installation tutorial (#4174) @hakumizuki
+- imprv: Slackbot proxy respone (#4194) @hakumizuki
+- imprv: Slackbot proxy respone (#4175, #4201) @zahmis
+- Imprv: Admin slack integration (#4190) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Recursive rename operation from `/parent` to `/parent/child` (#4101) @miya
+- fix: adminRequired middleware for socket.io (#4245) @yuki-takei
+- fix: Encode spaces in page path in LinkEditModal
+
+### 🧰 Maintenance
+
+- support: Supress warnings for mongo (#4247) @yuki-takei
+- support: Add bump-versions script (#4241) @yuki-takei
+- support: New release workflow (#4236) @yuki-takei
+- Support: Create @growi/core package
+- Support: Create @growi/ui package
+- Support: Include official plugins as sub packages
+- Support: Upgrade libs
+- - @slack/web-api
+- 
+- - date-fns
+- 
+- - helmet
+- 
+- - morgan
+- 
+- - socket.io
+- 
+- 
+
+## v4.3.3
+
+- Improvement: Welcome page markdown
+- Fix: Some recursive operation exclude descendant pages that are restricted for groups
+- - Rename / Delete / Delete completely / Put back / Duplicate
+- 
+- 
+- Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
+- Support: Upgrade libs
+- - @slack/web-api
+- 
+- - date-fns
+- 
+- - escape-string-regexp
+- 
+- 
+
+## v4.3.2
+
+- Feature: Hufflpuff theme
+- Improvement: CodeMirror header styles
+- Improvement: CodeMirror syntax-highlighting fenced code blocks
+- Improvement: Slack Integration Settings
+- - Error behavior when getting connection statuses
+- 
+- - Add links to docs
+- 
+- 
+- Improvement: /_api/v3/recent can be accessed with access token
+- Support: Using http-errors
+
+## v4.3.1
+
+- Fix: Build script for production
+
+## v4.3.0
+
+### BREAKING CHANGES
+
+- GROWI manages dependencies with `lerna`
+- - Use `npx lerna bootstrap` instead of `yarn install`
+- 
+- 
+- GROWI includes some official plugins in default
+- - Users no longer need to install [growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx), [growi-plugin-pukiwiki-like-linker](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) and [growi-plugin-attachment-refs](https://github.com/weseek/growi-plugin-attachment-refs) before build client.
+- 
+- 
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/43x.html](https://docs.growi.org/en/admin-guide/upgrading/43x.html)
+
+### Updates
+
+- Feature: New Slack Integration with Slack Bot
+- - Searching GROWI pages from Slack
+- 
+- - Creating GROWI pages from Slack
+- 
+- - - Easy record conversations
+- - 
+- 
+- - 
+- 
+- 
+- Feature: Enable/Disable option for share link
+- Feature: Re-send invitation mail from user management page
+- Improvement: Mark users who failed to send invitation emails
+- Fix: lsx plugin in the custom sidebar does not work when showing search result page
+- Support: Switch the official docker base image from Alpine based to Ubuntu based
+- Support: Upgrade libs
+- - striptags
+- 
+- 
+
+## v4.2.21
+
+- Improvement: Headers style on built-in editor
+- Improvement: Codemirror is now scrollable one editor height of empty space into view at the bottom of the editor
+- Improvement: Upgrade mongodb driver to fix [NODE-2784](https://jira.mongodb.org/browse/NODE-2784)
+- Support: Upgrade libs
+- - connect-mongo
+- 
+- - i18next
+- 
+- - migrate-mongo
+- 
+- - mongoose
+- 
+- - stream-to-promise
+- 
+- - validator
+- 
+- - ws
+- 
+- - nodemailer
+- 
+- - i18next-express-middleware
+- 
+- - growi-commons
+- 
+- - growi-plugin-attachment-refs
+- 
+- - growi-plugin-lsx
+- 
+- 
+
+## v4.2.20
+
+- Improvement: Error message when the password is too short
+- Improvement: Repeat XSS processing as a countermeasure against nesting
+- Fix: NoSQL injection of access-token-parser
+- Fix: Checking permission when operating share links
+- Fix: Invalid NaN label is shown when deletedAt of the page is undefined
+- - Introduced by v4.2.8
+- 
+- 
+
+## v4.2.19
+
+- Feature: Set max-age of the user's cookie with the env var `SESSION_MAX_AGE`
+- Feature: Set max-age of the user's cookie in admin page
+- Improvement: Change the first accessing page after installation to the top page
+- Support: Upgrade libs
+- - string-width
+- 
+- - diff
+- 
+- - archiver
+- 
+- 
+
+## v4.2.18
+
+- Feature: Convertible page contents width
+- Fix: Group selector of User Group Delete Modal does not show all groups
+- Fix: Global notification to Slack does not encode spaces of page path
+- Support: Upgrade libs
+- - @google-cloud/storage
+- 
+- 
+
+## v4.2.17
+
+- Improvement: Invoke garbage collection when reindex all pages by elasticsearch
+- Improvement: Hide Sidebar at shared pages
+- Fix: No unsaved alert is displayed without difference the latest markdown and editor value
+- Support: Update libs
+- - eslint-config-weseek
+- 
+- 
+
+## v4.2.16
+
+- Fix: "Only inside the group" causes an error
+- - Introduced by v4.2.15
+- 
+- 
+
+## v4.2.15
+
+- Improvement: toastr location for editing
+- Improvement: Handsontable with static backdrop to prevent from closing when backdrop is clicked
+- Fix: Accept invalid page path like `..%2f`
+- Fix: Pages updated date is corrupted after recursive operation
+- - Introduced by v4.2.8
+- 
+- 
+- Support: Upgrade libs
+- - reactstrap
+- 
+- 
+
+## v4.2.14
+
+- Feature: Add an option to restrict publishing email property for new users
+- Improvement: Invite modal in admin page without email server settings
+- Improvement: Global notification settings in admin page without email server settings
+- Fix: Can create pages on the share route
+- - Introduced by v4.2.8
+- 
+- 
+- Fix: Pages restrected by group are excluded for recurrence operation
+- - Introduced by v4.2.8
+- 
+- 
+- Fix: Rename and duplicate to descendants path does not work correctly
+- - Introduced by v4.2.8
+- 
+- 
+- Support: Update libs
+- - bunyan
+- 
+- - browser-bunyan
+- 
+- 
+
+## v4.2.13
+
+- Feature: Detect indent size automatically
+- Fix: Some API responses includes email unintentionally
+- Fix: An error always displayed in admin pages
+
+## v4.2.12
+
+- Feature: Custom Sidebar
+- Fix: Set language correctly for draw.io (diagrams.net)
+
+## v4.2.11
+
+- Fix: Rename decendants is not working
+- - Introduced by v4.2.8
+- 
+- 
+
+## v4.2.10
+
+- Feature: Staff Credits for apps on GROWI.cloud
+- Improvement: Hackmd button behavior when disabled
+- Improvement: Layout of comparing revisions
+- Fix: Empty trash is not working
+
+## v4.2.9
+
+- Feature: Comparing revisions
+- Improvement: Memory consumption when re-indexing for full text searching
+- Improvement: Site URL settings valildation
+- Fix: Show comfirmation when transiting page without save
+- Fix: Save slack channels history when user trigger notification is invoked
+- Fix: The label of alerts for move/rename/delete are borken
+
+## v4.2.8
+
+- Improvement: Performance for pages to rename/duplicate/delete/revert pages
+- Fix: Preview scrollbar doesn't sync to editor
+- - Introduced by v4.2.6
+- 
+- 
+- Fix: Failed to save temporaryUrlCached with using gcs
+- - Introduced by v4.2.3
+- 
+- 
+- Fix: Fixed not being able to update ses settings
+- - Introduced by v4.2.0
+- 
+- 
+- Fix: Fixed the display of updtedAt and createdAt being reversed
+- Fix: Pass app title value through the XSS filter
+
+## v4.2.7
+
+- Fix: Installer doesn't work on Chrome
+
+## v4.2.6
+
+- Feature: Add a button to jump to Comments section
+- Feature: Paste Bootstrap4 Grid HTML with GUI
+- Feature: Disable auto formating table option
+- Improvement: Layout of Edit Link Modal
+- Improvement: Focus to the first input when modal is opened
+- Improvement: Preview layout in edit mode
+- Improvement: Install process under redundant environment
+- Improvement: Add contributors
+- Fix: Upgrading to v4.x failed when the user uses Kibela Layout
+- - Introduced by v4.2.0
+- 
+- 
+- Fix: diagrams.net (draw.io) errors
+- Fix: Navbar is not rendered on old iOS
+- Support: Expose metrics with Promster
+- Support: Upgrade libs
+- - axios
+- 
+- 
+
+## v4.2.5
+
+- Improvement: Invoke garbage collection when reindex all pages by elasticsearch
+- - Turned out not working -- 2021.05.01
+- 
+- 
+- Fix: MathJax rendering does not work
+
+## v4.2.4
+
+- Fix: Fixed an error when creating a new page with `Ctrl-S`
+- - Introduced by v4.2.2
+- 
+- 
+- Fix: Fixed a strange diff in PageHistory due to Pagination
+- Fix: Fixed that the user group page could not be found when using api from the outside
+
+## v4.2.3
+
+- Feature: Insert/edit links with GUI
+- Feature: Auto reconnecting to search service
+- Improvement: New style of params for Healthcheck API
+- Fix: Referencing attachments when `FILE_UPLOAD_DISABLED` is true
+- Fix: The message of timeline for restricted pages
+- Fix: Parameter validation for Import/Export Archive API
+- Fix: Prevent regexp for Search Tags API
+- Fix: Add `Content-Security-Policy` when referencing attachments
+- Fix: Sanitize at presentation time
+- Fix: Remove page path string from message for page lists and timeline when there is no contents
+
+## v4.2.2
+
+- Fix: Consecutive save operations with built-in editor fail
+- - Introduced by v4.2.1
+- 
+- 
+
+## v4.2.1
+
+- Fix: Consecutive save operations with HackMD fail
+- - Introduced by v4.2.0
+- 
+- 
+- Fix: Switching theme to kibela fail
+- - Introduced by v4.2.0
+- 
+- 
+
+## v4.2.0
+
+### BREAKING CHANGES
+
+- GROWI v4.2.x no longer support Kibela layout
+- - Kibela theme is newly added and the configuration will migrate to it automatically
+- 
+- 
+
+### Updates
+
+- Feature: File Upload Settings on admin pages
+- Improvement: Basic layout of page
+- Support: Support MongoDB 4.0, 4.2 and 4.4
+- Support: Upgrade libs
+- - migrate-mongo
+- 
+- - mongoose
+- 
+- 
+
+## v4.1.13
+
+- Fix: MathJax rendering does not work
+
+## v4.1.12
+
+- Fix: Adjust line-height for pre under li
+- Fix: Emptying trash process is broken
+
+## v4.1.11
+
+- Improvement: Generating draft DOM id strategy
+- Fix: GROWI version downgrade causes a validation error for user.lang
+
+## v4.1.10
+
+- Fix: Make listing users API secure
+- Fix: Error message when the server denies guest user connecting with socket.io
+
+## v4.1.9
+
+- Feature: Environment variables to set max connection size to deliver push messages to all clients
+
+## v4.1.8
+
+- Improvement: Rebuilding progress bar colors for Full Text Search Management
+- Improvement: Support operations on page data with a null value for author
+
+## v4.1.7
+
+- Improvement: Fire global notification when a new page is created by uploading file
+- Fix: Change default `DRAWIO_URI` to embed.diagrams.net
+- Fix: An unhandled rejection occures when a user who does not send referer accesses
+
+## v4.1.6
+
+- Improvement: Hide Fab at admin pages
+- Fix: Presentation does not work
+- Fix: Update GrantSelector status when uploading a file to a new page
+- Fix: CopyDropdown origin refs draw.io host wrongly
+
+## v4.1.5
+
+- Feature: Independent S3 configuration and SES configuration for AWS
+- Fix: Author name does not displayed in page history
+- Fix: Hide unnecessary component when pringing
+
+## v4.1.4 (Discontinued)
+
+## v4.1.3
+
+- Feature: Create/edit linker with GUI
+- Improvement: Paging page histories
+- Improvement: Avoid using `cursor.snapshot()` in preparation for MongoDB version upgrade
+- Improvement: Allow to save "From e-mail address" only in App Settings
+- Improvement: Allow to empty "From e-mail address" in App Settings
+- Improvement: Export/Import archive data serially so as not to waste memory
+- Fix: To be able to delete attachment metadata even when the actual data does not exist
+- Fix: Limit the attrubutes of user data for `/_api/v3/users`
+- Fix: Prevent XSS with SVG
+- Upgrade libs
+- - optimize-css-assets-webpack-plugin
+- 
+- - terser-webpack-plugin
+- 
+- 
+
+## v4.1.2
+
+- Fix: Uploaded images do not displayed
+- - Introduced by v4.1.1
+- 
+- 
+
+## v4.1.1
+
+- Feature: External share link
+- Improvement: Optimize some features that operate revision data
+- - Page history
+- 
+- - Renaming pages
+- 
+- - Deleting pages
+- 
+- 
+- Fix: Cmd+c/v/... does not work on Mac
+- - Introduced by v4.1.0
+- 
+- 
+- Fix: "Append params" switch of CopyDropdown does not work when multiple CopyDropdown instance exists
+- Fix: "Append params" switch of CopyDropdown escapes spaces
+- Fix: Blockdiag does not be rendered
+- Fix: Access token parser
+
+## v4.1.0
+
+### BREAKING CHANGES
+
+- GROWI v4.1.x no longer support Node.js v10.x
+- GROWI v4.1.x no longer support growi-plugin-attachment-refs@v1
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/41x.html](https://docs.growi.org/en/admin-guide/upgrading/41x.html)
+
+### Updates
+
+- Feature: Server settings synchronization for multiple GROWI Apps
+- Feature: Page status alert synchronization for multiple GROWI Apps
+- Feature: Smooth scroll for anchor links
+- Feature: Mirror Mode with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
+- Improvement: Determine whether the "In Use" badge is displayed or not by attachment ID
+- Improvement: draw.io under NO_CDN environment
+- Fix: Deleting/renaming with recursive option affects pages that are inaccessible to active users
+- Fix: DrawioModal cuts without beginning/ending line
+- Fix: New settings of SMTP and AWS SES are not reflected when server is running
+- Fix: Sidebar layout broken when using Kibela layout
+- Support: Support Node.js v14
+- Support: Update libs
+- - mathjax
+- 
+- 
+
+## v4.0.11
+
+- Fix: Fab on search result page does not displayed
+- Fix: Adjust margin/padding for search result page
+- Fix: PageAlert broken
+- - Introduced by v4.0.9
+- 
+- 
+
+## v4.0.10
+
+- Improvement: Adjust ToC height
+- Fix: Fail to rename/delete a page set as "Anyone with the link"
+
+## v4.0.9
+
+- Feature: Detailed configurations for OpenID Connect
+- - Authorization Endpoint
+- 
+- - Token Endpoint
+- 
+- - Revocation Endpoint
+- 
+- - Introspection Endpoint
+- 
+- - UserInfo Endpoint
+- 
+- - Registration Endpoint
+- 
+- - JSON Web Key Set URI
+- 
+- 
+- Improvement: Navigations
+- - New floating subnavigation
+- 
+- - New open drawer button
+- 
+- - New fixed bottom navbar on mobile
+- 
+- - New fixed bottom navbar for editor on mobile
+- 
+- - FAB (Floating action button)
+- 
+- 
+- Improvement: Sticky admin navigation
+- Fix: Reseting password doesn't work
+- Fix: Styles for printing
+- Fix: Unable to create page with original path after emptying trash
+- I18n: Support zh-CN
+
+## v4.0.8 (Discontinued)
+
+## v4.0.7
+
+- Feature: Set request timeout for Elasticsearch with env var `ELASTICSEARCH_REQUEST_TIMEOUT`
+- Improvement: Apply styles faster on booting client
+- Fix: Styles are not applyed on installer
+- Fix: Remove last-resort `next()`
+- Fix: Enable/disable Notification settings couldn't change when either of the params is undefined
+- Fix: Text overflow
+
+## v4.0.6
+
+- Fix: Avatar images in Recent Changes are not shown
+- Fix: Full screen modal of Handsontable and Draw.io don't work
+- Fix: Shortcut for creating page respond with modifier key wrongly
+- - Introduced by v4.0.5
+- 
+- 
+
+## v4.0.5
+
+- Improvement: Return pre-defined session id when healthcheck
+- Improvement: Refactor caching for profile image
+- Improvement: Layout for global search help on mobile
+- Improvement: Layout for confidential notation
+- Fix: Shortcut for creating page doesn't work
+- Support: Dev in container
+- Support: Upgrade libs
+- - ldapjs
+- 
+- - node-sass
+- 
+- 
+
+## v4.0.4
+
+- Feature: Drawer/Dock mode selector
+- Improvement: Admin pages navigation
+- Improvement: Ensure not to avoid session management even when accessing to healthcheck
+- Support: Refactor unstated utils
+- Support: Upgrade libs
+- - connect-mongo
+- 
+- - connect-redis
+- 
+- - mongoose
+- 
+- - mongoose-gridfs
+- 
+- - mongoose-paginate-v2
+- 
+- 
+
+## v4.0.3
+
+- Feature: Copy page path dropdown with Append params switch
+- Improvement: Truncate overflowed user browsing history
+- Improvement: Tabs appearance on mobile
+- Improvement: Search help appearance on mobile
+- Improvement: Accessibility of login page
+- Fix: Editor was broken by long lines
+- Fix: Editor doesn't work on mobile
+- Fix: Word break in Recent Updated contents
+- Fix: navbar is broken on Safari
+
+## v4.0.2
+
+- Fix: Internal Server Error occurred when the guest user access to the pages that has likes
+- Fix: Some buttons are broken on Safari
+
+## v4.0.1
+
+- Improvement: Accessibility for Handsontable under dark mode
+- Improvement: Refactor '/pages.exist' API
+- Fix: Storing the state of sidebar
+- Fix: Comments order should be asc
+- Fix: Show/Hide replies button doesn't work
+- Fix: Tooltip doesn't work
+- Fix: Change the display of the scroll bar when modal is shown
+- Fix: Submit with enter key on Create/Rename modals
+- Fix: Show/Hide Unlink redirection button conditions
+- Fix: Link color in alerts
+- Support: Upgrade libs
+- - @atlaskit/drawer
+- 
+- - @atlaskit/navigation-next
+- 
+- 
+
+## v4.0.0
+
+### BREAKING CHANGES
+
+- Crowi Classic Behavior is removed
+- Crowi Classic Layout is removed
+- 'default-dark' theme is now merged as a dark mode variant of 'default' theme
+- 'blue-night' theme is now merged as a dark mode variant of 'mono-blue' theme
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/40x.html](https://docs.growi.org/en/admin-guide/upgrading/40x.html)
+
+### Updates
+
+- Feature: Sidebar
+- Feature: Recent changes on Sidebar
+- Feature: Switch Light/Dark Mode
+- Improvement: Migrate to Bootstrap 4
+- Improvement: Copy Page URL menu item to copy path dropdown
+- Improvement: Show contributors by Bootstrap Modal
+- Support: Upgrade libs
+- - bootstrap
+- 
+- 
+
+## v3.8.1
+
+### BREAKING CHANGES
+
+- Now Elasticsearch requires the privilege `cluster:monitor/health` instead of `cluster:monitor/nodes/info`
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/38x.html](https://docs.growi.org/en/admin-guide/upgrading/38x.html)
+
+### Updates
+
+- Improvement: Change the health check method for Elasticsearch
+- Fix: Unset overflow-y style for Edit Tags Modal
+- Fix: Duplicate page source is overwrited
+- - Introduced by 3.7.6
+- 
+- 
+
+## v3.8.0  (Discontinued)
+
+## v3.7.7
+
+- Feature: Empty trash pages
+- Improvement: Behavior of Reconnect to Elasticsearch button
+- Fix: Duplicate page source is overwrited
+- - Introduced by 3.7.6
+- 
+- 
+
+## v3.7.6  (Discontinued)
+
+## v3.7.5
+
+- Fix: Draw.io diagrams rendered twice
+- Fix: Behavior of password reset modal is strange
+- Fix: Import GROWI Archive doesn't restore some data correctly
+- Fix: Attachments list on root page and users top pages
+- Fix: Trash page is no longer editable
+- Fix: Rendering Timeline on /trash
+
+## v3.7.4
+
+- Fix: Broken by displaying user image
+
+## v3.7.3
+
+- Feature: Profile Image Cropping
+- Improvement: Reactify users pages
+- Improvement: Detect language and adjust the order of first and last names when creating accounts in OAuth
+- Fix: Installation is broken when selecting Japanese
+- - Introduced by 3.7.0
+- 
+- 
+- Fix: Mathjax Rendering is unstable (workaround)
+- - Introduced by 3.7.0
+- 
+- 
+- Fix: Notification Setting couldn't update without slack token
+- - Introduced by 3.6.6
+- 
+- 
+- Support: Add GROWI Contributers
+
+## v3.7.2
+
+- Feature: User Management Filtering/Sort
+- Feature: Show env vars on Admin pages
+- Fix: Attachment row z-index
+- I18n: HackMD integration alert
+
+## v3.7.1
+
+- Improvement: Add an option that make it possible to choose what to send notifications
+- Improvement: Add the env var `DRAWIO_URI`
+- Improvement: Accessibility for 'spring' theme
+- Improvement: Editor scroll sync behaves strangely when using draw.io blocks
+- Fix: Coudn't upload file on Comment Editor
+- - Introduced by 3.5.8
+- 
+- 
+- I18n: HackMD integration
+
+## v3.7.0
+
+### BREAKING CHANGES
+
+None.
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/37x.html](https://docs.growi.org/en/admin-guide/upgrading/37x.html)
+
+### Updates
+
+- Feature: [Draw.io](https://www.draw.io/) Integration
+- Feature: SAML Attribute-based Login Control
+- Improvement: Reactify admin pages (Security)
+- Improvement: Behavior of pre-editing screen of HackMD when user needs to resume
+
+## v3.6.10
+
+- Fix: Redirect logic for users except for actives
+- - Introduced by 3.6.9
+- 
+- 
+
+## v3.6.9
+
+- Improvement: Redirection when login/logout
+- Improvement: Add home icon before '/'
+- Fix: Client crashed when the first login
+- - Introduced by 3.6.8
+- 
+- 
+
+## v3.6.8
+
+- Improvement: Show page history side-by-side
+- Improvement: Optimize markdown rendering
+- Improvement: Reactify admin pages (Navigation)
+- Fix: Reply comments collapsed are broken
+- - Introduced by 3.6.7
+- 
+- 
+- Support: Update libs
+- - cross-env
+- 
+- - mkdirp
+- 
+- - diff2html
+- 
+- - jest
+- 
+- - stylelint
+- 
+- 
+
+## v3.6.7
+
+- Feature: Anchor link for comments
+- Improvement: Show error toastr when saving page is failed because of empty document
+- Fix: Admin Customise couldn't restore stored config value
+- - Introduced by 3.6.2
+- 
+- 
+- Fix: Admin Customise missed preview functions
+- - Introduced by 3.6.2
+- 
+- 
+- Fix: AWS doesn't work
+- - Introduced by 3.6.4
+- 
+- 
+- Fix: Ensure not to get unrelated indices information in Elasticsearch Management
+- - Introduced by 3.6.6
+- 
+- 
+- Support: Optimize bundles
+- Support: Optimize build-prod job with caching node_modules/.cache
+
+## v3.6.6
+
+- Feature: Reconnect to Elasticsearch from Full Text Search Management
+- Feature: Normalize indices of Elasticsearch from Full Text Search Management
+- Improvement: Add 'spring' theme
+- Improvement: Reactify admin pages (Notification)
+- Impromvement: Add `checkMiddlewaresStrictly` option to Healthcheck API
+- Improvement: Accessibility for History component under dark themes
+- Fix: Warning on client console when developing /admin/app
+- Support: Upgrade libs
+- - react-bootstrap-typeahead
+- 
+- 
+
+## v3.6.5 (Discontinued)
+
+## v3.6.4
+
+- Feature: Alert for stale page
+- Improvement: Reactify admin pages (Home)
+- Improvement: Reactify admin pages (App)
+- Improvement: Accessibility for editor icons of dark themes
+- Improvement: Accessibility for importing table data pane
+- Improvement: Resolve username and email when logging in with Google OAuth
+
+## v3.6.3
+
+- Improvement: Searching users in UserGroup Management
+- Fix: Repair google authentication by migrating to jaredhanson/passport-google-oauth2
+- Fix: Markdown Settings are broken by the button to import recommended settings
+- Support: Upgrade libs
+- - check-node-version
+- 
+- - file-loader
+- 
+- - mini-css-extract-plugin
+- 
+- 
+
+## v3.6.2
+
+- Improvement: Reactify admin pages (Customize)
+- Improvement: Ensure not to consider `[text|site](https://example.com]` as a row in the table
+- Improvement: Enter key behavior in markdown table
+- Fix: Pre-installed plugins in official docker image are not detected
+- - Introduced by 3.6.0
+- 
+- 
+- Fix: Emoji Autocomplete window does not float correctly
+- - Introduced by 3.5.0
+- 
+- 
+
+## v3.6.1
+
+### BREAKING CHANGES
+
+- GROWI v3.6.x no longer support Node.js v8.x
+- The name of database that is storing migrations meta data has been changed
+- - This affects **only when `MONGO_URI` has parameters**
+- 
+- - v3.5.x or above has a bug ([#1361](https://github.com/weseek/growi/issues/1361))
+- 
+- 
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/36x.html](https://docs.growi.org/en/admin-guide/upgrading/36x.html)
+
+### Updates
+
+- Improvement: Drop unnecessary MongoDB collection indexes
+- Improvement: Accessibility of Antarctic theme
+- Improvement: Reactify admin pages (Markdown Settings)
+- Fix: Appending tag is failed by wrong index of PageTagRelation
+- - Introduced by 3.5.20
+- 
+- 
+- Fix: Pages without heading slash is invalid but creatable
+- Fix: Connect to Elasticsearch with `httpAuth` param
+- Support: Support Node.js v12
+- Support: Optimize build in dev with hard-source-webpack-plugin
+- Support: Upgrade libs
+- - growi-commons
+- 
+- 
+
+## v3.6.0 (Discontinued)
+
+## v3.5.25
+
+- Improvement: Disable ESC key to close Handsontable Modal
+- Fix: Exported data of empty collection is broken
+- Fix: Some components crash after when the page with attachment has exported/imported
+
+## v3.5.24
+
+- Fix: Plugins are not working on Heroku
+
+## v3.5.23
+
+- Fix: Global Notification failed to send e-mail
+- Fix: Pagination is not working for trash list
+- Fix: Healthcheck API with `?connectToMiddlewares` returns error
+- Support: Upgrade libs
+- - growi-commons
+- 
+- 
+
+## v3.5.22
+
+- Improvement: Add `FILE_UPLOAD_DISABLED` env var
+
+## v3.5.21
+
+- Improvement: Cache control when retrieving attachment data
+- Fix: Inviting user doesn't work
+- - Introduced by 3.5.20
+- 
+- 
+
+## v3.5.20
+
+- Improvement: Organize MongoDB collection indexes uniqueness
+- Improvement: Reactify admin pages (External Account Management)
+- Fix: Search result or Timeline shows loading icon eternally when retrieving not accessible page
+- Support: Use SearchBox Elasticsearch Addon on Heroku
+- Support: Upgrade libs
+- - cross-env
+- 
+- - eslint-plugin-jest
+- 
+- - i18next
+- 
+- - i18next-browser-languagedetector
+- 
+- - migrate-mongo
+- 
+- - react-i18next
+- 
+- - validator
+- 
+- 
+
+## v3.5.19 (Discontinued)
+
+## v3.5.18
+
+- Improvement: Import GROWI Archive
+- - Process asynchronously
+- 
+- - Collection configurations
+- 
+- - Selectable mode (insert/upsert/flush and insert)
+- 
+- - Safely mode settings for configs and users collections
+- 
+- - Show errors view
+- 
+- 
+- Improvement: Optimize handling promise of stream when exporting archive
+- Improvement: Optimize handling promise of stream when building indices
+- Improvement: Add link to [docs.growi.org](https://docs.growi.org)
+- Fix: Monospace font code is broken when printing on Mac
+
+## v3.5.17
+
+- Feature: Upload to GCS (Google Cloud Storage)
+- Feature: Statistics API
+- Improvement: Optimize exporting
+- Improvement: Show progress bar when exporting
+- Improvement: Validate collection combinations when importing
+- Improvement: Reactify admin pages
+- Fix: Use HTTP PlantUML URL in default
+- - Introduced by 3.5.12
+- 
+- 
+- Fix: Config default values
+- Support: REPL with `console` npm scripts
+
+## v3.5.16
+
+- Fix: Full Text Search doesn't work after when building indices
+- - Introduced by 3.5.12
+- 
+- 
+
+## v3.5.15
+
+- Feature: Import/Export Page data
+- Fix: The link to Sandbox on Markdown Help Modal doesn't work
+- Support: Upgrade libs
+- - codemirror
+- 
+- 
+
+## v3.5.14 (Discontinued)
+
+## v3.5.13
+
+- Feature: Re-edit comments
+- Support: [growi-plugin-attachment-refs](https://github.com/weseek/growi-plugin-attachment-refs)
+- Support: Upgrade libs
+- - entities
+- 
+- - markdown-it
+- 
+- 
+
+## v3.5.12
+
+- Improvement: Use Elasticsearch Alias
+- Improvement: Connect to HTTPS PlantUML URL in default
+- Fix: Global Notification doesn't work after updating Webhook URL
+- Fix: User Trigger Notification is not be sent when channel is not specified
+- Support: Upgrade libs
+- - terser-webpack-plugin
+- 
+- 
+
+## v3.5.11
+
+- Fix: HackMD Editor shows 404 error when HackMD redirect to fqdn URI
+- - Introduced by 3.5.8
+- 
+- 
+- Fix: Timeline doesn't work
+- - Introduced by 3.5.1
+- 
+- 
+- Fix: Last Login field does not shown in /admin/user
+- Support: Upgrade libs
+- - env-cmd
+- 
+- - sass-loader
+- 
+- - webpack
+- 
+- - webpack-cli
+- 
+- - webpack-merge
+- 
+- 
+
+## v3.5.10
+
+- Feature: Send Global Notification with Slack
+- Improvement: Show loading spinner when fetching page history data
+- Improvement: Hierarchical page link when the page is in /Trash
+- Fix: Code Highlight Theme does not change
+- - Introduced by 3.5.2
+- 
+- 
+- Support: Upgrade libs
+- - date-fns
+- 
+- - eslint-config-weseek
+- 
+- 
+
+## v3.5.9
+
+- Fix: Editing table with Spreadsheet like GUI (Handsontable) is failed
+- Fix: Plugins are not initialized when first launching
+- - Introduced by 3.5.0
+- 
+- 
+- Support: Upgrade libs
+- - entities
+- 
+- - growi-commons
+- 
+- - openid-client
+- 
+- - rimraf
+- 
+- - style-loader
+- 
+- 
+
+## v3.5.8
+
+- Improvement: Controls when HackMD/CodiMD has unsaved draft
+- Improvement: Show hints if HackMD/CodiMD integration is not working
+- Improvement: GROWI server obtains HackMD/CodiMD page id from the 302 response header
+- Improvement: Comment Thread Layout
+- Improvement: Show commented date with date distance format
+
+## v3.5.7 (Discontinued)
+
+## v3.5.6
+
+- Fix: Saving new page is failed when empty string tag is set
+- Fix: Link of Create template page button in New Page Modal is broken
+- Fix: Global Notification dows not work when creating/moving/deleting/like/comment
+
+## v3.5.5
+
+- Feature: Support S3-compatible object storage (e.g. MinIO)
+- Feature: Enable/Disable ID/Password Authentication
+- Improvement: Login Mechanism with HTTP Basic Authentication header
+- Improvement: Reactify Table Of Contents
+- Fix: Profile images are broken in User Management
+- Fix: Template page under root page doesn't work
+- Support: Upgrade libs
+- - csv-to-markdown-table
+- 
+- - express-validator
+- 
+- - markdown-it
+- 
+- - mini-css-extract-plugin
+- 
+- - react-hotkeys
+- 
+- 
+
+## v3.5.4
+
+- Fix: List private pages wrongly
+- Fix: Global Notification Trigger Path does not parse glob correctly
+- Fix: Consecutive page deletion requests cause unexpected complete page deletion
+
+## v3.5.3
+
+- Improvement: Calculate string width when save with Spreadsheet like GUI (Handsontable)
+- Fix: Search Result Page doesn't work
+- Fix: Create/Update page API returns data includes author's password hash
+- Fix: Dropdown to copy page path/URL/MarkdownLink shows under CodeMirror vscrollbar
+- Fix: Link to /trash in Dropdown menu
+
+## v3.5.2
+
+- Feature: Remain metadata option when Move/Rename page
+- Improvement: Support code highlight for Swift and Kotlin
+- Fix: Couldn't restrict page with user group permission
+- Fix: Couldn't duplicate a page when it restricted by a user group permission
+- Fix: Consider timezone on admin page
+- Fix: Editor doesn't work on Microsoft Edge
+- Support: Upgrade libs
+- - growi-commons
+- 
+- 
+
+## v3.5.1
+
+### BREAKING CHANGES
+
+- GROWI no longer supports
+- - Protection system with Basic Authentication
+- 
+- - Crowi Classic Authentication Mechanism
+- 
+- - [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
+- 
+- 
+- GROWI no lonnger supports plugins with schema version 2
+- - Upgrade [weseek/growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx) to v3.0.0 or above
+- 
+- - Upgrade [weseek/growi-plugin-pukiwiki-like-linker
+- 
+- - ](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) to v3.0.0 or above
+- 
+- 
+- The restriction mode of the root page (`/`) will be set 'Public'
+- The restriction mode of the root page (`/`) can not be changed after v 3.5.1
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/35x.html](https://docs.growi.org/en/admin-guide/upgrading/35x.html)
+
+### Updates
+
+- Feature: Comment Thread
+- Feature: OpenID Connect authentication
+- Feature: HTTP Basic authentication
+- Feature: Staff Credits with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
+- Feature: Restricte Complete Deletion of Pages
+- Improvement Draft list
+- Fix: Deleting page completely
+- Fix: Search with `prefix:` param with CJK pathname
+- Fix: Could not edit UserGroup even if `PUBLIC_WIKI_ONLY` is not set
+- I18n: User Management Details
+- I18n: Group Management Details
+- Support: Apply unstated
+- Support: Use Babel 7
+- Support: Support plugins with schema version 3
+- Support: Abolish Old Config API
+- Support: Apply Jest for Tests
+- Support: Upgrade libs
+- - async
+- 
+- - axios
+- 
+- - connect-mongo
+- 
+- - css-loader
+- 
+- - eslint
+- 
+- - eslint-config-weseek
+- 
+- - eslint-plugin-import
+- 
+- - eslint-plugin-jest
+- 
+- - eslint-plugin-react
+- 
+- - file-loader
+- 
+- - googleapis
+- 
+- - i18next
+- 
+- - migrate-mongo
+- 
+- - mini-css-extract-plugin
+- 
+- - mongoose
+- 
+- - mongoose-gridfs
+- 
+- - mongoose-unique-validator
+- 
+- - null-loader
+- 
+- 
+
+## v3.5.0 (Discontinued)
+
+## v3.4.7
+
+- Improvement: Handle private pages on group deletion
+- Fix: Searching with `tag:xxx` syntax doesn't work
+- Fix: Check CSRF when updating user data
+- Fix: `createdAt` field initialization
+- I18n: Import data page
+- I18n: Group Management page
+
+## v3.4.6
+
+- Feature: Tags
+- Feature: Dropdown to copy page path/URL/MarkdownLink
+- Feature: List of drafts
+- Improvement: Replace icons of Editor Tool Bar
+- Improvement: Show display name when mouse hover to user image
+- Fix: URL in slack message is broken on Safari
+- Fix: Registration does not work when basic auth is enabled
+- Support: Publish API docs with swagger-jsdoc and ReDoc
+- Support: Upgrade libs
+- - cmd-env
+- 
+- - elasticsearch
+- 
+- - mongoose-gridfs
+- 
+- - node-dev
+- 
+- - null-loader
+- 
+- - react-codemirror
+- 
+- 
+
+## v3.4.5
+
+- Improvement: Pass autolink through the XSS filter according to CommonMark Spec
+- Fix: Update ElasticSearch index when deleting/duplicating pages
+- Fix: Xss filter breaks PlantUML arrows
+- Support: Support growi-plugin-lsx@2.2.0
+- Support: Upgrade libs
+- - growi-commons
+- 
+- - xss
+- 
+- 
+
+## v3.4.4
+
+- Fix: Comment component doesn't work
+
+## v3.4.3
+
+- Improvement: Add 'antarctic' theme
+- Support Apply eslint-config-airbnb based rules
+- Support Apply prettier and stylelint
+- Support: Upgrade libs
+- - csrf
+- 
+- - escape-string-regexp
+- 
+- - eslint
+- 
+- - express-session
+- 
+- - googleapis
+- 
+- - growi-commons
+- 
+- - i18next
+- 
+- - mini-css-extract-plugin
+- 
+- - nodemailer
+- 
+- - penpal
+- 
+- - react-i18next
+- 
+- - string-width
+- 
+- 
+
+## v3.4.2
+
+- Fix: Nofitication to Slack doesn't work
+- - Introduced by 3.4.0
+- 
+- 
+
+## v3.4.1
+
+- Fix: "Cannot find module 'stream-to-promise'" occured when build client with `FILE_UPLOAD=local`
+
+## v3.4.0
+
+### BREAKING CHANGES
+
+None.
+
+Upgrading Guide: [https://docs.growi.org/en/admin-guide/upgrading/34x.html](https://docs.growi.org/en/admin-guide/upgrading/34x.html)
+
+### Updates
+
+- Improvement: Restrict to access attachments when the user is not allowed to see page
+- Improvement: Show fans and visitors of page
+- Improvement: Full text search tokenizing
+- Improvement: Markdown comment on Crowi Classic Layout
+- Fix: Profile image is not displayed when `FILE_UPLOAD=mongodb`
+- Fix: Posting comment doesn't work under Crowi Classic Layout
+- - Introduced by 3.1.5
+- 
+- 
+- Fix: HackMD doesn't work when `siteUrl` ends with slash
+- Fix: Ensure not to be able to move/duplicate page to the path which has trailing slash
+- Support: Launch with Node.js v10
+- Support: Launch with MongoDB 3.6
+- Support: Launch with Elasticsearch 6.6
+- Support: Upgrade libs
+- - bootstrap-sass
+- 
+- - browser-sync
+- 
+- - react
+- 
+- - react-dom
+- 
+- 
+
+## v3.3.10
+
+- Feature: PlantUML and Blockdiag on presentation
+- Improvement: Render slides of presentation with GrowiRenderer
+- Fix: Unportalizing doesn't work
+- Support: Use mini-css-extract-plugin instead of extract extract-text-webpack-plugin
+- Support: Use terser-webpack-plugin instead of uglifyjs-webpack-plugin
+- Support: Upgrade libs
+- - csv-to-markdown-table
+- 
+- - file-loader
+- 
+- - googleapis
+- 
+- - i18next-browser-languagedetector
+- 
+- - mocha
+- 
+- - react-waypoint
+- 
+- - webpack
+- 
+- - webpack-assets-manifest
+- 
+- - webpack-cli
+- 
+- - webpack-merge
+- 
+- 
+
+## v3.3.9
+
+- Fix: Import from Qiita:Team doesn't work
+- - Introduced by 3.3.0
+- 
+- 
+- Fix: Typeahead shows autocomplete wrongly
+- - Introduced by 3.3.8
+- 
+- 
+- Support: Upgrade libs
+- - react-bootstrap-typeahead
+- 
+- 
+
+## v3.3.8
+
+- Fix: Move/Duplicate don't work
+- - Introduced by 3.3.7
+- 
+- 
+- Fix: Server doesn't respond when root page is restricted
+- Support: Upgrade libs
+- - react
+- 
+- - react-bootstrap-typeahead
+- 
+- 
+
+## v3.3.7
+
+- Feature: Editor toolbar
+- Feature: `prefix:/path` searching syntax to filter with page path prefix
+- Feature: Add an option to filter only children to searching box of navbar
+- Improvement: Suggest page path when moving/duplicating/searching
+- Fix: Anonymous users couldn't search
+- - Introduced by 3.3.6
+- 
+- 
+- I18n: Searching help
+- Support: Prepare to suppoert Node.js v10
+- Support: Upgrade libs
+- - node-sass
+- 
+- 
+
+## v3.3.6
+
+- Improvement: Site URL settings must be set
+- Improvement: Site URL settings can be set with environment variable
+- Fix: "Anyone with the link" ACL doesn't work correctly
+- - Introduced by 3.3.0
+- 
+- 
+- Fix: Related pages list of /admin/user-group-detail/xxx doesn't show anything
+- - Introduced by 3.3.0
+- 
+- 
+- Fix: Diff of revision contents doesn't appeared when notifing with slack
+- Fix: NPE occured on /admin/security when Crowi Classic Auth Mechanism is set
+- Fix: Coudn't render Timing Diagram with PlantUML
+- I18n: Cheatsheet for editor
+- I18n: Some admin pages
+- Support: Upgrade libs
+- - diff
+- 
+- - markdown-it-plantuml
+- 
+- - mongoose
+- 
+- - nodemailer
+- 
+- - mongoose-gridfs
+- 
+- - sinon
+- 
+- - sinon-chai
+- 
+- 
+
+## v3.3.5 (Discontinued)
+
+## v3.3.4
+
+- Improvement: SAML configuration with environment variables
+- Improvement: Upload file with pasting from clipboard
+- Fix: `/_api/revisions.get` doesn't populate author data correctly
+- Fix: Wrong OAuth callback url are shown at admin page
+- Fix: Connecting to MongoDB failed when processing migration
+- Support: Get ready to use new config management system
+
+## v3.3.3
+
+- Feature: Show line numbers to a code block
+- Feature: Bulk update the scope of descendant pages when create/update page
+- Improvement: The scope of ascendant page will be retrieved and set to controls in advance when creating a new page
+- Fix: Pages that is restricted by groups couldn't be shown in search result page
+- Fix: Pages order in search result page was wrong
+- Fix: Guest user can't search
+- Fix: Possibility that ExternalAccount deletion processing selects incorrect data
+- Support: Upgrade libs
+- - bootstrap-sass
+- 
+- - i18next
+- 
+- - migrate-mongo
+- 
+- - string-width
+- 
+- 
+
+## v3.3.2
+
+- Fix: Specified Group ACL is not persisted correctly
+- - Introduced by 3.3.0
+- 
+- 
+
+## v3.3.1
+
+- Feature: NO_CDN Mode
+- Feature: Add option to show/hide restricted pages in list
+- Feature: MongoDB GridFS quota
+- Improvement: Refactor Access Control
+- Improvement: Checkbox behavior of task list
+- Improvement: Fixed search input on search result page
+- Improvement: Add 'christmas' theme
+- Improvement: Select default language of new users
+- Fix: Hide restricted pages contents in timeline
+- Support: Upgrade libs
+- - googleapis
+- 
+- - passport-saml
+- 
+- 
+
+## v3.3.0 (Discontinued)
+
+## v3.2.10
+
+- Fix: Pages in trash are available to create
+- Fix: Couldn't create portal page under Crowi Classic Behavior
+- Fix: Table tag in Timeline/SearchResult missed border and BS3 styles
+- I18n: Installer
+
+## v3.2.9
+
+- Feature: Attachment Storing to MongoDB GridFS
+- Fix: row/col moving of Spreadsheet like GUI (Handsontable) doesn't work
+- Fix: Emoji AutoComplete dialog pops up at wrong position
+- Support: Upgrade libs
+- - codemirror
+- 
+- - react-codemirror2
+- 
+- 
+
+## v3.2.8
+
+- Improvement: Add an option to use email for account link when using SAML federation
+- Fix: Editor layout is sometimes broken
+- Fix: Normalize table data for Spreadsheet like GUI (Handsontable) when import
+- Support: Improve development environment
+- Support: Upgrade libs
+- - googleapis
+- 
+- - react-dropzone
+- 
+- 
+
+## v3.2.7
+
+- Feature: Import CSV/TSV/HTML table on Spreadsheet like GUI (Handsontable)
+- Fix: Pasting table data copied from Excel includes unnecessary line breaks
+- Fix: Page break Preset 1 for Presentation mode is broken
+- Fix: Login Form when LDAP login failed caused 500 Internal Server Error
+
+## v3.2.6
+
+- Feature: Add select alignment buttons of Spreadsheet like GUI (Handsontable)
+- Improvement: Shrink the rows that have no diff of revision history page
+- Fix: Login form rejects weak password
+- Fix: An error occured by uploading attachment file when the page is not exists
+- - Introduced by 2.3.5
+- 
+- 
+- Support: Upgrade libs
+- - i18next-express-middleware
+- 
+- - i18next-node-fs-backend
+- 
+- - i18next-sprintf-postprocessor
+- 
+- 
+
+## v3.2.5
+
+- Improvement: Expandable Spreadsheet like GUI (Handsontable)
+- Improvement: Move/Resize rows/columns of Spreadsheet like GUI (Handsontable)
+- Improvement: Prevent XSS of New Page modal
+- Fix: Recent Created tab of user home shows wrong page list
+- - Introduced by 3.2.4
+- 
+- 
+- Support: Upgrade libs
+- - @handsontable/react
+- 
+- - handsontable
+- 
+- - metismenu
+- 
+- - sinon
+- 
+- 
+
+## v3.2.4
+
+- Feature: Edit table with Spreadsheet like GUI (Handsontable)
+- Feature: Paging recent created in users home
+- Improvement: Specify certificate for SAML Authentication
+- Fix: SAML Authentication didn't work
+- - Introduced by 3.2.2
+- 
+- 
+- Fix: Failed to create new page with title which includes RegEx special characters
+- Fix: Preventing XSS Settings are not applied in default
+- - Introduced by 3.1.12
+- 
+- 
+- Support: Mongoose migration mechanism
+- Support: Upgrade libs
+- - googleapis
+- 
+- - mocha
+- 
+- - mongoose
+- 
+- - mongoose-paginate
+- 
+- - mongoose-unique-validator
+- 
+- - multer
+- 
+- 
+
+## v3.2.3
+
+- Feature: Kibela like layout
+- Improvement: Custom newpage separator for presentation view
+- Support: Shrink image size for themes which recently added
+
+## v3.2.2
+
+- Feature: SAML Authentication (SSO)
+- Improvement: Add 'wood' theme
+- Improvement: Add 'halloween' theme
+- Improvement: Add 'island' theme
+- Fix: Sending email function doesn't work
+- Support Upgrade libs
+- - style-loader
+- 
+- 
+
+## v3.2.1
+
+- Feature: Import data from esa.io
+- Feature: Import data from Qiita:Team
+- Feature: Add the endpoint for health check
+- Improvement: Adjust styles when printing
+- Fix: Renaming page doesn't work if the page was saved with shortcut
+- Support: Refactor directory structure
+- Support Upgrade libs
+- - file-loader
+- 
+- - googleapis
+- 
+- - postcss-loader
+- 
+- - sass-loader
+- 
+- - style-loader
+- 
+- 
+
+## v3.2.0
+
+- Feature: HackMD integration so that user will be able to simultaneously edit with multiple people
+- Feature: Login with Twitter Account (OAuth)
+- Fix: The Initial scroll position is wrong when reloading the page
+
+## v3.1.14
+
+- Improvement: Show help for header search box
+- Improvement: Add Markdown Cheatsheet to Editor component
+- Fix: Couldn't delete page completely from search result page
+- Fix: Tabs of trash page are broken
+
+## v3.1.13
+
+- Feature: Global Notification
+- Feature: Send Global Notification with E-mail
+- Improvement: Add attribute mappings for email to LDAP settings
+- Support: Upgrade libs
+- - autoprefixer
+- 
+- - css-loader
+- 
+- - method-override
+- 
+- - optimize-css-assets-webpack-plugin
+- 
+- - react
+- 
+- - react-bootstrap-typeahead
+- 
+- - react-dom
+- 
+- 
+
+## v3.1.12
+
+- Feature: Add XSS Settings
+- Feature: Notify to Slack when comment
+- Improvement: Prevent XSS in various situations
+- Improvement: Show forbidden message when the user accesses to ungranted page
+- Improvement: Add overlay styles for pasting file to comment form
+- Fix: Omit unnecessary css link
+- - Introduced by 3.1.10
+- 
+- 
+- Fix: Invitation mail do not be sent
+- Fix: Edit template button on New Page modal doesn't work
+
+## v3.1.11
+
+- Fix: OAuth doesn't work in production because callback URL field cannot be specified
+- - Introduced by 3.1.9
+- 
+- 
+
+## v3.1.10
+
+- Fix: Enter key on react-bootstrap-typeahead doesn't submit
+- - Introduced by 3.1.9
+- 
+- 
+- Fix: CodeMirror of `/admin/customize` is broken
+- - Introduced by 3.1.9
+- 
+- 
+
+## v3.1.9
+
+- Feature: Login with Google Account (OAuth)
+- Feature: Login with GitHub Account (OAuth)
+- Feature: Attach files in Comment
+- Improvement: Write comment with CodeMirror Editor
+- Improvement: Post comment with `Ctrl-Enter`
+- Improvement: Place the commented page at the beginning of the list
+- Improvement: Resolve errors on IE11 (Experimental)
+- Support: Migrate to webpack 4
+- Support: Upgrade libs
+- - eslint
+- 
+- - react-bootstrap-typeahead
+- 
+- - react-codemirror2
+- 
+- - webpack
+- 
+- 
+
+## v3.1.8 (Discontinued)
+
+## v3.1.7
+
+- Fix: Update hidden input 'pageForm[grant]' when save with `Ctrl-S`
+- Fix: Show alert message when conflict
+- Fix: `BLOCKDIAG_URI` environment variable doesn't work
+- Fix: Paste in markdown list doesn't work correctly
+- Support: Ensure to inject logger configuration from environment variables
+- Support: Upgrade libs
+- - sinon
+- 
+- - sinon-chai
+- 
+- 
+
+## v3.1.6
+
+- Feature: Support [blockdiag](http://blockdiag.com)
+- Feature: Add `BLOCKDIAG_URI` environment variable
+- Fix: Select modal for group is not shown
+- Support: Upgrade libs
+- - googleapis
+- 
+- - throttle-debounce
+- 
+- 
+
+## v3.1.5
+
+- Feature: Write comment with Markdown
+- Improvement: Support some placeholders for template page
+- Improvement: Omit unnecessary response header
+- Improvement: Support LDAP attribute mappings for user's full name
+- Improvement: Enable to scroll revision-toc
+- Fix: Posting to Slack doesn't work
+- - Introduced by 3.1.0
+- 
+- 
+- Fix: page.rename api doesn't work
+- Fix: HTML escaped characters in markdown are unescaped unexpectedly after page is saved
+- Fix: sanitize `#raw-text-original` content with 'entities'
+- Fix: Double newline character posted
+- - Introduced by 3.1.4
+- 
+- 
+- Fix: List and Comment components do not displayed
+- - Introduced by 3.1.4
+- 
+- 
+- Support: Upgrade libs
+- - markdown-it-toc-and-anchor-with-slugid
+- 
+- 
+
+## v3.1.4 (Discontinued)
+
+## v3.1.3 (Discontinued)
+
+## v3.1.2
+
+- Feature: Template page
+- 
+- Improvement: Add 'future' theme
+- 
+- Improvement: Modify syntax for Crowi compatible template feature
+- 
+- - *before*
+- 
+- - 
+- 
+- - ```~~~markdown
+- - 
+- - ```
+- 
+- - ```template:/page/name
+- - 
+- - ```
+- 
+- - page contents
+- 
+- - ```
+- - 
+- - ```
+- 
+- - ```
+- - 
+- - ```
+- 
+- - ```
+- - 
+- - ```
+- 
+- - *after*
+- 
+- - 
+- 
+- - ```~~~plane
+- - 
+- - ```
+- 
+- - ::: template:/page/name
+- 
+- - page contents
+- 
+- - :::
+- 
+- - ```
+- - 
+- - ```
+- 
+- - ```
+- - 
+- - ```
+- 
+- 
+- Improvement: Escape iframe tag in block codes
+- 
+- Support: Upgrade libs
+- 
+- - assets-webpack-plugin
+- 
+- - googleapis
+- 
+- - react-clipboard.js
+- 
+- - xss
+- 
+- 
+
+## v3.1.1
+
+- Improvement: Add 'blue-night' theme
+- Improvement: List up pages which restricted for Group ACL
+- Fix: PageGroupRelation didn't remove when page is removed completely
+
+## v3.1.0
+
+- Improvement: Group Access Control List - Select group modal
+- Improvement: Better input on mobile
+- Improvement: Detach code blocks correctly
+- Improvement: Auto-format markdown table which includes multibyte text
+- Improvement: Show icon when auto-format markdown table is activated
+- Improvement: Enable to switch show/hide border for highlight.js
+- Improvement: BindDN field allows also ActiveDirectory styles
+- Improvement: Show LDAP logs when testing login
+- Fix: Comment body doesn't break long terms
+- Fix: lsx plugin lists up pages that hit by forward match wrongly
+- - Introduced by 3.0.4
+- 
+- 
+- Fix: Editor is broken on IE11
+- Support: Multilingualize React components with i18next
+- Support: Organize dependencies
+- Support: Upgrade libs
+- - elasticsearch
+- 
+- - googleapis
+- 
+- 
+
+## v3.0.13
+
+- Improvement: Add Vim/Emacs/Sublime-Text icons for keybindings menu
+- Improvement: Add 'mono-blue' theme
+- Fix: Unportalize process failed silently
+- Fix: Sidebar breaks editor layouts
+- Support: Switch the logger from 'pino' to 'bunyan'
+- Support: Set the alias for 'debug' to the debug function of 'bunyan'
+- Support: Translate `/admin/security`
+- Support: Optimize bundles
+- - upgrade 'markdown-it-toc-and-anchor-with-slugid' and omit 'uslug'
+- 
+- 
+- Support: Optimize .eslintrc.js
+
+## v3.0.12
+
+- Feature: Support Vim/Emacs/Sublime-Text keybindings
+- Improvement: Add some CodeMirror themes (Eclipse, Dracula)
+- Improvement: Dynamic loading for CodeMirror theme files from CDN
+- Improvement: Prevent XSS when move/redirect/duplicate
+
+## v3.0.11
+
+- Fix: login.html is broken in iOS
+- Fix: Removing attachment is crashed
+- Fix: File-attaching error after new page creation
+- Support: Optimize development build
+- Support: Upgrade libs
+- - env-cmd
+- 
+- - googleapis
+- 
+- - sinon
+- 
+- 
+
+## v3.0.10
+
+- Improvement: Add 'nature' theme
+- Fix: Page list and Timeline layout for layout-growi
+- Fix: Adjust theme colors
+- - Introduced by 3.0.9
+- 
+- 
+
+## v3.0.9
+
+- Fix: Registering new LDAP User is failed
+- - Introduced by 3.0.6
+- 
+- 
+- Support: Organize scss for overriding bootstrap variables
+- Support: Upgrade libs
+- - codemirror
+- 
+- - react-codemirror2
+- 
+- - normalize-path
+- 
+- - style-loader
+- 
+- 
+
+## v3.0.8
+
+- Improvement: h1#revision-path occupies most of the screen when the page path is long
+- Improvement: Ensure not to save concealed email field to localStorage
+- Fix: Cannot input "c" and "e" on iOS
+
+## v3.0.7
+
+- Improvement: Enable to download an attached file with original name
+- Improvement: Use MongoDB for session store instead of Redis
+- Improvement: Update dropzone overlay icons and styles
+- Fix: Dropzone overlay elements doesn't show
+- - Introduced by 3.0.0
+- 
+- 
+- Fix: Broken page path of timeline
+- - Introduced by 3.0.4
+- 
+- 
+
+## v3.0.6
+
+- Improvement: Automatically bind external accounts newly logged in to local accounts when username match
+- Improvement: Simplify configuration for Slack Web API
+- Support: Use 'slack-node' instead of '@slack/client'
+- Support: Upgrade libs
+- - googleapis
+- 
+- - i18next
+- 
+- - i18next-express-middleware
+- 
+- - react-bootstrap-typeahead
+- 
+- - sass-loader
+- 
+- - uglifycss
+- 
+- 
+
+## v3.0.5
+
+- Improvement: Update lsx icons and styles
+- Fix: lsx plugins doesn't show page names
+
+## v3.0.4
+
+- Improvement: The option that switch whether add h1 section when create new page
+- Improvement: Encode page path that includes special character
+- Fix: Page-saving error after new page creation
+
+## v3.0.3
+
+- Fix: Login page is broken in iOS
+- Fix: Hide presentation tab if portal page
+- Fix: A few checkboxes doesn't work
+- - Invite user check with email in `/admin/user`
+- 
+- - Recursively check in rename modal
+- 
+- - Redirect check in rename modal
+- 
+- 
+- Fix: Activating invited user form url is wrong
+- Support: Use postcss-loader and autoprefixer
+
+## v3.0.2
+
+- Feature: Group Access Control List
+- Feature: Add site theme selector
+- Feature: Add a control to switch whether email shown or hidden by user
+- Feature: Custom title tag content
+- Fix: bosai version
+- Support: Rename to GROWI
+- Support: Add dark theme
+- Support: Refreshing bootstrap theme and icons
+- Support: Use Browsersync instead of easy-livereload
+- Support: Upgrade libs
+- - react-bootstrap
+- 
+- - react-bootstrap-typeahead
+- 
+- - react-clipboard.js
+- 
+- 
+
+## v3.0.1 (Discontinued)
+
+## v3.0.0 (Discontinued)
+
+## v2.4.4
+
+- Feature: Autoformat Markdown Table
+- Feature: highlight.js Theme Selector
+- Fix: The bug of updating numbering list by codemirror
+- Fix: Template LangProcessor doesn't work
+- - Introduced by 2.4.0
+- 
+- 
+- Support: Apply ESLint
+- Support: Upgrade libs
+- - react, react-dom
+- 
+- - codemirror, react-codemirror2
+- 
+- 
+
+## v2.4.3
+
+- Improvement: i18n in `/admin`
+- Improvement: Add `SESSION_NAME` environment variable
+- Fix: All Elements are cleared when the Check All button in DeletionMode
+- Support: Upgrade libs
+- - uglifycss
+- 
+- - sinon-chai
+- 
+- 
+
+## v2.4.2
+
+- Improvement: Ensure to set absolute url from root when attaching files when `FILE_UPLOAD=local`
+- Fix: Inline code blocks that includes doller sign are broken
+- Fix: Comment count is not updated when a comment of the page is deleted
+- Improvement: i18n in `/admin` (WIP)
+- Support: Upgrade libs
+- - googleapis
+- 
+- - markdown-it-plantuml
+- 
+- 
+
+## v2.4.1
+
+- Feature: Custom Header HTML
+- Improvement: Add highlight.js languages
+- - dockerfile, go, gradle, json, less, scss, typescript, yaml
+- 
+- 
+- Fix: Couldn't connect to PLANTUML_URI
+- - Introduced by 2.4.0
+- 
+- 
+- Fix: Couldn't render UML which includes CJK
+- - Introduced by 2.4.0
+- 
+- 
+- Support: Upgrade libs
+- - axios
+- 
+- - diff2html
+- 
+- 
+
+## v2.4.0
+
+- Feature: Support Footnotes
+- Feature: Support Task lists
+- Feature: Support Table with CSV
+- Feature: Enable to render UML diagrams with public plantuml.com server
+- Feature: Enable to switch whether rendering MathJax in realtime or not
+- Improvement: Replace markdown parser with markdown-it
+- Improvement: Generate anchor of headers with header strings
+- Improvement: Enhanced Scroll Sync on Markdown Editor/Preview
+- Improvement: Update `#revision-body` tab contents after saving with `Ctrl-S`
+- Fix: 500 Internal Server Error occures when basic-auth configuration is set
+
+## v2.3.9
+
+- Fix: `Ctrl-/` doesn't work on Chrome
+- Fix: Close Shortcuts help with `Ctrl-/`, ESC key
+- Fix: Jump to last line wrongly when `.revision-head-edit-button` clicked
+- Support: Upgrade libs
+- - googleapis
+- 
+- 
+
+## v2.3.8
+
+- Feature: Suggest page path when creating pages
+- Improvement: Prevent keyboard shortcuts when modal is opened
+- Improvement: PageHistory UI
+- Improvement: Ensure to scroll when edit button of section clicked
+- Improvement: Enabled to toggle the style for active line
+- Support: Upgrade libs
+- - style-loader
+- 
+- - react-codemirror2
+- 
+- 
+
+## v2.3.7
+
+- Fix: Open popups when `Ctrl+C` pressed
+- - Introduced by 2.3.5
+- 
+- 
+
+## v2.3.6
+
+- Feature: Theme Selector for Editor
+- Improvement: Remove unportalize button from crowi-plus layout
+- Fix: CSS for admin pages
+- Support: Shrink the size of libraries to include
+
+## v2.3.5
+
+- Feature: Enhanced Editor by CodeMirror
+- Feature: Emoji AutoComplete
+- Feature: Add keyboard shortcuts
+- Improvement: Attaching file with Dropzone.js
+- Improvement: Show shortcuts help with `Ctrl-/`
+- Fix: DOMs that has `.alert-info` class don't be displayed
+- Support: Switch and upgrade libs
+- - 8fold-marked -> marked
+- 
+- - react-bootstrap
+- 
+- - googleapis
+- 
+- - mongoose
+- 
+- - mongoose-unique-validator
+- 
+- - etc..
+- 
+- 
+
+## v2.3.4 (Discontinued)
+
+## v2.3.3
+
+- Fix: The XSS Library escapes inline code blocks
+- - Degraded by 2.3.0
+- 
+- 
+- Fix: NPE occurs on Elasticsearch when initial access
+- Fix: Couldn't invite users(failed to create)
+
+## v2.3.2
+
+- Improvement: Add LDAP group search options
+
+## v2.3.1
+
+- Fix: Blockquote doesn't work
+- - Degraded by 2.3.0
+- 
+- 
+- Fix: Couldn't create user with first LDAP logging in
+
+## v2.3.0
+
+- Feature: LDAP Authentication
+- Improvement: Prevent XSS
+- Fix: node versions couldn't be shown
+- Support: Upgrade libs
+- - express-pino-logger
+- 
+- 
+
+## v2.2.4
+
+- Fix: googleapis v23.0.0 lost the function `oauth2Client.setCredentials`
+- - Degraded by 2.2.2 updates
+- 
+- 
+- Fix: HeaderSearchBox didn't append 'q=' param when searching
+- - Degraded by 2.2.3 updates
+- 
+- 
+
+## v2.2.3
+
+- Fix: The server responds anything when using passport
+- - Degraded by 2.2.2 updates
+- 
+- 
+- Fix: Update `lastLoginAt` when login is success
+- Support: Replace moment with date-fns
+- Support: Upgrade react-bootstrap-typeahead
+- Improvement: Replace emojify.js with emojione
+
+## v2.2.2 (Discontinued)
+
+## v2.2.1
+
+- Feature: Duplicate page
+- Improve: Ensure that admin users can remove users waiting for approval
+- Fix: Modal doesn't work with React v16
+- Support: Upgrade React to 16
+- Support: Upgrade outdated libs
+
+## v2.2.0
+
+- Support: Merge official Crowi v1.6.3
+
+## v2.1.2
+
+- Improvement: Ensure to prevent suspending own account
+- Fix: Ensure to be able to use `.` for username when invited
+- Fix: monospace font for `&amp;lt;code&amp;gt;&amp;lt;/code&amp;gt;`
+
+## v2.1.1
+
+- Fix: The problem that React Modal doesn't work
+- Support: Lock some packages(react, react-dom, mongoose)
+
+## v2.1.0
+
+- Feature: Adopt Passport the authentication middleware
+- Feature: Selective batch deletion in search result page
+- Improvement: Ensure to be able to login with both of username or email
+- Fix: The problem that couldn't update user data in /me
+- Support: Upgrade outdated libs
+
+## v2.0.9
+
+- Fix: Server is down when a guest user accesses to someone's private pages
+- Support: Merge official Crowi (master branch)
+- Support: Upgrade outdated libs
+
+## v2.0.8
+
+- Fix: The problem that path including round bracket makes something bad
+- Fix: Recursively option processes also unexpedted pages
+- Fix: en_US translation
+
+## v2.0.7
+
+- Improvement: Add recursively option for Delete/Move/Putback operation
+- Improvement: Comment layout and sort order (crowi-plus Enhanced Layout)
+
+## v2.0.6
+
+- Fix: check whether `$APP_DIR/public/uploads` exists before creating symlink
+- - Fixed in weseek/crowi-plus-docker
+- 
+- 
+
+## v2.0.5
+
+- Improvement: Adjust styles for CodeMirror
+- Fix: File upload does not work when using crowi-plus-docker-compose and `FILE_UPLOAD=local` is set
+- - Fixed in weseek/crowi-plus-docker
+- 
+- 
+
+## v2.0.2 - 2.0.4 (Discontinued)
+
+## v2.0.1
+
+- Feature: Custom Script
+- Improvement: Adjust layout and styles for admin pages
+- Improvement: Record and show last updated date in user list page
+- Fix: Ignore Ctrl+(Shift+)Tab when editing (cherry-pick from the official)
+
+## v2.0.0
+
+- Feature: Enabled to integrate with Slack using Incoming Webhooks
+- Support: Upgrade all outdated libs
+
+## v1.2.16
+
+- Improvement: Condition for creating portal
+- Fix: Couldn't create new page after installation cleanly
+
+## v1.2.15
+
+- Improvement: Optimize cache settings for express server
+- Improvement: Add a logo link to the affix header
+- Fix: Child pages under `/trash` are not shown when applying crowi-plus Simplified Behavior
+
+## v1.2.14
+
+- Fix: Tabs(`a[data-toggle=&amp;quot;tab&amp;quot;][href=&amp;quot;#...&amp;quot;]`) push browser history twice
+- Fix: `a[href=&amp;quot;#edit-form&amp;quot;]` still save history even when disabling pushing states option
+
+## v1.2.13
+
+- Improvement: Enabled to switch whether to push states with History API when tabs changes
+- Fix: Layout of the Not Found page
+
+## v1.2.12 (Discontinued)
+
+## v1.2.11
+
+- Improvement: Enabled to open editing form from affix header
+- Improvement: Enabled to open editing form from each section headers
+
+## v1.2.10
+
+- Fix: Revise `server:prod:container` script for backward compatibility
+
+## v1.2.9
+
+- Improvement: Enabled to save with <kbd>⌘+S</kbd> on Mac
+- Improvement: Adopt the fastest logger 'pino'
+- Fix: The problem that can't upload profile image
+
+## v1.2.8
+
+- Fix: The problem that redirect doesn't work when using 'crowi-plus Simplified Behavior'
+
+## v1.2.7 (Discontinued)
+
+## v1.2.6
+
+- Fix: The problem that page_list widget doesn't show the picture of revision.author
+- Fix: Change implementation of Bootstrap3 toggle switch for admin pages
+
+## v1.2.5
+
+- Feature: crowi-plus Simplified Behavior
+- - `/page` and `/page/` both shows the page
+- 
+- - `/nonexistent_page` shows editing form
+- 
+- - All pages shows the list of sub pages
+- 
+- 
+- Improvement: Ensure to be able to disable Timeline feature
+
+## v1.2.4
+
+- Fix: Internal Server Error has occurred when a guest user visited the page someone added "liked"
+
+## v1.2.3
+
+- Improvement: Ensure to be able to use Presentation Mode even when not logged in
+- Improvement: Presentation Mode on IE11 (Experimental)
+- Fix: Broken Presentation Mode
+
+## v1.2.2
+
+- Support: Merge official Crowi (master branch)
+
+## v1.2.1
+
+- Fix: buildIndex error occured when access to installer
+
+## v1.2.0
+
+- Support: Merge official Crowi v1.6.2
+
+## v1.1.12
+
+- Feature: Remove Comment Button
+
+## v1.1.11
+
+- Fix: Omit Comment form from page_list (crowi-plus Enhanced Layout)
+- Fix: .search-box is broken on sm/xs screen
+
+## v1.1.10
+
+- Fix: .search-box is broken on sm/xs screen
+- Support: Browsable with IE11 (Experimental)
+
+## v1.1.9
+
+- Improvement: Ensure to generate indices of Elasticsearch when installed
+- Fix: Specify the version of Bonsai Elasticsearch on Heroku
+
+## v1.1.8
+
+- Fix: Depth of dropdown-menu when `.on-edit`
+- Fix: Error occured on saveing with `Ctrl-S`
+- Fix: Guest users browsing
+
+## v1.1.7
+
+- Feature: Add option to allow guest users to browse
+- Fix: crowi-plus Enhanced Layout
+
+## v1.1.6
+
+- Fix: crowi-plus Enhanced Layout
+
+## v1.1.5
+
+- Fix: crowi-plus Enhanced Layout
+- Support: Merge official Crowi v1.6.1 master branch [573144b]
+
+## v1.1.4
+
+- Feature: Ensure to select layout type from Admin Page
+- Feature: Add crowi-plus Enhanced Layout
+
+## v1.1.3
+
+- Improvement: Use POSIX-style paths (bollowed crowi/crowi#219 by @Tomasom)
+
+## v1.1.2
+
+- Imprv: Brushup fonts and styles
+- Fix: Ensure to specity revision id when saving with `Ctrl-S`
+
+## v1.1.1
+
+- Feature: Save with `Ctrl-S`
+- Imprv: Brushup fonts and styles
+
+## v1.1.0
+
+- Support: Merge official Crowi v1.6.1
+
+## v1.0.9
+
+- Feature: Delete user
+- Feature: Upload other than images
+
+## v1.0.8
+
+- Feature: Ensure to delete page completely
+- Feature: Ensure to delete redirect page
+- Fix: https access to Gravatar (this time for sure)
+
+## v1.0.7
+
+- Feature: Keyboard navigation for search box
+- Improvement: Intelligent Search
+
+## v1.0.6
+
+- Feature: Copy button that copies page path to clipboard
+- Fix: https access to Gravatar
+- Fix: server watching crash with `Error: read ECONNRESET` on Google Chrome
+
+## v1.0.5
+
+- Feature: Ensure to use Gravatar for profile image
+
+## v1.0.4
+
+- Improvement: Detach code blocks before preProcess
+- Support: Ensure to deploy to Heroku with INSTALL_PLUGINS env
+- Support: Ensure to load plugins easily when development
+
+## v1.0.3
+
+- Improvement: Adjust styles
+
+## v1.0.2
+
+- Improvement: For lsx
+
+## v1.0.1
+
+- Feature: Custom CSS
+- Support: Notify build failure to Slask
+
+## v1.0.0
+
+- Feature: Plugin mechanism
+- Feature: Switchable LineBreaks ON/OFF from admin page
+- Improvement: Exclude Environment-dependency
+- Improvement: Enhanced linker
+- Support: Add Dockerfile
+- Support: Abolish gulp
+- Support: LiveReload
+- Support: Update libs

+ 0 - 2007
CHANGES.md

@@ -1,2007 +0,0 @@
-# CHANGES
-
-## v4.4.0-RC
-
-### BREAKING CHANGES
-
-* Official plugins are now preinstalled
-
-### Updates
-
-* Improvement: Add attachment button in editor navbar
-* Fix: Recursive rename operation from `/parent` to `/parent/child` ([#4101](https://github.com/weseek/growi/pull/4101))
-* Fix: Encode spaces in page path in LinkEditModal
-* Support: Create @growi/core package
-* Support: Create @growi/ui package
-* Support: Improve error handling for @growi/slackbot-proxy
-* Support: Include official plugins as sub packages
-* Support: Upgrade libs
-    * @slack/web-api
-    * date-fns
-    * helmet
-    * morgan
-    * socket.io
-
-## v4.3.3-RC
-
-* Improvement: Welcome page markdown
-* Fix: Some recursive operation exclude descendant pages that are restricted for groups
-    * Rename / Delete / Delete completely / Put back / Duplicate
-* Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
-* Support: Upgrade libs
-    * @slack/web-api
-    * date-fns
-    * escape-string-regexp
-
-## v4.3.2
-
-* Feature: Hufflpuff theme
-* Improvement: CodeMirror header styles
-* Improvement: CodeMirror syntax-highlighting fenced code blocks
-* Improvement: Slack Integration Settings
-    * Error behavior when getting connection statuses
-    * Add links to docs
-* Improvement: /_api/v3/recent can be accessed with access token
-* Support: Using http-errors
-
-## v4.3.1
-
-* Fix: Build script for production
-
-## v4.3.0
-
-### BREAKING CHANGES
-
-* GROWI manages dependencies with `lerna`
-    * Use `npx lerna bootstrap` instead of `yarn install`
-* GROWI includes some official plugins in default
-    * Users no longer need to install [growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx), [growi-plugin-pukiwiki-like-linker](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) and [growi-plugin-attachment-refs](https://github.com/weseek/growi-plugin-attachment-refs) before build client.
-
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/43x.html>
-
-### Updates
-
-* Feature: New Slack Integration with Slack Bot
-    * Searching GROWI pages from Slack
-    * Creating GROWI pages from Slack
-        * Easy record conversations
-* Feature: Enable/Disable option for share link
-* Feature: Re-send invitation mail from user management page
-* Improvement: Mark users who failed to send invitation emails
-* Fix: lsx plugin in the custom sidebar does not work when showing search result page
-* Support: Switch the official docker base image from Alpine based to Ubuntu based
-* Support: Upgrade libs
-    * striptags
-
-## v4.2.21
-
-* Improvement: Headers style on built-in editor
-* Improvement: Codemirror is now scrollable one editor height of empty space into view at the bottom of the editor
-* Improvement: Upgrade mongodb driver to fix [NODE-2784](https://jira.mongodb.org/browse/NODE-2784)
-* Support: Upgrade libs
-    * connect-mongo
-    * i18next
-    * migrate-mongo
-    * mongoose
-    * stream-to-promise
-    * validator
-    * ws
-    * nodemailer
-    * i18next-express-middleware
-    * growi-commons
-    * growi-plugin-attachment-refs
-    * growi-plugin-lsx
-
-## v4.2.20
-
-* Improvement: Error message when the password is too short
-* Improvement: Repeat XSS processing as a countermeasure against nesting 
-* Fix: NoSQL injection of access-token-parser
-* Fix: Checking permission when operating share links
-* Fix: Invalid NaN label is shown when deletedAt of the page is undefined
-    * Introduced by v4.2.8
-
-## v4.2.19
-
-* Feature: Set max-age of the user's cookie with the env var `SESSION_MAX_AGE`
-* Feature: Set max-age of the user's cookie in admin page
-* Improvement: Change the first accessing page after installation to the top page
-* Support: Upgrade libs
-    * string-width
-    * diff
-    * archiver
-
-## v4.2.18
-
-* Feature: Convertible page contents width
-* Fix: Group selector of User Group Delete Modal does not show all groups
-* Fix: Global notification to Slack does not encode spaces of page path
-* Support: Upgrade libs
-    * @google-cloud/storage
-
-## v4.2.17
-
-* Improvement: Invoke garbage collection when reindex all pages by elasticsearch
-* Improvement: Hide Sidebar at shared pages
-* Fix: No unsaved alert is displayed without difference the latest markdown and editor value
-* Support: Update libs
-    * eslint-config-weseek
-
-## v4.2.16
-
-* Fix: "Only inside the group" causes an error
-    * Introduced by v4.2.15
-
-## v4.2.15
-
-* Improvement: toastr location for editing
-* Improvement: Handsontable with static backdrop to prevent from closing when backdrop is clicked
-* Fix: Accept invalid page path like `..%2f`
-* Fix: Pages updated date is corrupted after recursive operation
-    * Introduced by v4.2.8
-* Support: Upgrade libs
-    * reactstrap
-
-
-## v4.2.14
-
-* Feature: Add an option to restrict publishing email property for new users
-* Improvement: Invite modal in admin page without email server settings
-* Improvement: Global notification settings in admin page without email server settings
-* Fix: Can create pages on the share route
-    * Introduced by v4.2.8
-* Fix: Pages restrected by group are excluded for recurrence operation
-    * Introduced by v4.2.8
-* Fix: Rename and duplicate to descendants path does not work correctly
-    * Introduced by v4.2.8
-* Support: Update libs
-    * bunyan
-    * browser-bunyan
-
-## v4.2.13
-
-* Feature: Detect indent size automatically
-* Fix: Some API responses includes email unintentionally
-* Fix: An error always displayed in admin pages
-
-## v4.2.12
-
-* Feature: Custom Sidebar
-* Fix: Set language correctly for draw.io (diagrams.net)
-
-## v4.2.11
-
-* Fix: Rename decendants is not working
-    * Introduced by v4.2.8
-
-
-## v4.2.10
-
-* Feature: Staff Credits for apps on GROWI.cloud
-* Improvement: Hackmd button behavior when disabled
-* Improvement: Layout of comparing revisions
-* Fix: Empty trash is not working
-
-## v4.2.9
-
-* Feature: Comparing revisions
-* Improvement: Memory consumption when re-indexing for full text searching
-* Improvement: Site URL settings valildation
-* Fix: Show comfirmation when transiting page without save
-* Fix: Save slack channels history when user trigger notification is invoked
-* Fix: The label of alerts for move/rename/delete are borken
-
-## v4.2.8
-
-* Improvement: Performance for pages to rename/duplicate/delete/revert pages
-* Fix: Preview scrollbar doesn't sync to editor
-    * Introduced by v4.2.6
-* Fix: Failed to save temporaryUrlCached with using gcs
-    * Introduced by v4.2.3
-* Fix: Fixed not being able to update ses settings
-    * Introduced by v4.2.0
-* Fix: Fixed the display of updtedAt and createdAt being reversed
-* Fix: Pass app title value through the XSS filter
-
-## v4.2.7
-
-* Fix: Installer doesn't work on Chrome
-
-## v4.2.6
-
-* Feature: Add a button to jump to Comments section
-* Feature: Paste Bootstrap4 Grid HTML with GUI
-* Feature: Disable auto formating table option
-* Improvement: Layout of Edit Link Modal
-* Improvement: Focus to the first input when modal is opened
-* Improvement: Preview layout in edit mode
-* Improvement: Install process under redundant environment
-* Improvement: Add contributors
-* Fix: Upgrading to v4.x failed when the user uses Kibela Layout
-    * Introduced by v4.2.0
-* Fix: diagrams.net (draw.io) errors
-* Fix: Navbar is not rendered on old iOS
-* Support: Expose metrics with Promster
-* Support: Upgrade libs
-    * axios
-
-## v4.2.5
-
-* Improvement: Invoke garbage collection when reindex all pages by elasticsearch
-    * Turned out not working -- 2021.05.01
-* Fix: MathJax rendering does not work
-
-## v4.2.4
-
-* Fix: Fixed an error when creating a new page with `Ctrl-S`
-    * Introduced by v4.2.2
-* Fix: Fixed a strange diff in PageHistory due to Pagination
-* Fix: Fixed that the user group page could not be found when using api from the outside
-
-## v4.2.3
-
-* Feature: Insert/edit links with GUI
-* Feature: Auto reconnecting to search service
-* Improvement: New style of params for Healthcheck API
-* Fix: Referencing attachments when `FILE_UPLOAD_DISABLED` is true
-* Fix: The message of timeline for restricted pages
-* Fix: Parameter validation for Import/Export Archive API
-* Fix: Prevent regexp for Search Tags API
-* Fix: Add `Content-Security-Policy` when referencing attachments
-* Fix: Sanitize at presentation time
-* Fix: Remove page path string from message for page lists and timeline when there is no contents
-
-## v4.2.2
-
-* Fix: Consecutive save operations with built-in editor fail
-    * Introduced by v4.2.1
-
-## v4.2.1
-
-* Fix: Consecutive save operations with HackMD fail
-    * Introduced by v4.2.0
-* Fix: Switching theme to kibela fail
-    * Introduced by v4.2.0
-
-## v4.2.0
-
-### BREAKING CHANGES
-
-* GROWI v4.2.x no longer support Kibela layout
-    * Kibela theme is newly added and the configuration will migrate to it automatically
-
-### Updates
-
-* Feature: File Upload Settings on admin pages
-* Improvement: Basic layout of page
-* Support: Support MongoDB 4.0, 4.2 and 4.4
-* Support: Upgrade libs
-    * migrate-mongo
-    * mongoose
-
-## v4.1.13
-
-* Fix: MathJax rendering does not work
-
-## v4.1.12
-
-* Fix: Adjust line-height for pre under li
-* Fix: Emptying trash process is broken
-
-## v4.1.11
-
-* Improvement: Generating draft DOM id strategy
-* Fix: GROWI version downgrade causes a validation error for user.lang
-
-## v4.1.10
-
-* Fix: Make listing users API secure
-* Fix: Error message when the server denies guest user connecting with socket.io
-
-## v4.1.9
-
-* Feature: Environment variables to set max connection size to deliver push messages to all clients
-
-## v4.1.8
-
-* Improvement: Rebuilding progress bar colors for Full Text Search Management
-* Improvement: Support operations on page data with a null value for author
-
-## v4.1.7
-
-* Improvement: Fire global notification when a new page is created by uploading file
-* Fix: Change default `DRAWIO_URI` to embed.diagrams.net
-* Fix: An unhandled rejection occures when a user who does not send referer accesses
-
-## v4.1.6
-
-* Improvement: Hide Fab at admin pages
-* Fix: Presentation does not work
-* Fix: Update GrantSelector status when uploading a file to a new page
-* Fix: CopyDropdown origin refs draw.io host wrongly
-
-## v4.1.5
-
-* Feature: Independent S3 configuration and SES configuration for AWS
-* Fix: Author name does not displayed in page history
-* Fix: Hide unnecessary component when pringing
-
-## v4.1.4 (Missing number)
-
-## v4.1.3
-
-* Feature: Create/edit linker with GUI
-* Improvement: Paging page histories
-* Improvement: Avoid using `cursor.snapshot()` in preparation for MongoDB version upgrade
-* Improvement: Allow to save "From e-mail address" only in App Settings
-* Improvement: Allow to empty "From e-mail address" in App Settings
-* Improvement: Export/Import archive data serially so as not to waste memory
-* Fix: To be able to delete attachment metadata even when the actual data does not exist
-* Fix: Limit the attrubutes of user data for `/_api/v3/users`
-* Fix: Prevent XSS with SVG
-* Upgrade libs
-    * optimize-css-assets-webpack-plugin
-    * terser-webpack-plugin
-
-## v4.1.2
-
-* Fix: Uploaded images do not displayed
-    * Introduced by v4.1.1
-
-## v4.1.1
-
-* Feature: External share link
-* Improvement: Optimize some features that operate revision data
-    * Page history
-    * Renaming pages
-    * Deleting pages
-* Fix: Cmd+c/v/... does not work on Mac
-    * Introduced by v4.1.0
-* Fix: "Append params" switch of CopyDropdown does not work when multiple CopyDropdown instance exists
-* Fix: "Append params" switch of CopyDropdown escapes spaces
-* Fix: Blockdiag does not be rendered
-* Fix: Access token parser
-
-## v4.1.0
-
-### BREAKING CHANGES
-
-* GROWI v4.1.x no longer support Node.js v10.x
-* GROWI v4.1.x no longer support growi-plugin-attachment-refs@v1
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
-
-### Updates
-
-* Feature: Server settings synchronization for multiple GROWI Apps
-* Feature: Page status alert synchronization for multiple GROWI Apps
-* Feature: Smooth scroll for anchor links
-* Feature: Mirror Mode with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
-* Improvement: Determine whether the "In Use" badge is displayed or not by attachment ID
-* Improvement: draw.io under NO_CDN environment
-* Fix: Deleting/renaming with recursive option affects pages that are inaccessible to active users
-* Fix: DrawioModal cuts without beginning/ending line
-* Fix: New settings of SMTP and AWS SES are not reflected when server is running
-* Fix: Sidebar layout broken when using Kibela layout
-* Support: Support Node.js v14
-* Support: Update libs
-    * mathjax
-
-## v4.0.11
-
-* Fix: Fab on search result page does not displayed
-* Fix: Adjust margin/padding for search result page
-* Fix: PageAlert broken
-    * Introduced by v4.0.9
-
-## v4.0.10
-
-* Improvement: Adjust ToC height
-* Fix: Fail to rename/delete a page set as "Anyone with the link"
-
-## v4.0.9
-
-* Feature: Detailed configurations for OpenID Connect
-    * Authorization Endpoint
-    * Token Endpoint
-    * Revocation Endpoint
-    * Introspection Endpoint
-    * UserInfo Endpoint
-    * Registration Endpoint
-    * JSON Web Key Set URI
-* Improvement: Navigations
-    * New floating subnavigation
-    * New open drawer button
-    * New fixed bottom navbar on mobile
-    * New fixed bottom navbar for editor on mobile
-    * FAB (Floating action button)
-* Improvement: Sticky admin navigation
-* Fix: Reseting password doesn't work
-* Fix: Styles for printing
-* Fix: Unable to create page with original path after emptying trash
-* I18n: Support zh-CN
-
-## v4.0.8 (Missing number)
-
-## v4.0.7
-
-* Feature: Set request timeout for Elasticsearch with env var `ELASTICSEARCH_REQUEST_TIMEOUT`
-* Improvement: Apply styles faster on booting client
-* Fix: Styles are not applyed on installer
-* Fix: Remove last-resort `next()`
-* Fix: Enable/disable Notification settings couldn't change when either of the params is undefined
-* Fix: Text overflow
-
-## v4.0.6
-
-* Fix: Avatar images in Recent Changes are not shown
-* Fix: Full screen modal of Handsontable and Draw.io don't work
-* Fix: Shortcut for creating page respond with modifier key wrongly
-    * Introduced by v4.0.5
-
-## v4.0.5
-
-* Improvement: Return pre-defined session id when healthcheck
-* Improvement: Refactor caching for profile image
-* Improvement: Layout for global search help on mobile
-* Improvement: Layout for confidential notation
-* Fix: Shortcut for creating page doesn't work
-* Support: Dev in container
-* Support: Upgrade libs
-    * ldapjs
-    * node-sass
-
-
-## v4.0.4
-
-* Feature: Drawer/Dock mode selector
-* Improvement: Admin pages navigation
-* Improvement: Ensure not to avoid session management even when accessing to healthcheck
-* Support: Refactor unstated utils
-* Support: Upgrade libs
-    * connect-mongo
-    * connect-redis
-    * mongoose
-    * mongoose-gridfs
-    * mongoose-paginate-v2
-
-## v4.0.3
-
-* Feature: Copy page path dropdown with Append params switch
-* Improvement: Truncate overflowed user browsing history
-* Improvement: Tabs appearance on mobile
-* Improvement: Search help appearance on mobile
-* Improvement: Accessibility of login page
-* Fix: Editor was broken by long lines
-* Fix: Editor doesn't work on mobile
-* Fix: Word break in Recent Updated contents
-* Fix: navbar is broken on Safari
-
-## v4.0.2
-
-* Fix: Internal Server Error occurred when the guest user access to the pages that has likes
-* Fix: Some buttons are broken on Safari
-
-## v4.0.1
-
-* Improvement: Accessibility for Handsontable under dark mode
-* Improvement: Refactor '/pages.exist' API
-* Fix: Storing the state of sidebar
-* Fix: Comments order should be asc
-* Fix: Show/Hide replies button doesn't work
-* Fix: Tooltip doesn't work
-* Fix: Change the display of the scroll bar when modal is shown
-* Fix: Submit with enter key on Create/Rename modals
-* Fix: Show/Hide Unlink redirection button conditions
-* Fix: Link color in alerts
-* Support: Upgrade libs
-    * @atlaskit/drawer
-    * @atlaskit/navigation-next
-
-## v4.0.0
-
-### BREAKING CHANGES
-
-* Crowi Classic Behavior is removed
-* Crowi Classic Layout is removed
-* 'default-dark' theme is now merged as a dark mode variant of 'default' theme
-* 'blue-night' theme is now merged as a dark mode variant of 'mono-blue' theme
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/40x.html>
-
-### Updates
-
-* Feature: Sidebar
-* Feature: Recent changes on Sidebar
-* Feature: Switch Light/Dark Mode
-* Improvement: Migrate to Bootstrap 4
-* Improvement: Copy Page URL menu item to copy path dropdown
-* Improvement: Show contributors by Bootstrap Modal
-* Support: Upgrade libs
-    * bootstrap
-
-## v3.8.1
-
-### BREAKING CHANGES
-
-- Now Elasticsearch requires the privilege `cluster:monitor/health` instead of `cluster:monitor/nodes/info`
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/38x.html>
-
-### Updates
-
-* Improvement: Change the health check method for Elasticsearch
-* Fix: Unset overflow-y style for Edit Tags Modal
-* Fix: Duplicate page source is overwrited
-    * Introduced by 3.7.6
-
-## v3.8.0  (Missing number)
-
-## v3.7.7
-
-* Feature: Empty trash pages
-* Improvement: Behavior of Reconnect to Elasticsearch button
-* Fix: Duplicate page source is overwrited
-    * Introduced by 3.7.6
-
-## v3.7.6  (Missing number)
-
-## v3.7.5
-
-* Fix: Draw.io diagrams rendered twice
-* Fix: Behavior of password reset modal is strange
-* Fix: Import GROWI Archive doesn't restore some data correctly
-* Fix: Attachments list on root page and users top pages
-* Fix: Trash page is no longer editable
-* Fix: Rendering Timeline on /trash
-
-## v3.7.4
-
-* Fix: Broken by displaying user image
-
-## v3.7.3
-
-* Feature: Profile Image Cropping
-* Improvement: Reactify users pages
-* Improvement: Detect language and adjust the order of first and last names when creating accounts in OAuth
-* Fix: Installation is broken when selecting Japanese
-    * Introduced by 3.7.0
-* Fix: Mathjax Rendering is unstable (workaround)
-    * Introduced by 3.7.0
-* Fix: Notification Setting couldn't update without slack token
-    * Introduced by 3.6.6
-* Support: Add GROWI Contributers
-
-## v3.7.2
-
-* Feature: User Management Filtering/Sort
-* Feature: Show env vars on Admin pages
-* Fix: Attachment row z-index
-* I18n: HackMD integration alert
-
-## v3.7.1
-
-* Improvement: Add an option that make it possible to choose what to send notifications
-* Improvement: Add the env var `DRAWIO_URI`
-* Improvement: Accessibility for 'spring' theme
-* Improvement: Editor scroll sync behaves strangely when using draw.io blocks
-* Fix: Coudn't upload file on Comment Editor
-    * Introduced by 3.5.8
-* I18n: HackMD integration
-
-## v3.7.0
-
-### BREAKING CHANGES
-
-None.
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/37x.html>
-
-### Updates
-
-* Feature: [Draw.io](https://www.draw.io/) Integration
-* Feature: SAML Attribute-based Login Control
-* Improvement: Reactify admin pages (Security)
-* Improvement: Behavior of pre-editing screen of HackMD when user needs to resume
-
-## v3.6.10
-
-* Fix: Redirect logic for users except for actives
-    * Introduced by 3.6.9
-
-## v3.6.9
-
-* Improvement: Redirection when login/logout
-* Improvement: Add home icon before '/'
-* Fix: Client crashed when the first login
-    * Introduced by 3.6.8
-
-## v3.6.8
-
-* Improvement: Show page history side-by-side
-* Improvement: Optimize markdown rendering
-* Improvement: Reactify admin pages (Navigation)
-* Fix: Reply comments collapsed are broken
-    * Introduced by 3.6.7
-* Support: Update libs
-    * cross-env
-    * mkdirp
-    * diff2html
-    * jest
-    * stylelint
-
-## v3.6.7
-
-* Feature: Anchor link for comments
-* Improvement: Show error toastr when saving page is failed because of empty document
-* Fix: Admin Customise couldn't restore stored config value
-    * Introduced by 3.6.2
-* Fix: Admin Customise missed preview functions
-    * Introduced by 3.6.2
-* Fix: AWS doesn't work
-    * Introduced by 3.6.4
-* Fix: Ensure not to get unrelated indices information in Elasticsearch Management
-    * Introduced by 3.6.6
-* Support: Optimize bundles
-* Support: Optimize build-prod job with caching node_modules/.cache
-
-## v3.6.6
-
-* Feature: Reconnect to Elasticsearch from Full Text Search Management
-* Feature: Normalize indices of Elasticsearch from Full Text Search Management
-* Improvement: Add 'spring' theme
-* Improvement: Reactify admin pages (Notification)
-* Impromvement: Add `checkMiddlewaresStrictly` option to Healthcheck API
-* Improvement: Accessibility for History component under dark themes
-* Fix: Warning on client console when developing /admin/app
-* Support: Upgrade libs
-    * react-bootstrap-typeahead
-
-## v3.6.5 (Missing number)
-
-## v3.6.4
-
-* Feature: Alert for stale page
-* Improvement: Reactify admin pages (Home)
-* Improvement: Reactify admin pages (App)
-* Improvement: Accessibility for editor icons of dark themes
-* Improvement: Accessibility for importing table data pane
-* Improvement: Resolve username and email when logging in with Google OAuth
-
-## v3.6.3
-
-* Improvement: Searching users in UserGroup Management
-* Fix: Repair google authentication by migrating to jaredhanson/passport-google-oauth2
-* Fix: Markdown Settings are broken by the button to import recommended settings
-* Support: Upgrade libs
-    * check-node-version
-    * file-loader
-    * mini-css-extract-plugin
-
-## v3.6.2
-
-* Improvement: Reactify admin pages (Customize)
-* Improvement: Ensure not to consider `[text|site](https://example.com]` as a row in the table
-* Improvement: Enter key behavior in markdown table
-* Fix: Pre-installed plugins in official docker image are not detected
-    * Introduced by 3.6.0
-* Fix: Emoji Autocomplete window does not float correctly
-    * Introduced by 3.5.0
-
-## v3.6.1
-
-### BREAKING CHANGES
-
-* GROWI v3.6.x no longer support Node.js v8.x
-* The name of database that is storing migrations meta data has been changed
-    * This affects **only when `MONGO_URI` has parameters**
-    * v3.5.x or above has a bug ([#1361](https://github.com/weseek/growi/issues/1361))
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/36x.html>
-
-### Updates
-
-* Improvement: Drop unnecessary MongoDB collection indexes
-* Improvement: Accessibility of Antarctic theme
-* Improvement: Reactify admin pages (Markdown Settings)
-* Fix: Appending tag is failed by wrong index of PageTagRelation
-    * Introduced by 3.5.20
-* Fix: Pages without heading slash is invalid but creatable
-* Fix: Connect to Elasticsearch with `httpAuth` param
-* Support: Support Node.js v12
-* Support: Optimize build in dev with hard-source-webpack-plugin
-* Support: Upgrade libs
-    * growi-commons
-
-## v3.6.0 (Missing number)
-
-## v3.5.25
-
-* Improvement: Disable ESC key to close Handsontable Modal
-* Fix: Exported data of empty collection is broken
-* Fix: Some components crash after when the page with attachment has exported/imported
-
-## v3.5.24
-
-* Fix: Plugins are not working on Heroku
-
-## v3.5.23
-
-* Fix: Global Notification failed to send e-mail
-* Fix: Pagination is not working for trash list
-* Fix: Healthcheck API with `?connectToMiddlewares` returns error
-* Support: Upgrade libs
-    * growi-commons
-
-## v3.5.22
-
-* Improvement: Add `FILE_UPLOAD_DISABLED` env var
-
-## v3.5.21
-
-* Improvement: Cache control when retrieving attachment data
-* Fix: Inviting user doesn't work
-    * Introduced by 3.5.20
-
-## v3.5.20
-
-* Improvement: Organize MongoDB collection indexes uniqueness
-* Improvement: Reactify admin pages (External Account Management)
-* Fix: Search result or Timeline shows loading icon eternally when retrieving not accessible page
-* Support: Use SearchBox Elasticsearch Addon on Heroku
-* Support: Upgrade libs
-    * cross-env
-    * eslint-plugin-jest
-    * i18next
-    * i18next-browser-languagedetector
-    * migrate-mongo
-    * react-i18next
-    * validator
-
-## v3.5.19 (Missing number)
-
-## v3.5.18
-
-* Improvement: Import GROWI Archive
-    * Process asynchronously
-    * Collection configurations
-    * Selectable mode (insert/upsert/flush and insert)
-    * Safely mode settings for configs and users collections
-    * Show errors view
-* Improvement: Optimize handling promise of stream when exporting archive
-* Improvement: Optimize handling promise of stream when building indices
-* Improvement: Add link to [docs.growi.org](https://docs.growi.org)
-* Fix: Monospace font code is broken when printing on Mac
-
-## v3.5.17
-
-* Feature: Upload to GCS (Google Cloud Storage)
-* Feature: Statistics API
-* Improvement: Optimize exporting
-* Improvement: Show progress bar when exporting
-* Improvement: Validate collection combinations when importing
-* Improvement: Reactify admin pages
-* Fix: Use HTTP PlantUML URL in default
-    * Introduced by 3.5.12
-* Fix: Config default values
-* Support: REPL with `console` npm scripts
-
-## v3.5.16
-
-* Fix: Full Text Search doesn't work after when building indices
-    * Introduced by 3.5.12
-
-## v3.5.15
-
-* Feature: Import/Export Page data
-* Fix: The link to Sandbox on Markdown Help Modal doesn't work
-* Support: Upgrade libs
-    * codemirror
-
-## v3.5.14 (Missing number)
-
-## v3.5.13
-
-* Feature: Re-edit comments
-* Support: [growi-plugin-attachment-refs](https://github.com/weseek/growi-plugin-attachment-refs)
-* Support: Upgrade libs
-    * entities
-    * markdown-it
-
-## v3.5.12
-
-* Improvement: Use Elasticsearch Alias
-* Improvement: Connect to HTTPS PlantUML URL in default
-* Fix: Global Notification doesn't work after updating Webhook URL
-* Fix: User Trigger Notification is not be sent when channel is not specified
-* Support: Upgrade libs
-    * terser-webpack-plugin
-
-## v3.5.11
-
-* Fix: HackMD Editor shows 404 error when HackMD redirect to fqdn URI
-    * Introduced by 3.5.8
-* Fix: Timeline doesn't work
-    * Introduced by 3.5.1
-* Fix: Last Login field does not shown in /admin/user
-* Support: Upgrade libs
-    * env-cmd
-    * sass-loader
-    * webpack
-    * webpack-cli
-    * webpack-merge
-
-## v3.5.10
-
-* Feature: Send Global Notification with Slack
-* Improvement: Show loading spinner when fetching page history data
-* Improvement: Hierarchical page link when the page is in /Trash
-* Fix: Code Highlight Theme does not change
-    * Introduced by 3.5.2
-* Support: Upgrade libs
-    * date-fns
-    * eslint-config-weseek
-
-## v3.5.9
-
-* Fix: Editing table with Spreadsheet like GUI (Handsontable) is failed
-* Fix: Plugins are not initialized when first launching
-    * Introduced by 3.5.0
-* Support: Upgrade libs
-    * entities
-    * growi-commons
-    * openid-client
-    * rimraf
-    * style-loader
-
-## v3.5.8
-
-* Improvement: Controls when HackMD/CodiMD has unsaved draft
-* Improvement: Show hints if HackMD/CodiMD integration is not working
-* Improvement: GROWI server obtains HackMD/CodiMD page id from the 302 response header
-* Improvement: Comment Thread Layout
-* Improvement: Show commented date with date distance format
-
-## v3.5.7 (Missing number)
-
-## v3.5.6
-
-* Fix: Saving new page is failed when empty string tag is set
-* Fix: Link of Create template page button in New Page Modal is broken
-* Fix: Global Notification dows not work when creating/moving/deleting/like/comment
-
-## v3.5.5
-
-* Feature: Support S3-compatible object storage (e.g. MinIO)
-* Feature: Enable/Disable ID/Password Authentication
-* Improvement: Login Mechanism with HTTP Basic Authentication header
-* Improvement: Reactify Table Of Contents
-* Fix: Profile images are broken in User Management
-* Fix: Template page under root page doesn't work
-* Support: Upgrade libs
-    * csv-to-markdown-table
-    * express-validator
-    * markdown-it
-    * mini-css-extract-plugin
-    * react-hotkeys
-
-## v3.5.4
-
-* Fix: List private pages wrongly
-* Fix: Global Notification Trigger Path does not parse glob correctly
-* Fix: Consecutive page deletion requests cause unexpected complete page deletion
-
-## v3.5.3
-
-* Improvement: Calculate string width when save with Spreadsheet like GUI (Handsontable)
-* Fix: Search Result Page doesn't work
-* Fix: Create/Update page API returns data includes author's password hash
-* Fix: Dropdown to copy page path/URL/MarkdownLink shows under CodeMirror vscrollbar
-* Fix: Link to /trash in Dropdown menu
-
-## v3.5.2
-
-* Feature: Remain metadata option when Move/Rename page
-* Improvement: Support code highlight for Swift and Kotlin
-* Fix: Couldn't restrict page with user group permission
-* Fix: Couldn't duplicate a page when it restricted by a user group permission
-* Fix: Consider timezone on admin page
-* Fix: Editor doesn't work on Microsoft Edge
-* Support: Upgrade libs
-    * growi-commons
-
-## v3.5.1
-
-### BREAKING CHANGES
-
-* GROWI no longer supports
-    * Protection system with Basic Authentication
-    * Crowi Classic Authentication Mechanism
-    * [Crowi Template syntax](https://medium.com/crowi-book/crowi-v1-5-0-5a62e7c6be90)
-* GROWI no lonnger supports plugins with schema version 2
-    * Upgrade [weseek/growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx) to v3.0.0 or above
-    * Upgrade [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker
-) to v3.0.0 or above
-* The restriction mode of the root page (`/`) will be set 'Public'
-* The restriction mode of the root page (`/`) can not be changed after v 3.5.1
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/35x.html>
-
-### Updates
-
-* Feature: Comment Thread
-* Feature: OpenID Connect authentication
-* Feature: HTTP Basic authentication
-* Feature: Staff Credits with [Konami Code](https://en.wikipedia.org/wiki/Konami_Code)
-* Feature: Restricte Complete Deletion of Pages
-* Improvement Draft list
-* Fix: Deleting page completely
-* Fix: Search with `prefix:` param with CJK pathname
-* Fix: Could not edit UserGroup even if `PUBLIC_WIKI_ONLY` is not set
-* I18n: User Management Details
-* I18n: Group Management Details
-* Support: Apply unstated
-* Support: Use Babel 7
-* Support: Support plugins with schema version 3
-* Support: Abolish Old Config API
-* Support: Apply Jest for Tests
-* Support: Upgrade libs
-    * async
-    * axios
-    * connect-mongo
-    * css-loader
-    * eslint
-    * eslint-config-weseek
-    * eslint-plugin-import
-    * eslint-plugin-jest
-    * eslint-plugin-react
-    * file-loader
-    * googleapis
-    * i18next
-    * migrate-mongo
-    * mini-css-extract-plugin
-    * mongoose
-    * mongoose-gridfs
-    * mongoose-unique-validator
-    * null-loader
-
-## v3.5.0 (Missing number)
-
-## v3.4.7
-
-* Improvement: Handle private pages on group deletion
-* Fix: Searching with `tag:xxx` syntax doesn't work
-* Fix: Check CSRF when updating user data
-* Fix: `createdAt` field initialization
-* I18n: Import data page
-* I18n: Group Management page
-
-## v3.4.6
-
-* Feature: Tags
-* Feature: Dropdown to copy page path/URL/MarkdownLink
-* Feature: List of drafts
-* Improvement: Replace icons of Editor Tool Bar
-* Improvement: Show display name when mouse hover to user image
-* Fix: URL in slack message is broken on Safari
-* Fix: Registration does not work when basic auth is enabled
-* Support: Publish API docs with swagger-jsdoc and ReDoc
-* Support: Upgrade libs
-    * cmd-env
-    * elasticsearch
-    * mongoose-gridfs
-    * node-dev
-    * null-loader
-    * react-codemirror
-
-## v3.4.5
-
-* Improvement: Pass autolink through the XSS filter according to CommonMark Spec
-* Fix: Update ElasticSearch index when deleting/duplicating pages
-* Fix: Xss filter breaks PlantUML arrows
-* Support: Support growi-plugin-lsx@2.2.0
-* Support: Upgrade libs
-    * growi-commons
-    * xss
-
-## v3.4.4
-
-* Fix: Comment component doesn't work
-
-## v3.4.3
-
-* Improvement: Add 'antarctic' theme
-* Support Apply eslint-config-airbnb based rules
-* Support Apply prettier and stylelint
-* Support: Upgrade libs
-    * csrf
-    * escape-string-regexp
-    * eslint
-    * express-session
-    * googleapis
-    * growi-commons
-    * i18next
-    * mini-css-extract-plugin
-    * nodemailer
-    * penpal
-    * react-i18next
-    * string-width
-
-## v3.4.2
-
-* Fix: Nofitication to Slack doesn't work
-    * Introduced by 3.4.0
-
-## v3.4.1
-
-* Fix: "Cannot find module 'stream-to-promise'" occured when build client with `FILE_UPLOAD=local`
-
-## v3.4.0
-
-### BREAKING CHANGES
-
-None.
-
-Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/34x.html>
-
-### Updates
-
-* Improvement: Restrict to access attachments when the user is not allowed to see page
-* Improvement: Show fans and visitors of page
-* Improvement: Full text search tokenizing
-* Improvement: Markdown comment on Crowi Classic Layout
-* Fix: Profile image is not displayed when `FILE_UPLOAD=mongodb`
-* Fix: Posting comment doesn't work under Crowi Classic Layout
-    * Introduced by 3.1.5
-* Fix: HackMD doesn't work when `siteUrl` ends with slash
-* Fix: Ensure not to be able to move/duplicate page to the path which has trailing slash
-* Support: Launch with Node.js v10
-* Support: Launch with MongoDB 3.6
-* Support: Launch with Elasticsearch 6.6
-* Support: Upgrade libs
-    * bootstrap-sass
-    * browser-sync
-    * react
-    * react-dom
-
-
-## v3.3.10
-
-* Feature: PlantUML and Blockdiag on presentation
-* Improvement: Render slides of presentation with GrowiRenderer
-* Fix: Unportalizing doesn't work
-* Support: Use mini-css-extract-plugin instead of extract extract-text-webpack-plugin
-* Support: Use terser-webpack-plugin instead of uglifyjs-webpack-plugin
-* Support: Upgrade libs
-    * csv-to-markdown-table
-    * file-loader
-    * googleapis
-    * i18next-browser-languagedetector
-    * mocha
-    * react-waypoint
-    * webpack
-    * webpack-assets-manifest
-    * webpack-cli
-    * webpack-merge
-
-## v3.3.9
-
-* Fix: Import from Qiita:Team doesn't work
-    * Introduced by 3.3.0
-* Fix: Typeahead shows autocomplete wrongly
-    * Introduced by 3.3.8
-* Support: Upgrade libs
-    * react-bootstrap-typeahead
-
-## v3.3.8
-
-* Fix: Move/Duplicate don't work
-    * Introduced by 3.3.7
-* Fix: Server doesn't respond when root page is restricted
-* Support: Upgrade libs
-    * react
-    * react-bootstrap-typeahead
-
-## v3.3.7
-
-* Feature: Editor toolbar
-* Feature: `prefix:/path` searching syntax to filter with page path prefix
-* Feature: Add an option to filter only children to searching box of navbar
-* Improvement: Suggest page path when moving/duplicating/searching
-* Fix: Anonymous users couldn't search
-    * Introduced by 3.3.6
-* I18n: Searching help
-* Support: Prepare to suppoert Node.js v10
-* Support: Upgrade libs
-    * node-sass
-
-## v3.3.6
-
-* Improvement: Site URL settings must be set
-* Improvement: Site URL settings can be set with environment variable
-* Fix: "Anyone with the link" ACL doesn't work correctly
-    * Introduced by 3.3.0
-* Fix: Related pages list of /admin/user-group-detail/xxx doesn't show anything
-    * Introduced by 3.3.0
-* Fix: Diff of revision contents doesn't appeared when notifing with slack
-* Fix: NPE occured on /admin/security when Crowi Classic Auth Mechanism is set
-* Fix: Coudn't render Timing Diagram with PlantUML
-* I18n: Cheatsheet for editor
-* I18n: Some admin pages
-* Support: Upgrade libs
-    * diff
-    * markdown-it-plantuml
-    * mongoose
-    * nodemailer
-    * mongoose-gridfs
-    * sinon
-    * sinon-chai
-
-## v3.3.5 (Missing number)
-
-## v3.3.4
-
-* Improvement: SAML configuration with environment variables
-* Improvement: Upload file with pasting from clipboard
-* Fix: `/_api/revisions.get` doesn't populate author data correctly
-* Fix: Wrong OAuth callback url are shown at admin page
-* Fix: Connecting to MongoDB failed when processing migration
-* Support: Get ready to use new config management system
-
-## v3.3.3
-
-* Feature: Show line numbers to a code block
-* Feature: Bulk update the scope of descendant pages when create/update page
-* Improvement: The scope of ascendant page will be retrieved and set to controls in advance when creating a new page
-* Fix: Pages that is restricted by groups couldn't be shown in search result page
-* Fix: Pages order in search result page was wrong
-* Fix: Guest user can't search
-* Fix: Possibility that ExternalAccount deletion processing selects incorrect data
-* Support: Upgrade libs
-    * bootstrap-sass
-    * i18next
-    * migrate-mongo
-    * string-width
-
-## v3.3.2
-
-* Fix: Specified Group ACL is not persisted correctly
-    * Introduced by 3.3.0
-
-## v3.3.1
-
-* Feature: NO_CDN Mode
-* Feature: Add option to show/hide restricted pages in list
-* Feature: MongoDB GridFS quota
-* Improvement: Refactor Access Control
-* Improvement: Checkbox behavior of task list
-* Improvement: Fixed search input on search result page
-* Improvement: Add 'christmas' theme
-* Improvement: Select default language of new users
-* Fix: Hide restricted pages contents in timeline
-* Support: Upgrade libs
-    * googleapis
-    * passport-saml
-
-## v3.3.0 (Missing number)
-
-## v3.2.10
-
-* Fix: Pages in trash are available to create
-* Fix: Couldn't create portal page under Crowi Classic Behavior
-* Fix: Table tag in Timeline/SearchResult missed border and BS3 styles
-* I18n: Installer
-
-
-## v3.2.9
-
-* Feature: Attachment Storing to MongoDB GridFS
-* Fix: row/col moving of Spreadsheet like GUI (Handsontable) doesn't work
-* Fix: Emoji AutoComplete dialog pops up at wrong position
-* Support: Upgrade libs
-    * codemirror
-    * react-codemirror2
-
-## v3.2.8
-
-* Improvement: Add an option to use email for account link when using SAML federation
-* Fix: Editor layout is sometimes broken
-* Fix: Normalize table data for Spreadsheet like GUI (Handsontable) when import
-* Support: Improve development environment
-* Support: Upgrade libs
-    * googleapis
-    * react-dropzone
-
-## v3.2.7
-
-* Feature: Import CSV/TSV/HTML table on Spreadsheet like GUI (Handsontable)
-* Fix: Pasting table data copied from Excel includes unnecessary line breaks
-* Fix: Page break Preset 1 for Presentation mode is broken
-* Fix: Login Form when LDAP login failed caused 500 Internal Server Error
-
-## v3.2.6
-
-* Feature: Add select alignment buttons of Spreadsheet like GUI (Handsontable)
-* Improvement: Shrink the rows that have no diff of revision history page
-* Fix: Login form rejects weak password
-* Fix: An error occured by uploading attachment file when the page is not exists
-    * Introduced by 2.3.5
-* Support: Upgrade libs
-    * i18next-express-middleware
-    * i18next-node-fs-backend
-    * i18next-sprintf-postprocessor
-
-## v3.2.5
-
-* Improvement: Expandable Spreadsheet like GUI (Handsontable)
-* Improvement: Move/Resize rows/columns of Spreadsheet like GUI (Handsontable)
-* Improvement: Prevent XSS of New Page modal
-* Fix: Recent Created tab of user home shows wrong page list
-    * Introduced by 3.2.4
-* Support: Upgrade libs
-    * @handsontable/react
-    * handsontable
-    * metismenu
-    * sinon
-
-## v3.2.4
-
-* Feature: Edit table with Spreadsheet like GUI (Handsontable)
-* Feature: Paging recent created in users home
-* Improvement: Specify certificate for SAML Authentication
-* Fix: SAML Authentication didn't work
-    * Introduced by 3.2.2
-* Fix: Failed to create new page with title which includes RegEx special characters
-* Fix: Preventing XSS Settings are not applied in default
-    * Introduced by 3.1.12
-* Support: Mongoose migration mechanism
-* Support: Upgrade libs
-    * googleapis
-    * mocha
-    * mongoose
-    * mongoose-paginate
-    * mongoose-unique-validator
-    * multer
-
-## v3.2.3
-
-* Feature: Kibela like layout
-* Improvement: Custom newpage separator for presentation view
-* Support: Shrink image size for themes which recently added
-
-## v3.2.2
-
-* Feature: SAML Authentication (SSO)
-* Improvement: Add 'wood' theme
-* Improvement: Add 'halloween' theme
-* Improvement: Add 'island' theme
-* Fix: Sending email function doesn't work
-* Support Upgrade libs
-    * style-loader
-
-## v3.2.1
-
-* Feature: Import data from esa.io
-* Feature: Import data from Qiita:Team
-* Feature: Add the endpoint for health check
-* Improvement: Adjust styles when printing
-* Fix: Renaming page doesn't work if the page was saved with shortcut
-* Support: Refactor directory structure
-* Support Upgrade libs
-    * file-loader
-    * googleapis
-    * postcss-loader
-    * sass-loader
-    * style-loader
-
-## v3.2.0
-
-* Feature: HackMD integration so that user will be able to simultaneously edit with multiple people
-* Feature: Login with Twitter Account (OAuth)
-* Fix: The Initial scroll position is wrong when reloading the page
-
-## v3.1.14
-
-* Improvement: Show help for header search box
-* Improvement: Add Markdown Cheatsheet to Editor component
-* Fix: Couldn't delete page completely from search result page
-* Fix: Tabs of trash page are broken
-
-## v3.1.13
-
-* Feature: Global Notification
-* Feature: Send Global Notification with E-mail
-* Improvement: Add attribute mappings for email to LDAP settings
-* Support: Upgrade libs
-    * autoprefixer
-    * css-loader
-    * method-override
-    * optimize-css-assets-webpack-plugin
-    * react
-    * react-bootstrap-typeahead
-    * react-dom
-
-
-## v3.1.12
-
-* Feature: Add XSS Settings
-* Feature: Notify to Slack when comment
-* Improvement: Prevent XSS in various situations
-* Improvement: Show forbidden message when the user accesses to ungranted page
-* Improvement: Add overlay styles for pasting file to comment form
-* Fix: Omit unnecessary css link
-    * Introduced by 3.1.10
-* Fix: Invitation mail do not be sent
-* Fix: Edit template button on New Page modal doesn't work
-
-## v3.1.11
-
-* Fix: OAuth doesn't work in production because callback URL field cannot be specified
-    * Introduced by 3.1.9
-
-## v3.1.10
-
-* Fix: Enter key on react-bootstrap-typeahead doesn't submit
-    * Introduced by 3.1.9
-* Fix: CodeMirror of `/admin/customize` is broken
-    * Introduced by 3.1.9
-
-## v3.1.9
-
-* Feature: Login with Google Account (OAuth)
-* Feature: Login with GitHub Account (OAuth)
-* Feature: Attach files in Comment
-* Improvement: Write comment with CodeMirror Editor
-* Improvement: Post comment with `Ctrl-Enter`
-* Improvement: Place the commented page at the beginning of the list
-* Improvement: Resolve errors on IE11 (Experimental)
-* Support: Migrate to webpack 4
-* Support: Upgrade libs
-    * eslint
-    * react-bootstrap-typeahead
-    * react-codemirror2
-    * webpack
-
-## v3.1.8 (Missing number)
-
-## v3.1.7
-
-* Fix: Update hidden input 'pageForm[grant]' when save with `Ctrl-S`
-* Fix: Show alert message when conflict
-* Fix: `BLOCKDIAG_URI` environment variable doesn't work
-* Fix: Paste in markdown list doesn't work correctly
-* Support: Ensure to inject logger configuration from environment variables
-* Support: Upgrade libs
-    * sinon
-    * sinon-chai
-
-## v3.1.6
-
-* Feature: Support [blockdiag](http://blockdiag.com)
-* Feature: Add `BLOCKDIAG_URI` environment variable
-* Fix: Select modal for group is not shown
-* Support: Upgrade libs
-    * googleapis
-    * throttle-debounce
-
-## v3.1.5
-
-* Feature: Write comment with Markdown
-* Improvement: Support some placeholders for template page
-* Improvement: Omit unnecessary response header
-* Improvement: Support LDAP attribute mappings for user's full name
-* Improvement: Enable to scroll revision-toc
-* Fix: Posting to Slack doesn't work
-    * Introduced by 3.1.0
-* Fix: page.rename api doesn't work
-* Fix: HTML escaped characters in markdown are unescaped unexpectedly after page is saved
-* Fix: sanitize `#raw-text-original` content with 'entities'
-* Fix: Double newline character posted
-    * Introduced by 3.1.4
-* Fix: List and Comment components do not displayed
-    * Introduced by 3.1.4
-* Support: Upgrade libs
-    * markdown-it-toc-and-anchor-with-slugid
-
-
-## v3.1.4 (Missing number)
-
-
-## v3.1.3 (Missing number)
-
-
-## v3.1.2
-
-* Feature: Template page
-* Improvement: Add 'future' theme
-* Improvement: Modify syntax for Crowi compatible template feature
-    * *before*
-
-        ~~~markdown
-        ``` template:/page/name
-        page contents
-        ```
-        ~~~
-
-    * *after*
-
-        ~~~plane
-        ::: template:/page/name
-        page contents
-        :::
-        ~~~
-
-* Improvement: Escape iframe tag in block codes
-* Support: Upgrade libs
-    * assets-webpack-plugin
-    * googleapis
-    * react-clipboard.js
-    * xss
-
-## v3.1.1
-
-* Improvement: Add 'blue-night' theme
-* Improvement: List up pages which restricted for Group ACL
-* Fix: PageGroupRelation didn't remove when page is removed completely
-
-
-## v3.1.0
-
-* Improvement: Group Access Control List - Select group modal
-* Improvement: Better input on mobile
-* Improvement: Detach code blocks correctly
-* Improvement: Auto-format markdown table which includes multibyte text
-* Improvement: Show icon when auto-format markdown table is activated
-* Improvement: Enable to switch show/hide border for highlight.js
-* Improvement: BindDN field allows also ActiveDirectory styles
-* Improvement: Show LDAP logs when testing login
-* Fix: Comment body doesn't break long terms
-* Fix: lsx plugin lists up pages that hit by forward match wrongly
-    * Introduced by 3.0.4
-* Fix: Editor is broken on IE11
-* Support: Multilingualize React components with i18next
-* Support: Organize dependencies
-* Support: Upgrade libs
-    * elasticsearch
-    * googleapis
-
-## v3.0.13
-
-* Improvement: Add Vim/Emacs/Sublime-Text icons for keybindings menu
-* Improvement: Add 'mono-blue' theme
-* Fix: Unportalize process failed silently
-* Fix: Sidebar breaks editor layouts
-* Support: Switch the logger from 'pino' to 'bunyan'
-* Support: Set the alias for 'debug' to the debug function of 'bunyan'
-* Support: Translate `/admin/security`
-* Support: Optimize bundles
-    * upgrade 'markdown-it-toc-and-anchor-with-slugid' and omit 'uslug'
-* Support: Optimize .eslintrc.js
-
-## v3.0.12
-
-* Feature: Support Vim/Emacs/Sublime-Text keybindings
-* Improvement: Add some CodeMirror themes (Eclipse, Dracula)
-* Improvement: Dynamic loading for CodeMirror theme files from CDN
-* Improvement: Prevent XSS when move/redirect/duplicate
-
-## v3.0.11
-
-* Fix: login.html is broken in iOS
-* Fix: Removing attachment is crashed
-* Fix: File-attaching error after new page creation
-* Support: Optimize development build
-* Support: Upgrade libs
-    * env-cmd
-    * googleapis
-    * sinon
-
-## v3.0.10
-
-* Improvement: Add 'nature' theme
-* Fix: Page list and Timeline layout for layout-growi
-* Fix: Adjust theme colors
-    * Introduced by 3.0.9
-
-## v3.0.9
-
-* Fix: Registering new LDAP User is failed
-    * Introduced by 3.0.6
-* Support: Organize scss for overriding bootstrap variables
-* Support: Upgrade libs
-    * codemirror
-    * react-codemirror2
-    * normalize-path
-    * style-loader
-
-## v3.0.8
-
-* Improvement: h1#revision-path occupies most of the screen when the page path is long
-* Improvement: Ensure not to save concealed email field to localStorage
-* Fix: Cannot input "c" and "e" on iOS
-
-## v3.0.7
-
-* Improvement: Enable to download an attached file with original name
-* Improvement: Use MongoDB for session store instead of Redis
-* Improvement: Update dropzone overlay icons and styles
-* Fix: Dropzone overlay elements doesn't show
-    * Introduced by 3.0.0
-* Fix: Broken page path of timeline
-    * Introduced by 3.0.4
-
-## v3.0.6
-
-* Improvement: Automatically bind external accounts newly logged in to local accounts when username match
-* Improvement: Simplify configuration for Slack Web API
-* Support: Use 'slack-node' instead of '@slack/client'
-* Support: Upgrade libs
-    * googleapis
-    * i18next
-    * i18next-express-middleware
-    * react-bootstrap-typeahead
-    * sass-loader
-    * uglifycss
-
-## v3.0.5
-
-* Improvement: Update lsx icons and styles
-* Fix: lsx plugins doesn't show page names
-
-## v3.0.4
-
-* Improvement: The option that switch whether add h1 section when create new page
-* Improvement: Encode page path that includes special character
-* Fix: Page-saving error after new page creation
-
-## v3.0.3
-
-* Fix: Login page is broken in iOS
-* Fix: Hide presentation tab if portal page
-* Fix: A few checkboxes doesn't work
-    * Invite user check with email in `/admin/user`
-    * Recursively check in rename modal
-    * Redirect check in rename modal
-* Fix: Activating invited user form url is wrong
-* Support: Use postcss-loader and autoprefixer
-
-## v3.0.2
-
-* Feature: Group Access Control List
-* Feature: Add site theme selector
-* Feature: Add a control to switch whether email shown or hidden by user
-* Feature: Custom title tag content
-* Fix: bosai version
-* Support: Rename to GROWI
-* Support: Add dark theme
-* Support: Refreshing bootstrap theme and icons
-* Support: Use Browsersync instead of easy-livereload
-* Support: Upgrade libs
-    * react-bootstrap
-    * react-bootstrap-typeahead
-    * react-clipboard.js
-
-## v3.0.1 (Missing number)
-
-## v3.0.0 (Missing number)
-
-## v2.4.4
-
-* Feature: Autoformat Markdown Table
-* Feature: highlight.js Theme Selector
-* Fix: The bug of updating numbering list by codemirror
-* Fix: Template LangProcessor doesn't work
-    * Introduced by 2.4.0
-* Support: Apply ESLint
-* Support: Upgrade libs
-    * react, react-dom
-    * codemirror, react-codemirror2
-
-## v2.4.3
-
-* Improvement: i18n in `/admin`
-* Improvement: Add `SESSION_NAME` environment variable
-* Fix: All Elements are cleared when the Check All button in DeletionMode
-* Support: Upgrade libs
-    * uglifycss
-    * sinon-chai
-
-## v2.4.2
-
-* Improvement: Ensure to set absolute url from root when attaching files when `FILE_UPLOAD=local`
-* Fix: Inline code blocks that includes doller sign are broken
-* Fix: Comment count is not updated when a comment of the page is deleted
-* Improvement: i18n in `/admin` (WIP)
-* Support: Upgrade libs
-    * googleapis
-    * markdown-it-plantuml
-
-## v2.4.1
-
-* Feature: Custom Header HTML
-* Improvement: Add highlight.js languages
-    * dockerfile, go, gradle, json, less, scss, typescript, yaml
-* Fix: Couldn't connect to PLANTUML_URI
-    * Introduced by 2.4.0
-* Fix: Couldn't render UML which includes CJK
-    * Introduced by 2.4.0
-* Support: Upgrade libs
-    * axios
-    * diff2html
-
-## v2.4.0
-
-* Feature: Support Footnotes
-* Feature: Support Task lists
-* Feature: Support Table with CSV
-* Feature: Enable to render UML diagrams with public plantuml.com server
-* Feature: Enable to switch whether rendering MathJax in realtime or not
-* Improvement: Replace markdown parser with markdown-it
-* Improvement: Generate anchor of headers with header strings
-* Improvement: Enhanced Scroll Sync on Markdown Editor/Preview
-* Improvement: Update `#revision-body` tab contents after saving with `Ctrl-S`
-* Fix: 500 Internal Server Error occures when basic-auth configuration is set
-
-## v2.3.9
-
-* Fix: `Ctrl-/` doesn't work on Chrome
-* Fix: Close Shortcuts help with `Ctrl-/`, ESC key
-* Fix: Jump to last line wrongly when `.revision-head-edit-button` clicked
-* Support: Upgrade libs
-    * googleapis
-
-## v2.3.8
-
-* Feature: Suggest page path when creating pages
-* Improvement: Prevent keyboard shortcuts when modal is opened
-* Improvement: PageHistory UI
-* Improvement: Ensure to scroll when edit button of section clicked
-* Improvement: Enabled to toggle the style for active line
-* Support: Upgrade libs
-    * style-loader
-    * react-codemirror2
-
-## v2.3.7
-
-* Fix: Open popups when `Ctrl+C` pressed
-    * Introduced by 2.3.5
-
-## v2.3.6
-
-* Feature: Theme Selector for Editor
-* Improvement: Remove unportalize button from crowi-plus layout
-* Fix: CSS for admin pages
-* Support: Shrink the size of libraries to include
-
-## v2.3.5
-
-* Feature: Enhanced Editor by CodeMirror
-* Feature: Emoji AutoComplete
-* Feature: Add keyboard shortcuts
-* Improvement: Attaching file with Dropzone.js
-* Improvement: Show shortcuts help with `Ctrl-/`
-* Fix: DOMs that has `.alert-info` class don't be displayed
-* Support: Switch and upgrade libs
-    * 8fold-marked -> marked
-    * react-bootstrap
-    * googleapis
-    * mongoose
-    * mongoose-unique-validator
-    * etc..
-
-## v2.3.4 (Missing number)
-
-## v2.3.3
-
-* Fix: The XSS Library escapes inline code blocks
-    * Degraded by 2.3.0
-* Fix: NPE occurs on Elasticsearch when initial access
-* Fix: Couldn't invite users(failed to create)
-
-## v2.3.2
-
-* Improvement: Add LDAP group search options
-
-## v2.3.1
-
-* Fix: Blockquote doesn't work
-    * Degraded by 2.3.0
-* Fix: Couldn't create user with first LDAP logging in
-
-## v2.3.0
-
-* Feature: LDAP Authentication
-* Improvement: Prevent XSS
-* Fix: node versions couldn't be shown
-* Support: Upgrade libs
-    * express-pino-logger
-
-## v2.2.4
-
-* Fix: googleapis v23.0.0 lost the function `oauth2Client.setCredentials`
-    * Degraded by 2.2.2 updates
-* Fix: HeaderSearchBox didn't append 'q=' param when searching
-    * Degraded by 2.2.3 updates
-
-## v2.2.3
-
-* Fix: The server responds anything when using passport
-    * Degraded by 2.2.2 updates
-* Fix: Update `lastLoginAt` when login is success
-* Support: Replace moment with date-fns
-* Support: Upgrade react-bootstrap-typeahead
-* Improvement: Replace emojify.js with emojione
-
-## v2.2.2 (Missing number)
-
-## v2.2.1
-
-* Feature: Duplicate page
-* Improve: Ensure that admin users can remove users waiting for approval
-* Fix: Modal doesn't work with React v16
-* Support: Upgrade React to 16
-* Support: Upgrade outdated libs
-
-## v2.2.0
-
-* Support: Merge official Crowi v1.6.3
-
-## v2.1.2
-
-* Improvement: Ensure to prevent suspending own account
-* Fix: Ensure to be able to use `.` for username when invited
-* Fix: monospace font for `<code></code>`
-
-## v2.1.1
-
-* Fix: The problem that React Modal doesn't work
-* Support: Lock some packages(react, react-dom, mongoose)
-
-## v2.1.0
-
-* Feature: Adopt Passport the authentication middleware
-* Feature: Selective batch deletion in search result page
-* Improvement: Ensure to be able to login with both of username or email
-* Fix: The problem that couldn't update user data in /me
-* Support: Upgrade outdated libs
-
-## v2.0.9
-
-* Fix: Server is down when a guest user accesses to someone's private pages
-* Support: Merge official Crowi (master branch)
-* Support: Upgrade outdated libs
-
-## v2.0.8
-
-* Fix: The problem that path including round bracket makes something bad
-* Fix: Recursively option processes also unexpedted pages
-* Fix: en_US translation
-
-## v2.0.7
-
-* Improvement: Add recursively option for Delete/Move/Putback operation
-* Improvement: Comment layout and sort order (crowi-plus Enhanced Layout)
-
-## v2.0.6
-
-* Fix: check whether `$APP_DIR/public/uploads` exists before creating symlink
-    * Fixed in weseek/crowi-plus-docker
-
-## v2.0.5
-
-* Improvement: Adjust styles for CodeMirror
-* Fix: File upload does not work when using crowi-plus-docker-compose and `FILE_UPLOAD=local` is set  
-    * Fixed in weseek/crowi-plus-docker
-
-## v2.0.2 - 2.0.4 (Missing number)
-
-## v2.0.1
-
-* Feature: Custom Script
-* Improvement: Adjust layout and styles for admin pages
-* Improvement: Record and show last updated date in user list page
-* Fix: Ignore Ctrl+(Shift+)Tab when editing (cherry-pick from the official)
-
-## v2.0.0
-
-* Feature: Enabled to integrate with Slack using Incoming Webhooks
-* Support: Upgrade all outdated libs
-
-## v1.2.16
-
-* Improvement: Condition for creating portal
-* Fix: Couldn't create new page after installation cleanly
-
-## v1.2.15
-
-* Improvement: Optimize cache settings for express server
-* Improvement: Add a logo link to the affix header
-* Fix: Child pages under `/trash` are not shown when applying crowi-plus Simplified Behavior
-
-## v1.2.14
-
-* Fix: Tabs(`a[data-toggle="tab"][href="#..."]`) push browser history twice
-* Fix: `a[href="#edit-form"]` still save history even when disabling pushing states option
-
-## v1.2.13
-
-* Improvement: Enabled to switch whether to push states with History API when tabs changes
-* Fix: Layout of the Not Found page
-
-## v1.2.12 (Missing number)
-
-## v1.2.11
-
-* Improvement: Enabled to open editing form from affix header
-* Improvement: Enabled to open editing form from each section headers
-
-## v1.2.10
-
-* Fix: Revise `server:prod:container` script for backward compatibility
-
-## v1.2.9
-
-* Improvement: Enabled to save with <kbd>⌘+S</kbd> on Mac
-* Improvement: Adopt the fastest logger 'pino'
-* Fix: The problem that can't upload profile image
-
-## v1.2.8
-
-* Fix: The problem that redirect doesn't work when using 'crowi-plus Simplified Behavior'
-
-## v1.2.7 (Missing number)
-
-## v1.2.6
-
-* Fix: The problem that page_list widget doesn't show the picture of revision.author
-* Fix: Change implementation of Bootstrap3 toggle switch for admin pages
-
-## v1.2.5
-
-* Feature: crowi-plus Simplified Behavior
-    * `/page` and `/page/` both shows the page
-    * `/nonexistent_page` shows editing form
-    * All pages shows the list of sub pages
-* Improvement: Ensure to be able to disable Timeline feature
-
-## v1.2.4
-
-* Fix: Internal Server Error has occurred when a guest user visited the page someone added "liked"
-
-## v1.2.3
-
-* Improvement: Ensure to be able to use Presentation Mode even when not logged in
-* Improvement: Presentation Mode on IE11 (Experimental)
-* Fix: Broken Presentation Mode
-
-## v1.2.2
-
-* Support: Merge official Crowi (master branch)
-
-## v1.2.1
-
-* Fix: buildIndex error occured when access to installer
-
-## v1.2.0
-
-* Support: Merge official Crowi v1.6.2
-
-## v1.1.12
-
-* Feature: Remove Comment Button
-
-## v1.1.11
-
-* Fix: Omit Comment form from page_list (crowi-plus Enhanced Layout)
-* Fix: .search-box is broken on sm/xs screen
-
-## v1.1.10
-
-* Fix: .search-box is broken on sm/xs screen
-* Support: Browsable with IE11 (Experimental)
-
-## v1.1.9
-
-* Improvement: Ensure to generate indices of Elasticsearch when installed
-* Fix: Specify the version of Bonsai Elasticsearch on Heroku
-
-## v1.1.8
-
-* Fix: Depth of dropdown-menu when `.on-edit`
-* Fix: Error occured on saveing with `Ctrl-S`
-* Fix: Guest users browsing
-
-## v1.1.7
-
-* Feature: Add option to allow guest users to browse
-* Fix: crowi-plus Enhanced Layout
-
-## v1.1.6
-
-* Fix: crowi-plus Enhanced Layout
-
-## v1.1.5
-
-* Fix: crowi-plus Enhanced Layout
-* Support: Merge official Crowi v1.6.1 master branch [573144b]
-
-## v1.1.4
-
-* Feature: Ensure to select layout type from Admin Page
-* Feature: Add crowi-plus Enhanced Layout
-
-## v1.1.3
-
-* Improvement: Use POSIX-style paths (bollowed crowi/crowi#219 by @Tomasom)
-
-## v1.1.2
-
-* Imprv: Brushup fonts and styles
-* Fix: Ensure to specity revision id when saving with `Ctrl-S`
-
-## v1.1.1
-
-* Feature: Save with `Ctrl-S`
-* Imprv: Brushup fonts and styles
-
-## v1.1.0
-
-* Support: Merge official Crowi v1.6.1
-
-## v1.0.9
-
-* Feature: Delete user
-* Feature: Upload other than images
-
-## v1.0.8
-
-* Feature: Ensure to delete page completely
-* Feature: Ensure to delete redirect page
-* Fix: https access to Gravatar (this time for sure)
-
-## v1.0.7
-
-* Feature: Keyboard navigation for search box
-* Improvement: Intelligent Search
-
-## v1.0.6
-
-* Feature: Copy button that copies page path to clipboard
-* Fix: https access to Gravatar
-* Fix: server watching crash with `Error: read ECONNRESET` on Google Chrome
-
-## v1.0.5
-
-* Feature: Ensure to use Gravatar for profile image
-
-## v1.0.4
-
-* Improvement: Detach code blocks before preProcess
-* Support: Ensure to deploy to Heroku with INSTALL_PLUGINS env
-* Support: Ensure to load plugins easily when development
-
-## v1.0.3
-
-* Improvement: Adjust styles
-
-## v1.0.2
-
-* Improvement: For lsx
-
-## v1.0.1
-
-* Feature: Custom CSS
-* Support: Notify build failure to Slask
-
-## v1.0.0
-
-* Feature: Plugin mechanism
-* Feature: Switchable LineBreaks ON/OFF from admin page
-* Improvement: Exclude Environment-dependency
-* Improvement: Enhanced linker
-* Support: Add Dockerfile
-* Support: Abolish gulp
-* Support: LiveReload
-* Support: Update libs

+ 4 - 0
bin/github-actions/bump-versions/README.md

@@ -0,0 +1,4 @@
+bump-versions.js
+==============
+
+Custom cli for bumping package versions based on [algolia/shipjs@0.23.3](https://github.com/algolia/shipjs/tree/v0.23.3/packages/shipjs)

+ 18 - 0
bin/github-actions/bump-versions/cli.js

@@ -0,0 +1,18 @@
+import { print, parseArgs } from 'shipjs/src/util';
+import bumpVersions from './flow/bump-versions';
+
+export async function cli(argv) {
+  const { fn, arg: argSpec } = bumpVersions;
+  try {
+    const opts = parseArgs(argSpec, argv);
+    await fn(opts);
+  }
+  catch (error) {
+    if (error.code === 'ARG_UNKNOWN_OPTION') {
+      print(error);
+    }
+    else {
+      throw error;
+    }
+  }
+}

+ 71 - 0
bin/github-actions/bump-versions/flow/bump-versions.js

@@ -0,0 +1,71 @@
+import semver from 'semver';
+import { loadConfig, getCurrentVersion, getReleaseType } from 'shipjs-lib';
+
+import printDryRunBanner from 'shipjs/src/step/printDryRunBanner';
+import confirmNextVersion from 'shipjs/src/step/prepare/confirmNextVersion';
+import updateVersion from 'shipjs/src/step/prepare/updateVersion';
+import updateVersionMonorepo from 'shipjs/src/step/prepare/updateVersionMonorepo';
+import installDependencies from 'shipjs/src/step/prepare/installDependencies';
+
+import printHelp from '../step/printHelp';
+
+async function bumpVersions({
+  help = false,
+  dir = '.',
+  dryRun = false,
+  increment = 'patch',
+  preid = 'RC',
+}) {
+  if (help) {
+    printHelp();
+    return;
+  }
+  if (dryRun) {
+    printDryRunBanner();
+  }
+
+  const config = await loadConfig(dir, 'bump-versions.config');
+
+  // get current version
+  const { monorepo } = config;
+  const currentVersion = monorepo && monorepo.mainVersionFile
+    ? getCurrentVersion(dir, monorepo.mainVersionFile)
+    : getCurrentVersion(dir);
+
+  // determine next version
+  let nextVersion = semver.inc(currentVersion, increment, preid); // set preid if type is 'prerelease'
+  nextVersion = await confirmNextVersion({
+    yes: true,
+    currentVersion,
+    nextVersion,
+    dryRun,
+  });
+  const releaseType = getReleaseType(nextVersion);
+
+  // update
+  const updateVersionFn = monorepo
+    ? updateVersionMonorepo
+    : updateVersion;
+  await updateVersionFn({
+    config, nextVersion, releaseType, dir, dryRun,
+  });
+}
+
+const arg = {
+  '--dir': String,
+  '--help': Boolean,
+  '--dry-run': Boolean,
+  '--increment': String,
+  '--preid': String,
+
+  // Aliases
+  '-d': '--dir',
+  '-h': '--help',
+  '-D': '--dry-run',
+  '-i': '--increment',
+};
+
+export default {
+  arg,
+  fn: bumpVersions,
+};

+ 16 - 0
bin/github-actions/bump-versions/index.js

@@ -0,0 +1,16 @@
+#!/usr/bin/env node
+
+// eslint-disable-next-line no-global-assign
+require = require('esm')(module);
+
+(async function() {
+  try {
+    process.env.SHIPJS = true;
+    await require('./cli').cli(process.argv);
+  }
+  catch (e) {
+    // eslint-disable-next-line no-console
+    console.error(e);
+    process.exit(1);
+  }
+}());

+ 54 - 0
bin/github-actions/bump-versions/step/printHelp.js

@@ -0,0 +1,54 @@
+import runStep from 'shipjs/src/step/runStep';
+import { print } from 'shipjs/src/util';
+import { bold, underline } from 'shipjs/src/color';
+
+export default () => runStep({}, () => {
+  const indent = line => `\t${line}`;
+
+  const help = '--help';
+  const dir = `--dir ${underline('PATH')}`;
+  const increment = `--increment ${underline('LEVEL')}`;
+  const preId = `--preid ${underline('IDENTIFIER')}`;
+  const dryRun = '--dry-run';
+  const all = [help, dir, increment, preId, dryRun]
+    .map(x => `[${x}]`)
+    .join(' ');
+
+  const messages = [
+    bold('NAME'),
+    indent('bump-versions - Bump versions of packages.'),
+    '',
+    bold('USAGE'),
+    indent(`node ./bin/github-actions/bump-versions ${all}`),
+    '',
+    bold('OPTIONS'),
+    indent(`-h, ${help}`),
+    indent('  Print this help'),
+    '',
+    indent(`-d, ${dir}`),
+    indent(
+      `  Specify the ${underline(
+        'PATH',
+      )} of the repository (default: the current directory).`,
+    ),
+    '',
+    indent(`-i, ${increment}`),
+    indent(
+      `  Specify the ${underline(
+        'LEVEL',
+      )} for semver.inc() to increment a version (default: 'patch').`,
+    ),
+    '',
+    indent(`${preId}`),
+    indent(
+      `  Specify the ${underline(
+        'IDENTIFIER',
+      )} for semver.inc() with 'prerelease' type (default: 'RC').`,
+    ),
+    '',
+    indent(`-D, ${dryRun}`),
+    indent('  Displays the steps without actually doing them.'),
+    '',
+  ];
+  print(messages.join('\n'));
+});

+ 12 - 0
bump-versions.config.js

@@ -0,0 +1,12 @@
+/*
+ * Reference: https://community.algolia.com/shipjs/
+ */
+module.exports = {
+  monorepo: {
+    mainVersionFile: 'lerna.json',
+    packagesToBump: [
+      './',
+      'packages/*',
+    ],
+  },
+};

+ 2 - 2
lerna.json

@@ -1,8 +1,8 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
+  "version": "4.4.3-RC.0",
   "packages": [
     "packages/*"
-  ],
-  "version": "independent"
+  ]
 }

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.3.3-RC",
+  "version": "4.4.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -31,11 +31,10 @@
   "scripts": {
     "start": "yarn app:server",
     "prestart": "yarn app:build",
-    "app:build": "yarn lerna run build --scope @growi/app --scope @growi/slack --scope @growi/plugin-*",
+    "app:build": "yarn lerna run build",
     "app:server": "yarn lerna run server --scope @growi/app",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",
-    "version": "node -p \"require('./package.json').version\"",
     "//// scripts for backward compatibility": "",
     "build:prod": "echo !!! CAUTION !!! ==> The script 'build:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:build",
     "server:prod": "echo !!! CAUTION !!! ==> The script 'server:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:server"
@@ -66,6 +65,7 @@
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
     "rewire": "^5.0.0",
+    "shipjs": "^0.23.3",
     "ts-jest": "^27.0.4"
   },
   "engines": {

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

@@ -20,9 +20,7 @@ HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DEV_HTTPS=true
 # FORCE_WIKI_MODE=private
 # PROMSTER_ENABLED=true
-# SLACK_SIGNING_SECRET=''
-# SLACK_BOT_TOKEN=''
-SALT_FOR_GTOP_TOKEN="proxy"
-SALT_FOR_PTOG_TOKEN="growi"
+# SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET=''
+# SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345

+ 2 - 2
packages/app/bin/github-actions/update-readme.sh

@@ -2,5 +2,5 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.3\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.3-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.4\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`4\.4-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}-nocdn\2\3${RELEASED_VERSION}\4/" README.md

+ 6 - 6
packages/app/config/migrate.js

@@ -5,9 +5,13 @@
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
 
+import mongoose from 'mongoose';
+
+import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+
 const { URL } = require('url');
 
-const { getMongoUri } = require('~/server/util/mongoose-utils');
+initMongooseGlobalSettings();
 
 const mongoUri = getMongoUri();
 
@@ -17,11 +21,7 @@ const url = new URL(mongoUri);
 const mongodb = {
   url: mongoUri,
   databaseName: url.pathname.substring(1), // omit heading slash
-  options: {
-    useNewUrlParser: true, // removes a deprecation warning when connecting
-    useUnifiedTopology: true,
-    useFindAndModify: false,
-  },
+  options: mongoOptions,
 };
 
 module.exports = {

+ 0 - 2
packages/app/docker/Dockerfile

@@ -110,9 +110,7 @@ RUN tar cf packages.tar \
   package.json \
   yarn.lock \
   tsconfig.base.json \
-  packages/app/package.json \
   packages/app/config \
-  packages/app/dist \
   packages/app/public \
   packages/app/resource \
   packages/app/tmp \

+ 5 - 5
packages/app/docker/README.md

@@ -2,7 +2,7 @@
 GROWI Official docker image
 ========================
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Release%20Docker%20Images/badge.svg)](https://github.com/weseek/growi/actions) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
+[![Actions Status](https://github.com/weseek/growi/workflows/Release/badge.svg)](https://github.com/weseek/growi/actions) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
 
 ![GROWI-x-docker](https://user-images.githubusercontent.com/1638767/38307565-105956e2-384f-11e8-8534-b1128522d68d.png)
 
@@ -10,10 +10,10 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.3.0`, `4.3`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.0/docker/Dockerfile)
-* [`4.3.0-nocdn`, `4.3-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.0/docker/Dockerfile)
-* [`4.2.0`, `4.2` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
-* [`4.2.0-nocdn`, `4.2-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.2.0/docker/Dockerfile)
+* [`4.4.2`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.2/docker/Dockerfile)
+* [`4.4.2-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.2/docker/Dockerfile)
+* [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
+* [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 
 
 What is GROWI?

+ 12 - 13
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.3.3-RC",
+  "version": "4.4.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -10,7 +10,7 @@
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx shx mv transpiled/src dist && npx shx rm -rf transpiled",
+    "postbuild": "npx shx mv transpiled/src dist && npx shx cp -r src/server/views dist/server/ && npx shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -18,7 +18,7 @@
     "dev": "run-p dev:client dev:server",
     "dev:client": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
     "dev:client:nowatch": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
-    "dev:server": "yarn cross-env NODE_ENV=development yarn ts-node-dev src/server/app.ts --expose_gc",
+    "dev:server": "yarn cross-env NODE_ENV=development ts-node-dev --inspect --expose-gc -r tsconfig-paths/register -r dotenv-flow/config --transpile-only src/server/app.ts",
     "predev:client": "yarn cross-env NODE_ENV=development run-p resources:*",
     "predev:server": "yarn cross-env NODE_ENV=development yarn migrate",
     "//// for CI": "",
@@ -44,8 +44,7 @@
     "migrate:status": "yarn ts-node node_modules/.bin/migrate-mongo status -f config/migrate.js",
     "migrate:up": "yarn ts-node node_modules/.bin/migrate-mongo up -f config/migrate.js",
     "migrate:down": "yarn ts-node node_modules/.bin/migrate-mongo down -f config/migrate.js",
-    "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --inspect --transpile-only"
+    "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only"
   },
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
@@ -55,11 +54,10 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/plugin-attachment-refs": "^4.3.3-RC",
-    "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
-    "@growi/plugin-lsx": "^4.3.3-RC",
-    "@growi/slack": "^4.3.3-RC",
-    "@kobalab/socket.io-session": "^1.0.3",
+    "@growi/plugin-attachment-refs": "^4.4.3-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.3-RC.0",
+    "@growi/plugin-lsx": "^4.4.3-RC.0",
+    "@growi/slack": "^4.4.3-RC.0",
     "@promster/express": "^5.0.1",
     "@promster/server": "^6.0.0",
     "@slack/events-api": "^3.0.0",
@@ -91,6 +89,7 @@
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
+    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
@@ -132,7 +131,7 @@
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
-    "socket.io": "^4.0.0",
+    "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "swagger-jsdoc": "^3.4.0",
@@ -154,7 +153,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.3.3-RC",
+    "@growi/ui": "^4.4.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -221,7 +220,7 @@
     "reveal.js": "^3.5.0",
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
-    "socket.io-client": "^4.0.0",
+    "socket.io-client": "^4.2.0",
     "sticky-events": "^3.1.3",
     "style-loader": "^1.0.0",
     "styled-components": "^5.0.1",

BIN
packages/app/public/images/slack-integration/activate-public-dist.png


BIN
packages/app/public/images/slack-integration/basicinfo-all-checked.png


BIN
packages/app/public/images/slack-integration/click-add-to-slack.png


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

@@ -11,7 +11,13 @@
     "installed_version": "Installed version",
     "list_of_env_vars":"List of environment variables",
     "env_var_priority": "For environment variables other than security, the value of the database is obtained preferentially.",
-    "about_security": "Check <a href='/admin/security'>Securtiy Settings</a> for security environment variables."
+    "about_security": "Check <a href='/admin/security'>Security Settings</a> for security environment variables.",
+    "copy_prefilled_host_information": {
+      "default": "Copy prefilled host information",
+      "done": "Copied to clipboard!"
+    },
+    "bug_report": "Submitting a bug report",
+    "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
   "app_setting": {
     "site_name": "Site name",
@@ -322,6 +328,9 @@
       "enter_proxy_url_and_update": "Enter and update the Proxy URL that you copied in the above step in the <b>Proxy URL</b> of the <b>Custom bot with proxy integration</b> on this page.",
       "dont_need_update": "※If the value is already in there, there is no need to update it.",
       "select_install_your_app": "Select \"Install your app\".",
+      "go-to-manage-distribution": "Go to \"Manage Settings\" > \"Manage distribution\" on your Slack App page.",
+      "activate-public-distribution": "In \"Share Your App with Other Workspaces\", make sure all items are checked and click \"Activate Public Distribution\"",
+      "click-add-to-slack-button": "Click \"Add to Slack\" button.",
       "select_install_to_workspace": "Select \"Install to Workspace\".",
       "register_proxy_url": "Register Proxy URL with GROWI",
       "click_allow": "Select \"Allow\".",
@@ -331,6 +340,13 @@
       "manage_commands": "Manage GROWI commands",
       "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
       "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
+      "allowed_channels_description": "Input allowed channels for \"{{commandName}}\" command. Separate each channel with \",\" . Users can will be able to use \"{{commandName}}\" command from channels written here.",
+      "allow_all": "Allow all",
+      "deny_all": "Deny all",
+      "allow_specified": "Allow specified",
+      "allow_all_long": "Allow all (The command is allowed from any channel)",
+      "deny_all_long": "Deny all (The command is denied from any channel)",
+      "allow_specified_long": "Allow specified (The command is allowed from only specified channels)",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",

+ 8 - 0
packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt

@@ -0,0 +1,8 @@
+Password Reset Successful
+
+Hi {{ email }}
+
+Your password has been successfully reset.
+Please log in with your new password.
+
+Thank you,

+ 13 - 0
packages/app/resource/locales/en_US/notifications/notActiveUser.txt

@@ -0,0 +1,13 @@
+Password Reset
+
+Hi, {{ email }}
+
+A request has been received to change the password from {{ appTitle }}.
+However, this email is not registerd. Please try again with different email.
+
+If you did not request a password reset, you can safely ignore this email.
+
+-------------------------------------------------------------------------
+
+GROWI: {{ appTitle }}
+URL: {{ url }}

+ 10 - 0
packages/app/resource/locales/en_US/notifications/passwordReset.txt

@@ -0,0 +1,10 @@
+Password Reset
+
+Hi, {{ email }}
+
+A request has been received to change the password your GROWI account {{ appTitle }}.
+To reset your password, click on the link below.
+
+{{ url }}
+
+If you did not request a password reset, you can safely ignore this email.

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

@@ -604,7 +604,10 @@
     "Local": {
       "name": "ID/Password",
       "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
-      "enable_local": "Enable ID/Password"
+      "enable_local": "Enable ID/Password",
+      "password_reset_by_users": "Password reset by users",
+      "enable_password_reset_by_users": "Enable password reset by users",
+      "password_reset_desc": "when forgot password, users are able to reset it by themselves."
     },
     "ldap": {
       "enable_ldap": "Enable LDAP",
@@ -845,5 +848,20 @@
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
     "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",
     "failed_to_send_a_test_email":"Failed to send a test email using SMTP. Please check your settings."
+  },
+  "forgot_password":{
+    "forgot_password": "Forgot Password?",
+    "send": "Send",
+    "return_to_login": "Return to login",
+    "reset_password": "Reset Password",
+    "sign_in_instead": "Sign in instead",
+    "password_reset_request_desc": "You can reset your password here.",
+    "password_reset_excecution_desc": "Enter a new password",
+    "new_password": "New Password",
+    "confirm_new_password": "Confirm the new password",
+    "email_is_required": "Email is required",
+    "success_to_send_email": "Success to send email",
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
+    "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   }
 }

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

@@ -321,6 +321,9 @@
       "enter_proxy_url_and_update": "コピーしたProxy URLを、このページの<b>Custom bot with proxy 連携</b>の<b>Proxy URL</b>に入力、更新します。",
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "select_install_your_app": "Install your app をクリックします。",
+      "go-to-manage-distribution": "作成した Slack App の Settings から Manage distribution をクリックします。",
+      "activate-public-distribution": "\"Share Your App with Other Workspaces\" の 4つの項目全てにチェックマークがついたら、\"Activate Public Distribution\" を押します。",
+      "click-add-to-slack-button": "\"Add to Slack\" ボタンをクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",
       "register_proxy_url": "Proxy の URLをGROWIに登録する",
       "click_allow": "遷移先の画面にて、Allowをクリックします。",
@@ -330,6 +333,13 @@
       "manage_commands": "使用可能なGROWIコマンドを設定する",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
+      "allowed_channels_description": "\"{{commandName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{commandName}}\" コマンドを使用することができます。",
+      "allow_all": "全てのチャンネルを許可",
+      "deny_all": "全てのチャンネルを拒否",
+      "allow_specified": "特定のチャンネルを許可",
+      "allow_all-long": "全て許可 (このコマンドは全てのチャンネルから使用することができます)",
+      "deny_all-long": "全て拒否 (このコマンドはどのチャンネルからも使用することはできません)",
+      "allow_specified-long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",

+ 13 - 0
packages/app/resource/locales/ja_JP/notifications/notActiveUser.txt

@@ -0,0 +1,13 @@
+パスワードリセット
+
+こんにちは、 {{ email }}
+
+{{ appTitle }} からパスワード再設定のリクエストがありましたが、このemailは登録されておりません。
+他のemailアドレスで再度お試しください。
+
+もしこのリクエストに心当たりがない場合は、このメールを無視してください。
+
+-------------------------------------------------------------------------
+
+GROWI: {{ appTitle }}
+URL: {{ url }}

+ 10 - 0
packages/app/resource/locales/ja_JP/notifications/passwordReset.txt

@@ -0,0 +1,10 @@
+パスワード リセット
+
+こんにちは, {{ email }}
+
+あなたのGROWIアカウント {{ appTitle }} から、パスワード再設定のリクエストがありました。
+パスワードをリセットするには、以下のリンクをクリックしてください。
+
+{{ url }}
+
+もしこのリクエストに心当たりがない場合は、このメールを無視してください。

+ 6 - 0
packages/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt

@@ -0,0 +1,6 @@
+パスワードリセットに成功
+
+こんにちは、 {{ email }}
+
+あなたのパスワードは正常にリセットされました。
+新しいパスワードでログインしてください。

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

@@ -603,7 +603,10 @@
     "Local": {
       "name": "ID/Password",
       "note for the only env option": "現在LOCAL認証のON/OFFは環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
-      "enable_local": "ID/Password を有効にする"
+      "enable_local": "ID/Password を有効にする",
+      "password_reset_by_users": "ユーザーによるパスワード再設定",
+      "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
+      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。"
     },
     "ldap": {
       "enable_ldap": "LDAP を有効にする",
@@ -839,5 +842,20 @@
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
     "failed_to_send_a_test_email":"SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。"
+  },
+  "forgot_password":{
+    "forgot_password": "パスワードをお忘れですか?",
+    "send": "送信",
+    "return_to_login": "ログイン画面に戻る",
+    "reset_password": "パスワード リセット",
+    "sign_in_instead": "ログインする",
+    "password_reset_request_desc": "ここからパスワードリセットできます",
+    "password_reset_excecution_desc": "新しいパスワードを入力してください",
+    "new_password": "新しいパスワード",
+    "confirm_new_password": "新しいパスワードの確認",
+    "email_is_required": "メールを入力してください",
+    "success_to_send_email": "メールを送信しました",
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
+    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   }
 }

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

@@ -331,6 +331,9 @@
       "enter_proxy_url_and_update": "上述过程中复制的ProxyURL到本页的<b>Custom bot with proxy 一体化</b>的<b>ProxyURL</b>。",
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "select_install_your_app": "选择 \"Install your app\"。",
+      "go-to-manage-distribution": "一旦你创建了你的Slack应用程序,进入设置并点击管理分发。",
+      "activate-public-distribution": "当 \"Share Your App with Other Workspaces\" 中的所有四个项目都被勾选后,按 \"Activate Public Distribution\"。",
+      "click-add-to-slack-button": "点击 \"Add to Slack\" 按钮。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",
       "register_proxy_url": "向 GROWI 注册代理 URL",
       "click_allow": "选择 \"Allow\"。",
@@ -340,6 +343,13 @@
       "manage_commands": "管理 GROWI 命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
+      "allowed_channels_description": "为 \"{{commandName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{commandName}}\"。",
+      "allow_all": "允许所有",
+      "deny_all": "拒绝所有",
+      "allow_specified": "允许指定",
+      "allow_all_long": "允许所有(允许从任何通道发出命令)",
+      "deny_all_long": "拒绝所有(该命令被拒绝于任何通道)",
+      "allow_specified_long": "允许指定(该命令只允许来自指定的通道)",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "error_check_logs_below": "发生了错误。请检查以下日志。",

+ 6 - 0
packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt

@@ -0,0 +1,6 @@
+密码重置成功
+
+嗨, {{email}}
+
+您的密码已成功重置。
+请使用您的新密码登录。

+ 13 - 0
packages/app/resource/locales/zh_CN/notifications/notActiveUser.txt

@@ -0,0 +1,13 @@
+重设密码
+
+嗨,{{电子邮件}}
+
+已收到来自 {{appTitle}} 的更改密码请求。
+但是,此电子邮件未注册。请使用其他电子邮件重试。
+
+如果您没有要求重置密码,则可以放心地忽略此电子邮件。
+
+-------------------------------------------------------------------------
+
+GROWI: {{ appTitle }}
+URL: {{ url }}

+ 10 - 0
packages/app/resource/locales/zh_CN/notifications/passwordReset.txt

@@ -0,0 +1,10 @@
+重设密码
+
+嗨,{{ email }}
+
+已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+要重置密码,请单击下面的链接。
+
+{{ url }}
+
+如果您没有要求重置密码,则可以放心地忽略此电子邮件。

+ 20 - 2
packages/app/resource/locales/zh_CN/translation.json

@@ -42,7 +42,7 @@
   "Update": "更新",
 	"Update Page": "更新本页",
 	"Warning": "警告",
-	"Sign in": "登录",
+  "Sign in": "登录",
 	"Sign up is here": "注册",
 	"Sign in is here": "登录",
 	"Sign up": "注册",
@@ -592,7 +592,10 @@
 		"Local": {
 			"name": "ID/Password",
 			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
-			"enable_local": "Enable ID/Password"
+      "enable_local": "Enable ID/Password",
+      "password_reset_by_users": "用户重置密码",
+      "enable_password_reset_by_users": "启用用户重置密码",
+      "password_reset_desc": "忘记密码时,用户可以自行重置"
 		},
 		"ldap": {
 			"enable_ldap": "Enable LDAP",
@@ -850,5 +853,20 @@
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
     "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
     "failed_to_send_a_test_email":"SMTP方式测试邮件发送失败,请检查相关设定。"
+  },
+  "forgot_password":{
+    "forgot_password": "忘记密码?",
+    "send": "发送",
+    "return_to_login": "返回登录",
+    "reset_password": "重设密码",
+    "sign_in_instead": "改为登录",
+    "password_reset_request_desc": "您可以在此处重置密码",
+    "password_reset_excecution_desc": "输入新的密码",
+    "new_password": "新密码",
+    "confirm_new_password": "确认新密码",
+    "email_is_required": "电子邮件是必需的",
+    "success_to_send_email": "我发了一封电子邮件",
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
+    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   }
 }

+ 34 - 0
packages/app/src/client/nologin.jsx

@@ -9,6 +9,8 @@ import AppContainer from '~/client/services/AppContainer';
 
 import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
+import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
+import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 
 const i18n = i18nFactory();
 
@@ -38,6 +40,7 @@ if (loginFormElem) {
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
+  const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
 
   let registrationWhiteList = loginFormElem.dataset.registrationWhiteList;
@@ -68,6 +71,7 @@ if (loginFormElem) {
           isRegistrationEnabled={isRegistrationEnabled}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
+          isPasswordResetEnabled={isPasswordResetEnabled}
           isLocalStrategySetup={isLocalStrategySetup}
           isLdapStrategySetup={isLdapStrategySetup}
           objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
@@ -77,3 +81,33 @@ if (loginFormElem) {
     loginFormElem,
   );
 }
+
+// render PasswordResetRequestForm
+const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
+const appContainer = new AppContainer();
+appContainer.initApp();
+if (passwordResetRequestFormElem) {
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <Provider inject={[appContainer]}>
+        <PasswordResetRequestForm />
+      </Provider>
+    </I18nextProvider>,
+    passwordResetRequestFormElem,
+  );
+}
+
+// render PasswordResetExecutionForm
+const passwordResetExecutionFormElem = document.getElementById('password-reset-execution-form');
+if (passwordResetExecutionFormElem) {
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <Provider inject={[appContainer]}>
+        <PasswordResetExecutionForm />
+      </Provider>
+    </I18nextProvider>,
+    passwordResetExecutionFormElem,
+  );
+}

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

@@ -18,12 +18,19 @@ export default class AdminHomeContainer extends Container {
 
     this.appContainer = appContainer;
 
+    this.copyStateValues = {
+      DEFAULT: 'default',
+      DONE: 'done',
+    };
+    this.timer = null;
+
     this.state = {
       retrieveError: null,
       growiVersion: '',
       nodeVersion: '',
       npmVersion: '',
       yarnVersion: '',
+      copyState: this.copyStateValues.DEFAULT,
       installedPlugins: [],
     };
 
@@ -36,6 +43,10 @@ export default class AdminHomeContainer extends Container {
     return 'AdminHomeContainer';
   }
 
+  componentWillUnmount() {
+    clearTimeout(this.timer);
+  }
+
   /**
    * retrieve admin home data
    */
@@ -44,14 +55,15 @@ export default class AdminHomeContainer extends Container {
       const response = await this.appContainer.apiv3.get('/admin-home/');
       const { adminHomeParams } = response.data;
 
-      this.setState({
+      this.setState(prevState => ({
+        ...prevState,
         growiVersion: adminHomeParams.growiVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         npmVersion: adminHomeParams.npmVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
-      });
+      }));
     }
     catch (err) {
       logger.error(err);
@@ -59,4 +71,40 @@ export default class AdminHomeContainer extends Container {
     }
   }
 
+  /**
+   * sets button text when copying system information
+   */
+  onCopyPrefilledHostInformation() {
+    this.setState(prevState => ({
+      ...prevState,
+      copyState: this.copyStateValues.DONE,
+    }));
+
+    this.timer = setTimeout(() => {
+      this.setState(prevState => ({
+        ...prevState,
+        copyState: this.copyStateValues.DEFAULT,
+      }));
+    }, 500);
+  }
+
+  /**
+   * generates prefilled host information as markdown
+   */
+  generatePrefilledHostInformationMarkdown() {
+    return `| item     | version |
+| ---      | --- |
+|OS        ||
+|GROWI     |${this.state.growiVersion}|
+|node.js   |${this.state.nodeVersion}|
+|npm       |${this.state.npmVersion}|
+|yarn      |${this.state.yarnVersion}|
+|Using Docker|yes/no|
+|Using [growi-docker-compose][growi-docker-compose]|yes/no|
+
+[growi-docker-compose]: https://github.com/weseek/growi-docker-compose
+
+*(Accessing https://{GROWI_HOST}/admin helps you to fill in above versions)*`;
+  }
+
 }

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

@@ -22,6 +22,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: this.dummyRegistrationMode,
       registrationWhiteList: [],
       useOnlyEnvVars: false,
+      isPasswordResetEnabled: false,
     };
 
   }
@@ -34,6 +35,7 @@ export default class AdminLocalSecurityContainer extends Container {
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
+        isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
       });
     }
     catch (err) {
@@ -66,14 +68,22 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ registrationWhiteList: value.split('\n') });
   }
 
+  /**
+   * Switch password reset enabled
+   */
+  switchIsPasswordResetEnabled() {
+    this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
+  }
+
   /**
    * update local security setting
    */
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
+      isPasswordResetEnabled,
     });
 
     const { localSettingParams } = response.data;
@@ -81,6 +91,7 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
+      isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
     });
 
     return localSettingParams;

+ 11 - 25
packages/app/src/client/services/PageContainer.js

@@ -125,6 +125,10 @@ export default class PageContainer extends Container {
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
     this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
+
+    this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
+    this.emitJoinPageRoomRequest();
+
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
 
@@ -467,7 +471,6 @@ export default class PageContainer extends Container {
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: socketIoContainer.getSocketClientId(),
       path: pagePath,
       body: markdown,
     });
@@ -483,7 +486,6 @@ export default class PageContainer extends Container {
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: socketIoContainer.getSocketClientId(),
       page_id: pageId,
       revision_id: revisionId,
       body: markdown,
@@ -508,7 +510,6 @@ export default class PageContainer extends Container {
       completely,
       page_id: this.state.pageId,
       revision_id: this.state.revisionId,
-      socketClientId: socketIoContainer.getSocketClientId(),
     });
 
   }
@@ -522,7 +523,6 @@ export default class PageContainer extends Container {
     return this.appContainer.apiPost('/pages.revertRemove', {
       recursively,
       page_id: this.state.pageId,
-      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
@@ -538,7 +538,6 @@ export default class PageContainer extends Container {
       isRemainMetadata,
       newPagePath,
       path,
-      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
@@ -565,6 +564,13 @@ export default class PageContainer extends Container {
     });
   }
 
+  // request to server so the client to join a room for each page
+  emitJoinPageRoomRequest() {
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
+    const socket = socketIoContainer.getSocket();
+    socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
+  }
+
   addWebSocketEventHandlers() {
     // eslint-disable-next-line @typescript-eslint/no-this-alias
     const pageContainer = this;
@@ -572,11 +578,6 @@ export default class PageContainer extends Container {
     const socket = socketIoContainer.getSocket();
 
     socket.on('page:create', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
 
       // update remote page data
@@ -587,11 +588,6 @@ export default class PageContainer extends Container {
     });
 
     socket.on('page:update', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
       // update remote page data
@@ -602,11 +598,6 @@ export default class PageContainer extends Container {
     });
 
     socket.on('page:delete', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
 
       // update remote page data
@@ -617,11 +608,6 @@ export default class PageContainer extends Container {
     });
 
     socket.on('page:editingWithHackmd', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
 
       // update isHackmdDraftUpdatingInRealtime

+ 0 - 5
packages/app/src/client/services/SocketIoContainer.js

@@ -23,7 +23,6 @@ export default class SocketIoContainer extends Container {
     this.socket = io(ns, {
       transports: ['websocket'],
     });
-    this.socketClientId = Math.floor(Math.random() * 100000);
 
     this.socket.on('connect_error', (error) => {
       logger.error(error);
@@ -48,8 +47,4 @@ export default class SocketIoContainer extends Container {
     return this.socket;
   }
 
-  getSocketClientId() {
-    return this.socketClientId;
-  }
-
 }

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

@@ -1,6 +1,8 @@
 import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '~/client/util/apiNotification';
@@ -63,6 +65,32 @@ class AdminHome extends React.Component {
             {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
           </div>
         </div>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
+            <div className="d-flex align-items-center">
+              <CopyToClipboard
+                text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
+                onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
+              >
+                <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
+                  {t('admin:admin_top:copy_prefilled_host_information:default')}
+                </button>
+              </CopyToClipboard>
+              <Tooltip
+                placement="bottom"
+                isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
+                target="prefilledHostInformationButton"
+                fade={false}
+              >
+                {t('admin:admin_top:copy_prefilled_host_information:done')}
+              </Tooltip>
+              {/* eslint-disable-next-line react/no-danger */}
+              <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
+            </div>
+          </div>
+        </div>
       </Fragment>
     );
   }

+ 5 - 2
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -4,9 +4,12 @@ import React, {
 import PropTypes from 'prop-types';
 
 import {
-  Card, CardBody, TabContent, TabPane,
+  TabContent, TabPane,
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -48,7 +51,7 @@ const SkeltonListItem = () => (
 const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
   const { t } = useTranslation();
 
-  const isCautionVisible = currentBotType === 'officialBot' || currentBotType === 'customBotWithProxy';
+  const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
 
   return (
     <li className="list-group-item">

+ 22 - 1
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -32,7 +32,7 @@ class LocalSecuritySettingContents extends React.Component {
 
   render() {
     const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode } = adminLocalSecurityContainer.state;
+    const { registrationMode, isPasswordResetEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
 
     return (
@@ -157,6 +157,27 @@ class LocalSecuritySettingContents extends React.Component {
               </div>
             </div>
 
+            <div className="row">
+              <label className="col-12 col-md-3 text-left text-md-right  col-form-label">{t('security_setting.Local.password_reset_by_users')}</label>
+              <div className="col-12 col-md-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    type="checkbox"
+                    className="custom-control-input"
+                    id="isPasswordResetEnabled"
+                    checked={isPasswordResetEnabled}
+                    onChange={() => adminLocalSecurityContainer.switchIsPasswordResetEnabled()}
+                  />
+                  <label className="custom-control-label" htmlFor="isPasswordResetEnabled">
+                    {t('security_setting.Local.enable_password_reset_by_users')}
+                  </label>
+                </div>
+                <p className="form-text text-muted small">
+                  {t('security_setting.Local.password_reset_desc')}
+                </p>
+              </div>
+            </div>
+
             <div className="row my-3">
               <div className="offset-3 col-6">
                 <button

+ 8 - 5
packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx

@@ -2,17 +2,18 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 
+import { SlackbotType } from '@growi/slack';
 
 const botDetails = {
   officialBot: {
-    botType: 'officialBot',
+    botType: SlackbotType.OFFICIAL,
     botTypeCategory: 'official_bot',
     setUp: 'easy',
     multiWSIntegration: 'possible',
     securityControl: 'impossible',
   },
   customBotWithoutProxy: {
-    botType: 'customBotWithoutProxy',
+    botType: SlackbotType.CUSTOM_WITHOUT_PROXY,
     botTypeCategory: 'custom_bot',
     supplementaryBotName: 'without_proxy',
     setUp: 'normal',
@@ -20,7 +21,7 @@ const botDetails = {
     securityControl: 'possible',
   },
   customBotWithProxy: {
-    botType: 'customBotWithProxy',
+    botType: SlackbotType.CUSTOM_WITH_PROXY,
     botTypeCategory: 'custom_bot',
     supplementaryBotName: 'with_proxy',
     setUp: 'hard',
@@ -32,6 +33,8 @@ const botDetails = {
 const BotTypeCard = (props) => {
   const { t } = useTranslation('admin');
 
+  const isBotTypeOfficial = props.botType === SlackbotType.OFFICIAL;
+
   return (
     <div
       className={`card admin-bot-card rounded border-radius-sm shadow ${props.isActive ? 'border-primary' : ''}`}
@@ -41,7 +44,7 @@ const BotTypeCard = (props) => {
     >
       <div>
         <h3 className={`card-header mb-0 py-3
-              ${props.botType === 'officialBot' ? 'd-flex align-items-center justify-content-center' : 'text-center'}
+              ${isBotTypeOfficial ? 'd-flex align-items-center justify-content-center' : 'text-center'}
               ${props.isActive ? 'bg-primary grw-botcard-title-active' : ''}`}
         >
           <span className="mr-2">
@@ -49,7 +52,7 @@ const BotTypeCard = (props) => {
           </span>
 
           {/*  A recommended badge is shown on official bot card, supplementary names are shown on Custom bot cards   */}
-          {props.botType === 'officialBot'
+          { isBotTypeOfficial
             ? (
               <span className="badge badge-info mr-2">
                 {t('admin:slack_integration.selecting_bot_types.recommended')}

+ 3 - 3
packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -127,7 +127,7 @@ const CustomBotWithProxySettings = (props) => {
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
-            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
@@ -148,8 +148,8 @@ const CustomBotWithProxySettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
-                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
-                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
+                permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
+                permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

+ 3 - 3
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx

@@ -67,8 +67,8 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
             readOnly
           />
           <p className="form-text text-muted">
-            {/* eslint-disable-next-line react/no-danger */}
-            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACK_SIGNING_SECRET' }) }} />
+            {/* eslint-disable-next-line max-len, react/no-danger */}
+            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET' }) }} />
           </p>
         </div>
 
@@ -97,7 +97,7 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           />
           <p className="form-text text-muted">
             {/* eslint-disable-next-line react/no-danger */}
-            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACK_BOT_TOKEN' }) }} />
+            <small dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.use_env_var_if_empty', { variable: 'SLACKBOT_WITHOUT_PROXY_BOT_TOKEN' }) }} />
           </p>
         </div>
 

+ 2 - 0
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -50,6 +50,7 @@ const CustomBotWithoutProxySettings = (props) => {
           slackSigningSecret={props.slackSigningSecret}
           onTestConnectionInvoked={props.onTestConnectionInvoked}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
+          commandPermission={props.commandPermission}
         />
       </div>
     </>
@@ -69,6 +70,7 @@ CustomBotWithoutProxySettings.propTypes = {
   onUpdatedSecretToken: PropTypes.func.isRequired,
   onTestConnectionInvoked: PropTypes.func.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
+  commandPermission: PropTypes.object,
 };
 
 export default CustomBotWithoutProxySettingsWrapper;

+ 14 - 2
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -7,6 +7,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import CustomBotWithoutProxySecretTokenSection from './CustomBotWithoutProxySecretTokenSection';
 import { addLogs } from './slak-integration-util';
+import ManageCommandsProcessWithoutProxy from './ManageCommandsProcessWithoutProxy';
 
 
 export const botInstallationStep = {
@@ -20,7 +21,7 @@ export const botInstallationStep = {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
   const {
     appContainer, activeStep, onTestConnectionInvoked,
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv,
+    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission,
   } = props;
   const successMessage = 'Successfully sent to Slack workspace.';
 
@@ -124,7 +125,17 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
-        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <i className="ml-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_commands')}</>}
+      >
+        <ManageCommandsProcessWithoutProxy
+          commandPermission={props.commandPermission}
+          apiv3Put={props.appContainer.apiv3.put}
+        />
+      </Accordion>
+      <Accordion
+        defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
+        // eslint-disable-next-line max-len
+        title={<><span className="mr-2">⑤</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <i className="ml-3 text-success fa fa-check"></i>}</>}
       >
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <div className="d-flex justify-content-center">
@@ -185,6 +196,7 @@ CustomBotWithoutProxySettingsAccordion.propTypes = {
   slackSigningSecretEnv: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
+  commandPermission: PropTypes.object,
 
 };
 

+ 240 - 91
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
@@ -8,52 +8,131 @@ import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 
 const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
 
+const PermissionTypes = {
+  ALLOW_ALL: 'allowAll',
+  DENY_ALL: 'denyAll',
+  ALLOW_SPECIFIED: 'allowSpecified',
+};
+
+const CommandUsageTypes = {
+  BROADCAST_USE: 'broadcastUse',
+  SINGLE_USE: 'singleUse',
+};
+
+// A utility function that returns the new state but identical to the previous state
+const getUpdatedChannelsList = (prevState, commandName, value) => {
+  // string to array
+  const allowedChannelsArray = value.split(',');
+  // trim whitespace from all elements
+  const trimedAllowedChannelsArray = allowedChannelsArray.map(channelName => channelName.trim());
+
+  prevState[commandName] = trimedAllowedChannelsArray;
+  return prevState;
+};
+
+// A utility function that returns the new state
+const getUpdatedPermissionSettings = (prevState, commandName, value) => {
+  const newState = { ...prevState };
+  switch (value) {
+    case PermissionTypes.ALLOW_ALL:
+      newState[commandName] = true;
+      break;
+    case PermissionTypes.DENY_ALL:
+      newState[commandName] = false;
+      break;
+    case PermissionTypes.ALLOW_SPECIFIED:
+      newState[commandName] = [];
+      break;
+    default:
+      logger.error('Not implemented');
+      break;
+  }
+
+  return newState;
+};
+
+// A utility function that returns the permission type from the permission value
+const getPermissionTypeFromValue = (value) => {
+  if (Array.isArray(value)) {
+    return PermissionTypes.ALLOW_SPECIFIED;
+  }
+  if (typeof value === 'boolean') {
+    return value ? PermissionTypes.ALLOW_ALL : PermissionTypes.DENY_ALL;
+  }
+  logger.error('The value type must be boolean or string[]');
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const ManageCommandsProcess = ({
-  apiv3Put, slackAppIntegrationId, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
 }) => {
   const { t } = useTranslation();
-  const [selectedCommandsForBroadcastUse, setSelectedCommandsForBroadcastUse] = useState(new Set(supportedCommandsForBroadcastUse));
-  const [selectedCommandsForSingleUse, setSelectedCommandsForSingleUse] = useState(new Set(supportedCommandsForSingleUse));
 
-  const toggleCheckboxForBroadcast = (e) => {
+  const [permissionsForBroadcastUseCommandsState, setPermissionsForBroadcastUseCommandsState] = useState({
+    search: permissionsForBroadcastUseCommands.search,
+  });
+  const [permissionsForSingleUseCommandsState, setPermissionsForSingleUseCommandsState] = useState({
+    create: permissionsForSingleUseCommands.create,
+    togetter: permissionsForSingleUseCommands.togetter,
+  });
+  const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
+    const initialState = {};
+    Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
+    Object.entries(permissionsForSingleUseCommandsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
+    return initialState;
+  });
+
+  const updatePermissionsForBroadcastUseCommandsState = useCallback((e) => {
     const { target } = e;
-    const { name, checked } = target;
-
-    setSelectedCommandsForBroadcastUse((prevState) => {
-      const selectedCommands = new Set(prevState);
-      if (checked) {
-        selectedCommands.add(name);
-      }
-      else {
-        selectedCommands.delete(name);
-      }
-
-      return selectedCommands;
+    const { name: commandName, value } = target;
+
+    // update state
+    setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setCurrentPermissionTypes((prevState) => {
+      const newState = { ...prevState };
+      newState[commandName] = value;
+      return newState;
     });
-  };
+  }, []);
 
-  const toggleCheckboxForSingleUse = (e) => {
+  const updatePermissionsForSingleUseCommandsState = useCallback((e) => {
     const { target } = e;
-    const { name, checked } = target;
-
-    setSelectedCommandsForSingleUse((prevState) => {
-      const selectedCommands = new Set(prevState);
-      if (checked) {
-        selectedCommands.add(name);
-      }
-      else {
-        selectedCommands.delete(name);
-      }
-
-      return selectedCommands;
+    const { name: commandName, value } = target;
+
+    // update state
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setCurrentPermissionTypes((prevState) => {
+      const newState = { ...prevState };
+      newState[commandName] = value;
+      return newState;
     });
-  };
+  }, []);
+
+  const updateChannelsListForBroadcastUseCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    // update state
+    setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+  }, []);
 
-  const updateCommandsHandler = async() => {
+  const updateChannelsListForSingleUseCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    // update state
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+  }, []);
+
+  const updateCommandsHandler = async(e) => {
     try {
       await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
-        supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
-        supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
+        permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
+        permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
       });
       toastSuccess(t('toaster.update_successed', { target: 'Token' }));
     }
@@ -63,69 +142,139 @@ const ManageCommandsProcess = ({
     }
   };
 
+  const PermissionSettingForEachCommandComponent = ({ commandName, commandUsageType }) => {
+    const hiddenClass = currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
+    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
 
-  return (
-    <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
-      <div className="d-flex flex-column align-items-center">
-
-        <div>
-          <p className="font-weight-bold mb-0">Multiple GROWI</p>
-          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.multiple_growi_command')}</p>
-          <div className="custom-control custom-checkbox">
-            <div className="row mb-5">
-              {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
-                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
-                return (
-                  <div className="col-sm-6 my-1" key={commandName}>
-                    <input
-                      type="checkbox"
-                      className="custom-control-input"
-                      id={checkboxId}
-                      name={commandName}
-                      value={commandName}
-                      checked={selectedCommandsForBroadcastUse.has(commandName)}
-                      onChange={toggleCheckboxForBroadcast}
-                    />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
-                      {commandName}
-                    </label>
-                  </div>
-                );
-              })}
+    const permissionSettings = isCommandBroadcastUse ? permissionsForBroadcastUseCommandsState : permissionsForSingleUseCommandsState;
+    const permission = permissionSettings[commandName];
+    if (permission === undefined) logger.error('Must be implemented');
+
+    const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+
+    return (
+      <div className="my-1 mb-2">
+        <div className="row align-items-center mb-3">
+          <p className="col-md-5 text-md-right text-capitalize mb-2"><strong>{commandName}</strong></p>
+          <div className="col dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+              type="button"
+              id="dropdownMenuButton"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              <span className="float-left">
+                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_ALL
+                && t('admin:slack_integration.accordion.allow_all')}
+                {currentPermissionTypes[commandName] === PermissionTypes.DENY_ALL
+                && t('admin:slack_integration.accordion.deny_all')}
+                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED
+                && t('admin:slack_integration.accordion.allow_specified')}
+              </span>
+            </button>
+            <div className="dropdown-menu">
+              <button
+                className="dropdown-item"
+                type="button"
+                name={commandName}
+                value={PermissionTypes.ALLOW_ALL}
+                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
+              >
+                {t('admin:slack_integration.accordion.allow_all_long')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                name={commandName}
+                value={PermissionTypes.DENY_ALL}
+                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
+              >
+                {t('admin:slack_integration.accordion.deny_all_long')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                name={commandName}
+                value={PermissionTypes.ALLOW_SPECIFIED}
+                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
+              >
+                {t('admin:slack_integration.accordion.allow_specified_long')}
+              </button>
             </div>
           </div>
+        </div>
+        <div className={`row ${hiddenClass}`}>
+          <div className="col-md-7 offset-md-5">
+            <textarea
+              className="form-control"
+              type="textarea"
+              name={commandName}
+              defaultValue={textareaDefaultValue}
+              onChange={isCommandBroadcastUse ? updateChannelsListForBroadcastUseCommandsState : updateChannelsListForSingleUseCommandsState}
+            />
+            <p className="form-text text-muted small">
+              {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
+              <br />
+            </p>
+          </div>
+        </div>
+      </div>
+    );
+  };
 
-          <p className="font-weight-bold mb-0">Single GROWI</p>
-          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.single_growi_command')}</p>
-          <div className="custom-control custom-checkbox">
-            <div className="row mb-5">
-              {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
-                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
-                return (
-                  <div className="col-sm-6 my-1" key={commandName}>
-                    <input
-                      type="checkbox"
-                      className="custom-control-input"
-                      id={checkboxId}
-                      name={commandName}
-                      value={commandName}
-                      checked={selectedCommandsForSingleUse.has(commandName)}
-                      onChange={toggleCheckboxForSingleUse}
-                    />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
-                      {commandName}
-                    </label>
-                  </div>
-                );
-              })}
-            </div>
+  PermissionSettingForEachCommandComponent.propTypes = {
+    commandName: PropTypes.string,
+    commandUsageType: PropTypes.string,
+  };
+
+  const PermissionSettingsForEachCommandTypeComponent = ({ commandUsageType }) => {
+    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
+    const defaultCommandsName = isCommandBroadcastUse ? defaultSupportedCommandsNameForBroadcastUse : defaultSupportedCommandsNameForSingleUse;
+    return (
+      <>
+        <div className="row">
+          <div className="col-md-7 offset-md-2">
+            <p className="font-weight-bold mb-1">{isCommandBroadcastUse ? 'Multiple GROWI' : 'Single GROWI'}</p>
+            <p className="text-muted">
+              {isCommandBroadcastUse
+                ? t('admin:slack_integration.accordion.multiple_growi_command')
+                : t('admin:slack_integration.accordion.single_growi_command')}
+            </p>
           </div>
         </div>
+        <div className="custom-control custom-checkbox">
+          <div className="row mb-5 d-block">
+            {defaultCommandsName.map((commandName) => {
+              // eslint-disable-next-line max-len
+              return <PermissionSettingForEachCommandComponent key={`${commandName}-component`} commandName={commandName} commandUsageType={commandUsageType} />;
+            })}
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  PermissionSettingsForEachCommandTypeComponent.propTypes = {
+    commandUsageType: PropTypes.string,
+  };
+
+
+  return (
+    <div className="py-4 px-5">
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <div className="row d-flex flex-column align-items-center">
+
+        <div className="col-8">
+          {Object.values(CommandUsageTypes).map((commandUsageType) => {
+            return <PermissionSettingsForEachCommandTypeComponent key={commandUsageType} commandUsageType={commandUsageType} />;
+          })}
+        </div>
       </div>
       <div className="row">
         <button
-          type="button"
+          type="submit"
           className="btn btn-primary mx-auto"
           onClick={updateCommandsHandler}
         >
@@ -139,8 +288,8 @@ const ManageCommandsProcess = ({
 ManageCommandsProcess.propTypes = {
   apiv3Put: PropTypes.func,
   slackAppIntegrationId: PropTypes.string.isRequired,
-  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
-  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
+  permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
+  permissionsForSingleUseCommands: PropTypes.object.isRequired,
 };
 
 export default ManageCommandsProcess;

+ 242 - 0
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx

@@ -0,0 +1,242 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import loggerFactory from '~/utils/logger';
+
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
+
+const PermissionTypes = {
+  ALLOW_ALL: 'allowAll',
+  DENY_ALL: 'denyAll',
+  ALLOW_SPECIFIED: 'allowSpecified',
+};
+
+const defaultCommandsName = [...defaultSupportedCommandsNameForBroadcastUse, ...defaultSupportedCommandsNameForSingleUse];
+
+
+// A utility function that returns the new state but identical to the previous state
+const getUpdatedChannelsList = (commandPermissionObj, commandName, value) => {
+  // string to array
+  const allowedChannelsArray = value.split(',');
+  // trim whitespace from all elements
+  const trimedAllowedChannelsArray = allowedChannelsArray.map(channelName => channelName.trim());
+
+  commandPermissionObj[commandName] = trimedAllowedChannelsArray;
+  return commandPermissionObj;
+};
+
+// A utility function that returns the new state
+const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value) => {
+  const editedCommandPermissionObj = { ...commandPermissionObj };
+  switch (value) {
+    case PermissionTypes.ALLOW_ALL:
+      editedCommandPermissionObj[commandName] = true;
+      break;
+    case PermissionTypes.DENY_ALL:
+      editedCommandPermissionObj[commandName] = false;
+      break;
+    case PermissionTypes.ALLOW_SPECIFIED:
+      editedCommandPermissionObj[commandName] = [];
+      break;
+    default:
+      logger.error('Not implemented');
+      break;
+  }
+  return editedCommandPermissionObj;
+};
+
+
+const PermissionSettingForEachCommandComponent = ({
+  commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
+}) => {
+  const { t } = useTranslation();
+
+  if (editingCommandPermission == null) {
+    return null;
+  }
+
+  function permissionTypeClickHandler(e) {
+    if (onPermissionTypeClicked == null) {
+      return;
+    }
+    onPermissionTypeClicked(e);
+  }
+
+  function onPermissionListChangeHandler(e) {
+    if (onPermissionListChanged == null) {
+      return;
+    }
+    onPermissionListChanged(e);
+  }
+
+  const permission = editingCommandPermission[commandName];
+  const hiddenClass = Array.isArray(permission) ? '' : 'd-none';
+  const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+
+
+  return (
+    <div className="my-1 mb-2">
+      <div className="row align-items-center mb-3">
+        <p className="col my-auto text-capitalize align-middle">{commandName}</p>
+        <div className="col dropdown">
+          <button
+            className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+            type="button"
+            id="dropdownMenuButton"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="true"
+          >
+            <span className="float-left">
+              {permission === true && t('admin:slack_integration.accordion.allow_all')}
+              {permission === false && t('admin:slack_integration.accordion.deny_all')}
+              {Array.isArray(permission) && t('admin:slack_integration.accordion.allow_specified')}
+            </span>
+          </button>
+          <div className="dropdown-menu">
+            <button
+              className="dropdown-item"
+              type="button"
+              name={commandName}
+              value={PermissionTypes.ALLOW_ALL}
+              onClick={e => permissionTypeClickHandler(e)}
+            >
+              {t('admin:slack_integration.accordion.allow_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={commandName}
+              value={PermissionTypes.DENY_ALL}
+              onClick={e => permissionTypeClickHandler(e)}
+            >
+              {t('admin:slack_integration.accordion.deny_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={commandName}
+              value={PermissionTypes.ALLOW_SPECIFIED}
+              onClick={e => permissionTypeClickHandler(e)}
+            >
+              {t('admin:slack_integration.accordion.allow_specified_long')}
+            </button>
+          </div>
+        </div>
+      </div>
+      <div className={`row-12 row-md-6 ${hiddenClass}`}>
+        <textarea
+          className="form-control"
+          type="textarea"
+          name={commandName}
+          value={textareaDefaultValue}
+          onChange={e => onPermissionListChangeHandler(e)}
+        />
+        <p className="form-text text-muted small">
+          {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
+          <br />
+        </p>
+      </div>
+    </div>
+  );
+};
+
+PermissionSettingForEachCommandComponent.propTypes = {
+  commandName: PropTypes.string,
+  editingCommandPermission: PropTypes.object,
+  onPermissionTypeClicked: PropTypes.func,
+  onPermissionListChanged: PropTypes.func,
+};
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
+  const { t } = useTranslation();
+  const [editingCommandPermission, setEditingCommandPermission] = useState({});
+
+  const updatePermissionsCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+
+    // update state
+    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
+  }, []);
+
+
+  useEffect(() => {
+    if (commandPermission == null) {
+      return;
+    }
+    const updatedState = { ...commandPermission };
+    setEditingCommandPermission(updatedState);
+  }, [commandPermission]);
+
+  const updateChannelsListState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    // update state
+    setEditingCommandPermission((commandPermissionObj) => {
+      return {
+        ...getUpdatedChannelsList(commandPermissionObj, commandName, value),
+      };
+    });
+  }, []);
+
+  const updateCommandsHandler = async(e) => {
+    try {
+      await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
+        commandPermission: editingCommandPermission,
+      });
+      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+  return (
+    <div className="py-4 px-5">
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5 d-block">
+              { defaultCommandsName.map((commandName) => {
+                // eslint-disable-next-line max-len
+                return (
+                  <PermissionSettingForEachCommandComponent
+                    key={`${commandName}-component`}
+                    commandName={commandName}
+                    editingCommandPermission={editingCommandPermission}
+                    onPermissionTypeClicked={updatePermissionsCommandsState}
+                    onPermissionListChanged={updateChannelsListState}
+                  />
+                );
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className="row">
+        <button
+          type="submit"
+          className="btn btn-primary mx-auto"
+          onClick={updateCommandsHandler}
+        >
+          { t('Update') }
+        </button>
+      </div>
+    </div>
+  );
+};
+
+ManageCommandsProcessWithoutProxy.propTypes = {
+  apiv3Put: PropTypes.func,
+  commandPermission: PropTypes.object,
+};
+
+export default ManageCommandsProcessWithoutProxy;

+ 7 - 4
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -1,6 +1,9 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -92,7 +95,7 @@ const OfficialBotSettings = (props) => {
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
-            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
@@ -109,12 +112,12 @@ const OfficialBotSettings = (props) => {
                 />
               </div>
               <WithProxyAccordions
-                botType="officialBot"
+                botType={SlackbotType.OFFICIAL}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
-                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
-                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
+                permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
+                permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

+ 11 - 5
packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -1,6 +1,9 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -12,7 +15,7 @@ import ConfirmBotChangeModal from './ConfirmBotChangeModal';
 import BotTypeCard from './BotTypeCard';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 
-const botTypes = ['officialBot', 'customBotWithoutProxy', 'customBotWithProxy'];
+const botTypes = Object.values(SlackbotType);
 
 const SlackIntegration = (props) => {
 
@@ -24,6 +27,7 @@ const SlackIntegration = (props) => {
   const [slackBotToken, setSlackBotToken] = useState(null);
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
+  const [commandPermission, setCommandPermission] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
@@ -37,7 +41,7 @@ const SlackIntegration = (props) => {
     try {
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const {
-        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri,
+        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri, commandPermission,
       } = data.settings;
 
       setErrorMsg(data.errorMsg);
@@ -50,6 +54,7 @@ const SlackIntegration = (props) => {
       setSlackBotTokenEnv(slackBotTokenEnvVars);
       setSlackAppIntegrations(slackAppIntegrations);
       setProxyServerUri(proxyServerUri);
+      setCommandPermission(commandPermission);
     }
     catch (err) {
       toastError(err);
@@ -125,7 +130,7 @@ const SlackIntegration = (props) => {
   let settingsComponent = null;
 
   switch (currentBotType) {
-    case 'officialBot':
+    case SlackbotType.OFFICIAL:
       settingsComponent = (
         <OfficialBotSettings
           slackAppIntegrations={slackAppIntegrations}
@@ -138,7 +143,7 @@ const SlackIntegration = (props) => {
         />
       );
       break;
-    case 'customBotWithoutProxy':
+    case SlackbotType.CUSTOM_WITHOUT_PROXY:
       settingsComponent = (
         <CustomBotWithoutProxySettings
           slackBotTokenEnv={slackBotTokenEnv}
@@ -148,10 +153,11 @@ const SlackIntegration = (props) => {
           onTestConnectionInvoked={fetchSlackIntegrationData}
           onUpdatedSecretToken={changeSecretAndToken}
           connectionStatuses={connectionStatuses}
+          commandPermission={commandPermission}
         />
       );
       break;
-    case 'customBotWithProxy':
+    case SlackbotType.CUSTOM_WITH_PROXY:
       settingsComponent = (
         <CustomBotWithProxySettings
           slackAppIntegrations={slackAppIntegrations}

+ 64 - 46
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -1,8 +1,12 @@
 /* eslint-disable react/prop-types */
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+import { SlackbotType } from '@growi/slack';
+
+import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -67,15 +71,16 @@ const BotInstallProcessForCustomBotWithProxy = () => {
   const { t } = useTranslation();
   return (
     <div className="container w-75 py-5">
-      <p>1. {t('admin:slack_integration.accordion.select_install_your_app')}</p>
-      <img src="/images/slack-integration/slack-bot-install-your-app-introduction.png" className="border border-light img-fluid mb-5" />
-      <p>2. {t('admin:slack_integration.accordion.select_install_to_workspace')}</p>
-      <img src="/images/slack-integration/slack-bot-install-to-workspace.png" className="border border-light img-fluid mb-5" />
-      <p>3. {t('admin:slack_integration.accordion.click_allow')}</p>
+      <p>1. {t('admin:slack_integration.accordion.go-to-manage-distribution')}</p>
+      <p>2. {t('admin:slack_integration.accordion.activate-public-distribution')}</p>
+      <img src="/images/slack-integration/activate-public-dist.png" className="border border-light img-fluid mb-5" />
+      <p>3. {t('admin:slack_integration.accordion.click-add-to-slack-button')}</p>
+      <img src="/images/slack-integration/click-add-to-slack.png" className="border border-light img-fluid mb-5" />
+      <p>4. {t('admin:slack_integration.accordion.click_allow')}</p>
       <img src="/images/slack-integration/slack-bot-install-your-app-transition-destination.png" className="border border-light img-fluid mb-5" />
-      <p>4. {t('admin:slack_integration.accordion.install_complete_if_checked')}</p>
-      <img src="/images/slack-integration/slack-bot-install-your-app-complete.png" className="border border-light img-fluid mb-5" />
-      <p>5. {t('admin:slack_integration.accordion.invite_bot_to_channel')}</p>
+      <p>5. {t('admin:slack_integration.accordion.install_complete_if_checked')}</p>
+      <img src="/images/slack-integration/basicinfo-all-checked.png" className="border border-light img-fluid mb-5" />
+      <p>6. {t('admin:slack_integration.accordion.invite_bot_to_channel')}</p>
       <img src="/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png" className="border border-light img-fluid mb-1" />
       <img src="/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png" className="border border-light img-fluid" />
     </div>
@@ -111,6 +116,31 @@ const RegisteringProxyUrlProcess = () => {
   );
 };
 
+// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
+const CustomCopyToClipBoard = (props) => {
+  const { t } = useTranslation();
+  const [tooltipOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, []);
+  return (
+    <>
+      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
+        <div className="btn input-group-text" id="tooltipTarget">
+          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
+        </div>
+      </CopyToClipboard>
+      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
+        {t(props.message)}
+      </Tooltip>
+    </>
+  );
+};
+
 const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers((props) => {
   const { t } = useTranslation();
   const { appContainer, slackAppIntegrationId } = props;
@@ -137,11 +167,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
         <div className="col-md-6">
           <div className="input-group-prepend mx-1">
             <input className="form-control" type="text" value={props.tokenPtoG || ''} readOnly />
-            <CopyToClipboard text={props.tokenPtoG || ''} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
-              <div className="btn input-group-text">
-                <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-              </div>
-            </CopyToClipboard>
+            <CustomCopyToClipBoard textToBeCopied={props.tokenPtoG || ''} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
           </div>
         </div>
       </div>
@@ -150,11 +176,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
         <div className="col-md-6">
           <div className="input-group-prepend mx-1">
             <input className="form-control" type="text" value={props.tokenGtoP || ''} readOnly />
-            <CopyToClipboard text={props.tokenGtoP || ''} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
-              <div className="btn input-group-text">
-                <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-              </div>
-            </CopyToClipboard>
+            <CustomCopyToClipBoard textToBeCopied={props.tokenGtoP || ''} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
           </div>
         </div>
       </div>
@@ -189,11 +211,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
             <div className="input-group align-items-center pl-2 mb-3">
               <div className="input-group-prepend w-75">
                 <input className="form-control" type="text" value={props.growiUrl} readOnly />
-                <CopyToClipboard text={props.growiUrl} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
-                  <div className="btn input-group-text">
-                    <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-                  </div>
-                </CopyToClipboard>
+                <CustomCopyToClipBoard textToBeCopied={props.growiUrl} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
               </div>
             </div>
 
@@ -319,6 +337,15 @@ const WithProxyAccordions = (props) => {
       />,
     },
     '③': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
+        permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+      />,
+    },
+    '④': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -328,15 +355,6 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
     },
-    '④': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
   };
 
   const CustomBotIntegrationProcedure = {
@@ -363,6 +381,15 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
+        permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+      />,
+    },
+    '⑥': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -372,18 +399,9 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
     },
-    '⑥': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
   };
 
-  const integrationProcedureMapping = props.botType === 'officialBot' ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
+  const integrationProcedureMapping = props.botType === SlackbotType.OFFICIAL ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
 
   return (
     <div
@@ -416,12 +434,12 @@ const WithProxyAccordions = (props) => {
 const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
 WithProxyAccordions.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  botType: PropTypes.string.isRequired,
+  botType: PropTypes.oneOf(Object.values(SlackbotType)).isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
-  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
-  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
+  permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
+  permissionsForSingleUseCommands: PropTypes.object.isRequired,
 };
 
 export default WithProxyAccordionsWrapper;

+ 1 - 1
packages/app/src/components/EmptyTrashModal.jsx

@@ -23,7 +23,7 @@ const EmptyTrashModal = (props) => {
     setErrs(null);
 
     try {
-      await appContainer.apiv3Delete('/pages/empty-trash', { socketClientId: socketIoContainer.getSocketClientId() });
+      await appContainer.apiv3Delete('/pages/empty-trash');
       window.location.reload();
     }
     catch (err) {

+ 7 - 0
packages/app/src/components/LoginForm.jsx

@@ -251,6 +251,7 @@ class LoginForm extends React.Component {
       isLocalStrategySetup,
       isLdapStrategySetup,
       isRegistrationEnabled,
+      isPasswordResetEnabled,
       objOfIsExternalAuthEnableds,
     } = this.props;
 
@@ -268,6 +269,11 @@ class LoginForm extends React.Component {
                 {isRegistrationEnabled && (
                   <div className="row">
                     <div className="col-12 text-right py-2">
+                      {isPasswordResetEnabled && (
+                        <a href="/forgot-password" className="d-block link-switch mb-1">
+                          <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                        </a>
+                      )}
                       <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
                         <i className="ti-check-box"></i> {t('Sign up is here')}
                       </a>
@@ -307,6 +313,7 @@ LoginForm.propTypes = {
   isRegistrationEnabled: PropTypes.bool,
   registrationMode: PropTypes.string,
   registrationWhiteList: PropTypes.array,
+  isPasswordResetEnabled: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,

+ 1 - 1
packages/app/src/components/PagePathHierarchicalLink.jsx

@@ -46,7 +46,7 @@ const PagePathHierarchicalLink = (props) => {
   const RootElm = ({ children }) => {
     return props.isInnerElem
       ? <>{children}</>
-      : <span className="grw-page-path-hierarchical-link d-inline-block text-break">{children}</span>;
+      : <span className="grw-page-path-hierarchical-link text-break">{children}</span>;
   };
 
   return (

+ 96 - 0
packages/app/src/components/PasswordResetExecutionForm.jsx

@@ -0,0 +1,96 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '~/utils/logger';
+import { withUnstatedContainers } from './UnstatedUtils';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+const logger = loggerFactory('growi:passwordReset');
+
+
+const PasswordResetExecutionForm = (props) => {
+  const { t, appContainer } = props;
+
+  const [newPassword, setNewPassword] = useState('');
+  const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
+  const [validationErrorI18n, setValidationErrorI18n] = useState('');
+
+  // get token from URL
+  const pathname = window.location.pathname.split('/');
+  const token = pathname[2];
+
+  const changePassword = async(e) => {
+    e.preventDefault();
+
+    if (newPassword === '' || newPasswordConfirm === '') {
+      setValidationErrorI18n('personal_settings.password_is_not_set');
+      return;
+    }
+
+    if (newPassword !== newPasswordConfirm) {
+      setValidationErrorI18n('forgot_password.password_and_confirm_password_does_not_match');
+      return;
+    }
+
+    try {
+      await appContainer.apiv3Put('/forgot-password', {
+        token, newPassword, newPasswordConfirm,
+      });
+
+      setValidationErrorI18n('');
+
+      toastSuccess(t('toaster.update_successed', { target: t('Password') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+
+  };
+
+  return (
+    <form role="form" onSubmit={changePassword}>
+      <div className="form-group">
+        <div className="input-group">
+          <input
+            name="password"
+            placeholder={t('forgot_password.new_password')}
+            className="form-control"
+            type="password"
+            onChange={e => setNewPassword(e.target.value)}
+          />
+        </div>
+      </div>
+      <div className="form-group">
+        <div className="input-group">
+          <input
+            name="password"
+            placeholder={t('forgot_password.confirm_new_password')}
+            className="form-control"
+            type="password"
+            onChange={e => setNewPasswordConfirm(e.target.value)}
+          />
+        </div>
+        {validationErrorI18n !== '' && (
+          <p className="text-danger mt-2">{t(validationErrorI18n)}</p>
+        )}
+      </div>
+      <div className="form-group">
+        <input name="reset-password-btn" className="btn btn-lg btn-primary btn-block" value={t('forgot_password.reset_password')} type="submit" />
+      </div>
+      <a href="/login">
+        <i className="icon-login mr-1"></i>{t('forgot_password.sign_in_instead')}
+      </a>
+    </form>
+  );
+};
+
+const PasswordResetExecutionFormWrapper = withUnstatedContainers(PasswordResetExecutionForm, [AppContainer]);
+
+PasswordResetExecutionForm.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(PasswordResetExecutionFormWrapper);

+ 66 - 0
packages/app/src/components/PasswordResetRequestForm.jsx

@@ -0,0 +1,66 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import AppContainer from '~/client/services/AppContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+
+const PasswordResetRequestForm = (props) => {
+  const { t, appContainer } = props;
+  const [email, setEmail] = useState('');
+
+  const changeEmail = (inputValue) => {
+    setEmail(inputValue);
+  };
+
+  const sendPasswordResetRequestMail = async(e) => {
+    e.preventDefault();
+    if (email === '') {
+      toastError('err', t('forgot_password.email_is_required'));
+      return;
+    }
+
+    try {
+      await appContainer.apiv3Post('/forgot-password', { email });
+      toastSuccess(t('forgot_password.success_to_send_email'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <form onSubmit={sendPasswordResetRequestMail}>
+      <div className="form-group">
+        <div className="input-group">
+          <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
+        </div>
+      </div>
+      <div className="form-group">
+        <button
+          className="btn btn-lg btn-primary btn-block"
+          type="submit"
+        >
+          {t('forgot_password.send')}
+        </button>
+      </div>
+      <a href="/login">
+        <i className="icon-login mr-1"></i>{t('forgot_password.return_to_login')}
+      </a>
+    </form>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PasswordResetRequestFormWrapper = withUnstatedContainers(PasswordResetRequestForm, [AppContainer]);
+
+PasswordResetRequestForm.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(PasswordResetRequestFormWrapper);

+ 1 - 1
packages/app/src/components/Sidebar/CustomSidebar.jsx

@@ -61,7 +61,7 @@ const CustomSidebar = (props) => {
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
-        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={fetchDataAndRenderHtml}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload-cs" onClick={fetchDataAndRenderHtml}>
           <i className="icon icon-reload"></i>
         </button>
       </div>

+ 138 - 30
packages/app/src/components/Sidebar/RecentChanges.jsx

@@ -10,6 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 import LinkedPagePath from '~/models/linked-page-path';
 
+import FootstampIcon from '../FootstampIcon';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
@@ -17,6 +19,106 @@ import { toastError } from '~/client/util/apiNotification';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
 const logger = loggerFactory('growi:History');
+
+function PageItemLower({ page }) {
+  return (
+    <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
+      <div className="d-flex">
+        <div className="footstamp-icon mr-1 d-inline-block"><FootstampIcon /></div>
+        <div className="mr-2 grw-list-counts d-inline-block">{page.seenUsers.length}</div>
+        <div className="icon-bubble mr-1 d-inline-block"></div>
+        <div className="mr-2 grw-list-counts d-inline-block">{page.commentCount}</div>
+      </div>
+      <div className="grw-formatted-distance-date small mt-auto">
+        <FormattedDistanceDate id={page._id} date={page.updatedAt} />
+      </div>
+    </div>
+  );
+}
+PageItemLower.propTypes = {
+  page: PropTypes.any,
+};
+function LargePageItem({ page }) {
+  const dPagePath = new DevidedPagePath(page.path, false, true);
+  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+  const FormerLink = () => (
+    <div className="grw-page-path-text-muted-container small">
+      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+    </div>
+  );
+
+  let locked;
+  if (page.grant !== 1) {
+    locked = <span><i className="icon-lock ml-2" /></span>;
+  }
+
+  const tags = page.tags;
+  const tagElements = tags.map((tag) => {
+    return (
+      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+        {tag.name}
+      </a>
+    );
+  });
+
+  return (
+    <li className="list-group-item py-3 px-0">
+      <div className="d-flex w-100">
+        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+        <div className="flex-grow-1 ml-2">
+          { !dPagePath.isRoot && <FormerLink /> }
+          <h5 className="my-2">
+            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+            {locked}
+          </h5>
+          <div className="grw-tag-labels mt-1 mb-2">
+            { tagElements }
+          </div>
+          <PageItemLower page={page} />
+        </div>
+      </div>
+    </li>
+  );
+}
+LargePageItem.propTypes = {
+  page: PropTypes.any,
+};
+
+function SmallPageItem({ page }) {
+  const dPagePath = new DevidedPagePath(page.path, false, true);
+  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+  const FormerLink = () => (
+    <div className="grw-page-path-text-muted-container small">
+      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+    </div>
+  );
+
+  let locked;
+  if (page.grant !== 1) {
+    locked = <span><i className="icon-lock ml-2" /></span>;
+  }
+
+  return (
+    <li className="list-group-item py-2 px-0">
+      <div className="d-flex w-100">
+        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+        <div className="flex-grow-1 ml-2">
+          { !dPagePath.isRoot && <FormerLink /> }
+          <h5 className="my-0">
+            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+            {locked}
+          </h5>
+          <PageItemLower page={page} />
+        </div>
+      </div>
+    </li>
+  );
+}
+SmallPageItem.propTypes = {
+  page: PropTypes.any,
+};
 class RecentChanges extends React.Component {
 
   static propTypes = {
@@ -26,10 +128,16 @@ class RecentChanges extends React.Component {
 
   constructor(props) {
     super(props);
-
+    this.state = {
+      isRecentChangesSidebarSmall: false,
+    };
     this.reloadData = this.reloadData.bind(this);
   }
 
+  componentWillMount() {
+    this.retrieveSizePreferenceFromLocalStorage();
+  }
+
   async componentDidMount() {
     this.reloadData();
   }
@@ -46,36 +154,22 @@ class RecentChanges extends React.Component {
     }
   }
 
-  PageItem = ({ page }) => {
-    const dPagePath = new DevidedPagePath(page.path, false, true);
-    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    const FormerLink = () => (
-      <div className="grw-page-path-text-muted-container small">
-        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-      </div>
-    );
+  retrieveSizePreferenceFromLocalStorage() {
+    if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
+      this.setState({
+        isRecentChangesSidebarSmall: true,
+      });
+    }
+  }
 
-    return (
-      <li className="list-group-item p-2">
-        <div className="d-flex w-100">
-          <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
-          <div className="flex-grow-1 ml-2">
-            { !dPagePath.isRoot && <FormerLink /> }
-            <h5 className="mb-1">
-              <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            </h5>
-            <div className="text-right small">
-              <FormattedDistanceDate id={page.id} date={page.updatedAt} />
-            </div>
-          </div>
-        </div>
-      </li>
-    );
+  changeSizeHandler = (e) => {
+    this.setState({
+      isRecentChangesSidebarSmall: e.target.checked,
+    });
+    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
   }
 
   render() {
-    const { PageItem } = this;
     const { t } = this.props;
     const { recentlyUpdatedPages } = this.props.appContainer.state;
 
@@ -84,13 +178,26 @@ class RecentChanges extends React.Component {
         <div className="grw-sidebar-content-header p-3 d-flex">
           <h3 className="mb-0">{t('Recent Changes')}</h3>
           {/* <h3 className="mb-0">{t('Recent Created')}</h3> */} {/* TODO: impl switching */}
-          <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={this.reloadData}>
+          <button type="button" className="btn btn-sm ml-auto grw-btn-reload-rc" onClick={this.reloadData}>
             <i className="icon icon-reload"></i>
           </button>
+          <div className="grw-recent-changes-resize-button custom-control custom-switch ml-2">
+            <input
+              id="recentChangesResize"
+              className="custom-control-input"
+              type="checkbox"
+              checked={this.state.isRecentChangesSidebarSmall}
+              onChange={e => this.setState({ isRecentChangesSidebarSmall: e.target.checked })}
+            />
+            <label className="custom-control-label" htmlFor="recentChangesResize">
+            </label>
+          </div>
         </div>
-        <div className="grw-sidebar-content-body p-3">
+        <div className="grw-sidebar-content-body grw-recent-changes p-3">
           <ul className="list-group list-group-flush">
-            { recentlyUpdatedPages.map(page => <PageItem key={page.id} page={page} />) }
+            {recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
+              ? <SmallPageItem key={page._id} page={page} />
+              : <LargePageItem key={page._id} page={page} />))}
           </ul>
         </div>
       </>
@@ -104,4 +211,5 @@ class RecentChanges extends React.Component {
  */
 const RecentChangesWrapper = withUnstatedContainers(RecentChanges, [AppContainer]);
 
+
 export default withTranslation()(RecentChangesWrapper);

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

@@ -89,6 +89,8 @@ const StickyStretchableScroller = (props) => {
       railVisible: true,
       position: 'right',
       height: isScrollEnabled ? viewHeight : contentsHeight,
+      wheelStep: 10,
+      allowPageScroll: true,
     });
 
     // destroy

+ 65 - 0
packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js

@@ -0,0 +1,65 @@
+import mongoose from 'mongoose';
+
+import Config from '~/server/models/config';
+import config from '^/config/migrate';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    await Config.bulkWrite([
+      {
+        updateOne: {
+          filter: { key: 'slackbot:proxyServerUri' },
+          update: { key: 'slackbot:proxyUri' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:token' },
+          update: { key: 'slackbot:withoutProxy:botToken' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:signingSecret' },
+          update: { key: 'slackbot:withoutProxy:signingSecret' },
+        },
+      },
+    ]);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    await Config.bulkWrite([
+      {
+        updateOne: {
+          filter: { key: 'slackbot:proxyUri' },
+          update: { key: 'slackbot:proxyServerUri' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:withoutProxy:botToken' },
+          update: { key: 'slackbot:token' },
+        },
+      },
+      {
+        updateOne: {
+          filter: { key: 'slackbot:withoutProxy:signingSecret' },
+          update: { key: 'slackbot:signingSecret' },
+        },
+      },
+    ]);
+
+    logger.info('Migration has successfully applied');
+  },
+};

+ 33 - 0
packages/app/src/migrations/20210906194521-slack-app-integration-set-default-value.js

@@ -0,0 +1,33 @@
+import mongoose from 'mongoose';
+
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+import config from '^/config/migrate';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:slack-app-integration-set-default-value');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    // Add columns + set all default commands if supportedCommandsForBroadcastUse column does not exist
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    // Add togetter command if supportedCommandsForBroadcastUse already exists
+    const slackAppIntegrations = await SlackAppIntegration.find();
+    slackAppIntegrations.forEach(async(doc) => {
+      if (!doc.supportedCommandsForSingleUse.includes('togetter')) {
+        doc.supportedCommandsForSingleUse.push('togetter');
+      }
+      await doc.save();
+    });
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down() {
+    // no rollback
+  },
+};

+ 110 - 0
packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -0,0 +1,110 @@
+import mongoose from 'mongoose';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+
+import config from '^/config/migrate';
+import loggerFactory from '~/utils/logger';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+
+
+const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    // create default data
+    const defaultDataForBroadcastUse = {};
+    defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
+      defaultDataForBroadcastUse[commandName] = false;
+    });
+    const defaultDataForSingleUse = {};
+    defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
+      defaultDataForSingleUse[commandName] = false;
+    });
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const copyForBroadcastUse = defaultDataForBroadcastUse;
+      const copyForSingleUse = defaultDataForSingleUse;
+      doc._doc.supportedCommandsForBroadcastUse.forEach((commandName) => {
+        copyForBroadcastUse[commandName] = true;
+      });
+      doc._doc.supportedCommandsForSingleUse.forEach((commandName) => {
+        copyForSingleUse[commandName] = true;
+      });
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: [
+            {
+              $set: {
+                permissionsForBroadcastUseCommands: copyForBroadcastUse,
+                permissionsForSingleUseCommands: copyForSingleUse,
+              },
+            },
+            {
+              $unset: ['supportedCommandsForBroadcastUse', 'supportedCommandsForSingleUse'],
+            },
+          ],
+        },
+      };
+    });
+
+    await SlackAppIntegration.bulkWrite(operations);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, next) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const dataForBroadcastUse = [];
+      const dataForSingleUse = [];
+      doc.permissionsForBroadcastUseCommands.forEach((value, commandName) => {
+        if (value === true) {
+          dataForBroadcastUse.push(commandName);
+        }
+      });
+      doc.permissionsForSingleUseCommands.forEach((value, commandName) => {
+        if (value === true) {
+          dataForSingleUse.push(commandName);
+        }
+      });
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: [
+            {
+              $set: {
+                supportedCommandsForBroadcastUse: dataForBroadcastUse,
+                supportedCommandsForSingleUse: dataForSingleUse,
+              },
+            },
+            {
+              $unset: ['permissionsForBroadcastUseCommands', 'permissionsForSingleUseCommands'],
+            },
+          ],
+        },
+      };
+    });
+
+    await SlackAppIntegration.bulkWrite(operations);
+
+    next();
+    logger.info('Migration has successfully applied');
+  },
+};

+ 0 - 1
packages/app/src/server/.node-dev.json

@@ -2,7 +2,6 @@
   "ignore": [
     "package.json",
     "public/manifest.json",
-    "config/env.",
     "config/webpack."
   ]
 }

+ 3 - 1
packages/app/src/server/console.js

@@ -2,7 +2,7 @@ const repl = require('repl');
 const fs = require('fs');
 const path = require('path');
 const mongoose = require('mongoose');
-const { getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
+const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
 
 const models = require('./models');
 
@@ -32,6 +32,8 @@ fs.readFile(replHistoryPath, 'utf8', (err, data) => {
 replServer.context.mongoose = mongoose;
 replServer.context.models = models;
 
+initMongooseGlobalSettings();
+
 mongoose.connect(getMongoUri(), mongoOptions)
   .then(() => {
     replServer.context.db = mongoose.connection.db;

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

@@ -9,7 +9,7 @@ import CdnResourcesService from '~/services/cdn-resources-service';
 import InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
-import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import ConfigManager from '../service/config-manager';
@@ -35,7 +35,7 @@ function Crowi() {
   this.publicDir = path.join(projectRoot, 'public') + sep;
   this.resourceDir = path.join(projectRoot, 'resource') + sep;
   this.localeDir = path.join(this.resourceDir, 'locales') + sep;
-  this.viewsDir = path.join(projectRoot, 'src', 'server', 'views') + sep;
+  this.viewsDir = path.resolve(__dirname, '../views') + sep;
   this.tmpDir = path.join(projectRoot, 'tmp') + sep;
   this.cacheDir = path.join(this.tmpDir, 'cache');
 
@@ -218,6 +218,8 @@ Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
   const mongoUri = getMongoUri();
 
+  initMongooseGlobalSettings();
+
   return mongoose.connect(mongoUri, mongoOptions);
 };
 

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

@@ -4,7 +4,8 @@ const logger = loggerFactory('growi:middleware:admin-required');
 
 module.exports = (crowi, fallback = null) => {
 
-  return async(req, res, next) => {
+  return function(req, res, next) {
+
     if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.admin) {
         return next();

+ 27 - 0
packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts

@@ -0,0 +1,27 @@
+import { NextFunction, Request, Response } from 'express';
+import createError from 'http-errors';
+
+import PasswordResetOrder, { IPasswordResetOrder } from '../models/password-reset-order';
+
+export type ReqWithPasswordResetOrder = Request & {
+  passwordResetOrder: IPasswordResetOrder,
+};
+
+export default async(req: ReqWithPasswordResetOrder, res: Response, next: NextFunction): Promise<void> => {
+  const token = req.params.token || req.body.token;
+
+  if (token == null) {
+    return next(createError(400, 'Token not found', { code: 'token-not-found' }));
+  }
+
+  const passwordResetOrder = await PasswordResetOrder.findOne({ token });
+
+  // check if the token is valid
+  if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
+    return next(createError(400, 'passwordResetOrder is null or expired or revoked', { code: 'password-reset-order-is-not-appropriate' }));
+  }
+
+  req.passwordResetOrder = passwordResetOrder;
+
+  return next();
+};

+ 3 - 5
packages/app/src/server/models/page.js

@@ -772,7 +772,7 @@ module.exports = function(crowi) {
     // find
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-    const pages = await builder.query.exec('find');
+    const pages = await builder.query.lean().exec('find');
 
     const result = {
       pages, totalCount, offset: opt.offset, limit: opt.limit,
@@ -962,7 +962,6 @@ module.exports = function(crowi) {
     const format = options.format || 'markdown';
     const redirectTo = options.redirectTo || null;
     const grantUserGroupId = options.grantUserGroupId || null;
-    const socketClientId = options.socketClientId || null;
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -995,7 +994,7 @@ module.exports = function(crowi) {
     savedPage = await this.findByPath(revision.path);
     await savedPage.populateDataToShowRevision();
 
-    pageEvent.emit('create', savedPage, user, socketClientId);
+    pageEvent.emit('create', savedPage, user);
 
     return savedPage;
   };
@@ -1007,7 +1006,6 @@ module.exports = function(crowi) {
     const grant = options.grant || pageData.grant; //                                  use the previous data if absence
     const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-    const socketClientId = options.socketClientId || null;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     pageData.applyScope(user, grant, grantUserGroupId);
@@ -1023,7 +1021,7 @@ module.exports = function(crowi) {
       savedPage = await this.syncRevisionToHackmd(savedPage);
     }
 
-    pageEvent.emit('update', savedPage, user, socketClientId);
+    pageEvent.emit('update', savedPage, user);
 
     return savedPage;
   };

+ 72 - 0
packages/app/src/server/models/password-reset-order.ts

@@ -0,0 +1,72 @@
+import mongoose, {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import uniqueValidator from 'mongoose-unique-validator';
+import crypto from 'crypto';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+export interface IPasswordResetOrder {
+  token: string,
+  email: string,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  relatedUser: any,
+  isRevoked: boolean,
+  createdAt: Date,
+  expiredAt: Date,
+}
+
+export interface PasswordResetOrderDocument extends IPasswordResetOrder, Document {
+  isExpired(): Promise<boolean>
+  revokeOneTimeToken(): Promise<void>
+}
+
+export interface PasswordResetOrderModel extends Model<PasswordResetOrderDocument> {
+  generateOneTimeToken(): string
+  createPasswordResetOrder(email: string): PasswordResetOrderDocument
+}
+
+const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
+  token: { type: String, required: true, unique: true },
+  email: { type: String, required: true },
+  relatedUser: { type: ObjectId, ref: 'User' },
+  isRevoked: { type: Boolean, default: false, required: true },
+  createdAt: { type: Date, default: Date.now, required: true },
+  expiredAt: { type: Date, default: Date.now() + 600000, required: true },
+});
+schema.plugin(uniqueValidator);
+
+schema.statics.generateOneTimeToken = function() {
+  const buf = crypto.randomBytes(256);
+  const token = buf.toString('hex');
+
+  return token;
+};
+
+schema.statics.createPasswordResetOrder = async function(email) {
+  let token;
+  let duplicateToken;
+
+  do {
+    token = this.generateOneTimeToken();
+    // eslint-disable-next-line no-await-in-loop
+    duplicateToken = await this.findOne({ token });
+  } while (duplicateToken != null);
+
+  const passwordResetOrderData = await this.create({ token, email });
+
+  return passwordResetOrderData;
+};
+
+schema.methods.isExpired = function() {
+  return this.expiredAt.getTime() < Date.now();
+};
+
+schema.methods.revokeOneTimeToken = async function() {
+  this.isRevoked = true;
+  return this.save();
+};
+
+export default getOrCreateModel<PasswordResetOrderDocument, PasswordResetOrderModel>('PasswordResetOrder', schema);

+ 14 - 6
packages/app/src/server/models/slack-app-integration.js

@@ -1,22 +1,26 @@
 const crypto = require('crypto');
 const mongoose = require('mongoose');
+const { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } = require('@growi/slack');
+
 
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
   isPrimary: { type: Boolean, unique: true, sparse: true },
-  supportedCommandsForBroadcastUse: { type: [String], default: [] },
-  supportedCommandsForSingleUse: { type: [String], default: [] },
+  permissionsForBroadcastUseCommands: Map,
+  permissionsForSingleUseCommands: Map,
 });
 
 class SlackAppIntegration {
 
-  static generateAccessTokens() {
+  crowi;
+
+  static generateAccessTokens(saltForGtoP, saltForPtoG) {
     const now = new Date().getTime();
     const hasher1 = crypto.createHash('sha512');
     const hasher2 = crypto.createHash('sha512');
-    const tokenGtoP = hasher1.update(`gtop${now.toString()}${process.env.SALT_FOR_GTOP_TOKEN}`).digest('base64');
-    const tokenPtoG = hasher2.update(`ptog${now.toString()}${process.env.SALT_FOR_PTOG_TOKEN}`).digest('base64');
+    const tokenGtoP = hasher1.update(`gtop-${saltForGtoP}-${now.toString()}`).digest('base64');
+    const tokenPtoG = hasher2.update(`ptog-${saltForPtoG}-${now.toString()}`).digest('base64');
     return [tokenGtoP, tokenPtoG];
   }
 
@@ -26,8 +30,12 @@ class SlackAppIntegration {
     let tokenPtoG;
     let generateTokens;
 
+    // get salt strings
+    const saltForGtoP = this.crowi.configManager.getConfig('crowi', 'slackbot:withProxy:saltForGtoP');
+    const saltForPtoG = this.crowi.configManager.getConfig('crowi', 'slackbot:withProxy:saltForPtoG');
+
     do {
-      generateTokens = this.generateAccessTokens();
+      generateTokens = this.generateAccessTokens(saltForGtoP, saltForPtoG);
       tokenGtoP = generateTokens[0];
       tokenPtoG = generateTokens[1];
       // eslint-disable-next-line no-await-in-loop

+ 120 - 0
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -0,0 +1,120 @@
+import rateLimit from 'express-rate-limit';
+
+import PasswordResetOrder from '~/server/models/password-reset-order';
+import ErrorV3 from '~/server/models/vo/error-apiv3';
+import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+const { body } = require('express-validator');
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  const { appService, mailService, configManager } = crowi;
+  const User = crowi.model('User');
+  const path = require('path');
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  const validator = {
+    password: [
+      body('newPassword').isString().not().isEmpty()
+        .isLength({ min: 6 })
+        .withMessage('password must be at least 6 characters long'),
+      // checking if password confirmation matches password
+      body('newPasswordConfirm').isString().not().isEmpty()
+        .custom((value, { req }) => {
+          return (value === req.body.newPassword);
+        }),
+    ],
+  };
+
+  const apiLimiter = rateLimit({
+    windowMs: 15 * 60 * 1000, // 15 minutes
+    max: 5, // limit each IP to 5 requests per windowMs
+    message:
+      'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
+  });
+
+  async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
+    return mailService.send({
+      to: email,
+      subject: txtFileName,
+      template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+      vars: {
+        appTitle: appService.getAppTitle(),
+        email,
+        url,
+      },
+    });
+  }
+
+  router.post('/', async(req, res) => {
+    const { email } = req.body;
+    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+    const i18n = req.language || grobalLang;
+    const appUrl = appService.getSiteUrl();
+
+    try {
+      const user = await User.findOne({ email });
+
+      // when the user is not found or active
+      if (user == null || user.status !== 2) {
+        await sendPasswordResetEmail('notActiveUser', i18n, email, appUrl);
+        return res.apiv3();
+      }
+
+      const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
+      const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
+      const oneTimeUrl = url.href;
+      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl);
+      return res.apiv3();
+    }
+    catch (err) {
+      const msg = 'Error occurred during password reset request procedure.';
+      logger.error(err);
+      return res.apiv3Err(`${msg} Cause: ${err}`);
+    }
+  });
+
+  router.put('/', injectResetOrderByTokenMiddleware, async(req, res) => {
+    const { passwordResetOrder } = req;
+    const { email } = passwordResetOrder;
+    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+    const i18n = req.language || grobalLang;
+    const { newPassword } = req.body;
+
+    const user = await User.findOne({ email });
+
+    // when the user is not found or active
+    if (user == null || user.status !== 2) {
+      return res.apiv3Err('update-password-failed');
+    }
+
+    try {
+      const userData = await user.updatePassword(newPassword);
+      const serializedUserData = serializeUserSecurely(userData);
+      passwordResetOrder.revokeOneTimeToken();
+      await sendPasswordResetEmail('passwordResetSuccessful', i18n, email);
+      return res.apiv3({ userData: serializedUserData });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('update-password-failed');
+    }
+  });
+
+  // middleware to handle error
+  router.use((error, req, res, next) => {
+    if (error != null) {
+      return res.apiv3Err(new ErrorV3(error.message, error.code));
+    }
+    next();
+  });
+
+  return router;
+};

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

@@ -51,5 +51,7 @@ module.exports = (crowi) => {
   router.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
 
+  router.use('/forgot-password', require('./forgot-password')(crowi));
+
   return router;
 };

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

@@ -132,6 +132,7 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting } = crowi.models;
   const { exportService } = crowi;
 

+ 76 - 15
packages/app/src/server/routes/apiv3/pages.js

@@ -4,6 +4,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const pathUtils = require('growi-commons').pathUtils;
+const mongoose = require('mongoose');
 
 const { body } = require('express-validator');
 const { query } = require('express-validator');
@@ -22,6 +23,36 @@ const LIMIT_FOR_LIST = 10;
  *    name: Pages
  */
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Tags:
+ *        description: Tags
+ *        type: array
+ *        items:
+ *          $ref: '#/components/schemas/Tag/properties/name'
+ *        example: ['daily', 'report', 'tips']
+ *
+ *      Tag:
+ *        description: Tag
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: tag ID
+ *            example: 5e2d6aede35da4004ef7e0b7
+ *          name:
+ *            type: string
+ *            description: tag name
+ *            example: daily
+ *          count:
+ *            type: number
+ *            description: Count of tagged pages
+ *            example: 3
+ */
+
 /**
  * @swagger
  *
@@ -76,7 +107,7 @@ const LIMIT_FOR_LIST = 10;
  *          path:
  *            type: string
  *            description: page path
- *            example: /
+ *            example: /Sandbox/Math
  *          redirectTo:
  *            type: string
  *            description: redirect path
@@ -137,7 +168,6 @@ module.exports = (crowi) => {
       body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
       body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
       body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
       body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
     ],
     renamePage: [
@@ -147,7 +177,6 @@ module.exports = (crowi) => {
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
-      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
     ],
 
     duplicatePage: [
@@ -176,7 +205,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /pages/create:
+   *    /pages:
    *      post:
    *        tags: [Pages]
    *        operationId: createPage
@@ -193,6 +222,14 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/Page/properties/path'
    *                  grant:
    *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                  grantUserGroupId:
+   *                    type: string
+   *                    description: UserGroup ID
+   *                    example: 5ae5fccfc5577b0004dbd8ab
+   *                  pageTags:
+   *                    type: array
+   *                    items:
+   *                      $ref: '#/components/schemas/Tag'
    *                required:
    *                  - body
    *                  - path
@@ -203,14 +240,23 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        tags:
+   *                          type: array
+   *                          items:
+   *                            $ref: '#/components/schemas/Tags'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
    *          409:
    *            description: page path is already existed
    */
   router.post('/', accessTokenParser, loginRequiredStrictly, csrf, validator.createPage, apiV3FormValidator, async(req, res) => {
     const {
-      body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, socketClientId, pageTags,
+      body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
     } = req.body;
 
     let { path } = req.body;
@@ -224,7 +270,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Failed to post page', 'page_exists'), 500);
     }
 
-    const options = { socketClientId };
+    const options = {};
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -311,6 +357,26 @@ module.exports = (crowi) => {
         }
       });
 
+      const PageTagRelation = mongoose.model('PageTagRelation');
+      const ids = result.pages.map((page) => { return page._id });
+      const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
+
+      // { pageId: [{ tag }, ...] }
+      const relationsMap = new Map();
+      // increment relationsMap
+      relations.forEach((relation) => {
+        const pageId = relation.relatedPage.toString();
+        if (!relationsMap.has(pageId)) {
+          relationsMap.set(pageId, []);
+        }
+        relationsMap.get(pageId).push(relation.relatedTag);
+      });
+      // add tags to each page
+      result.pages.forEach((page) => {
+        const pageId = page._id.toString();
+        page.tags = relationsMap.has(pageId) ? relationsMap.get(pageId) : [];
+      });
+
       return res.apiv3(result);
     }
     catch (err) {
@@ -379,7 +445,6 @@ module.exports = (crowi) => {
     const options = {
       createRedirectPage: req.body.isRenameRedirect,
       updateMetadata: !req.body.isRemainMetadata,
-      socketClientId: +req.body.socketClientId || undefined,
     };
 
     if (!isCreatablePage(newPagePath)) {
@@ -429,9 +494,6 @@ module.exports = (crowi) => {
     return res.apiv3(result);
   });
 
-  validator.emptyTrash = [
-    query('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
-  ];
   /**
    * @swagger
    *
@@ -443,9 +505,8 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, validator.emptyTrash, apiV3FormValidator, async(req, res) => {
-    const socketClientId = parseInt(req.query.socketClientId);
-    const options = { socketClientId };
+  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
+    const options = {};
 
     try {
       const pages = await crowi.pageService.deleteCompletelyDescendantsWithStream({ path: '/trash' }, req.user, options);

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

@@ -380,6 +380,7 @@ module.exports = (crowi) => {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
       },
       generalAuth: {
         isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
@@ -747,6 +748,7 @@ module.exports = (crowi) => {
     const requestParams = {
       'security:registrationMode': req.body.registrationMode,
       'security:registrationWhiteList': req.body.registrationWhiteList,
+      'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
     };
     try {
       await updateAndReloadStrategySettings('local', requestParams);
@@ -754,6 +756,7 @@ module.exports = (crowi) => {
       const localSettingParams = {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
       };
       return res.apiv3({ localSettingParams });
     }

+ 118 - 51
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,3 +1,5 @@
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 
 const mongoose = require('mongoose');
@@ -19,7 +21,6 @@ const logger = loggerFactory('growi:routes:apiv3:slack-integration-settings');
 
 const router = express.Router();
 
-const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
 
 /**
  * @swagger
@@ -52,8 +53,7 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
-
-  const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+  const SlackAppIntegration = crowi.model('SlackAppIntegration');
 
   const validator = {
     botType: [
@@ -61,7 +61,7 @@ module.exports = (crowi) => {
     ],
     slackIntegration: [
       body('currentBotType')
-        .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
+        .isIn(Object.values(SlackbotType)),
     ],
     proxyUri: [
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
@@ -102,22 +102,18 @@ module.exports = (crowi) => {
 
     const params = {
       'slackbot:currentBotType': initializedType,
-      'slackbot:signingSecret': null,
-      'slackbot:token': null,
-      'slackbot:proxyServerUri': null,
+      'slackbot:withoutProxy:signingSecret': null,
+      'slackbot:withoutProxy:botToken': null,
+      'slackbot:proxyUri': null,
+      'slackbot:withoutProxy:commandPermission': null,
     };
 
-    // set url if officialBot is specified
-    if (initializedType === 'officialBot') {
-      params['slackbot:proxyServerUri'] = OFFICIAL_SLACKBOT_PROXY_URI;
-    }
-
     return updateSlackBotSettings(params);
   }
 
   async function getConnectionStatusesFromProxy(tokens) {
     const csv = tokens.join(',');
-    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
 
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
       headers: {
@@ -130,7 +126,7 @@ module.exports = (crowi) => {
   }
 
   async function requestToProxyServer(token, method, endpoint, body) {
-    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
     if (proxyUri == null) {
       throw new Error('Proxy URL is not registered');
     }
@@ -173,15 +169,16 @@ module.exports = (crowi) => {
 
     // retrieve settings
     const settings = {};
-    if (currentBotType === 'customBotWithoutProxy') {
-      settings.slackSigningSecretEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:signingSecret');
-      settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:token');
-      settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
-      settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:token');
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
+      settings.slackSigningSecretEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:signingSecret');
+      settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:botToken');
+      settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
+      settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
+      settings.commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
     }
     else {
-      settings.proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
-      settings.proxyUriEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:proxyServerUri');
+      settings.proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
+      settings.proxyUriEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:proxyUri');
     }
 
     // retrieve connection statuses
@@ -191,7 +188,7 @@ module.exports = (crowi) => {
     if (currentBotType == null) {
       // no need to do anything
     }
-    else if (currentBotType === 'customBotWithoutProxy') {
+    else if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const token = settings.slackBotToken;
       // check the token is not null
       if (token != null) {
@@ -248,6 +245,25 @@ module.exports = (crowi) => {
     await resetAllBotSettings(initializedBotType);
     crowi.slackIntegrationService.publishUpdatedMessage();
 
+    if (initializedBotType === 'customBotWithoutProxy') {
+      // set without-proxy command permissions at bot type changing
+      const commandPermission = {};
+      [...defaultSupportedCommandsNameForBroadcastUse, ...defaultSupportedCommandsNameForSingleUse].forEach((commandName) => {
+        commandPermission[commandName] = true;
+      });
+
+      const requestParams = { 'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission) };
+      try {
+        await updateSlackBotSettings(requestParams);
+        crowi.slackIntegrationService.publishUpdatedMessage();
+      }
+      catch (error) {
+        const msg = 'Error occured in updating command permission settigns';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      }
+    }
+
     // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
     const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
     return res.apiv3({ slackBotTypeParam });
@@ -333,25 +349,25 @@ module.exports = (crowi) => {
    */
   router.put('/without-proxy/update-settings', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType !== 'customBotWithoutProxy') {
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not CustomBotWithoutProxy';
       return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
     }
 
     const { slackSigningSecret, slackBotToken } = req.body;
     const requestParams = {
-      'slackbot:signingSecret': slackSigningSecret,
-      'slackbot:token': slackBotToken,
+      'slackbot:withoutProxy:signingSecret': slackSigningSecret,
+      'slackbot:withoutProxy:botToken': slackBotToken,
     };
     try {
       await updateSlackBotSettings(requestParams);
       crowi.slackIntegrationService.publishUpdatedMessage();
 
       const customBotWithoutProxySettingParams = {
-        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
-        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:token'),
+        slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret'),
+        slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken'),
       };
-      return res.apiv3({ customBotWithoutProxySettingParams });
+      return res.apiv3();
     }
     catch (error) {
       const msg = 'Error occured in updating Custom bot setting';
@@ -360,6 +376,43 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/without-proxy/update-permissions/:
+   *      put:
+   *        tags: [UpdateWithoutProxyPermissions]
+   *        operationId: putWithoutProxyPermissions
+   *        summary: update customBotWithoutProxy permissions
+   *        description: Update customBotWithoutProxy permissions.
+   *        responses:
+   *           200:
+   *             description: Succeeded to put CustomBotWithoutProxy permissions.
+   */
+
+  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
+      const msg = 'Not CustomBotWithoutProxy';
+      return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
+    }
+
+    const { commandPermission } = req.body;
+    const requestParams = {
+      'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission),
+    };
+    try {
+      await updateSlackBotSettings(requestParams);
+      crowi.slackIntegrationService.publishUpdatedMessage();
+      return res.apiv3();
+    }
+    catch (error) {
+      const msg = 'Error occured in updating command permission settigns';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+    }
+  });
+
 
   /**
    * @swagger
@@ -375,21 +428,30 @@ module.exports = (crowi) => {
    *            description: Succeeded to create slack app integration
    */
   router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
+    if (SlackAppIntegrationRecordsNum >= 10) {
+      const msg = 'Not be able to create more than 10 slack workspace integration settings';
+      logger.error('Error', msg);
+      return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
+    }
+
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
-      const count = await SlackAppIntegration.countDocuments();
-      if (count >= 10) {
-        const msg = 'Not be able to create more than 10 slack workspace integration settings';
-        logger.error('Error', msg);
-        return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
-      }
+      const initialSupportedCommandsForBroadcastUse = new Map();
+      const initialSupportedCommandsForSingleUse = new Map();
+
+      defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
+        initialSupportedCommandsForBroadcastUse.set(commandName, true);
+      });
+      defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
+        initialSupportedCommandsForSingleUse.set(commandName, true);
+      });
 
       const slackAppTokens = await SlackAppIntegration.create({
         tokenGtoP,
         tokenPtoG,
-        isPrimary: count === 0 ? true : undefined,
-        supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
-        supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
+        permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
+        permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
       });
       return res.apiv3(slackAppTokens, 200);
     }
@@ -414,7 +476,6 @@ module.exports = (crowi) => {
    *            description: Succeeded to delete access tokens for slack
    */
   router.delete('/slack-app-integrations/:id', validator.deleteIntegration, apiV3FormValidator, async(req, res) => {
-    const SlackAppIntegration = mongoose.model('SlackAppIntegration');
     const { id } = req.params;
 
     try {
@@ -438,7 +499,7 @@ module.exports = (crowi) => {
   router.put('/proxy-uri', loginRequiredStrictly, adminRequired, csrf, validator.proxyUri, apiV3FormValidator, async(req, res) => {
     const { proxyUri } = req.body;
 
-    const requestParams = { 'slackbot:proxyServerUri': proxyUri };
+    const requestParams = { 'slackbot:proxyUri': proxyUri };
 
     try {
       await updateSlackBotSettings(requestParams);
@@ -544,30 +605,36 @@ module.exports = (crowi) => {
    */
   // eslint-disable-next-line max-len
   router.put('/slack-app-integrations/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
-    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = req.body;
     const { id } = req.params;
 
+    const updatePermissionsForBroadcastUseCommands = new Map(Object.entries(permissionsForBroadcastUseCommands));
+    const updatePermissionsForSingleUseCommands = new Map(Object.entries(permissionsForSingleUseCommands));
+
     try {
       const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
         id,
-        { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse },
+        {
+          permissionsForBroadcastUseCommands: updatePermissionsForBroadcastUseCommands,
+          permissionsForSingleUseCommands: updatePermissionsForSingleUseCommands,
+        },
         { new: true },
       );
 
-      const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+      const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
       if (proxyUri != null) {
         await requestToProxyServer(
           slackAppIntegration.tokenGtoP,
           'put',
           '/g2s/supported-commands',
           {
-            supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
-            supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+            permissionsForBroadcastUseCommands: slackAppIntegration.permissionsForBroadcastUseCommands,
+            permissionsForSingleUseCommands: slackAppIntegration.permissionsForSingleUseCommands,
           },
         );
       }
 
-      return res.apiv3({ slackAppIntegration });
+      return res.apiv3({});
     }
     catch (error) {
       const msg = `Error occured in updating settings. Cause: ${error.message}`;
@@ -592,12 +659,12 @@ module.exports = (crowi) => {
   // eslint-disable-next-line max-len
   router.post('/slack-app-integrations/:id/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.relationTest, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType === 'customBotWithoutProxy') {
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
     }
 
-    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
     if (proxyUri == null) {
       return res.apiv3Err(new ErrorV3('Proxy URL is null.', 'not-proxy-Uri'), 400);
     }
@@ -616,8 +683,8 @@ module.exports = (crowi) => {
         'post',
         '/g2s/relation-test',
         {
-          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
-          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+          permissionsForBroadcastUseCommands: slackAppIntegration.permissionsForBroadcastUseCommands,
+          permissionsForSingleUseCommands: slackAppIntegration.permissionsForSingleUseCommands,
         },
       );
 
@@ -666,12 +733,12 @@ module.exports = (crowi) => {
    */
   router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.slackChannel, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType !== 'customBotWithoutProxy') {
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Select Without Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
     }
 
-    const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:token');
+    const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
     const status = await getConnectionStatus(slackBotToken);
     if (status.error != null) {
       return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));

+ 65 - 45
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -4,12 +4,13 @@ const express = require('express');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const { verifySlackRequest, generateWebClient, getSupportedGrowiActionsRegExps } = require('@growi/slack');
+const { verifySlackRequest, parseSlashCommand } = require('@growi/slack');
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
+const { checkPermission } = require('../../util/slack-integration');
 
 module.exports = (crowi) => {
   this.app = crowi.express;
@@ -26,14 +27,14 @@ module.exports = (crowi) => {
       return res.status(400).send({ message });
     }
 
-    const slackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
+    const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
 
     logger.debug('verifyAccessTokenFromProxy', {
       tokenPtoG,
-      slackAppIntegrationCount,
+      SlackAppIntegrationCount,
     });
 
-    if (slackAppIntegrationCount === 0) {
+    if (SlackAppIntegrationCount === 0) {
       return res.status(403).send({
         message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n'
         + 'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. '
@@ -44,57 +45,78 @@ module.exports = (crowi) => {
     next();
   }
 
-  async function checkCommandPermission(req, res, next) {
+  async function extractPermissionsCommands(tokenPtoG) {
+    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    if (slackAppIntegration == null) return null;
+    const permissionsForBroadcastUseCommands = slackAppIntegration.permissionsForBroadcastUseCommands;
+    const permissionsForSingleUseCommands = slackAppIntegration.permissionsForSingleUseCommands;
+
+    return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
+  }
+
+  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  async function checkCommandsPermission(req, res, next) {
+    if (req.body.text == null) return next(); // when /relation-test
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const extractPermissions = await extractPermissionsCommands(tokenPtoG);
+
+    let commandPermission;
+    if (extractPermissions != null) { // with proxy
+      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
+      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
+    }
+    else { // without proxy
+      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+    }
+
+    const growiCommand = parseSlashCommand(req.body);
+    const fromChannel = req.body.channel_name;
+    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    if (isPermitted) return next();
+
+    // IT IS NOT WORKING. FIX THIS GW-7441
+    return res.status(403).send('It is not allowed to run the command to this GROWI.');
+  }
 
-    const relation = await SlackAppIntegration.findOne({ tokenPtoG });
-    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = relation;
-    const supportedCommands = supportedCommandsForBroadcastUse.concat(supportedCommandsForSingleUse);
-    const supportedGrowiActionsRegExps = getSupportedGrowiActionsRegExps(supportedCommands);
+  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  async function checkInteractionsPermission(req, res, next) {
+    const payload = JSON.parse(req.body.payload);
+    if (payload == null) return next(); // when /relation-test
 
-    // get command name from req.body
-    let command = '';
     let actionId = '';
     let callbackId = '';
-    let payload;
-    if (req.body.payload) {
-      payload = JSON.parse(req.body.payload);
-    }
+    let fromChannel = '';
 
-    if (req.body.text == null && !payload) { // when /relation-test
-      return next();
-    }
-
-    if (!payload) { // when request is to /commands
-      command = req.body.text.split(' ')[0];
-    }
-    else if (payload.actions) { // when request is to /interactions && block_actions
+    if (payload.actions) { // when request is to /interactions && block_actions
       actionId = payload.actions[0].action_id;
+      fromChannel = payload.channel.name;
     }
     else { // when request is to /interactions && view_submission
       callbackId = payload.view.callback_id;
+      fromChannel = JSON.parse(payload.view.private_metadata).channelName;
     }
 
-    let isActionSupported = false;
-    supportedGrowiActionsRegExps.forEach((regexp) => {
-      if (regexp.test(actionId) || regexp.test(callbackId)) {
-        isActionSupported = true;
-      }
-    });
-
-    // validate
-    if (command && !supportedCommands.includes(command)) {
-      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const extractPermissions = await extractPermissionsCommands(tokenPtoG);
+    let commandPermission;
+    if (extractPermissions != null) { // with proxy
+      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
+      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
     }
-    if ((actionId || callbackId) && !isActionSupported) {
-      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    else { // without proxy
+      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
     }
 
-    next();
+    const callbacIdkOrActionId = callbackId || actionId;
+    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    if (isPermitted) return next();
+
+    // IT IS NOT WORKING FIX. THIS GW-7441
+    return res.status(403).send('It is not allowed to run the command to this GROWI.');
   }
 
   const addSigningSecretToReq = (req, res, next) => {
-    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
+    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
     return next();
   };
 
@@ -116,7 +138,6 @@ module.exports = (crowi) => {
       text: 'Processing your request ...',
     });
 
-
     const args = body.text.split(' ');
     const command = args[0];
 
@@ -129,14 +150,13 @@ module.exports = (crowi) => {
 
   }
 
-  router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
+  router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleCommands(req, res, client);
   });
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
     const { body } = req;
-
     // eslint-disable-next-line max-len
     // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
     if (body.type === 'url_verification') {
@@ -145,7 +165,6 @@ module.exports = (crowi) => {
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-
     return handleCommands(req, res, client);
   });
 
@@ -186,12 +205,12 @@ module.exports = (crowi) => {
 
   }
 
-  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
+  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, checkInteractionsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleInteractions(req, res, client);
   });
 
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkInteractionsPermission, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
 
@@ -201,8 +220,9 @@ module.exports = (crowi) => {
   router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = slackAppIntegration;
 
-    return res.send(slackAppIntegration);
+    return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
   });
 
   return router;

+ 21 - 0
packages/app/src/server/routes/forgot-password.ts

@@ -0,0 +1,21 @@
+import {
+  NextFunction, Request, RequestHandler, Response,
+} from 'express';
+import { ReqWithPasswordResetOrder } from '../middlewares/inject-reset-order-by-token-middleware';
+
+export const forgotPassword = (req: Request, res: Response): void => {
+  return res.render('forgot-password');
+};
+
+export const resetPassword = (req: ReqWithPasswordResetOrder, res: Response): void => {
+  const { passwordResetOrder } = req;
+  return res.render('reset-password', { email: passwordResetOrder.email });
+};
+
+// middleware to handle error
+export const handleHttpErrosMiddleware = (error: Error & { code: string }, req: Request, res: Response, next: NextFunction): Promise<RequestHandler> | void => {
+  if (error != null) {
+    return res.render('forgot-password/error', { key: error.code });
+  }
+  next();
+};

+ 2 - 2
packages/app/src/server/routes/hackmd.js

@@ -341,11 +341,11 @@ module.exports = function(crowi, app) {
    * @param {object} res
    */
   const saveOnHackmd = async function(req, res) {
-    const page = req.page;
+    const { page, user } = req;
 
     try {
       await Page.updateHasDraftOnHackmd(page, true);
-      pageEvent.emit('saveOnHackmd', page);
+      pageEvent.emit('saveOnHackmd', page, user);
       return res.json(ApiResponse.success());
     }
     catch (err) {

+ 19 - 0
packages/app/src/server/routes/index.js

@@ -1,5 +1,19 @@
+import express from 'express';
+
+import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
+
+import * as forgotPassword from './forgot-password';
+
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
+const rateLimit = require('express-rate-limit');
+
+const apiLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 5, // limit each IP to 5 requests per windowMs
+  message:
+    'Too many requests sent from this IP, please try again after 15 minutes',
+});
 
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 
@@ -175,6 +189,11 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
+  app.use('/forgot-password', express.Router()
+    .get('/', forgotPassword.forgotPassword)
+    .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
+    .use(forgotPassword.handleHttpErrosMiddleware));
+
   app.get('/share/:linkId', page.showSharedPage);
 
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);

+ 4 - 8
packages/app/src/server/routes/page.js

@@ -682,7 +682,6 @@ module.exports = function(crowi, app) {
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
-    const socketClientId = req.body.socketClientId || undefined;
     const pageTags = req.body.pageTags || undefined;
 
     if (body === null || pagePath === null) {
@@ -698,7 +697,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
 
-    const options = { socketClientId };
+    const options = {};
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -816,7 +815,6 @@ module.exports = function(crowi, app) {
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd; // cast to boolean
-    const socketClientId = req.body.socketClientId || undefined;
     const pageTags = req.body.pageTags || undefined;
 
     if (pageId === null || pageBody === null || revisionId === null) {
@@ -835,7 +833,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
     }
 
-    const options = { isSyncRevisionToHackmd, socketClientId };
+    const options = { isSyncRevisionToHackmd };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -1152,14 +1150,13 @@ module.exports = function(crowi, app) {
   api.remove = async function(req, res) {
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
-    const socketClientId = req.body.socketClientId || undefined;
 
     // get completely flag
     const isCompletely = (req.body.completely != null);
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
 
-    const options = { socketClientId };
+    const options = {};
 
     const page = await Page.findByIdAndViewer(pageId, req.user);
 
@@ -1213,7 +1210,6 @@ module.exports = function(crowi, app) {
    */
   api.revertRemove = async function(req, res, options) {
     const pageId = req.body.page_id;
-    const socketClientId = req.body.socketClientId || undefined;
 
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
@@ -1224,7 +1220,7 @@ module.exports = function(crowi, app) {
       if (page == null) {
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
-      page = await crowi.pageService.revertDeletedPage(page, req.user, { socketClientId }, isRecursively);
+      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively);
     }
     catch (err) {
       logger.error('Error occured while get setting', err);

+ 41 - 14
packages/app/src/server/service/config-loader.ts

@@ -6,6 +6,7 @@ import ConfigModel, {
   Config, defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs,
 } from '../models/config';
 
+
 const logger = loggerFactory('growi:service:ConfigLoader');
 
 enum ValueType { NUMBER, STRING, BOOLEAN }
@@ -306,6 +307,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
+  LOCAL_STRATEGY_PASSWORD_RESET_ENABLED: {
+    ns:      'crowi',
+    key:     'security:passport-local:isPasswordResetEnabled',
+    type:    ValueType.BOOLEAN,
+    default: true,
+  },
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
@@ -444,39 +451,59 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
-  SLACK_SIGNING_SECRET: {
+  GROWI_APP_ID_FOR_GROWI_CLOUD: {
     ns:      'crowi',
-    key:     'slackbot:signingSecret',
+    key:     'app:growiAppIdForCloud',
     type:    ValueType.STRING,
     default: null,
   },
-  SLACK_BOT_TOKEN: {
+  DEFAULT_EMAIL_PUBLISHED: {
     ns:      'crowi',
-    key:     'slackbot:token',
+    key:     'customize:isEmailPublishedForNewUser',
+    type:    ValueType.BOOLEAN,
+    default: true,
+  },
+  SLACKBOT_TYPE: {
+    ns:      'crowi',
+    key:     'slackbot:currentBotType', // enum SlackbotType
     type:    ValueType.STRING,
     default: null,
   },
-  SLACK_INTEGRATION_PROXY_URI: {
+  SLACKBOT_INTEGRATION_PROXY_URI: {
     ns:      'crowi',
-    key:     'slackbot:proxyServerUri',
+    key:     'slackbot:proxyUri',
     type:    ValueType.STRING,
     default: null,
   },
-  SLACK_BOT_TYPE: {
+  SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET: {
     ns:      'crowi',
-    key:     'slackbot:currentBotType', // 'officialBot' || 'customBotWithoutProxy' || 'customBotWithProxy'
+    key:     'slackbot:withoutProxy:signingSecret',
+    type:    ValueType.STRING,
+    default: null,
   },
-  GROWI_APP_ID_FOR_GROWI_CLOUD: {
+  SLACKBOT_WITHOUT_PROXY_BOT_TOKEN: {
     ns:      'crowi',
-    key:     'app:growiAppIdForCloud',
+    key:     'slackbot:withoutProxy:botToken',
     type:    ValueType.STRING,
     default: null,
   },
-  DEFAULT_EMAIL_PUBLISHED: {
+  SLACKBOT_WITHOUT_PROXY_COMMAND_PERMISSION: {
     ns:      'crowi',
-    key:     'customize:isEmailPublishedForNewUser',
-    type:    ValueType.BOOLEAN,
-    default: true,
+    key:     'slackbot:withoutProxy:commandPermission',
+    type:    ValueType.STRING,
+    default: null,
+  },
+  SLACKBOT_WITH_PROXY_SALT_FOR_GTOP: {
+    ns:      'crowi',
+    key:     'slackbot:withProxy:saltForGtoP',
+    type:    ValueType.STRING,
+    default: 'gtop',
+  },
+  SLACKBOT_WITH_PROXY_SALT_FOR_PTOG: {
+    ns:      'crowi',
+    key:     'slackbot:withProxy:saltForPtoG',
+    type:    ValueType.STRING,
+    default: 'ptog',
   },
 };
 

+ 6 - 10
packages/app/src/server/service/page.js

@@ -78,7 +78,6 @@ class PageService {
     const path = page.path;
     const createRedirectPage = options.createRedirectPage || false;
     const updateMetadata = options.updateMetadata || false;
-    const socketClientId = options.socketClientId || null;
 
     // sanitize path
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
@@ -105,8 +104,8 @@ class PageService {
       await Page.create(path, body, user, { redirectTo: newPagePath });
     }
 
-    this.pageEvent.emit('delete', page, user, socketClientId);
-    this.pageEvent.emit('create', renamedPage, user, socketClientId);
+    this.pageEvent.emit('delete', page, user);
+    this.pageEvent.emit('create', renamedPage, user);
 
     return renamedPage;
   }
@@ -415,7 +414,6 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    const socketClientId = options.socketClientId || null;
     if (!Page.isDeletableName(page.path)) {
       throw new Error('Page is not deletable.');
     }
@@ -434,8 +432,8 @@ class PageService {
     const body = `redirect ${newPath}`;
     await Page.create(page.path, body, user, { redirectTo: newPath });
 
-    this.pageEvent.emit('delete', page, user, socketClientId);
-    this.pageEvent.emit('create', deletedPage, user, socketClientId);
+    this.pageEvent.emit('delete', page, user);
+    this.pageEvent.emit('create', deletedPage, user);
 
     return deletedPage;
   }
@@ -530,13 +528,12 @@ class PageService {
   async deleteMultipleCompletely(pages, user, options = {}) {
     const ids = pages.map(page => (page._id));
     const paths = pages.map(page => (page.path));
-    const socketClientId = options.socketClientId || null;
 
     logger.debug('Deleting completely', paths);
 
     await this.deleteCompletelyOperation(ids, paths);
 
-    this.pageEvent.emit('deleteCompletely', pages, user, socketClientId); // update as renamed page
+    this.pageEvent.emit('deleteCompletely', pages, user); // update as renamed page
 
     return;
   }
@@ -544,7 +541,6 @@ class PageService {
   async deleteCompletely(page, user, options = {}, isRecursively = false) {
     const ids = [page._id];
     const paths = [page.path];
-    const socketClientId = options.socketClientId || null;
 
     logger.debug('Deleting completely', paths);
 
@@ -554,7 +550,7 @@ class PageService {
       this.deleteCompletelyDescendantsWithStream(page, user, options);
     }
 
-    this.pageEvent.emit('delete', page, user, socketClientId); // update as renamed page
+    this.pageEvent.emit('delete', page, user); // update as renamed page
 
     return;
   }

+ 8 - 4
packages/app/src/server/service/search.js

@@ -34,26 +34,30 @@ class SearchService {
   }
 
   get isSearchboxEnabled() {
-    return this.configManager.getConfig('crowi', 'app:searchboxSslUrl') != null;
+    const uri = this.configManager.getConfig('crowi', 'app:searchboxSslUrl');
+    return uri != null && uri.length > 0;
   }
 
   get isElasticsearchEnabled() {
-    return this.configManager.getConfig('crowi', 'app:elasticsearchUri') != null;
+    const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+    return uri != null && uri.length > 0;
   }
 
   generateDelegator() {
     logger.info('Initializing search delegator');
 
     if (this.isSearchboxEnabled) {
-      logger.info('Searchbox is enabled');
       const SearchboxDelegator = require('./search-delegator/searchbox');
+      logger.info('Searchbox is enabled');
       return new SearchboxDelegator(this.configManager, this.crowi.socketIoService);
     }
     if (this.isElasticsearchEnabled) {
-      logger.info('Elasticsearch (not Searchbox) is enabled');
       const ElasticsearchDelegator = require('./search-delegator/elasticsearch');
+      logger.info('Elasticsearch (not Searchbox) is enabled');
       return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
     }
+
+    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
   }
 
   registerUpdateEvent() {

+ 1 - 1
packages/app/src/server/service/slack-command-handler/create.js

@@ -34,7 +34,7 @@ module.exports = (crowi) => {
           inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
           inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
-        private_metadata: JSON.stringify({ channelId: body.channel_id }),
+        private_metadata: JSON.stringify({ channelId: body.channel_id, channelName: body.channel_name }),
       },
     });
   };

+ 2 - 2
packages/app/src/server/service/slack-command-handler/search.js

@@ -188,7 +188,7 @@ module.exports = (crowi) => {
   handler.showNextResults = async function(client, payload) {
     const parsedValue = JSON.parse(payload.actions[0].value);
 
-    const { body, args, offsetNum } = parsedValue;
+    const { body, args, offset: offsetNum } = parsedValue;
     const newOffsetNum = offsetNum + 10;
     let searchResult;
     try {
@@ -258,7 +258,7 @@ module.exports = (crowi) => {
           },
           accessory: {
             type: 'button',
-            action_id: 'shareSingleSearchResult',
+            action_id: 'search:shareSinglePageResult',
             text: {
               type: 'plain_text',
               text: 'Share',

+ 16 - 16
packages/app/src/server/service/slack-command-handler/togetter.js

@@ -46,9 +46,9 @@ module.exports = (crowi) => {
     const channel = payload.channel.id;
     try {
       // validate form
-      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
+      const { path, oldest, newest } = await this.togetterValidateForm(client, payload);
       // get messages
-      result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
+      result = await this.togetterGetMessages(client, payload, channel, path, newest, oldest);
       // clean messages
       const cleanedContents = await this.togetterCleanMessages(result.messages);
 
@@ -66,9 +66,9 @@ module.exports = (crowi) => {
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const path = payload.state.values.page_path.page_path.value;
     let oldest = payload.state.values.oldest.oldest.value;
-    let latest = payload.state.values.latest.latest.value;
+    let newest = payload.state.values.newest.newest.value;
     oldest = oldest.trim();
-    latest = latest.trim();
+    newest = newest.trim();
     if (!path) {
       throw new SlackbotError({
         method: 'postMessage',
@@ -91,34 +91,34 @@ module.exports = (crowi) => {
         mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
       });
     }
-    if (!regexpDatetime.test(latest)) {
+    if (!regexpDatetime.test(newest)) {
       throw new SlackbotError({
         method: 'postMessage',
         to: 'dm',
-        popupMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
-        mainMessage: 'Datetime format for latest must be yyyy/MM/dd-HH:mm',
+        popupMessage: 'Datetime format for newest must be yyyy/MM/dd-HH:mm',
+        mainMessage: 'Datetime format for newest must be yyyy/MM/dd-HH:mm',
       });
     }
     oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset;
     // + 60s in order to include messages between hh:mm.00s and hh:mm.59s
-    latest = parse(latest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
+    newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60;
 
-    if (oldest > latest) {
+    if (oldest > newest) {
       throw new SlackbotError({
         method: 'postMessage',
         to: 'dm',
-        popupMessage: 'Oldest datetime must be older than the latest date time.',
-        mainMessage: 'Oldest datetime must be older than the latest date time.',
+        popupMessage: 'Oldest datetime must be older than the newest date time.',
+        mainMessage: 'Oldest datetime must be older than the newest date time.',
       });
     }
 
-    return { path, oldest, latest };
+    return { path, oldest, newest };
   };
 
-  handler.togetterGetMessages = async function(client, payload, channel, path, latest, oldest) {
+  handler.togetterGetMessages = async function(client, payload, channel, path, newest, oldest) {
     const result = await client.conversations.history({
       channel,
-      latest,
+      newest,
       oldest,
       limit: 100,
       inclusive: true,
@@ -196,9 +196,9 @@ module.exports = (crowi) => {
 
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
     return [
-      markdownSectionBlock('Select the oldest and latest datetime of the messages to use.'),
+      markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'),
       inputBlock(this.plainTextInputElementWithInitialTime('oldest'), 'oldest', 'Oldest datetime'),
-      inputBlock(this.plainTextInputElementWithInitialTime('latest'), 'latest', 'Latest datetime'),
+      inputBlock(this.plainTextInputElementWithInitialTime('newest'), 'newest', 'Newest datetime'),
       inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'),
       actionsBlock(
         buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),

+ 28 - 8
packages/app/src/server/service/slack-integration.ts

@@ -2,10 +2,11 @@ import mongoose from 'mongoose';
 
 import { IncomingWebhookSendArguments } from '@slack/webhook';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
-import { generateWebClient, markdownSectionBlock } from '@growi/slack';
 
+import { generateWebClient, markdownSectionBlock, SlackbotType } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
+
 import S2sMessage from '../models/vo/s2s-message';
 
 import ConfigManager from './config-manager';
@@ -15,6 +16,7 @@ import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:SlackBotService');
 
+const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
 
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 
@@ -99,22 +101,41 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   private isCheckTypeValid(): boolean {
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType == null) {
-      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
+      throw new Error('The config \'SLACKBOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
     }
 
     return true;
   }
 
+  get proxyUriForCurrentType(): string {
+    const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
+
+    // TODO assert currentBotType is not null and CUSTOM_WITHOUT_PROXY
+
+    let proxyUri: string;
+
+    switch (currentBotType) {
+      case SlackbotType.OFFICIAL:
+        proxyUri = OFFICIAL_SLACKBOT_PROXY_URI;
+        break;
+      default:
+        proxyUri = this.configManager.getConfig('crowi', 'slackbot:proxyUri');
+        break;
+    }
+
+    return proxyUri;
+  }
+
   /**
-   * generate WebClient instance for 'customBotWithoutProxy' type
+   * generate WebClient instance for CUSTOM_WITHOUT_PROXY type
    */
   async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
     this.isCheckTypeValid();
 
-    const token = this.configManager.getConfig('crowi', 'slackbot:token');
+    const token = this.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
 
     if (token == null) {
-      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:token\') must be set.');
+      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:withoutProxy:botToken\') must be set.');
     }
 
     return generateWebClient(token);
@@ -147,7 +168,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
 
-    if (currentBotType === 'customBotWithoutProxy') {
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       return this.generateClientForCustomBotWithoutProxy();
     }
 
@@ -170,8 +191,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     this.isCheckTypeValid();
 
     // connect to proxy
-    const proxyServerUri = this.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
-    const serverUri = new URL('/g2s', proxyServerUri);
+    const serverUri = new URL('/g2s', this.proxyUriForCurrentType);
     const headers = {
       'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,
     };

+ 43 - 12
packages/app/src/server/service/socket-io.js

@@ -1,9 +1,9 @@
 import loggerFactory from '~/utils/logger';
+import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 const socketIo = require('socket.io');
 const expressSession = require('express-session');
 const passport = require('passport');
-const socketioSession = require('@kobalab/socket.io-session');
 
 const logger = loggerFactory('growi:service:socket-io');
 
@@ -24,7 +24,8 @@ class SocketIoService {
     return (this.io != null);
   }
 
-  attachServer(server) {
+  // Since the Order is important, attachServer() should be async
+  async attachServer(server) {
     this.io = socketIo(server, {
       transports: ['websocket'],
     });
@@ -34,12 +35,15 @@ class SocketIoService {
 
     // setup middlewares
     // !!CAUTION!! -- ORDER IS IMPORTANT
-    this.setupSessionMiddleware();
-    this.setupLoginRequiredMiddleware();
-    this.setupAdminRequiredMiddleware();
-    this.setupCheckConnectionLimitsMiddleware();
+    await this.setupSessionMiddleware();
+    await this.setupLoginRequiredMiddleware();
+    await this.setupAdminRequiredMiddleware();
+    await this.setupCheckConnectionLimitsMiddleware();
 
-    this.setupStoreGuestIdEventHandler();
+    await this.setupStoreGuestIdEventHandler();
+
+    await this.setupLoginedUserRoomsJoinOnConnection();
+    await this.setupDefaultSocketJoinRoomsEventHandler();
   }
 
   getDefaultSocket() {
@@ -59,13 +63,20 @@ class SocketIoService {
 
   /**
    * use passport session
-   * @see https://qiita.com/kobalab/items/083e507fb01159fe9774
+   * @see https://socket.io/docs/v4/middlewares/#Compatibility-with-Express-middleware
    */
   setupSessionMiddleware() {
-    const sessionMiddleware = socketioSession(expressSession(this.crowi.sessionConfig), passport);
-    this.io.use(sessionMiddleware.express_session);
-    this.io.use(sessionMiddleware.passport_initialize);
-    this.io.use(sessionMiddleware.passport_session);
+    const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);
+
+    this.io.use(wrap(expressSession(this.crowi.sessionConfig)));
+    this.io.use(wrap(passport.initialize()));
+    this.io.use(wrap(passport.session()));
+
+    // express and passport session on main socket doesn't shared to child namespace socket
+    // need to define the session for specific namespace
+    this.getAdminSocket().use(wrap(expressSession(this.crowi.sessionConfig)));
+    this.getAdminSocket().use(wrap(passport.initialize()));
+    this.getAdminSocket().use(wrap(passport.session()));
   }
 
   /**
@@ -117,6 +128,26 @@ class SocketIoService {
     });
   }
 
+  setupLoginedUserRoomsJoinOnConnection() {
+    this.io.on('connection', (socket) => {
+      const user = socket.request.user;
+      if (user == null) {
+        logger.debug('Socket io: An anonymous user has connected');
+        return;
+      }
+      socket.join(getRoomNameWithId(RoomPrefix.USER, user._id));
+    });
+  }
+
+  setupDefaultSocketJoinRoomsEventHandler() {
+    this.io.on('connection', (socket) => {
+      // set event handlers for joining rooms
+      socket.on('join:page', ({ pageId }) => {
+        socket.join(getRoomNameWithId(RoomPrefix.PAGE, pageId));
+      });
+    });
+  }
+
   async checkConnectionLimitsForAdmin(socket, next) {
     const namespaceName = socket.nsp.name;
 

+ 31 - 8
packages/app/src/server/service/system-events/sync-page-status.ts

@@ -5,6 +5,8 @@ import { S2cMessagePageUpdated } from '../../models/vo/s2c-message';
 import { S2sMessageHandlable } from '../s2s-messaging/handlable';
 import { S2sMessagingService } from '../s2s-messaging/base';
 
+import { RoomPrefix, getRoomNameWithId } from '../../util/socket-io-helpers';
+
 const logger = loggerFactory('growi:service:system-events:SyncPageStatusService');
 
 /**
@@ -84,33 +86,54 @@ class SyncPageStatusService implements S2sMessageHandlable {
     const { socketIoService } = this;
 
     // register events
-    this.emitter.on('create', (page, user, socketClientId) => {
+    this.emitter.on('create', (page, user) => {
       logger.debug('\'create\' event emitted.');
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
-      socketIoService.getDefaultSocket().emit('page:create', { s2cMessagePageUpdated, socketClientId });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:create', { s2cMessagePageUpdated });
 
       this.publishToOtherServers('page:create', { s2cMessagePageUpdated });
     });
-    this.emitter.on('update', (page, user, socketClientId) => {
+    this.emitter.on('update', (page, user) => {
       logger.debug('\'update\' event emitted.');
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
-      socketIoService.getDefaultSocket().emit('page:update', { s2cMessagePageUpdated, socketClientId });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:update', { s2cMessagePageUpdated });
 
       this.publishToOtherServers('page:update', { s2cMessagePageUpdated });
     });
-    this.emitter.on('delete', (page, user, socketClientId) => {
+    this.emitter.on('delete', (page, user) => {
       logger.debug('\'delete\' event emitted.');
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
-      socketIoService.getDefaultSocket().emit('page:delete', { s2cMessagePageUpdated, socketClientId });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:delete', { s2cMessagePageUpdated });
 
       this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
     });
-    this.emitter.on('saveOnHackmd', (page) => {
+    this.emitter.on('saveOnHackmd', (page, user) => {
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page);
-      socketIoService.getDefaultSocket().emit('page:editingWithHackmd', { s2cMessagePageUpdated });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:editingWithHackmd', { s2cMessagePageUpdated });
+
       this.publishToOtherServers('page:editingWithHackmd', { s2cMessagePageUpdated });
     });
   }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików