Browse Source

Merge branch 'master' of https://github.com/weseek/growi into imprv/gw7211-fix-adminRequired-middleware-scoket.io

* 'master' of https://github.com/weseek/growi: (378 commits)
  update update-release-pr job
  add release-version step
  fix logging for JoinToConversationMiddleware
  determine proxy uri by slackIntegrationService.proxyUriForCurrentType
  determine proxy uri by slackIntegrationService.proxyServerUri
  ensure not to set slackbot:proxyUri when reseting all
  impl proxyUriForCurrentType
  copy vies dir when postbuild
  fix viewsDir
  prepare to release v4.4
  fix missing process for release
  update docs
  fix draft-release workflow
  modify checkout settings for draft-release workflow
  fix test
  fix: pr-to-master.yml
  invoke initMongooseGlobalSettings
  organize mongoOptions and add initMongooseGlobalSettings
  fix elasticsearch initializing process
  chore: clean Dockerfile
  ...

# Conflicts:
#	packages/app/package.json
Luqman Grune 4 years ago
parent
commit
a35ce81b0c
100 changed files with 1815 additions and 503 deletions
  1. 3 0
      .github/git-pr-release-template.erb
  2. 46 0
      .github/release-drafter.yml
  3. 19 71
      .github/workflows/ci-slackbot-proxy.yml
  4. 27 111
      .github/workflows/ci.yml
  5. 62 0
      .github/workflows/draft-release.yml
  6. 39 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. 7 3
      CHANGELOG.md
  11. 4 0
      bin/bump-versions/README.md
  12. 16 0
      bin/bump-versions/cli.js
  13. 71 0
      bin/bump-versions/flow/bump-versions.js
  14. 12 0
      bin/bump-versions/index.js
  15. 55 0
      bin/bump-versions/step/printHelp.js
  16. 12 0
      bump-versions.config.js
  17. 2 2
      package.json
  18. 2 4
      packages/app/.env.development
  19. 2 2
      packages/app/bin/github-actions/update-readme.sh
  20. 6 6
      packages/app/config/migrate.js
  21. 0 2
      packages/app/docker/Dockerfile
  22. 5 5
      packages/app/docker/README.md
  23. 12 3
      packages/app/package.json
  24. BIN
      packages/app/public/images/slack-integration/activate-public-dist.png
  25. BIN
      packages/app/public/images/slack-integration/basicinfo-all-checked.png
  26. BIN
      packages/app/public/images/slack-integration/click-add-to-slack.png
  27. 10 1
      packages/app/resource/locales/en_US/admin/admin.json
  28. 8 0
      packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt
  29. 13 0
      packages/app/resource/locales/en_US/notifications/notActiveUser.txt
  30. 10 0
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  31. 19 1
      packages/app/resource/locales/en_US/translation.json
  32. 3 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  33. 13 0
      packages/app/resource/locales/ja_JP/notifications/notActiveUser.txt
  34. 10 0
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  35. 6 0
      packages/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt
  36. 19 1
      packages/app/resource/locales/ja_JP/translation.json
  37. 3 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  38. 6 0
      packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt
  39. 13 0
      packages/app/resource/locales/zh_CN/notifications/notActiveUser.txt
  40. 10 0
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  41. 20 2
      packages/app/resource/locales/zh_CN/translation.json
  42. 34 0
      packages/app/src/client/nologin.jsx
  43. 50 2
      packages/app/src/client/services/AdminHomeContainer.js
  44. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  45. 21 0
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  46. 5 2
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  47. 22 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  48. 8 5
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  49. 3 3
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx
  50. 4 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  51. 7 4
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  52. 14 10
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  53. 7 0
      packages/app/src/components/LoginForm.jsx
  54. 96 0
      packages/app/src/components/PasswordResetExecutionForm.jsx
  55. 66 0
      packages/app/src/components/PasswordResetRequestForm.jsx
  56. 2 0
      packages/app/src/components/StickyStretchableScroller.jsx
  57. 65 0
      packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  58. 3 1
      packages/app/src/server/console.js
  59. 4 2
      packages/app/src/server/crowi/index.js
  60. 27 0
      packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.ts
  61. 72 0
      packages/app/src/server/models/password-reset-order.ts
  62. 12 4
      packages/app/src/server/models/slack-app-integration.js
  63. 120 0
      packages/app/src/server/routes/apiv3/forgot-password.js
  64. 2 0
      packages/app/src/server/routes/apiv3/index.js
  65. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  66. 28 31
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  67. 1 1
      packages/app/src/server/routes/apiv3/slack-integration.js
  68. 21 0
      packages/app/src/server/routes/forgot-password.ts
  69. 19 0
      packages/app/src/server/routes/index.js
  70. 34 14
      packages/app/src/server/service/config-loader.ts
  71. 8 4
      packages/app/src/server/service/search.js
  72. 16 16
      packages/app/src/server/service/slack-command-handler/togetter.js
  73. 28 8
      packages/app/src/server/service/slack-integration.ts
  74. 11 1
      packages/app/src/server/util/mongoose-utils.ts
  75. 45 0
      packages/app/src/server/views/forgot-password.html
  76. 54 0
      packages/app/src/server/views/forgot-password/error.html
  77. 2 0
      packages/app/src/server/views/login.html
  78. 7 0
      packages/app/src/server/views/login/error.html
  79. 48 0
      packages/app/src/server/views/reset-password.html
  80. 4 0
      packages/app/src/test/config/migrate.test.js
  81. 3 1
      packages/app/src/test/global-setup.js
  82. 17 8
      packages/app/src/test/service/page.test.js
  83. 2 1
      packages/app/src/test/setup.js
  84. 1 1
      packages/core/package.json
  85. 0 1
      packages/plugin-attachment-refs/index.js
  86. 1 1
      packages/plugin-attachment-refs/package.json
  87. 0 0
      packages/plugin-attachment-refs/src/index.js
  88. 1 1
      packages/plugin-lsx/package.json
  89. 11 1
      packages/plugin-lsx/src/index.js
  90. 0 11
      packages/plugin-lsx/src/meta.js
  91. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  92. 8 1
      packages/plugin-pukiwiki-like-linker/src/index.js
  93. 0 8
      packages/plugin-pukiwiki-like-linker/src/meta.js
  94. 1 1
      packages/slack/package.json
  95. 1 0
      packages/slack/src/index.ts
  96. 5 0
      packages/slack/src/interfaces/slackbot-types.ts
  97. 3 4
      packages/slackbot-proxy/package.json
  98. 13 9
      packages/slackbot-proxy/src/controllers/slack.ts
  99. 75 73
      packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts
  100. 5 3
      packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts

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

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

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

@@ -0,0 +1,46 @@
+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:
+      - '/chore/i'
+      - '/ci/i'
+      - '/docs/i'
+      - '/test/i'
+
+exclude-labels:
+  - 'exclude from changelog'
+template: |
+  ### Changes
+
+  $CHANGES

+ 19 - 71
.github/workflows/ci-slackbot-proxy.yml

@@ -18,30 +18,14 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         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: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
     - name: Print dependencies
     - name: Print dependencies
@@ -49,6 +33,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
+
     - name: yarn lint
     - name: yarn lint
       run: |
       run: |
         yarn lerna run lint --scope @growi/slack --scope @growi/slackbot-proxy
         yarn lerna run lint --scope @growi/slack --scope @growi/slackbot-proxy
@@ -86,30 +71,14 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         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
     - name: lerna bootstrap
-      if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
     - name: Print dependencies
     - name: Print dependencies
@@ -117,6 +86,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
+
     - name: yarn dev:ci
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy
       working-directory: ./packages/slackbot-proxy
       run: |
       run: |
@@ -129,6 +99,7 @@ jobs:
         TYPEORM_DATABASE: growi-slackbot-proxy
         TYPEORM_DATABASE: growi-slackbot-proxy
         TYPEORM_USERNAME: root
         TYPEORM_USERNAME: root
         TYPEORM_PASSWORD:
         TYPEORM_PASSWORD:
+
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       uses: weseek/ghaction-slack-notification@master
       if: failure()
       if: failure()
@@ -159,36 +130,13 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         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
     - name: lerna bootstrap
       run: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap

+ 27 - 111
.github/workflows/ci.yml

@@ -18,30 +18,14 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         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: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
     - name: Print dependencies
     - name: Print dependencies
@@ -49,6 +33,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
+
     - name: lerna run lint for plugins
     - name: lerna run lint for plugins
       run: |
       run: |
         yarn lerna run lint --scope @growi/plugin-*
         yarn lerna run lint --scope @growi/plugin-*
@@ -86,30 +71,14 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         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: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
     - name: Print dependencies
     - name: Print dependencies
@@ -117,6 +86,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
+
     - name: yarn test
     - name: yarn test
       working-directory: ./packages/app
       working-directory: ./packages/app
       run: |
       run: |
@@ -162,46 +132,14 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         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: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
     - name: Print dependencies
     - name: Print dependencies
@@ -209,6 +147,7 @@ jobs:
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
+
     - name: yarn dev:ci
     - name: yarn dev:ci
       working-directory: ./packages/app
       working-directory: ./packages/app
       run: |
       run: |
@@ -247,37 +186,14 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
+
+    - uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         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: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
     - name: Print dependencies
     - 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 }}
+

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

@@ -0,0 +1,39 @@
+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
+
+    steps:
+      - uses: release-drafter/release-drafter@v5
+        with:
+          disable-releaser: true
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  check-title:
+    runs-on: ubuntu-latest
+    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:
     steps:
     - uses: actions/checkout@v2
     - 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
     - name: Docker meta
       id: meta
       id: meta
@@ -26,8 +24,8 @@ jobs:
       with:
       with:
         images: weseek/growi,ghcr.io/weseek/growi
         images: weseek/growi,ghcr.io/weseek/growi
         tags: |
         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
     - name: Login to docker.io registry
       run: |
       run: |

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

@@ -1,9 +1,10 @@
 name: Release Docker Image for @growi/slackbot-proxy
 name: Release Docker Image for @growi/slackbot-proxy
 
 
 on:
 on:
-  push:
+  pull_request:
     branches:
     branches:
       - release/slackbot-proxy/**
       - release/slackbot-proxy/**
+    types: [closed]
 
 
 jobs:
 jobs:
 
 
@@ -12,13 +13,12 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - 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
     - name: Docker meta
       id: 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
         images: weseek/growi-slackbot-proxy,ghcr.io/weseek/growi-slackbot-proxy,asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy
         tags: |
         tags: |
           type=raw,value=latest
           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
     - name: Login to docker.io registry
       run: |
       run: |

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

@@ -1,57 +1,127 @@
 name: Release
 name: Release
 
 
 on:
 on:
-  push:
+  pull_request:
     branches:
     branches:
       - release/current
       - release/current
       - release/*.*.*
       - release/*.*.*
+    types: [closed]
 
 
 jobs:
 jobs:
-  github-release:
+  create-github-release:
 
 
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
+    if: github.event.pull_request.merged == true
+
     outputs:
     outputs:
-      RELEASE_VERSION: ${{ steps.bump-version.outputs.RELEASE_VERSION }}
+      RELEASED_VERSION: ${{ steps.package-json.outputs.packageVersion }}
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - 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: |
       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: |
       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/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: |
       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:
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         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/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:
   build-image:
-    needs: github-release
+    needs: create-github-release
 
 
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
@@ -61,11 +131,8 @@ jobs:
 
 
     steps:
     steps:
     - uses: actions/checkout@v2
     - 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
     - name: Setup suffix
       id: suffix
       id: suffix
@@ -82,9 +149,9 @@ jobs:
           suffix=${{ steps.suffix.outputs.SUFFIX }}
           suffix=${{ steps.suffix.outputs.SUFFIX }}
         tags: |
         tags: |
           type=raw,value=latest
           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
     - name: Login to docker.io registry
       run: |
       run: |
@@ -138,7 +205,7 @@ jobs:
       with:
       with:
         channel: '#release'
         channel: '#release'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         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
     - name: Check whether workspace is clean
       run: |
       run: |

+ 7 - 3
CHANGES.md → CHANGELOG.md

@@ -1,6 +1,8 @@
-# CHANGES
+# Changelog
 
 
-## v4.4.0-RC
+## [Unreleased](https://github.com/weseek/growi/compare/v4.3.3...HEAD)
+
+*Please do not manually update this file. We've automated the process.*
 
 
 ### BREAKING CHANGES
 ### BREAKING CHANGES
 
 
@@ -8,6 +10,8 @@
 
 
 ### Updates
 ### Updates
 
 
+* Feature: Password resetting by user
+* Feature: User trigger notification and Global notification are available by new Slack integration
 * Improvement: Add attachment button in editor navbar
 * 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: Recursive rename operation from `/parent` to `/parent/child` ([#4101](https://github.com/weseek/growi/pull/4101))
 * Fix: Encode spaces in page path in LinkEditModal
 * Fix: Encode spaces in page path in LinkEditModal
@@ -22,7 +26,7 @@
     * morgan
     * morgan
     * socket.io
     * socket.io
 
 
-## v4.3.3-RC
+## v4.3.3
 
 
 * Improvement: Welcome page markdown
 * Improvement: Welcome page markdown
 * Fix: Some recursive operation exclude descendant pages that are restricted for groups
 * Fix: Some recursive operation exclude descendant pages that are restricted for groups

+ 4 - 0
bin/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)

+ 16 - 0
bin/bump-versions/cli.js

@@ -0,0 +1,16 @@
+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/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 });
+  installDependencies({ config, 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,
+};

+ 12 - 0
bin/bump-versions/index.js

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

+ 55 - 0
bin/bump-versions/step/printHelp.js

@@ -0,0 +1,55 @@
+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 @@
+module.exports = {
+  monorepo: {
+    mainVersionFile: 'package.json',
+    packagesToBump: [
+      'packages/app',
+      'packages/core',
+      'packages/slack',
+      'packages/ui',
+      'packages/plugin-*',
+    ],
+  },
+};

+ 2 - 2
package.json

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

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

@@ -20,9 +20,7 @@ HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DEV_HTTPS=true
 # DEV_HTTPS=true
 # FORCE_WIKI_MODE=private
 # FORCE_WIKI_MODE=private
 # PROMSTER_ENABLED=true
 # 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_CLOUD_URI='http://growi.cloud'
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345

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

@@ -2,5 +2,5 @@
 
 
 cd docker
 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>
  * @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 { URL } = require('url');
 
 
-const { getMongoUri } = require('~/server/util/mongoose-utils');
+initMongooseGlobalSettings();
 
 
 const mongoUri = getMongoUri();
 const mongoUri = getMongoUri();
 
 
@@ -17,11 +21,7 @@ const url = new URL(mongoUri);
 const mongodb = {
 const mongodb = {
   url: mongoUri,
   url: mongoUri,
   databaseName: url.pathname.substring(1), // omit heading slash
   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 = {
 module.exports = {

+ 0 - 2
packages/app/docker/Dockerfile

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

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

@@ -2,7 +2,7 @@
 GROWI Official docker image
 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)
 ![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
 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.0`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.0/docker/Dockerfile)
+* [`4.4.0-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.0/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?
 What is GROWI?

+ 12 - 3
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "4.3.3-RC",
+  "version": "4.4.0-RC",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// 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",
     "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",
     "clean": "npx shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "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": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -55,10 +55,18 @@
   "dependencies": {
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
+<<<<<<< HEAD
     "@growi/plugin-attachment-refs": "^4.3.3-RC",
     "@growi/plugin-attachment-refs": "^4.3.3-RC",
     "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
     "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
     "@growi/plugin-lsx": "^4.3.3-RC",
     "@growi/plugin-lsx": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
+=======
+    "@growi/plugin-attachment-refs": "^4.4.0-RC",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.0-RC",
+    "@growi/plugin-lsx": "^4.4.0-RC",
+    "@growi/slack": "^4.4.0-RC",
+    "@kobalab/socket.io-session": "^1.0.3",
+>>>>>>> 9e8737a66d1779e35b6c39a305c17bd0c0150044
     "@promster/express": "^5.0.1",
     "@promster/express": "^5.0.1",
     "@promster/server": "^6.0.0",
     "@promster/server": "^6.0.0",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -90,6 +98,7 @@
     "express-bunyan-logger": "^1.3.3",
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
     "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
     "express-mongo-sanitize": "^2.1.0",
+    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
@@ -153,7 +162,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.3.3-RC",
+    "@growi/ui": "^4.4.0-RC",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",

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


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

@@ -11,7 +11,13 @@
     "installed_version": "Installed version",
     "installed_version": "Installed version",
     "list_of_env_vars":"List of environment variables",
     "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.",
     "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": {
   "app_setting": {
     "site_name": "Site name",
     "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.",
       "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.",
       "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\".",
       "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\".",
       "select_install_to_workspace": "Select \"Install to Workspace\".",
       "register_proxy_url": "Register Proxy URL with GROWI",
       "register_proxy_url": "Register Proxy URL with GROWI",
       "click_allow": "Select \"Allow\".",
       "click_allow": "Select \"Allow\".",

+ 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": {
     "Local": {
       "name": "ID/Password",
       "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> .",
       "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": {
     "ldap": {
       "enable_ldap": "Enable LDAP",
       "enable_ldap": "Enable LDAP",
@@ -845,5 +848,20 @@
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
     "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.",
     "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."
     "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"
   }
   }
 }
 }

+ 3 - 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>に入力、更新します。",
       "enter_proxy_url_and_update": "コピーしたProxy URLを、このページの<b>Custom bot with proxy 連携</b>の<b>Proxy URL</b>に入力、更新します。",
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "select_install_your_app": "Install your app をクリックします。",
       "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 をクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",
       "register_proxy_url": "Proxy の URLをGROWIに登録する",
       "register_proxy_url": "Proxy の URLをGROWIに登録する",
       "click_allow": "遷移先の画面にて、Allowをクリックします。",
       "click_allow": "遷移先の画面にて、Allowをクリックします。",

+ 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": {
     "Local": {
       "name": "ID/Password",
       "name": "ID/Password",
       "note for the only env option": "現在LOCAL認証のON/OFFは環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
       "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": {
     "ldap": {
       "enable_ldap": "LDAP を有効にする",
       "enable_ldap": "LDAP を有効にする",
@@ -839,5 +842,20 @@
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
     "failed_to_send_a_test_email":"SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。"
     "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": "パスワードと確認パスワードが一致しません"
   }
   }
 }
 }

+ 3 - 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>。",
       "enter_proxy_url_and_update": "上述过程中复制的ProxyURL到本页的<b>Custom bot with proxy 一体化</b>的<b>ProxyURL</b>。",
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "select_install_your_app": "选择 \"Install your app\"。",
       "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\"。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",
       "register_proxy_url": "向 GROWI 注册代理 URL",
       "register_proxy_url": "向 GROWI 注册代理 URL",
       "click_allow": "选择 \"Allow\"。",
       "click_allow": "选择 \"Allow\"。",

+ 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": "更新",
 	"Update Page": "更新本页",
 	"Update Page": "更新本页",
 	"Warning": "警告",
 	"Warning": "警告",
-	"Sign in": "登录",
+  "Sign in": "登录",
 	"Sign up is here": "注册",
 	"Sign up is here": "注册",
 	"Sign in is here": "登录",
 	"Sign in is here": "登录",
 	"Sign up": "注册",
 	"Sign up": "注册",
@@ -592,7 +592,10 @@
 		"Local": {
 		"Local": {
 			"name": "ID/Password",
 			"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> .",
 			"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": {
 		"ldap": {
 			"enable_ldap": "Enable LDAP",
 			"enable_ldap": "Enable LDAP",
@@ -850,5 +853,20 @@
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
     "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
     "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
     "failed_to_send_a_test_email":"SMTP方式测试邮件发送失败,请检查相关设定。"
     "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 InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import LoginForm from '../components/LoginForm';
+import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
+import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 
 
 const i18n = i18nFactory();
 const i18n = i18nFactory();
 
 
@@ -38,6 +40,7 @@ if (loginFormElem) {
   const email = loginFormElem.dataset.email;
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
   const registrationMode = loginFormElem.dataset.registrationMode;
+  const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
 
 
 
   let registrationWhiteList = loginFormElem.dataset.registrationWhiteList;
   let registrationWhiteList = loginFormElem.dataset.registrationWhiteList;
@@ -68,6 +71,7 @@ if (loginFormElem) {
           isRegistrationEnabled={isRegistrationEnabled}
           isRegistrationEnabled={isRegistrationEnabled}
           registrationMode={registrationMode}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
           registrationWhiteList={registrationWhiteList}
+          isPasswordResetEnabled={isPasswordResetEnabled}
           isLocalStrategySetup={isLocalStrategySetup}
           isLocalStrategySetup={isLocalStrategySetup}
           isLdapStrategySetup={isLdapStrategySetup}
           isLdapStrategySetup={isLdapStrategySetup}
           objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
           objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
@@ -77,3 +81,33 @@ if (loginFormElem) {
     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.appContainer = appContainer;
 
 
+    this.copyStateValues = {
+      DEFAULT: 'default',
+      DONE: 'done',
+    };
+    this.timer = null;
+
     this.state = {
     this.state = {
       retrieveError: null,
       retrieveError: null,
       growiVersion: '',
       growiVersion: '',
       nodeVersion: '',
       nodeVersion: '',
       npmVersion: '',
       npmVersion: '',
       yarnVersion: '',
       yarnVersion: '',
+      copyState: this.copyStateValues.DEFAULT,
       installedPlugins: [],
       installedPlugins: [],
     };
     };
 
 
@@ -36,6 +43,10 @@ export default class AdminHomeContainer extends Container {
     return 'AdminHomeContainer';
     return 'AdminHomeContainer';
   }
   }
 
 
+  componentWillUnmount() {
+    clearTimeout(this.timer);
+  }
+
   /**
   /**
    * retrieve admin home data
    * retrieve admin home data
    */
    */
@@ -44,14 +55,15 @@ export default class AdminHomeContainer extends Container {
       const response = await this.appContainer.apiv3.get('/admin-home/');
       const response = await this.appContainer.apiv3.get('/admin-home/');
       const { adminHomeParams } = response.data;
       const { adminHomeParams } = response.data;
 
 
-      this.setState({
+      this.setState(prevState => ({
+        ...prevState,
         growiVersion: adminHomeParams.growiVersion,
         growiVersion: adminHomeParams.growiVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         npmVersion: adminHomeParams.npmVersion,
         npmVersion: adminHomeParams.npmVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         envVars: adminHomeParams.envVars,
-      });
+      }));
     }
     }
     catch (err) {
     catch (err) {
       logger.error(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,
       registrationMode: this.dummyRegistrationMode,
       registrationWhiteList: [],
       registrationWhiteList: [],
       useOnlyEnvVars: false,
       useOnlyEnvVars: false,
+      isPasswordResetEnabled: false,
     };
     };
 
 
   }
   }
@@ -34,6 +35,7 @@ export default class AdminLocalSecurityContainer extends Container {
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         registrationMode: localSetting.registrationMode,
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
         registrationWhiteList: localSetting.registrationWhiteList,
+        isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -66,14 +68,22 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ registrationWhiteList: value.split('\n') });
     this.setState({ registrationWhiteList: value.split('\n') });
   }
   }
 
 
+  /**
+   * Switch password reset enabled
+   */
+  switchIsPasswordResetEnabled() {
+    this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
+  }
+
   /**
   /**
    * update local security setting
    * update local security setting
    */
    */
   async updateLocalSecuritySetting() {
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
       registrationWhiteList,
+      isPasswordResetEnabled,
     });
     });
 
 
     const { localSettingParams } = response.data;
     const { localSettingParams } = response.data;
@@ -81,6 +91,7 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({
     this.setState({
       registrationMode: localSettingParams.registrationMode,
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       registrationWhiteList: localSettingParams.registrationWhiteList,
+      isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
     });
     });
 
 
     return localSettingParams;
     return localSettingParams;

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

@@ -1,6 +1,7 @@
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
@@ -63,6 +64,26 @@ class AdminHome extends React.Component {
             {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
             {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
           </div>
           </div>
         </div>
         </div>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
+            <p>
+              <CopyToClipboard
+                text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
+                onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
+              >
+                <button type="button" className="btn btn-primary">
+                  {adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DEFAULT
+                    ? t('admin:admin_top:copy_prefilled_host_information:default')
+                    : t('admin:admin_top:copy_prefilled_host_information:done')}
+                </button>
+              </CopyToClipboard>{' '}
+              {/* eslint-disable-next-line react/no-danger */}
+              <span dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
+            </p>
+          </div>
+        </div>
       </Fragment>
       </Fragment>
     );
     );
   }
   }

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

@@ -4,9 +4,12 @@ import React, {
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import {
 import {
-  Card, CardBody, TabContent, TabPane,
+  TabContent, TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -48,7 +51,7 @@ const SkeltonListItem = () => (
 const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
 const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const isCautionVisible = currentBotType === 'officialBot' || currentBotType === 'customBotWithProxy';
+  const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
 
 
   return (
   return (
     <li className="list-group-item">
     <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() {
   render() {
     const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
     const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode } = adminLocalSecurityContainer.state;
+    const { registrationMode, isPasswordResetEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
 
 
     return (
     return (
@@ -157,6 +157,27 @@ class LocalSecuritySettingContents extends React.Component {
               </div>
               </div>
             </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="row my-3">
               <div className="offset-3 col-6">
               <div className="offset-3 col-6">
                 <button
                 <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 PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import { SlackbotType } from '@growi/slack';
 
 
 const botDetails = {
 const botDetails = {
   officialBot: {
   officialBot: {
-    botType: 'officialBot',
+    botType: SlackbotType.OFFICIAL,
     botTypeCategory: 'official_bot',
     botTypeCategory: 'official_bot',
     setUp: 'easy',
     setUp: 'easy',
     multiWSIntegration: 'possible',
     multiWSIntegration: 'possible',
     securityControl: 'impossible',
     securityControl: 'impossible',
   },
   },
   customBotWithoutProxy: {
   customBotWithoutProxy: {
-    botType: 'customBotWithoutProxy',
+    botType: SlackbotType.CUSTOM_WITHOUT_PROXY,
     botTypeCategory: 'custom_bot',
     botTypeCategory: 'custom_bot',
     supplementaryBotName: 'without_proxy',
     supplementaryBotName: 'without_proxy',
     setUp: 'normal',
     setUp: 'normal',
@@ -20,7 +21,7 @@ const botDetails = {
     securityControl: 'possible',
     securityControl: 'possible',
   },
   },
   customBotWithProxy: {
   customBotWithProxy: {
-    botType: 'customBotWithProxy',
+    botType: SlackbotType.CUSTOM_WITH_PROXY,
     botTypeCategory: 'custom_bot',
     botTypeCategory: 'custom_bot',
     supplementaryBotName: 'with_proxy',
     supplementaryBotName: 'with_proxy',
     setUp: 'hard',
     setUp: 'hard',
@@ -32,6 +33,8 @@ const botDetails = {
 const BotTypeCard = (props) => {
 const BotTypeCard = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
+  const isBotTypeOfficial = props.botType === SlackbotType.OFFICIAL;
+
   return (
   return (
     <div
     <div
       className={`card admin-bot-card rounded border-radius-sm shadow ${props.isActive ? 'border-primary' : ''}`}
       className={`card admin-bot-card rounded border-radius-sm shadow ${props.isActive ? 'border-primary' : ''}`}
@@ -41,7 +44,7 @@ const BotTypeCard = (props) => {
     >
     >
       <div>
       <div>
         <h3 className={`card-header mb-0 py-3
         <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' : ''}`}
               ${props.isActive ? 'bg-primary grw-botcard-title-active' : ''}`}
         >
         >
           <span className="mr-2">
           <span className="mr-2">
@@ -49,7 +52,7 @@ const BotTypeCard = (props) => {
           </span>
           </span>
 
 
           {/*  A recommended badge is shown on official bot card, supplementary names are shown on Custom bot cards   */}
           {/*  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">
               <span className="badge badge-info mr-2">
                 {t('admin:slack_integration.selecting_bot_types.recommended')}
                 {t('admin:slack_integration.selecting_bot_types.recommended')}

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

@@ -67,8 +67,8 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
             readOnly
             readOnly
           />
           />
           <p className="form-text text-muted">
           <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>
           </p>
         </div>
         </div>
 
 
@@ -97,7 +97,7 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           />
           />
           <p className="form-text text-muted">
           <p className="form-text text-muted">
             {/* eslint-disable-next-line react/no-danger */}
             {/* 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>
           </p>
         </div>
         </div>
 
 

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

@@ -1,6 +1,9 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -109,7 +112,7 @@ const OfficialBotSettings = (props) => {
                 />
                 />
               </div>
               </div>
               <WithProxyAccordions
               <WithProxyAccordions
-                botType="officialBot"
+                botType={SlackbotType.OFFICIAL}
                 slackAppIntegrationId={slackAppIntegration._id}
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
                 tokenPtoG={tokenPtoG}

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

@@ -1,6 +1,9 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
@@ -12,7 +15,7 @@ import ConfirmBotChangeModal from './ConfirmBotChangeModal';
 import BotTypeCard from './BotTypeCard';
 import BotTypeCard from './BotTypeCard';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 
 
-const botTypes = ['officialBot', 'customBotWithoutProxy', 'customBotWithProxy'];
+const botTypes = Object.values(SlackbotType);
 
 
 const SlackIntegration = (props) => {
 const SlackIntegration = (props) => {
 
 
@@ -125,7 +128,7 @@ const SlackIntegration = (props) => {
   let settingsComponent = null;
   let settingsComponent = null;
 
 
   switch (currentBotType) {
   switch (currentBotType) {
-    case 'officialBot':
+    case SlackbotType.OFFICIAL:
       settingsComponent = (
       settingsComponent = (
         <OfficialBotSettings
         <OfficialBotSettings
           slackAppIntegrations={slackAppIntegrations}
           slackAppIntegrations={slackAppIntegrations}
@@ -138,7 +141,7 @@ const SlackIntegration = (props) => {
         />
         />
       );
       );
       break;
       break;
-    case 'customBotWithoutProxy':
+    case SlackbotType.CUSTOM_WITHOUT_PROXY:
       settingsComponent = (
       settingsComponent = (
         <CustomBotWithoutProxySettings
         <CustomBotWithoutProxySettings
           slackBotTokenEnv={slackBotTokenEnv}
           slackBotTokenEnv={slackBotTokenEnv}
@@ -151,7 +154,7 @@ const SlackIntegration = (props) => {
         />
         />
       );
       );
       break;
       break;
-    case 'customBotWithProxy':
+    case SlackbotType.CUSTOM_WITH_PROXY:
       settingsComponent = (
       settingsComponent = (
         <CustomBotWithProxySettings
         <CustomBotWithProxySettings
           slackAppIntegrations={slackAppIntegrations}
           slackAppIntegrations={slackAppIntegrations}

+ 14 - 10
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -3,6 +3,9 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -67,15 +70,16 @@ const BotInstallProcessForCustomBotWithProxy = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
     <div className="container w-75 py-5">
     <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" />
       <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-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" />
       <img src="/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png" className="border border-light img-fluid" />
     </div>
     </div>
@@ -383,7 +387,7 @@ const WithProxyAccordions = (props) => {
     },
     },
   };
   };
 
 
-  const integrationProcedureMapping = props.botType === 'officialBot' ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
+  const integrationProcedureMapping = props.botType === SlackbotType.OFFICIAL ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
 
 
   return (
   return (
     <div
     <div
@@ -416,7 +420,7 @@ const WithProxyAccordions = (props) => {
 const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
 const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
 WithProxyAccordions.propTypes = {
 WithProxyAccordions.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  botType: PropTypes.string.isRequired,
+  botType: PropTypes.oneOf(Object.values(SlackbotType)).isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
   tokenGtoP: PropTypes.string,

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

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

+ 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);

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

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

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

@@ -2,7 +2,7 @@ const repl = require('repl');
 const fs = require('fs');
 const fs = require('fs');
 const path = require('path');
 const path = require('path');
 const mongoose = require('mongoose');
 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');
 const models = require('./models');
 
 
@@ -32,6 +32,8 @@ fs.readFile(replHistoryPath, 'utf8', (err, data) => {
 replServer.context.mongoose = mongoose;
 replServer.context.mongoose = mongoose;
 replServer.context.models = models;
 replServer.context.models = models;
 
 
+initMongooseGlobalSettings();
+
 mongoose.connect(getMongoUri(), mongoOptions)
 mongoose.connect(getMongoUri(), mongoOptions)
   .then(() => {
   .then(() => {
     replServer.context.db = mongoose.connection.db;
     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 InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 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 { projectRoot } from '~/utils/project-dir-utils';
 
 
 import ConfigManager from '../service/config-manager';
 import ConfigManager from '../service/config-manager';
@@ -35,7 +35,7 @@ function Crowi() {
   this.publicDir = path.join(projectRoot, 'public') + sep;
   this.publicDir = path.join(projectRoot, 'public') + sep;
   this.resourceDir = path.join(projectRoot, 'resource') + sep;
   this.resourceDir = path.join(projectRoot, 'resource') + sep;
   this.localeDir = path.join(this.resourceDir, 'locales') + 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.tmpDir = path.join(projectRoot, 'tmp') + sep;
   this.cacheDir = path.join(this.tmpDir, 'cache');
   this.cacheDir = path.join(this.tmpDir, 'cache');
 
 
@@ -214,6 +214,8 @@ Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
   // mongoUri = mongodb://user:password@host/dbname
   const mongoUri = getMongoUri();
   const mongoUri = getMongoUri();
 
 
+  initMongooseGlobalSettings();
+
   return mongoose.connect(mongoUri, mongoOptions);
   return mongoose.connect(mongoUri, mongoOptions);
 };
 };
 
 

+ 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();
+};

+ 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);

+ 12 - 4
packages/app/src/server/models/slack-app-integration.js

@@ -11,12 +11,14 @@ const schema = new mongoose.Schema({
 
 
 class SlackAppIntegration {
 class SlackAppIntegration {
 
 
-  static generateAccessTokens() {
+  crowi;
+
+  static generateAccessTokens(saltForGtoP, saltForPtoG) {
     const now = new Date().getTime();
     const now = new Date().getTime();
     const hasher1 = crypto.createHash('sha512');
     const hasher1 = crypto.createHash('sha512');
     const hasher2 = 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];
     return [tokenGtoP, tokenPtoG];
   }
   }
 
 
@@ -26,8 +28,12 @@ class SlackAppIntegration {
     let tokenPtoG;
     let tokenPtoG;
     let generateTokens;
     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 {
     do {
-      generateTokens = this.generateAccessTokens();
+      generateTokens = this.generateAccessTokens(saltForGtoP, saltForPtoG);
       tokenGtoP = generateTokens[0];
       tokenGtoP = generateTokens[0];
       tokenPtoG = generateTokens[1];
       tokenPtoG = generateTokens[1];
       // eslint-disable-next-line no-await-in-loop
       // eslint-disable-next-line no-await-in-loop
@@ -41,7 +47,9 @@ class SlackAppIntegration {
 }
 }
 
 
 module.exports = function(crowi) {
 module.exports = function(crowi) {
+
   SlackAppIntegration.crowi = crowi;
   SlackAppIntegration.crowi = crowi;
+
   schema.loadClass(SlackAppIntegration);
   schema.loadClass(SlackAppIntegration);
   return mongoose.model('SlackAppIntegration', schema);
   return mongoose.model('SlackAppIntegration', schema);
 };
 };

+ 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('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
   router.use('/staffs', require('./staffs')(crowi));
 
 
+  router.use('/forgot-password', require('./forgot-password')(crowi));
+
   return router;
   return router;
 };
 };

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

+ 28 - 31
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,5 +1,8 @@
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const express = require('express');
 const express = require('express');
 const { body, query, param } = require('express-validator');
 const { body, query, param } = require('express-validator');
@@ -19,7 +22,6 @@ const logger = loggerFactory('growi:routes:apiv3:slack-integration-settings');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
-const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
 
 
 /**
 /**
  * @swagger
  * @swagger
@@ -61,7 +63,7 @@ module.exports = (crowi) => {
     ],
     ],
     slackIntegration: [
     slackIntegration: [
       body('currentBotType')
       body('currentBotType')
-        .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
+        .isIn(Object.values(SlackbotType)),
     ],
     ],
     proxyUri: [
     proxyUri: [
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
@@ -102,22 +104,17 @@ module.exports = (crowi) => {
 
 
     const params = {
     const params = {
       'slackbot:currentBotType': initializedType,
       'slackbot:currentBotType': initializedType,
-      'slackbot:signingSecret': null,
-      'slackbot:token': null,
-      'slackbot:proxyServerUri': null,
+      'slackbot:withoutProxy:signingSecret': null,
+      'slackbot:withoutProxy:botToken': null,
+      'slackbot:proxyUri': null,
     };
     };
 
 
-    // set url if officialBot is specified
-    if (initializedType === 'officialBot') {
-      params['slackbot:proxyServerUri'] = OFFICIAL_SLACKBOT_PROXY_URI;
-    }
-
     return updateSlackBotSettings(params);
     return updateSlackBotSettings(params);
   }
   }
 
 
   async function getConnectionStatusesFromProxy(tokens) {
   async function getConnectionStatusesFromProxy(tokens) {
     const csv = tokens.join(',');
     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'), {
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
       headers: {
       headers: {
@@ -130,7 +127,7 @@ module.exports = (crowi) => {
   }
   }
 
 
   async function requestToProxyServer(token, method, endpoint, body) {
   async function requestToProxyServer(token, method, endpoint, body) {
-    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
     if (proxyUri == null) {
     if (proxyUri == null) {
       throw new Error('Proxy URL is not registered');
       throw new Error('Proxy URL is not registered');
     }
     }
@@ -173,15 +170,15 @@ module.exports = (crowi) => {
 
 
     // retrieve settings
     // retrieve settings
     const 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');
     }
     }
     else {
     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
     // retrieve connection statuses
@@ -191,7 +188,7 @@ module.exports = (crowi) => {
     if (currentBotType == null) {
     if (currentBotType == null) {
       // no need to do anything
       // no need to do anything
     }
     }
-    else if (currentBotType === 'customBotWithoutProxy') {
+    else if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const token = settings.slackBotToken;
       const token = settings.slackBotToken;
       // check the token is not null
       // check the token is not null
       if (token != null) {
       if (token != null) {
@@ -333,23 +330,23 @@ module.exports = (crowi) => {
    */
    */
   router.put('/without-proxy/update-settings', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
   router.put('/without-proxy/update-settings', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType !== 'customBotWithoutProxy') {
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not CustomBotWithoutProxy';
       const msg = 'Not CustomBotWithoutProxy';
       return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
       return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
     }
     }
 
 
     const { slackSigningSecret, slackBotToken } = req.body;
     const { slackSigningSecret, slackBotToken } = req.body;
     const requestParams = {
     const requestParams = {
-      'slackbot:signingSecret': slackSigningSecret,
-      'slackbot:token': slackBotToken,
+      'slackbot:withoutProxy:signingSecret': slackSigningSecret,
+      'slackbot:withoutProxy:botToken': slackBotToken,
     };
     };
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
       crowi.slackIntegrationService.publishUpdatedMessage();
       crowi.slackIntegrationService.publishUpdatedMessage();
 
 
       const customBotWithoutProxySettingParams = {
       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({ customBotWithoutProxySettingParams });
     }
     }
@@ -438,7 +435,7 @@ module.exports = (crowi) => {
   router.put('/proxy-uri', loginRequiredStrictly, adminRequired, csrf, validator.proxyUri, apiV3FormValidator, async(req, res) => {
   router.put('/proxy-uri', loginRequiredStrictly, adminRequired, csrf, validator.proxyUri, apiV3FormValidator, async(req, res) => {
     const { proxyUri } = req.body;
     const { proxyUri } = req.body;
 
 
-    const requestParams = { 'slackbot:proxyServerUri': proxyUri };
+    const requestParams = { 'slackbot:proxyUri': proxyUri };
 
 
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
@@ -554,7 +551,7 @@ module.exports = (crowi) => {
         { new: true },
         { new: true },
       );
       );
 
 
-      const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+      const proxyUri = crowi.slackIntegrationService.proxyUriForCurrentType;
       if (proxyUri != null) {
       if (proxyUri != null) {
         await requestToProxyServer(
         await requestToProxyServer(
           slackAppIntegration.tokenGtoP,
           slackAppIntegration.tokenGtoP,
@@ -592,12 +589,12 @@ module.exports = (crowi) => {
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.post('/slack-app-integrations/:id/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.relationTest, apiV3FormValidator, async(req, res) => {
   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');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType === 'customBotWithoutProxy') {
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Not Proxy Type';
       const msg = 'Not Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
       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) {
     if (proxyUri == null) {
       return res.apiv3Err(new ErrorV3('Proxy URL is null.', 'not-proxy-Uri'), 400);
       return res.apiv3Err(new ErrorV3('Proxy URL is null.', 'not-proxy-Uri'), 400);
     }
     }
@@ -666,12 +663,12 @@ module.exports = (crowi) => {
    */
    */
   router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.slackChannel, apiV3FormValidator, async(req, res) => {
   router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.slackChannel, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-    if (currentBotType !== 'customBotWithoutProxy') {
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
       const msg = 'Select Without Proxy Type';
       const msg = 'Select Without Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
       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);
     const status = await getConnectionStatus(slackBotToken);
     if (status.error != null) {
     if (status.error != null) {
       return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));
       return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));

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

@@ -94,7 +94,7 @@ module.exports = (crowi) => {
   }
   }
 
 
   const addSigningSecretToReq = (req, res, next) => {
   const addSigningSecretToReq = (req, res, next) => {
-    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
+    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
     return next();
     return next();
   };
   };
 
 

+ 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();
+};

+ 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 multer = require('multer');
 const autoReap = require('multer-autoreap');
 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
 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.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
   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('/share/:linkId', page.showSharedPage);
 
 
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);

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

@@ -306,6 +306,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     type:    ValueType.BOOLEAN,
     default: false,
     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: {
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
@@ -444,39 +450,53 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_SIGNING_SECRET: {
+  GROWI_APP_ID_FOR_GROWI_CLOUD: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'slackbot:signingSecret',
+    key:     'app:growiAppIdForCloud',
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_BOT_TOKEN: {
+  DEFAULT_EMAIL_PUBLISHED: {
+    ns:      'crowi',
+    key:     'customize:isEmailPublishedForNewUser',
+    type:    ValueType.BOOLEAN,
+    default: true,
+  },
+  SLACKBOT_TYPE: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'slackbot:token',
+    key:     'slackbot:currentBotType', // enum SlackbotType
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_INTEGRATION_PROXY_URI: {
+  SLACKBOT_INTEGRATION_PROXY_URI: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'slackbot:proxyServerUri',
+    key:     'slackbot:proxyUri',
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  SLACK_BOT_TYPE: {
+  SLACKBOT_WITHOUT_PROXY_SIGNING_SECRET: {
     ns:      'crowi',
     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',
     ns:      'crowi',
-    key:     'app:growiAppIdForCloud',
+    key:     'slackbot:withoutProxy:botToken',
     type:    ValueType.STRING,
     type:    ValueType.STRING,
     default: null,
     default: null,
   },
   },
-  DEFAULT_EMAIL_PUBLISHED: {
+  SLACKBOT_WITH_PROXY_SALT_FOR_GTOP: {
     ns:      'crowi',
     ns:      'crowi',
-    key:     'customize:isEmailPublishedForNewUser',
-    type:    ValueType.BOOLEAN,
-    default: true,
+    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',
   },
   },
 };
 };
 
 

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

@@ -34,26 +34,30 @@ class SearchService {
   }
   }
 
 
   get isSearchboxEnabled() {
   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() {
   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() {
   generateDelegator() {
     logger.info('Initializing search delegator');
     logger.info('Initializing search delegator');
 
 
     if (this.isSearchboxEnabled) {
     if (this.isSearchboxEnabled) {
-      logger.info('Searchbox is enabled');
       const SearchboxDelegator = require('./search-delegator/searchbox');
       const SearchboxDelegator = require('./search-delegator/searchbox');
+      logger.info('Searchbox is enabled');
       return new SearchboxDelegator(this.configManager, this.crowi.socketIoService);
       return new SearchboxDelegator(this.configManager, this.crowi.socketIoService);
     }
     }
     if (this.isElasticsearchEnabled) {
     if (this.isElasticsearchEnabled) {
-      logger.info('Elasticsearch (not Searchbox) is enabled');
       const ElasticsearchDelegator = require('./search-delegator/elasticsearch');
       const ElasticsearchDelegator = require('./search-delegator/elasticsearch');
+      logger.info('Elasticsearch (not Searchbox) is enabled');
       return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
       return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
     }
     }
+
+    logger.info('No elasticsearch URI is specified so that full text search is disabled.');
   }
   }
 
 
   registerUpdateEvent() {
   registerUpdateEvent() {

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

@@ -46,9 +46,9 @@ module.exports = (crowi) => {
     const channel = payload.channel.id;
     const channel = payload.channel.id;
     try {
     try {
       // validate form
       // validate form
-      const { path, oldest, latest } = await this.togetterValidateForm(client, payload);
+      const { path, oldest, newest } = await this.togetterValidateForm(client, payload);
       // get messages
       // get messages
-      result = await this.togetterGetMessages(client, payload, channel, path, latest, oldest);
+      result = await this.togetterGetMessages(client, payload, channel, path, newest, oldest);
       // clean messages
       // clean messages
       const cleanedContents = await this.togetterCleanMessages(result.messages);
       const cleanedContents = await this.togetterCleanMessages(result.messages);
 
 
@@ -66,9 +66,9 @@ module.exports = (crowi) => {
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const path = payload.state.values.page_path.page_path.value;
     const path = payload.state.values.page_path.page_path.value;
     let oldest = payload.state.values.oldest.oldest.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();
     oldest = oldest.trim();
-    latest = latest.trim();
+    newest = newest.trim();
     if (!path) {
     if (!path) {
       throw new SlackbotError({
       throw new SlackbotError({
         method: 'postMessage',
         method: 'postMessage',
@@ -91,34 +91,34 @@ module.exports = (crowi) => {
         mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
         mainMessage: 'Datetime format for oldest must be yyyy/MM/dd-HH:mm',
       });
       });
     }
     }
-    if (!regexpDatetime.test(latest)) {
+    if (!regexpDatetime.test(newest)) {
       throw new SlackbotError({
       throw new SlackbotError({
         method: 'postMessage',
         method: 'postMessage',
         to: 'dm',
         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;
     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
     // + 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({
       throw new SlackbotError({
         method: 'postMessage',
         method: 'postMessage',
         to: 'dm',
         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({
     const result = await client.conversations.history({
       channel,
       channel,
-      latest,
+      newest,
       oldest,
       oldest,
       limit: 100,
       limit: 100,
       inclusive: true,
       inclusive: true,
@@ -196,9 +196,9 @@ module.exports = (crowi) => {
 
 
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
   handler.togetterMessageBlocks = function(messages, body, args, limit) {
     return [
     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('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'),
       inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'),
       actionsBlock(
       actionsBlock(
         buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }),
         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 { IncomingWebhookSendArguments } from '@slack/webhook';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
 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 loggerFactory from '~/utils/logger';
+
 import S2sMessage from '../models/vo/s2s-message';
 import S2sMessage from '../models/vo/s2s-message';
 
 
 import ConfigManager from './config-manager';
 import ConfigManager from './config-manager';
@@ -15,6 +16,7 @@ import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:SlackBotService');
 const logger = loggerFactory('growi:service:SlackBotService');
 
 
+const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
 
 
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
 
 
@@ -99,22 +101,41 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   private isCheckTypeValid(): boolean {
   private isCheckTypeValid(): boolean {
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType == null) {
     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;
     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> {
   async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
     this.isCheckTypeValid();
     this.isCheckTypeValid();
 
 
-    const token = this.configManager.getConfig('crowi', 'slackbot:token');
+    const token = this.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
 
 
     if (token == null) {
     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);
     return generateWebClient(token);
@@ -147,7 +168,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
 
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
 
 
-    if (currentBotType === 'customBotWithoutProxy') {
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       return this.generateClientForCustomBotWithoutProxy();
       return this.generateClientForCustomBotWithoutProxy();
     }
     }
 
 
@@ -170,8 +191,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     this.isCheckTypeValid();
     this.isCheckTypeValid();
 
 
     // connect to proxy
     // 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 = {
     const headers = {
       'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,
       'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,
     };
     };

+ 11 - 1
packages/app/src/server/util/mongoose-utils.ts

@@ -2,6 +2,13 @@ import mongoose, {
   Model, Document, ConnectionOptions, Schema,
   Model, Document, ConnectionOptions, Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
+export const initMongooseGlobalSettings = (): void => {
+  // supress deprecation warnings
+  // see: https://mongoosejs.com/docs/deprecations.html
+  mongoose.set('useFindAndModify', false);
+  mongoose.set('useCreateIndex', true);
+};
+
 export const getMongoUri = (): string => {
 export const getMongoUri = (): string => {
   const { env } = process;
   const { env } = process;
 
 
@@ -26,6 +33,9 @@ export const getOrCreateModel = <Interface, Method>(modelName: string, schema: S
   return mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName, schema);
   return mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName, schema);
 };
 };
 
 
+// supress deprecation warnings
+// see: https://mongoosejs.com/docs/deprecations.html
 export const mongoOptions: ConnectionOptions = {
 export const mongoOptions: ConnectionOptions = {
-  useFindAndModify: false,
+  useNewUrlParser: true,
+  useUnifiedTopology: true,
 };
 };

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

@@ -0,0 +1,45 @@
+{% extends './layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('forgot_password.forgot_password')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+ {% block html_additional_headers %}
+   <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+ {% endblock %}
+
+{% block layout_main %}
+
+  <div id="main" class="main">
+    <div id="content-main" class="content-main container-lg">
+      <div class="container">
+        <div class="row justify-content-md-center">
+          <div class="col-md-6 mt-5">
+            <div class="text-center">
+              <h1><i class="icon-lock large"></i></h1>
+              <h2 class="text-center">{{ t('forgot_password.forgot_password') }}</h2>
+              <p>{{ t('forgot_password.password_reset_request_desc') }}</p>
+              <div id="password-reset-request-form"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+{% endblock %}

+ 54 - 0
packages/app/src/server/views/forgot-password/error.html

@@ -0,0 +1,54 @@
+{% extends '../layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('forgot_password.reset_password')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+ {% block html_additional_headers %}
+   <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+ {% endblock %}
+
+{% block layout_main %}
+
+  <div id="main" class="main">
+    <div id="content-main" class="content-main container-lg">
+      <div class="container">
+        <div class="row justify-content-md-center">
+          <div class="col-md-6 mt-5">
+            <div class="text-center">
+              <h1><i class="icon-lock-open large"></i></h1>
+              <h2 class="text-center">{{ t('forgot_password.reset_password') }}</h2>
+                {% if key === 'password-reset-order-is-not-appropriate' %}
+                <div>
+                  <div class="alert alert-warning mb-3">
+                    <h2>{{ t('forgot_password.incorrect_token_or_expired_url') }}</h2>
+                  </div>
+                  <a href="/forgot-password" class="link-switch">
+                    <i class="icon-key"></i> {{ t('forgot_password.forgot_password') }}
+                  </a>
+                </div>
+                {% endif %}
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+
+{% endblock %}

+ 2 - 0
packages/app/src/server/views/login.html

@@ -109,6 +109,7 @@
 
 
       {% set registrationMode = getConfig('crowi', 'security:registrationMode') %}
       {% set registrationMode = getConfig('crowi', 'security:registrationMode') %}
       {% set isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
       {% set isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
+      {% set isPasswordResetEnabled = getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') %}
 
 
       <div
       <div
         id="login-form"
         id="login-form"
@@ -119,6 +120,7 @@
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
+        data-is-password-reset-enabled = "{{ isPasswordResetEnabled }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         data-is-google-auth-enabled = "{{ getConfig('crowi', 'security:passport-google:isEnabled') }}"
         data-is-google-auth-enabled = "{{ getConfig('crowi', 'security:passport-google:isEnabled') }}"

+ 7 - 0
packages/app/src/server/views/login/error.html

@@ -37,6 +37,13 @@
         <div class="alert alert-success">
         <div class="alert alert-success">
           <h2>{{ t('login.Registration successful') }}</h2>
           <h2>{{ t('login.Registration successful') }}</h2>
         </div>
         </div>
+        {% elseif reason === 'password-reset-order' %}
+        <div class="alert alert-warning mb-3">
+          <h2>{{ t('forgot_password.incorrect_token_or_expired_url') }}</h2>
+        </div>
+          <a href="/forgot-password" class="link-switch">
+            <i class="icon-key"></i> {{ t('forgot_password.forgot_password') }}
+          </a>
         {% else %}
         {% else %}
         <div class="alert alert-warning">
         <div class="alert alert-warning">
             <h2>{{ t('login.Sign in error') }}</h2>
             <h2>{{ t('login.Sign in error') }}</h2>

+ 48 - 0
packages/app/src/server/views/reset-password.html

@@ -0,0 +1,48 @@
+{% extends './layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('forgot_password.reset_password')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+ {% block html_additional_headers %}
+   <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+ {% endblock %}
+
+{% block layout_main %}
+
+  <div id="main" class="main">
+    <div id="content-main" class="content-main container-lg">
+      <div class="container">
+        <div class="row justify-content-md-center">
+          <div class="col-md-6 mt-5">
+            <div class="text-center">
+              <h1><i class="icon-lock-open large"></i></h1>
+              <h2 class="text-center">{{ t('forgot_password.reset_password') }}</h2>
+              <h5>{{ email }}</h5>
+              <p class="mt-4">{{ t('forgot_password.password_reset_excecution_desc') }}</p>
+              <div id="password-reset-execution-form"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+    </div>
+  </div>
+
+{% endblock %}

+ 4 - 0
packages/app/src/test/config/migrate.test.js

@@ -13,9 +13,12 @@ describe('config/migrate.js', () => {
   `('returns', ({ MONGO_URI, expectedUrl, expectedDbName }) => {
   `('returns', ({ MONGO_URI, expectedUrl, expectedDbName }) => {
     test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
     test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
 
 
+      const initMongooseGlobalSettingsMock = jest.fn();
+
       // mock for mongoose-utils
       // mock for mongoose-utils
       jest.doMock('~/server/util/mongoose-utils', () => {
       jest.doMock('~/server/util/mongoose-utils', () => {
         return {
         return {
+          initMongooseGlobalSettings: initMongooseGlobalSettingsMock,
           getMongoUri: () => {
           getMongoUri: () => {
             return MONGO_URI;
             return MONGO_URI;
           },
           },
@@ -26,6 +29,7 @@ describe('config/migrate.js', () => {
 
 
       jest.dontMock('~/server/util/mongoose-utils');
       jest.dontMock('~/server/util/mongoose-utils');
 
 
+      expect(initMongooseGlobalSettingsMock).toHaveBeenCalledTimes(1);
       expect(mongoUri).toBe(MONGO_URI);
       expect(mongoUri).toBe(MONGO_URI);
       expect(mongodb.url).toBe(expectedUrl);
       expect(mongodb.url).toBe(expectedUrl);
       expect(mongodb.databaseName).toBe(expectedDbName);
       expect(mongodb.databaseName).toBe(expectedDbName);

+ 3 - 1
packages/app/src/test/global-setup.js

@@ -9,7 +9,7 @@ import 'tsconfig-paths/register';
 
 
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 
 
 // check env
 // check env
 if (process.env.NODE_ENV !== 'test') {
 if (process.env.NODE_ENV !== 'test') {
@@ -21,6 +21,8 @@ if (process.env.NODE_ENV !== 'test') {
 // const { getInstance } = require('./setup-crowi');
 // const { getInstance } = require('./setup-crowi');
 
 
 module.exports = async() => {
 module.exports = async() => {
+  initMongooseGlobalSettings();
+
   await mongoose.connect(getMongoUri(), mongoOptions);
   await mongoose.connect(getMongoUri(), mongoOptions);
 
 
   // drop database
   // drop database

+ 17 - 8
packages/app/src/test/service/page.test.js

@@ -305,18 +305,27 @@ describe('PageService', () => {
     });
     });
 
 
     test('rename page with different tree with isRecursively [shallower]', async() => {
     test('rename page with different tree with isRecursively [shallower]', async() => {
+      // setup
+      expect(await Page.findOne({ path: '/level1' })).toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2/child' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
+
+      // when
+      //   rename /level1/level2 --> /level1
       await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {}, true);
       await crowi.pageService.renamePage(parentForRename7, '/level1', testUser1, {}, true);
-      const expectPage1 = await Page.findOne({ path: '/level1' });
-      const expectPage2 = await Page.findOne({ path: '/level1/child' });
-      const expectPage3 = await Page.findOne({ path: '/level1/level2/level2' });
-      const expectPage4 = await Page.findOne({ path: '/level1-2021H1' });
 
 
-      expect(expectPage1).not.toBeNull();
-      expect(expectPage2).not.toBeNull();
-      expect(expectPage3).not.toBeNull();
+      // then
+      expect(await Page.findOne({ path: '/level1' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/child' })).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2' })).toBeNull();
+      expect(await Page.findOne({ path: '/level1/level2/child' })).toBeNull();
+      // The changed path is duplicated with the existing path (/level1/level2), so it will not be changed
+      expect(await Page.findOne({ path: '/level1/level2/level2' })).not.toBeNull();
 
 
       // Check that pages that are not to be renamed have not been renamed
       // Check that pages that are not to be renamed have not been renamed
-      expect(expectPage4).not.toBeNull();
+      expect(await Page.findOne({ path: '/level1-2021H1' })).not.toBeNull();
     });
     });
   });
   });
 
 

+ 2 - 1
packages/app/src/test/setup.js

@@ -7,13 +7,14 @@
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
-const { getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
+const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
 
 
 mongoose.Promise = global.Promise;
 mongoose.Promise = global.Promise;
 
 
 jest.setTimeout(30000); // default 5000
 jest.setTimeout(30000); // default 5000
 
 
 beforeAll(async() => {
 beforeAll(async() => {
+  initMongooseGlobalSettings();
   await mongoose.connect(getMongoUri(), mongoOptions);
   await mongoose.connect(getMongoUri(), mongoOptions);
 });
 });
 
 

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "4.3.3-RC",
+  "version": "4.4.0-RC",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "keywords": [
   "keywords": [

+ 0 - 1
packages/plugin-attachment-refs/index.js

@@ -1 +0,0 @@
-module.exports = require('./src/meta');

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

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

+ 0 - 0
packages/plugin-attachment-refs/src/meta.js → packages/plugin-attachment-refs/src/index.js


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

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

+ 11 - 1
packages/plugin-lsx/src/index.js

@@ -1 +1,11 @@
-module.exports = require('./meta');
+const isProd = process.env.NODE_ENV === 'production';
+
+module.exports = {
+  pluginSchemaVersion: 4,
+  serverEntries: [
+    isProd ? 'dist/cjs/server-entry.js' : 'src/server-entry.js',
+  ],
+  clientEntries: [
+    'src/client-entry.js',
+  ],
+};

+ 0 - 11
packages/plugin-lsx/src/meta.js

@@ -1,11 +0,0 @@
-const isProd = process.env.NODE_ENV === 'production';
-
-module.exports = {
-  pluginSchemaVersion: 4,
-  serverEntries: [
-    isProd ? 'dist/cjs/server-entry.js' : 'src/server-entry.js',
-  ],
-  clientEntries: [
-    'src/client-entry.js',
-  ],
-};

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

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

+ 8 - 1
packages/plugin-pukiwiki-like-linker/src/index.js

@@ -1 +1,8 @@
-module.exports = require('./meta');
+module.exports = {
+  pluginSchemaVersion: 4,
+  serverEntries: [
+  ],
+  clientEntries: [
+    'src/client-entry.js',
+  ],
+};

+ 0 - 8
packages/plugin-pukiwiki-like-linker/src/meta.js

@@ -1,8 +0,0 @@
-module.exports = {
-  pluginSchemaVersion: 4,
-  serverEntries: [
-  ],
-  clientEntries: [
-    'src/client-entry.js',
-  ],
-};

+ 1 - 1
packages/slack/package.json

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

+ 1 - 0
packages/slack/src/index.ts

@@ -25,6 +25,7 @@ export const defaultSupportedCommandsNameForSingleUse: string[] = [
 export * from './interfaces/growi-command';
 export * from './interfaces/growi-command';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
 export * from './interfaces/request-from-slack';
+export * from './interfaces/slackbot-types';
 export * from './models/errors';
 export * from './models/errors';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-growi-to-slack-request';
 export * from './middlewares/verify-slack-request';
 export * from './middlewares/verify-slack-request';

+ 5 - 0
packages/slack/src/interfaces/slackbot-types.ts

@@ -0,0 +1,5 @@
+export enum SlackbotType {
+  OFFICIAL = 'officialBot',
+  CUSTOM_WITHOUT_PROXY = 'customBotWithoutProxy',
+  CUSTOM_WITH_PROXY = 'customBotWithProxy',
+}

+ 3 - 4
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "1.0.2",
+  "version": "1.0.3",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -18,13 +18,12 @@
     "predev": "yarn cp:bootstrap:dev",
     "predev": "yarn cp:bootstrap:dev",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests",
     "test": "cross-env NODE_ENV=test jest --passWithNoTests",
     "lint": "eslint src --ext .ts",
     "lint": "eslint src --ext .ts",
-    "lint:fix": "eslint src --ext .ts --fix",
-    "version": "node -p \"require('./package.json').version\""
+    "lint:fix": "eslint src --ext .ts --fix"
   },
   },
   "// comments for dependencies": {},
   "// comments for dependencies": {},
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.8.0",
     "@godaddy/terminus": "^4.8.0",
-    "@growi/slack": "^4.3.3-RC",
+    "@growi/slack": "^4.4.0-RC",
     "@slack/oauth": "^2.0.1",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",
     "@tsed/common": "^6.43.0",

+ 13 - 9
packages/slackbot-proxy/src/controllers/slack.ts

@@ -1,5 +1,5 @@
 import {
 import {
-  BodyParams, Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
+  Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
 } from '@tsed/common';
 } from '@tsed/common';
 
 
 import axios from 'axios';
 import axios from 'axios';
@@ -19,7 +19,10 @@ import { InstallationRepository } from '~/repositories/installation';
 import { RelationRepository } from '~/repositories/relation';
 import { RelationRepository } from '~/repositories/relation';
 import { OrderRepository } from '~/repositories/order';
 import { OrderRepository } from '~/repositories/order';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
-import { AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware } from '~/middlewares/slack-to-growi/authorizer';
+import {
+  AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware, AuthorizeEventsMiddleware,
+} from '~/middlewares/slack-to-growi/authorizer';
+import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
@@ -331,14 +334,15 @@ export class SlackCtrl {
   }
   }
 
 
   @Post('/events')
   @Post('/events')
-  async handleEvent(@BodyParams() body:{[key:string]:string} /* , @Res() res: Res */): Promise<void|string> {
-    // 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') {
-      return body.challenge;
-    }
+  @UseBefore(UrlVerificationMiddleware, AuthorizeEventsMiddleware)
+  async handleEvent(@Req() req: SlackOauthReq): Promise<void> {
 
 
-    logger.info('receive event', body);
+    const { authorizeResult } = req;
+    const client = generateWebClient(authorizeResult.botToken);
+
+    if (req.body.event.type === 'app_home_opened') {
+      await postWelcomeMessage(client, req.body.event.channel);
+    }
 
 
     return;
     return;
   }
   }

+ 75 - 73
packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts

@@ -6,56 +6,29 @@ import {
 import Logger from 'bunyan';
 import Logger from 'bunyan';
 
 
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
-import { InstallationRepository } from '~/repositories/installation';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-@Middleware()
-export class AuthorizeCommandMiddleware implements IMiddleware {
-
-  @Inject()
-  installerService: InstallerService;
-
-  @Inject()
-  installationRepository: InstallationRepository;
-
-  private logger: Logger;
 
 
-  constructor() {
-    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeCommandMiddleware');
-  }
+const getCommonMiddleware = (query:InstallationQuery<boolean>, installerService:InstallerService, logger:Logger) => {
+  return async(req: SlackOauthReq, res: Res): Promise<void|Res> => {
 
 
-  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
-    const { body } = req;
-
-    // extract id from body
-    const teamId = body.team_id;
-    const enterpriseId = body.enterprise_id;
-    const isEnterpriseInstall = body.is_enterprise_install === 'true';
-
-    if (teamId == null && enterpriseId == null) {
+    if (query.teamId == null && query.enterpriseId == null) {
       res.writeHead(400, 'No installation found');
       res.writeHead(400, 'No installation found');
       return res.end();
       return res.end();
     }
     }
 
 
-    // create query from body
-    const query: InstallationQuery<boolean> = {
-      teamId,
-      enterpriseId,
-      isEnterpriseInstall,
-    };
-
     let result: AuthorizeResult;
     let result: AuthorizeResult;
     try {
     try {
-      result = await this.installerService.installer.authorize(query);
+      result = await installerService.installer.authorize(query);
 
 
       if (result.botToken == null) {
       if (result.botToken == null) {
-        res.writeHead(403, `The installation for the team(${teamId || enterpriseId}) has no botToken`);
+        res.writeHead(403, `The installation for the team(${query.teamId || query.enterpriseId}) has no botToken`);
         return res.end();
         return res.end();
       }
       }
     }
     }
     catch (e) {
     catch (e) {
-      this.logger.error(e.message);
+      logger.error(e.message);
 
 
       res.writeHead(500, e.message);
       res.writeHead(500, e.message);
       return res.end();
       return res.end();
@@ -63,19 +36,39 @@ export class AuthorizeCommandMiddleware implements IMiddleware {
 
 
     // set authorized data
     // set authorized data
     req.authorizeResult = result;
     req.authorizeResult = result;
-  }
-
-}
+  };
+};
+@Middleware()
+export class AuthorizeCommandMiddleware implements IMiddleware {
 
 
+  private logger: Logger;
 
 
-@Middleware()
-export class AuthorizeInteractionMiddleware implements IMiddleware {
+  constructor() {
+    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeCommandMiddleware');
+  }
 
 
   @Inject()
   @Inject()
   installerService: InstallerService;
   installerService: InstallerService;
 
 
-  @Inject()
-  installationRepository: InstallationRepository;
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|Res> {
+    const { body } = req;
+    const teamId = body.team_id;
+    const enterpriseId = body.enterprise_id;
+    const isEnterpriseInstall = body.is_enterprise_install === 'true';
+    const query: InstallationQuery<boolean> = {
+      teamId,
+      enterpriseId,
+      isEnterpriseInstall,
+    };
+
+    const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
+    await commonMiddleware(req, res);
+  }
+
+}
+
+@Middleware()
+export class AuthorizeInteractionMiddleware implements IMiddleware {
 
 
   private logger: Logger;
   private logger: Logger;
 
 
@@ -83,52 +76,61 @@ export class AuthorizeInteractionMiddleware implements IMiddleware {
     this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeInteractionMiddleware');
     this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeInteractionMiddleware');
   }
   }
 
 
-  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
-    const { body } = req;
+    @Inject()
+    installerService: InstallerService;
+
+    async use(@Req() req: SlackOauthReq, @Res() res:Res): Promise<void|Res> {
+      const { body } = req;
+
+      const payload = JSON.parse(body.payload);
+
+      // extract id from body.payload
+      const teamId = payload.team?.id;
+      const enterpriseId = payload.enterprise?.id;
+      const isEnterpriseInstall = payload.is_enterprise_install === 'true';
 
 
-    if (body.payload == null) {
+      if (body.payload == null) {
       // do nothing
       // do nothing
-      this.logger.info('body does not have payload');
-      return;
+        this.logger.info('body does not have payload');
+        return;
+      }
+
+      const query: InstallationQuery<boolean> = {
+        teamId,
+        enterpriseId,
+        isEnterpriseInstall,
+      };
+
+      const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
+      await commonMiddleware(req, res);
     }
     }
 
 
-    const payload = JSON.parse(body.payload);
+}
+@Middleware()
+export class AuthorizeEventsMiddleware implements IMiddleware {
 
 
-    // extract id from body
-    const teamId = payload.team?.id;
-    const enterpriseId = payload.enterprise?.id;
-    const isEnterpriseInstall = payload.is_enterprise_install === 'true';
+  private logger: Logger;
 
 
-    if (teamId == null && enterpriseId == null) {
-      res.writeHead(400, 'No installation found');
-      return res.end();
-    }
+  constructor() {
+    this.logger = loggerFactory('slackbot-proxy:middlewares:AuthorizeEventsMiddleware');
+  }
 
 
-    // create query from body
+  @Inject()
+  installerService: InstallerService;
+
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|Res> {
+    const { body } = req;
+    const teamId = body.team_id;
+    const enterpriseId = body.enterprise_id;
+    const isEnterpriseInstall = body.is_enterprise_install === 'true';
     const query: InstallationQuery<boolean> = {
     const query: InstallationQuery<boolean> = {
       teamId,
       teamId,
       enterpriseId,
       enterpriseId,
       isEnterpriseInstall,
       isEnterpriseInstall,
     };
     };
 
 
-    let result: AuthorizeResult;
-    try {
-      result = await this.installerService.installer.authorize(query);
-
-      if (result.botToken == null) {
-        res.writeHead(403, `The installation for the team(${teamId || enterpriseId}) has no botToken`);
-        return res.end();
-      }
-    }
-    catch (e) {
-      this.logger.error(e.message);
-
-      res.writeHead(500, e.message);
-      return res.end();
-    }
-
-    // set authorized data
-    req.authorizeResult = result;
+    const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
+    await commonMiddleware(req, res);
   }
   }
 
 
 }
 }

+ 5 - 3
packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts

@@ -23,9 +23,11 @@ export class JoinToConversationMiddleware implements IMiddleware {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const client = generateWebClient(authorizeResult.botToken!);
     const client = generateWebClient(authorizeResult.botToken!);
 
 
-    const joinResult = await client.conversations.join({ channel: body.channel_id });
-    if (!joinResult.ok) {
-      logger.error(joinResult.error, joinResult);
+    try {
+      await client.conversations.join({ channel: body.channel_id });
+    }
+    catch (err) {
+      logger.error(err);
     }
     }
   }
   }
 
 

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