Browse Source

Merge branch 'master' into imprv/GW-7163-custom-with-proxy-modify-tutorial

Haku Mizuki 4 years ago
parent
commit
e63ed9b06c
88 changed files with 1978 additions and 546 deletions
  1. 2 0
      .devcontainer/Dockerfile
  2. 4 0
      .devcontainer/docker-compose.yml
  3. 21 3
      CHANGES.md
  4. 1 1
      package.json
  5. 2 4
      packages/app/.env.development
  6. 7 6
      packages/app/package.json
  7. 10 0
      packages/app/resource/locales/en_US/admin/admin.json
  8. 8 0
      packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt
  9. 13 0
      packages/app/resource/locales/en_US/notifications/notActiveUser.txt
  10. 10 0
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  11. 19 1
      packages/app/resource/locales/en_US/translation.json
  12. 53 16
      packages/app/resource/locales/en_US/welcome.md
  13. 10 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  14. 13 0
      packages/app/resource/locales/ja_JP/notifications/notActiveUser.txt
  15. 10 0
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  16. 6 0
      packages/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt
  17. 19 1
      packages/app/resource/locales/ja_JP/translation.json
  18. 43 10
      packages/app/resource/locales/ja_JP/welcome.md
  19. 10 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  20. 6 0
      packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt
  21. 13 0
      packages/app/resource/locales/zh_CN/notifications/notActiveUser.txt
  22. 10 0
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  23. 20 2
      packages/app/resource/locales/zh_CN/translation.json
  24. 53 16
      packages/app/resource/locales/zh_CN/welcome.md
  25. 5 3
      packages/app/src/client/admin.jsx
  26. 34 0
      packages/app/src/client/nologin.jsx
  27. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  28. 9 37
      packages/app/src/client/services/AdminNotificationContainer.js
  29. 91 0
      packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  30. 71 0
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  31. 21 21
      packages/app/src/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx
  32. 114 20
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  33. 0 80
      packages/app/src/components/Admin/Notification/SlackIntegrationNotificationSetting.jsx
  34. 22 1
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  35. 8 5
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  36. 3 3
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx
  37. 4 1
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  38. 7 4
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  39. 5 2
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  40. 7 0
      packages/app/src/components/LoginForm.jsx
  41. 96 0
      packages/app/src/components/PasswordResetExecutionForm.jsx
  42. 66 0
      packages/app/src/components/PasswordResetRequestForm.jsx
  43. 2 0
      packages/app/src/components/StickyStretchableScroller.jsx
  44. 65 0
      packages/app/src/migrations/20210830074539-update-configs-for-slackbot.js
  45. 24 0
      packages/app/src/server/middlewares/inject-reset-order-by-token-middleware.js
  46. 1 0
      packages/app/src/server/models/index.js
  47. 5 0
      packages/app/src/server/models/page.js
  48. 57 0
      packages/app/src/server/models/password-reset-order.js
  49. 12 4
      packages/app/src/server/models/slack-app-integration.js
  50. 115 0
      packages/app/src/server/routes/apiv3/forgot-password.js
  51. 3 0
      packages/app/src/server/routes/apiv3/index.js
  52. 9 70
      packages/app/src/server/routes/apiv3/notification-setting.js
  53. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  54. 128 0
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  55. 30 27
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  56. 6 2
      packages/app/src/server/routes/apiv3/slack-integration.js
  57. 21 0
      packages/app/src/server/routes/forgot-password.js
  58. 13 0
      packages/app/src/server/routes/index.js
  59. 2 1
      packages/app/src/server/routes/page.js
  60. 34 14
      packages/app/src/server/service/config-loader.ts
  61. 37 52
      packages/app/src/server/service/page.js
  62. 8 7
      packages/app/src/server/service/slack-integration.ts
  63. 1 1
      packages/app/src/server/views/admin/slack-integration-legacy.html
  64. 45 0
      packages/app/src/server/views/forgot-password.html
  65. 54 0
      packages/app/src/server/views/forgot-password/error.html
  66. 2 0
      packages/app/src/server/views/login.html
  67. 7 0
      packages/app/src/server/views/login/error.html
  68. 48 0
      packages/app/src/server/views/reset-password.html
  69. 98 1
      packages/app/src/test/service/page.test.js
  70. 1 1
      packages/core/package.json
  71. 1 1
      packages/plugin-attachment-refs/package.json
  72. 1 1
      packages/plugin-lsx/package.json
  73. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  74. 1 1
      packages/slack/package.json
  75. 1 0
      packages/slack/src/index.ts
  76. 5 0
      packages/slack/src/interfaces/slackbot-types.ts
  77. 1 0
      packages/slack/src/utils/required-scopes.ts
  78. 21 6
      packages/slack/src/utils/webclient-factory.ts
  79. 2 2
      packages/slackbot-proxy/package.json
  80. 1 1
      packages/slackbot-proxy/src/Server.ts
  81. 20 10
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  82. 25 22
      packages/slackbot-proxy/src/controllers/slack.ts
  83. 9 2
      packages/slackbot-proxy/src/middlewares/growi-to-slack/add-webclient-response-to-res.ts
  84. 75 73
      packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts
  85. 20 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts
  86. 19 7
      packages/slackbot-proxy/src/views/install-succeeded.ejs
  87. 1 1
      packages/ui/package.json
  88. 5 0
      yarn.lock

+ 2 - 0
.devcontainer/Dockerfile

@@ -14,6 +14,8 @@ ARG USER_UID=1000
 ARG USER_GID=$USER_UID
 
 RUN mkdir -p /workspace/growi/node_modules
+RUN mkdir -p /workspace/growi/packages/app/node_modules
+RUN mkdir -p /workspace/growi/packages/slackbot-proxy/node_modules
 
 # [Optional] Update UID/GID if needed
 RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \

+ 4 - 0
.devcontainer/docker-compose.yml

@@ -24,6 +24,8 @@ services:
     volumes:
       - ..:/workspace/growi:delegated
       - node_modules:/workspace/growi/node_modules
+      - node_modules_app:/workspace/growi/packages/app/node_modules
+      - node_modules_slackbot-proxy:/workspace/growi/packages/slackbot-proxy/node_modules
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
 
     tty: true
@@ -80,3 +82,5 @@ services:
       - /files/sqlite
 volumes:
   node_modules:
+  node_modules_app:
+  node_modules_slackbot-proxy:

+ 21 - 3
CHANGES.md

@@ -1,10 +1,18 @@
 # CHANGES
 
-## v4.3.3-RC
+## v4.4.0-RC
 
+### BREAKING CHANGES
+
+* Official plugins are now preinstalled
+
+### 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
+* 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: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
 * Support: Create @growi/core package
 * Support: Create @growi/ui package
 * Support: Improve error handling for @growi/slackbot-proxy
@@ -12,11 +20,21 @@
 * Support: Upgrade libs
     * @slack/web-api
     * date-fns
-    * escape-string-regexp
     * helmet
     * morgan
     * socket.io
 
+## v4.3.3
+
+* Improvement: Welcome page markdown
+* Fix: Some recursive operation exclude descendant pages that are restricted for groups
+    * Rename / Delete / Delete completely / Put back / Duplicate
+* Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
+* Support: Upgrade libs
+    * @slack/web-api
+    * date-fns
+    * escape-string-regexp
+
 ## v4.3.2
 
 * Feature: Hufflpuff theme

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.3.3-RC",
+  "version": "4.4.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

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

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

+ 7 - 6
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.3.3-RC",
+  "version": "4.4.0-RC",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -55,10 +55,10 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/plugin-attachment-refs": "^4.3.3-RC",
-    "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
-    "@growi/plugin-lsx": "^4.3.3-RC",
-    "@growi/slack": "^4.3.3-RC",
+    "@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",
     "@promster/express": "^5.0.1",
     "@promster/server": "^6.0.0",
@@ -91,6 +91,7 @@
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
     "express-mongo-sanitize": "^2.1.0",
+    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
@@ -154,7 +155,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.3.3-RC",
+    "@growi/ui": "^4.4.0-RC",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

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

@@ -252,6 +252,12 @@
     "download": "Download",
     "delete": "Delete"
   },
+  "external_notification": {
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "header_status": "Slack Integration Status",
+    "caution_enabled": "CAUTION: Currently, notifications that are configured in this page will send only to the Slack Workspace set as primary."
+  },
   "slack_integration": {
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
@@ -352,6 +358,10 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
   },
+  "slack_integration_legacy": {
+    "alert_disabled": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
+    "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
+  },
   "user_management": {
     "invite_users": "Temporarily issue a new user",
     "click_twice_same_checkbox": "You should check at least one checkbox.",

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

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

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

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

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

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

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

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

+ 53 - 16
packages/app/resource/locales/en_US/welcome.md

@@ -1,27 +1,64 @@
-# Welcome to GROWI :anchor:
+# :tada: Welcome to GROWI
 
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
-<div class="card border-primary">
-  <div class="card-header bg-primary text-light">Tips</div>
-  <div class="card-body"><ul>
-    <li>Ctrl(⌘) + "/" to show quick help</li>
-    <li>You can write HTML with <a href="https://getbootstrap.com/docs/4.5/components/">Bootstrap 4</a>.</li>
-  </ul></div>
+GROWI is a Wiki for Individuals and Corporations | A knowledge base tool.
+Knowledge in companies, university laboratories, and clubs can be easily shared and anyone can edit the page.
+
+We can easily write what we know and edit it together, we can **simplify the tacit knowledge (knowledge which is hard to explain with words) in our team**.  
+Let's increase the information exchange everyday.
+
+### :beginner: How to create a page easily 
+
+- Start from "**Create**" button on the upper right, or the **Pencil Icon** on the lower right.
+    - The page title can be edited again later, don't worry about the title.
+        - On title input field, it's possible to create the page's hierarchy with half-width `/` (slash).
+        - (Example)Try entering `/category1/category2/page-title-we-want-to-create`.
+- We can create a bullet point by adding `-`  at the beginning of the line.
+- We can also copy and paste, drag and drop attachments such as images, PDF, Word/Excel/PowerPoint, etc.
+- Once we finished, press the "**Update**" button to publish the page.
+    - We can also save it by `Ctrl(⌘) +S`.
+
+For more information: [Tutorial#Create New Page](https://docs.growi.org/en/guide/tutorial/create_page.html#create-new-page)
+
+<div class="mt-4 card border-primary">
+  <div class="card-header bg-primary text-light">
+    Tips
+  </div>
+  <div class="card-body">
+    <ul>
+      <li>Ctrl(⌘) + "/" to show quick help.</li>
+      <li>We can write HTML with <a href="https://getbootstrap.com/docs/4.5/components/">Bootstrap 4</a>.</li>
+    </ul>
+  </div>
 </div>
 
-Contents
-=========
+# :anchor: For administrator <small>〜After you construct the site〜</small>
+
+### :arrow_right: Do you will use a Wiki with more than one person?
+- :heavy_check_mark: Let's invite some members.
+    - [Add/invite new members to the Wiki](https://docs.growi.org/en/admin-guide/management-cookbook/user-management.html#temporary-issuance-of-a-new-user)
+### :arrow_right: Work with Slack to receive page and comment notifications.
+- :heavy_check_mark:  [Slack integration](https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#overview)
+### :arrow_right: Are you switching from another system?
+- :heavy_check_mark: It's possible to import data from other GROWI, esa.io, Qiita:Team.
+    -  [Import Data](https://docs.growi.org/en/admin-guide/management-cookbook/import.html)
+
+For more information: [Admin Guide](https://docs.growi.org/en/admin-guide/)
+
+
+# Content List Example
+
+We can display the content list using a table and `$lsx`.
 
-|All Pages|[/Sandbox]|
-| --- | --- |
-| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Go to Sandbox](/Sandbox)</span></div> $lsx(/Sandbox)|
+| All page list (First 15 pages)      | [/Sandbox] List of subordinate pages |
+| ----------------------------------- | ------------------------------------ |
+| $lsx(/,num=15)                      | $lsx(/Sandbox)                       |
 
-Slack
-=====
+# Slack
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
-Let's join our Slack channel for all to help make GROWI better.
-In addition to discussing development, we also accept questions at the time of introduction.
+We welcome newcomers joining our slack channel to help improve Growi.
+In addition to discussing development, we are also happy to answer your questions when you join.

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

@@ -252,6 +252,12 @@
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
+  "external_notification": {
+    "enabled": "有効",
+    "disabled": "無効",
+    "header_status": "Slack 連携の状態",
+    "caution_enabled": "CAUTION: このページで設定される通知は、Primary として設定された Slack ワークスペースにのみ送信されます。 "
+  },
   "slack_integration": {
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
@@ -351,6 +357,10 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
   },
+  "slack_integration_legacy": {
+    "alert_disabled": "<a href='/admin/slack-integration'>新しい設定</a>が有効になっているため、この 'Slack連携 (レガシー)' は現在無効になっています。",
+    "alert_deplicated": "この 'Slack連携 (レガシー)' は将来廃止されます。代わりに<a href='/admin/slack-integration'>新しいSlack連携機能</a>を利用してください。"
+  },
   "user_management": {
     "invite_users": "新規ユーザーの仮発行",
     "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",

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

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

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

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

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

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

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

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

+ 43 - 10
packages/app/resource/locales/ja_JP/welcome.md

@@ -1,9 +1,27 @@
-# Welcome to GROWI :anchor:
-
+# :tada: GROWI へようこそ
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
-<div class="card border-primary">
+GROWI は個人・法人向けの Wiki | ナレッジベースツールです。  
+会社や大学の研究室、サークルでのナレッジ情報を簡単に共有でき、作られたページは誰でも編集が可能です。
+
+知っている情報をカジュアルに書き出しみんなで編集することで、**チーム内での暗黙知を減らす**ことができます。  
+当たり前に共有される情報を日々増やしていきましょう。
+
+### :beginner: 簡単なページの作り方
+
+- 右上の "**作成**"ボタンまたは右下の**鉛筆アイコン**のボタンからページを書き始めることができます
+    - ページタイトルは後から変更できますので、適当に入力しても大丈夫です
+        - タイトル入力欄では、半角の `/` (スラッシュ) でページ階層を作れます
+        - (例)`/カテゴリ1/カテゴリ2/作りたいページタイトル` のように入力してみてください
+- `- ` を行頭につけると、この文章のような箇条書きを書くことができます
+- 画像やPDF、Word/Excel/PowerPointなどの添付ファイルも、コピー&ペースト、ドラッグ&ドロップで貼ることができます
+- 書けたら "**更新**" ボタンを押してページを公開しましょう
+    - `Ctrl(⌘) +S` でも保存できます
+
+さらに詳しくはこちら: [チュートリアル#新規ページ作成](https://docs.growi.org/ja/guide/tutorial/create_page.html#新規ページ作成)
+
+<div class="mt-4 card border-primary">
   <div class="card-header bg-primary text-light">Tips</div>
   <div class="card-body"><ul>
     <li>Ctrl(⌘) + "/" でショートカットヘルプを表示します</li>
@@ -11,15 +29,30 @@
   </ul></div>
 </div>
 
-Contents
-=========
 
-|All Pages|[/Sandbox]|
-| --- | --- |
-| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Sandboxをチェック](/Sandbox)</span></div> $lsx(/Sandbox)|
+# :anchor: 管理者の方へ <small>〜Wikiを作ったら〜</small>
+
+### :arrow_right: 複数人でWikiを使いますか?
+- :heavy_check_mark: メンバーを招待しましょう
+    - [Wikiに新しいメンバーを追加・招待する](https://docs.growi.org/ja/admin-guide/management-cookbook/user-management.html#%E6%96%B0%E8%A6%8F%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AE%E4%BB%AE%E7%99%BA%E8%A1%8C)
+### :arrow_right: Slackと連携してページやコメントの通知を受け取りましょう
+- :heavy_check_mark:  [Slack連携](https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/#%E6%A6%82%E8%A6%81)
+### :arrow_right: 他のシステムからの乗り換えですか?
+- :heavy_check_mark: 他の GROWI、esa. io、Qiita:Team のデータをインポートすることが出来ます
+    -  [データのインポート](https://docs.growi.org/ja/admin-guide/management-cookbook/import.html)
+
+さらに詳しくはこちら: [管理者ガイド](https://docs.growi.org/ja/admin-guide/)
+
+
+# コンテンツリストアップ例
+
+テーブルと `$lsx` を使ってコンテンツリストを表示できます。
+
+| 全てのページリスト (First 15 pages) | [/Sandbox] 配下ページ一覧 |
+| ----------------------------------- | ------------------------- |
+| $lsx(/,num=15)                      | $lsx(/Sandbox)            |
 
-Slack
-=====
+# Slack
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 

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

@@ -262,6 +262,12 @@
     "download": "下载",
     "delete": "删除"
   },
+  "external_notification": {
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "header_status": "Slack整合状态",
+    "caution_enabled": "CAUTION: 目前,在此页面中配置的通知只会通知设置为主要的 Slack 工作区。 "
+  },
   "slack_integration": {
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
@@ -361,6 +367,10 @@
       "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
     }
   },
+  "slack_integration_legacy": {
+    "alert_disabled": "由于<a href='/admin/slack-integration'>新设置</a>已启用,因此该'旧版Slack一体化'目前已被禁用。",
+    "alert_deplicated": "这个 '旧版Slack一体化' 已经过时了,将来会停止使用。使用<a href='/admin/slack-integration'>新的设置</a>来代替。"
+  },
   "user_management": {
     "invite_users": "临时发布新用户",
     "click_twice_same_checkbox": "您应该至少选中一个复选框。",

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

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

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

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

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

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

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

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

+ 53 - 16
packages/app/resource/locales/zh_CN/welcome.md

@@ -1,27 +1,64 @@
-# 欢迎来到GROWI :anchor:
+# :tada: 欢迎来到GROWI
 
 [![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
 [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
-<div class="card border-primary">
-  <div class="card-header bg-primary text-light">提示</div>
-  <div class="card-body"><ul>
-    <li>(按Ctrl>)+“/”to show quick help</li>
-    <li>>你可以写HTML与</a href=”https://getbootstrap.com docs/4.5 components/“Bootstrap 4</a></li>
-  </ul></div>
+GROWI是一个针对个人和公司的Wiki - 一个知识库工具。
+公司、大学实验室和俱乐部的知识可以轻松共享,任何人都可以编辑页面。
+
+我们可以很容易地写下我们知道的东西,并一起编辑,我们可以**简化我们团队中的隐性知识(难以用语言解释的知识)**。 
+让我们每天增加信息交流。
+
+### :beginner: 如何轻松制作一个页面 
+
+- 从右上方的 "**创建**"按钮,或右下方的**铅笔图标开始。
+    - 页面标题以后可以再编辑,不用担心标题的问题。
+        - 在标题输入栏,可以用半宽的`/`(斜线)创建页面的层次。
+        - 例子)尝试输入`/category1/category2/page-title-we-want-to-create`。
+- 我们可以通过在行首添加`-`来创建一个要点。
+- 我们还可以复制和粘贴,拖放附件,如图片、PDF、Word/Excel/PowerPoint等。
+- 一旦我们完成了,按 "**更新**"按钮来发布页面。
+    - 我们也可以通过`Ctrl(⌘) +S`来保存。
+
+了解更多信息: [Tutorial#Create New Page](https://docs.growi.org/en/guide/tutorial/create_page.html#create-new-page)
+
+<div class="mt-4 card border-primary">
+  <div class="card-header bg-primary text-light">
+    Tips
+  </div>
+  <div class="card-body">
+    <ul>
+      <li>Ctrl(⌘) + "/" 显示快速帮助。</li>
+      <li>你可以用 <a href="https://getbootstrap.com/docs/4.5/components/">Bootstrap 4编写HTML</a>.</li>
+    </ul>
+  </div>
 </div>
 
-Contents
-=========
+# :anchor: 对于管理员来说 <small>〜如果你创建了一个Wiki〜</small>
+
+### :arrow_right: 你会和多个人一起使用Wiki吗?
+- :heavy_check_mark: 让我们邀请一些成员。
+    - [Add/invite new members to the Wiki](https://docs.growi.org/en/admin-guide/management-cookbook/user-management.html#temporary-issuance-of-a-new-user)
+### :arrow_right: 与Slack合作,接收页面和评论通知。
+- :heavy_check_mark:  [Slack integration](https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#overview)
+### :arrow_right: 你是否从另一个系统转换?
+- :heavy_check_mark: 可以从其他GROWI, esa.io, Qiita:Team导入数据。
+    -  [Import Data](https://docs.growi.org/en/admin-guide/management-cookbook/import.html)
+
+了解更多信息: [Admin Guide](https://docs.growi.org/en/admin-guide/)
+
+
+# 内容列表示例
+
+你可以用一个表格和`$lsx`来显示内容列表。
 
-|All Pages|[/Sandbox]|
-| --- | --- |
-| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Go to Sandbox](/Sandbox)</span></div> $lsx(/Sandbox)|
+| 所有页面列表(前15页)      | [/Sandbox] 下级页面列表 |
+| ---------------------------| ------------------------|
+| $lsx(/,num=15)             | $lsx(/Sandbox)          |
 
-Slack 
-=====
+# Slack
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
-让我们加入我们所有人的休闲渠道,帮助成长。
-除了讨论发展,我们在介绍时也接受提问。
+我们欢迎新人加入我们的slack频道,帮助改善Growi
+除了讨论发展问题,我们也很乐意在你加入时回答你的问题

+ 5 - 3
packages/app/src/client/admin.jsx

@@ -10,7 +10,7 @@ import ErrorBoundary from '../components/ErrorBoudary';
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
 import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import SlackIntegrationNotificationSetting from '../components/Admin/Notification/SlackIntegrationNotificationSetting';
+import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
 import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
 import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
 import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
@@ -46,6 +46,7 @@ import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityC
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 
 import { appContainer, componentMappings } from './base';
 
@@ -65,6 +66,7 @@ const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
 const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
+const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const injectableContainers = [
@@ -78,7 +80,7 @@ const injectableContainers = [
   adminUsersContainer,
   adminExternalAccountsContainer,
   adminNotificationContainer,
-  adminNotificationContainer,
+  adminSlackIntegrationLegacyContainer,
   adminMarkDownContainer,
   adminUserGroupDetailContainer,
 ];
@@ -99,7 +101,7 @@ Object.assign(componentMappings, {
   'admin-export-page': <ExportArchiveDataPage />,
   'admin-notification-setting': <NotificationSetting />,
   'admin-slack-integration': <SlackIntegration />,
-  'admin-slack-integration-notification-setting': <SlackIntegrationNotificationSetting />,
+  'admin-slack-integration-legacy': <LegacySlackIntegration />,
   'admin-global-notification-setting': <ManageGlobalNotification />,
   'admin-user-page': <UserManagement />,
   'admin-external-account-setting': <ManageExternalAccount />,

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

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

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

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

+ 9 - 37
packages/app/src/client/services/AdminNotificationContainer.js

@@ -10,15 +10,14 @@ export default class AdminNotificationContainer extends Container {
     super();
 
     this.appContainer = appContainer;
-    this.dummyWebhookUrl = 0;
-    this.dummyWebhookUrlForError = 1;
 
     this.state = {
       retrieveError: null,
-      selectSlackOption: 'Incoming Webhooks',
-      webhookUrl: this.dummyWebhookUrl,
-      isIncomingWebhookPrioritized: false,
-      slackToken: '',
+
+      isSlackbotConfigured: null,
+      isSlackLegacyConfigured: null,
+      currentBotType: null,
+
       userNotifications: [],
       isNotificationForOwnerPageEnabled: false,
       isNotificationForGroupPageEnabled: false,
@@ -42,9 +41,10 @@ export default class AdminNotificationContainer extends Container {
     const { notificationParams } = response.data;
 
     this.setState({
-      webhookUrl: notificationParams.webhookUrl,
-      isIncomingWebhookPrioritized: notificationParams.isIncomingWebhookPrioritized,
-      slackToken: notificationParams.slackToken,
+      isSlackbotConfigured: notificationParams.isSlackbotConfigured,
+      isSlackLegacyConfigured: notificationParams.isSlackLegacyConfigured,
+      currentBotType: notificationParams.currentBotType,
+
       userNotifications: notificationParams.userNotifications,
       isNotificationForOwnerPageEnabled: notificationParams.isNotificationForOwnerPageEnabled,
       isNotificationForGroupPageEnabled: notificationParams.isNotificationForGroupPageEnabled,
@@ -52,34 +52,6 @@ export default class AdminNotificationContainer extends Container {
     });
   }
 
-  /**
-   * Switch slackOption
-   */
-  switchSlackOption(slackOption) {
-    this.setState({ selectSlackOption: slackOption });
-  }
-
-  /**
-   * Change webhookUrl
-   */
-  changeWebhookUrl(webhookUrl) {
-    this.setState({ webhookUrl });
-  }
-
-  /**
-   * Switch incomingWebhookPrioritized
-   */
-  switchIsIncomingWebhookPrioritized() {
-    this.setState({ isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized });
-  }
-
-  /**
-   * Change slackToken
-   */
-  changeSlackToken(slackToken) {
-    this.setState({ slackToken });
-  }
-
   /**
    * Update slackAppConfiguration
    * @memberOf SlackAppConfiguration

+ 91 - 0
packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js

@@ -0,0 +1,91 @@
+import { Container } from 'unstated';
+
+/**
+ * Service container for admin LegacySlackIntegration setting page (LegacySlackIntegration.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminSlackIntegrationLegacyContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.dummyWebhookUrl = 0;
+    this.dummyWebhookUrlForError = 1;
+
+    this.state = {
+      isSlackbotConfigured: false,
+      retrieveError: null,
+      selectSlackOption: 'Incoming Webhooks',
+      webhookUrl: this.dummyWebhookUrl,
+      isIncomingWebhookPrioritized: false,
+      slackToken: '',
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminSlackIntegrationLegacyContainer';
+  }
+
+  /**
+   * Retrieve notificationData
+   */
+  async retrieveData() {
+    const response = await this.appContainer.apiv3.get('/slack-integration-legacy-settings/');
+    const { slackIntegrationParams } = response.data;
+
+    this.setState({
+      isSlackbotConfigured: slackIntegrationParams.isSlackbotConfigured,
+      webhookUrl: slackIntegrationParams.webhookUrl,
+      isIncomingWebhookPrioritized: slackIntegrationParams.isIncomingWebhookPrioritized,
+      slackToken: slackIntegrationParams.slackToken,
+    });
+  }
+
+  /**
+   * Switch slackOption
+   */
+  switchSlackOption(slackOption) {
+    this.setState({ selectSlackOption: slackOption });
+  }
+
+  /**
+   * Change webhookUrl
+   */
+  changeWebhookUrl(webhookUrl) {
+    this.setState({ webhookUrl });
+  }
+
+  /**
+   * Switch incomingWebhookPrioritized
+   */
+  switchIsIncomingWebhookPrioritized() {
+    this.setState({ isIncomingWebhookPrioritized: !this.state.isIncomingWebhookPrioritized });
+  }
+
+  /**
+   * Change slackToken
+   */
+  changeSlackToken(slackToken) {
+    this.setState({ slackToken });
+  }
+
+  /**
+   * Update slackAppConfiguration
+   * @memberOf SlackAppConfiguration
+   */
+  async updateSlackAppConfiguration() {
+    const response = await this.appContainer.apiv3.put('/slack-integration-legacy-settings/', {
+      webhookUrl: this.state.webhookUrl,
+      isIncomingWebhookPrioritized: this.state.isIncomingWebhookPrioritized,
+      slackToken: this.state.slackToken,
+    });
+
+    return response;
+  }
+
+}

+ 71 - 0
packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx

@@ -0,0 +1,71 @@
+import React, { useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastError } from '~/client/util/apiNotification';
+import { toArrayIfNot } from '~/utils/array-utils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
+
+import SlackConfiguration from './SlackConfiguration';
+
+const logger = loggerFactory('growi:NotificationSetting');
+
+let retrieveErrors = null;
+function LegacySlackIntegration(props) {
+  const { t } = useTranslation();
+  const { adminSlackIntegrationLegacyContainer } = props;
+
+  if (adminSlackIntegrationLegacyContainer.state.webhookUrl === adminSlackIntegrationLegacyContainer.dummyWebhookUrl) {
+    throw (async() => {
+      try {
+        await adminSlackIntegrationLegacyContainer.retrieveData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        logger.error(errs);
+        retrieveErrors = errs;
+        adminSlackIntegrationLegacyContainer.setState({ webhookUrl: adminSlackIntegrationLegacyContainer.dummyWebhookUrlForError });
+      }
+    })();
+  }
+
+  if (adminSlackIntegrationLegacyContainer.state.webhookUrl === adminSlackIntegrationLegacyContainer.dummyWebhookUrlForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
+  }
+
+  const isDisabled = adminSlackIntegrationLegacyContainer.state.isSlackbotConfigured;
+
+  return (
+    <>
+      { isDisabled && (
+        <div className="alert alert-danger">
+          <i className="icon-minus icon-fw"></i>
+          {/* eslint-disable-next-line react/no-danger */}
+          <span dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_disabled') }}></span>
+        </div>
+      ) }
+
+      <div className="alert alert-warning">
+        <i className="icon-info icon-fw"></i>
+        {/* eslint-disable-next-line react/no-danger */}
+        <span dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_deplicated') }}></span>
+      </div>
+
+      <SlackConfiguration />
+    </>
+  );
+}
+
+const LegacySlackIntegrationWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(LegacySlackIntegration), [AdminSlackIntegrationLegacyContainer]);
+
+LegacySlackIntegration.propTypes = {
+  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
+};
+
+export default LegacySlackIntegrationWithUnstatedContainer;

+ 21 - 21
packages/app/src/components/Admin/Notification/SlackAppConfiguration.jsx → packages/app/src/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx

@@ -8,12 +8,12 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:slackAppConfiguration');
 
-class SlackAppConfiguration extends React.Component {
+class SlackConfiguration extends React.Component {
 
   constructor(props) {
     super(props);
@@ -22,10 +22,10 @@ class SlackAppConfiguration extends React.Component {
   }
 
   async onClickSubmit() {
-    const { t, adminNotificationContainer } = this.props;
+    const { t, adminSlackIntegrationLegacyContainer } = this.props;
 
     try {
-      await adminNotificationContainer.updateSlackAppConfiguration();
+      await adminSlackIntegrationLegacyContainer.updateSlackAppConfiguration();
       toastSuccess(t('notification_setting.updated_slackApp'));
     }
     catch (err) {
@@ -35,7 +35,7 @@ class SlackAppConfiguration extends React.Component {
   }
 
   render() {
-    const { t, adminNotificationContainer } = this.props;
+    const { t, adminSlackIntegrationLegacyContainer } = this.props;
 
     return (
       <React.Fragment>
@@ -50,18 +50,18 @@ class SlackAppConfiguration extends React.Component {
                 aria-haspopup="true"
                 aria-expanded="true"
               >
-                {`Slack ${adminNotificationContainer.state.selectSlackOption}`}
+                {`Slack ${adminSlackIntegrationLegacyContainer.state.selectSlackOption}`}
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => adminNotificationContainer.switchSlackOption('Incoming Webhooks')}>
+                <button className="dropdown-item" type="button" onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('Incoming Webhooks')}>
                   Slack Incoming Webhooks
                 </button>
-                <button className="dropdown-item" type="button" onClick={() => adminNotificationContainer.switchSlackOption('App')}>Slack App</button>
+                <button className="dropdown-item" type="button" onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('App')}>Slack App</button>
               </div>
             </div>
           </div>
         </div>
-        {adminNotificationContainer.state.selectSlackOption === 'Incoming Webhooks' ? (
+        {adminSlackIntegrationLegacyContainer.state.selectSlackOption === 'Incoming Webhooks' ? (
           <React.Fragment>
             <h2 className="border-bottom mb-5">{t('notification_setting.slack_incoming_configuration')}</h2>
 
@@ -71,8 +71,8 @@ class SlackAppConfiguration extends React.Component {
                 <input
                   className="form-control"
                   type="text"
-                  defaultValue={adminNotificationContainer.state.webhookUrl || ''}
-                  onChange={e => adminNotificationContainer.changeWebhookUrl(e.target.value)}
+                  defaultValue={adminSlackIntegrationLegacyContainer.state.webhookUrl || ''}
+                  onChange={e => adminSlackIntegrationLegacyContainer.changeWebhookUrl(e.target.value)}
                 />
               </div>
             </div>
@@ -84,8 +84,8 @@ class SlackAppConfiguration extends React.Component {
                     type="checkbox"
                     className="custom-control-input"
                     id="cbPrioritizeIWH"
-                    checked={adminNotificationContainer.state.isIncomingWebhookPrioritized || false}
-                    onChange={() => { adminNotificationContainer.switchIsIncomingWebhookPrioritized() }}
+                    checked={adminSlackIntegrationLegacyContainer.state.isIncomingWebhookPrioritized || false}
+                    onChange={() => { adminSlackIntegrationLegacyContainer.switchIsIncomingWebhookPrioritized() }}
                   />
                   <label className="custom-control-label" htmlFor="cbPrioritizeIWH">
                     {t('notification_setting.prioritize_webhook')}
@@ -111,7 +111,7 @@ class SlackAppConfiguration extends React.Component {
                 <a
                   href="#slack-incoming-webhooks"
                   data-toggle="tab"
-                  onClick={() => adminNotificationContainer.switchSlackOption('Incoming Webhooks')}
+                  onClick={() => adminSlackIntegrationLegacyContainer.switchSlackOption('Incoming Webhooks')}
                 >
                   {t('notification_setting.use_instead')}
                 </a>
@@ -123,8 +123,8 @@ class SlackAppConfiguration extends React.Component {
                   <input
                     className="form-control"
                     type="text"
-                    defaultValue={adminNotificationContainer.state.slackToken || ''}
-                    onChange={e => adminNotificationContainer.changeSlackToken(e.target.value)}
+                    defaultValue={adminSlackIntegrationLegacyContainer.state.slackToken || ''}
+                    onChange={e => adminSlackIntegrationLegacyContainer.changeSlackToken(e.target.value)}
                   />
                 </div>
               </div>
@@ -135,7 +135,7 @@ class SlackAppConfiguration extends React.Component {
 
         <AdminUpdateButtonRow
           onClick={this.onClickSubmit}
-          disabled={adminNotificationContainer.state.retrieveError != null}
+          disabled={adminSlackIntegrationLegacyContainer.state.retrieveError != null}
         />
 
         <hr />
@@ -170,13 +170,13 @@ class SlackAppConfiguration extends React.Component {
 
 }
 
-const SlackAppConfigurationWrapper = withUnstatedContainers(SlackAppConfiguration, [AppContainer, AdminNotificationContainer]);
+const SlackConfigurationWrapper = withUnstatedContainers(SlackConfiguration, [AppContainer, AdminSlackIntegrationLegacyContainer]);
 
-SlackAppConfiguration.propTypes = {
+SlackConfiguration.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
+  adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
 
 };
 
-export default withTranslation()(SlackAppConfigurationWrapper);
+export default withTranslation()(SlackConfigurationWrapper);

+ 114 - 20
packages/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -1,7 +1,15 @@
-import React, { useMemo, useState } from 'react';
+import React, {
+  useCallback, useEffect, useMemo, useState,
+} from 'react';
 import PropTypes from 'prop-types';
 
-import { TabContent, TabPane } from 'reactstrap';
+import {
+  TabContent, TabPane,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -19,9 +27,76 @@ import GlobalNotification from './GlobalNotification';
 const logger = loggerFactory('growi:NotificationSetting');
 
 let retrieveErrors = null;
+
+
+// eslint-disable-next-line react/prop-types
+const Badge = ({ isEnabled }) => {
+  const { t } = useTranslation();
+
+  return isEnabled
+    ? <span className="badge badge-success">{t('admin:external_notification.enabled')}</span>
+    : <span className="badge badge-secondary">{t('admin:external_notification.disabled')}</span>;
+};
+
+const SkeltonListItem = () => (
+  <li className="list-group-item">
+    <h4 className="mb-2">
+      <span className="badge badge-secondary">――</span>
+      <span className="ml-2">...</span>
+    </h4>
+  </li>
+);
+
+// eslint-disable-next-line react/prop-types
+const SlackIntegrationListItem = ({ isEnabled, currentBotType }) => {
+  const { t } = useTranslation();
+
+  const isCautionVisible = currentBotType === SlackbotType.OFFICIAL || currentBotType === SlackbotType.CUSTOM_WITH_PROXY;
+
+  return (
+    <li className="list-group-item">
+      <h4>
+        <Badge isEnabled={isEnabled} />
+        <a href="/admin/slack-integration" className="ml-2">{t('slack_integration')}</a>
+      </h4>
+      { isCautionVisible && (
+        <ul className="mt-2 pl-4">
+          {/* eslint-disable-next-line react/no-danger */}
+          <li dangerouslySetInnerHTML={{ __html: t('admin:external_notification.caution_enabled') }} />
+        </ul>
+      ) }
+    </li>
+  );
+};
+
+// eslint-disable-next-line react/prop-types
+const LegacySlackIntegrationListItem = ({ isEnabled }) => {
+  const { t } = useTranslation();
+
+  return (
+    <li className="list-group-item">
+      <h4>
+        <Badge isEnabled={isEnabled} />
+        <a href="/admin/slack-integration-legacy" className="ml-2">{t('legacy_slack_integration')}</a>
+      </h4>
+      { isEnabled && (
+        <ul className="mt-2 pl-4">
+          <li>
+            {/* eslint-disable-next-line react/no-danger */}
+            <span className="text-danger" dangerouslySetInnerHTML={{ __html: t('admin:slack_integration_legacy.alert_deplicated') }}></span>
+          </li>
+        </ul>
+      ) }
+    </li>
+  );
+};
+
 function NotificationSetting(props) {
   const { adminNotificationContainer } = props;
 
+  const { t } = useTranslation();
+
+  const [isMounted, setMounted] = useState(false);
   const [activeTab, setActiveTab] = useState('user_trigger_notification');
   const [activeComponents, setActiveComponents] = useState(new Set(['user_trigger_notification']));
 
@@ -30,24 +105,24 @@ function NotificationSetting(props) {
     setActiveComponents(activeComponents.add(selectedTab));
   };
 
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrl) {
-    throw (async() => {
-      try {
-        await adminNotificationContainer.retrieveNotificationData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        logger.error(errs);
-        retrieveErrors = errs;
-        adminNotificationContainer.setState({ webhookUrl: adminNotificationContainer.dummyWebhookUrlForError });
-      }
-    })();
-  }
-
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrlForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+  const fetchData = useCallback(async() => {
+    try {
+      await adminNotificationContainer.retrieveNotificationData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+      logger.error(errs);
+      retrieveErrors = errs;
+    }
+    finally {
+      setMounted(true);
+    }
+  }, [adminNotificationContainer]);
+
+  useEffect(() => {
+    fetchData();
+  }, [fetchData]);
 
   const navTabMapping = useMemo(() => {
     return {
@@ -64,8 +139,27 @@ function NotificationSetting(props) {
     };
   }, []);
 
+  const { isSlackbotConfigured, isSlackLegacyConfigured, currentBotType } = adminNotificationContainer.state;
+  const isSlackEnabled = isSlackbotConfigured;
+  const isSlackLegacyEnabled = !isSlackbotConfigured && isSlackLegacyConfigured;
+
   return (
     <>
+      <h2 className="admin-setting-header">{t('admin:external_notification.header_status')}</h2>
+      <ul className="list-group">
+        { !isMounted && <SkeltonListItem />}
+        { isMounted && (
+          <>
+            <SlackIntegrationListItem isEnabled={isSlackEnabled} currentBotType={currentBotType} />
+            {/* Legacy Slack Integration become visible only when new Slack Integration is disabled */}
+            { !isSlackEnabled && <LegacySlackIntegrationListItem isEnabled={isSlackLegacyEnabled} /> }
+          </>
+        ) }
+      </ul>
+
+
+      <h2 className="admin-setting-header mt-5">{t('Notification Settings')}</h2>
+
       <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
 
       <TabContent activeTab={activeTab} className="p-5">

+ 0 - 80
packages/app/src/components/Admin/Notification/SlackIntegrationNotificationSetting.jsx

@@ -1,80 +0,0 @@
-import React, { useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-
-import { TabContent, TabPane } from 'reactstrap';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
-import { toArrayIfNot } from '~/utils/array-utils';
-import { withLoadingSppiner } from '../../SuspenseUtils';
-
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-
-import { CustomNavTab } from '../../CustomNavigation/CustomNav';
-
-import SlackAppConfiguration from './SlackAppConfiguration';
-
-const logger = loggerFactory('growi:NotificationSetting');
-
-let retrieveErrors = null;
-function NotificationSetting(props) {
-  const { adminNotificationContainer } = props;
-
-  const [activeTab, setActiveTab] = useState('slack_configuration');
-  const [activeComponents, setActiveComponents] = useState(new Set(['slack_configuration']));
-
-  const switchActiveTab = (selectedTab) => {
-    setActiveTab(selectedTab);
-    setActiveComponents(activeComponents.add(selectedTab));
-  };
-
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrl) {
-    throw (async() => {
-      try {
-        await adminNotificationContainer.retrieveNotificationData();
-      }
-      catch (err) {
-        const errs = toArrayIfNot(err);
-        toastError(errs);
-        logger.error(errs);
-        retrieveErrors = errs;
-        adminNotificationContainer.setState({ webhookUrl: adminNotificationContainer.dummyWebhookUrlForError });
-      }
-    })();
-  }
-
-  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrlForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
-
-  const navTabMapping = useMemo(() => {
-    return {
-      slack_configuration: {
-        Icon: () => <i className="icon-settings" />,
-        i18n: 'Slack configuration',
-        index: 0,
-      },
-    };
-  }, []);
-
-  return (
-    <>
-      <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={switchActiveTab} hideBorderBottom />
-
-      <TabContent activeTab={activeTab} className="p-5">
-        <TabPane tabId="slack_configuration">
-          {activeComponents.has('slack_configuration') && <SlackAppConfiguration />}
-        </TabPane>
-      </TabContent>
-    </>
-  );
-}
-
-const NotificationSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(NotificationSetting), [AdminNotificationContainer]);
-
-NotificationSetting.propTypes = {
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-};
-
-export default NotificationSettingWithUnstatedContainer;

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

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

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

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

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

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

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

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

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

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

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

@@ -3,6 +3,9 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+import { SlackbotType } from '@growi/slack';
+
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -384,7 +387,7 @@ const WithProxyAccordions = (props) => {
     },
   };
 
-  const integrationProcedureMapping = props.botType === 'officialBot' ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
+  const integrationProcedureMapping = props.botType === SlackbotType.OFFICIAL ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
 
   return (
     <div
@@ -417,7 +420,7 @@ const WithProxyAccordions = (props) => {
 const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
 WithProxyAccordions.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  botType: PropTypes.string.isRequired,
+  botType: PropTypes.oneOf(Object.values(SlackbotType)).isRequired,
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,

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

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

+ 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', 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,
       position: 'right',
       height: isScrollEnabled ? viewHeight : contentsHeight,
+      wheelStep: 10,
+      allowPageScroll: true,
     });
 
     // destroy

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

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

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

@@ -0,0 +1,24 @@
+const createError = require('http-errors');
+
+module.exports = (crowi, app) => {
+  const PasswordResetOrder = crowi.model('PasswordResetOrder');
+
+  return async(req, res, next) => {
+    const token = req.params.token || req.body.token;
+
+    if (token == null) {
+      req.error = { key: 'token-not-found', message: 'Token not found' };
+    }
+
+    const passwordResetOrder = await PasswordResetOrder.findOne({ token });
+
+    // check if the token is valid
+    if (passwordResetOrder == null || passwordResetOrder.isExpired() || passwordResetOrder.isRevoked) {
+      req.error = { key: 'password-reset-order-is-not-appropriate', message: 'passwordResetOrder is null or expired or revoked' };
+    }
+
+    req.passwordResetOrder = passwordResetOrder;
+
+    return next();
+  };
+};

+ 1 - 0
packages/app/src/server/models/index.js

@@ -17,4 +17,5 @@ module.exports = {
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   ShareLink: require('./share-link'),
   SlackAppIntegration: require('./slack-app-integration'),
+  PasswordResetOrder: require('./password-reset-order'),
 };

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

@@ -831,6 +831,11 @@ module.exports = function(crowi) {
    */
   pageSchema.statics.addConditionToFilteringByViewerForList = addConditionToFilteringByViewerForList;
 
+  /**
+   * export addConditionToFilteringByViewerToEdit as static method
+   */
+  pageSchema.statics.addConditionToFilteringByViewerToEdit = addConditionToFilteringByViewerToEdit;
+
   /**
    * Throw error for growi-lsx-plugin (v1.x)
    */

+ 57 - 0
packages/app/src/server/models/password-reset-order.js

@@ -0,0 +1,57 @@
+const mongoose = require('mongoose');
+const uniqueValidator = require('mongoose-unique-validator');
+const crypto = require('crypto');
+
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+const schema = new mongoose.Schema({
+  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);
+
+class PasswordResetOrder {
+
+  static generateOneTimeToken() {
+    const buf = crypto.randomBytes(256);
+    const token = buf.toString('hex');
+
+    return token;
+  }
+
+  static async createPasswordResetOrder(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;
+  }
+
+  isExpired() {
+    return this.expiredAt.getTime() < Date.now();
+  }
+
+  async revokeOneTimeToken() {
+    this.isRevoked = true;
+    return this.save();
+  }
+
+}
+
+module.exports = function(crowi) {
+  PasswordResetOrder.crowi = crowi;
+  schema.loadClass(PasswordResetOrder);
+  const model = mongoose.model('PasswordResetOrder', schema);
+  return model;
+};

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

@@ -11,12 +11,14 @@ const schema = new mongoose.Schema({
 
 class SlackAppIntegration {
 
-  static generateAccessTokens() {
+  crowi;
+
+  static generateAccessTokens(saltForGtoP, saltForPtoG) {
     const now = new Date().getTime();
     const hasher1 = crypto.createHash('sha512');
     const hasher2 = crypto.createHash('sha512');
-    const tokenGtoP = hasher1.update(`gtop${now.toString()}${process.env.SALT_FOR_GTOP_TOKEN}`).digest('base64');
-    const tokenPtoG = hasher2.update(`ptog${now.toString()}${process.env.SALT_FOR_PTOG_TOKEN}`).digest('base64');
+    const tokenGtoP = hasher1.update(`gtop-${saltForGtoP}-${now.toString()}`).digest('base64');
+    const tokenPtoG = hasher2.update(`ptog-${saltForPtoG}-${now.toString()}`).digest('base64');
     return [tokenGtoP, tokenPtoG];
   }
 
@@ -26,8 +28,12 @@ class SlackAppIntegration {
     let tokenPtoG;
     let generateTokens;
 
+    // get salt strings
+    const saltForGtoP = this.crowi.configManager.getConfig('crowi', 'slackbot:withProxy:saltForGtoP');
+    const saltForPtoG = this.crowi.configManager.getConfig('crowi', 'slackbot:withProxy:saltForPtoG');
+
     do {
-      generateTokens = this.generateAccessTokens();
+      generateTokens = this.generateAccessTokens(saltForGtoP, saltForPtoG);
       tokenGtoP = generateTokens[0];
       tokenPtoG = generateTokens[1];
       // eslint-disable-next-line no-await-in-loop
@@ -41,7 +47,9 @@ class SlackAppIntegration {
 }
 
 module.exports = function(crowi) {
+
   SlackAppIntegration.crowi = crowi;
+
   schema.loadClass(SlackAppIntegration);
   return mongoose.model('SlackAppIntegration', schema);
 };

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

@@ -0,0 +1,115 @@
+import rateLimit from 'express-rate-limit';
+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 PasswordResetOrder = crowi.model('PasswordResetOrder');
+  const User = crowi.model('User');
+  const path = require('path');
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+  const injectResetOrderByTokenMiddleware = require('../../middlewares/inject-reset-order-by-token-middleware')(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);
+    }
+  });
+
+  router.put('/', apiLimiter, csrf, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, async(req, res) => {
+
+    if (req.error != null) {
+      return res.apiv3Err(req.error.message);
+    }
+
+    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');
+    }
+  });
+
+  return router;
+};

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

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

+ 9 - 70
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -1,6 +1,8 @@
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
+import UpdatePost from '../../models/update-post';
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 
@@ -13,11 +15,6 @@ const { body } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const validator = {
-  slackConfiguration: [
-    body('webhookUrl').if(value => value != null).isString().trim(),
-    body('isIncomingWebhookPrioritized').isBoolean(),
-    body('slackToken').if(value => value != null).isString().trim(),
-  ],
   userNotification: [
     body('pathPattern').isString().trim(),
     body('channel').isString().trim(),
@@ -50,18 +47,6 @@ const validator = {
  *
  *  components:
  *    schemas:
- *      SlackConfigurationParams:
- *        type: object
- *        properties:
- *          webhookUrl:
- *            type: string
- *            description: incoming webhooks url
- *          isIncomingWebhookPrioritized:
- *            type: boolean
- *            description: use incoming webhooks even if Slack App settings are enabled
- *          slackToken:
- *            type: string
- *            description: OAuth access token
  *      UserNotificationParams:
  *        type: object
  *        properties:
@@ -107,7 +92,6 @@ module.exports = (crowi) => {
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
-  const UpdatePost = crowi.model('UpdatePost');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
   const GlobalNotificationMailSetting = crowi.models.GlobalNotificationMailSetting;
@@ -134,9 +118,11 @@ module.exports = (crowi) => {
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
     const notificationParams = {
-      webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
-      isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
-      slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
+      // status of slack intagration
+      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
+      isSlackLegacyConfigured: crowi.slackIntegrationService.isSlackLegacyConfigured,
+      currentBotType: await crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
+
       userNotifications: await UpdatePost.findAll(),
       isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification', 'notification:owner-page:isEnabled'),
       isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification', 'notification:group-page:isEnabled'),
@@ -145,52 +131,6 @@ module.exports = (crowi) => {
     return res.apiv3({ notificationParams });
   });
 
-  /**
-   * @swagger
-   *
-   *    /notification-setting/slack-configuration:
-   *      put:
-   *        tags: [NotificationSetting]
-   *        description: Update slack configuration setting
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/SlackConfigurationParams'
-   *        responses:
-   *          200:
-   *            description: Succeeded to update slack configuration setting
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/SlackConfigurationParams'
-   */
-  router.put('/slack-configuration', loginRequiredStrictly, adminRequired, csrf, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
-
-    const requestParams = {
-      'slack:incomingWebhookUrl': req.body.webhookUrl,
-      'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
-      'slack:token': req.body.slackToken,
-    };
-
-    try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('notification', requestParams);
-      const responseParams = {
-        webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
-        isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
-        slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
-      };
-      return res.apiv3({ responseParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating slack configuration';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
-    }
-
-  });
-
   /**
   * @swagger
   *
@@ -220,12 +160,11 @@ module.exports = (crowi) => {
   */
   router.post('/user-notification', loginRequiredStrictly, adminRequired, csrf, validator.userNotification, apiV3FormValidator, async(req, res) => {
     const { pathPattern, channel } = req.body;
-    const UpdatePost = crowi.model('UpdatePost');
 
     try {
       logger.info('notification.add', pathPattern, channel);
       const responseParams = {
-        createdUser: await UpdatePost.create(pathPattern, channel, req.user),
+        createdUser: await UpdatePost.createUpdatePost(pathPattern, channel, req.user),
         userNotifications: await UpdatePost.findAll(),
       };
       return res.apiv3({ responseParams }, 201);
@@ -267,7 +206,7 @@ module.exports = (crowi) => {
     const { id } = req.params;
 
     try {
-      const deletedNotificaton = await UpdatePost.remove(id);
+      const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
       return res.apiv3(deletedNotificaton);
     }
     catch (err) {

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

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

+ 128 - 0
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -0,0 +1,128 @@
+import loggerFactory from '~/utils/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator');
+
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const validator = {
+  slackConfiguration: [
+    body('webhookUrl').if(value => value != null).isString().trim(),
+    body('isIncomingWebhookPrioritized').isBoolean(),
+    body('slackToken').if(value => value != null).isString().trim(),
+  ],
+};
+
+/**
+ * @swagger
+ *  tags:
+ *    name: SlackIntegrationLegacySetting
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      SlackConfigurationParams:
+ *        type: object
+ *        properties:
+ *          webhookUrl:
+ *            type: string
+ *            description: incoming webhooks url
+ *          isIncomingWebhookPrioritized:
+ *            type: boolean
+ *            description: use incoming webhooks even if Slack App settings are enabled
+ *          slackToken:
+ *            type: string
+ *            description: OAuth access token
+ */
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-legacy-setting/:
+   *      get:
+   *        tags: [SlackIntegrationLegacySetting]
+   *        description: Get slack configuration setting
+   *        responses:
+   *          200:
+   *            description: params of slack configuration setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    notificationParams:
+   *                      type: object
+   *                      description: slack configuration setting params
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    const slackIntegrationParams = {
+      isSlackbotConfigured: crowi.slackIntegrationService.isSlackbotConfigured,
+      webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
+      isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
+      slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
+    };
+    return res.apiv3({ slackIntegrationParams });
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-legacy-setting/:
+   *      put:
+   *        tags: [SlackIntegrationLegacySetting]
+   *        description: Update slack configuration setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/SlackConfigurationParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update slack configuration setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/SlackConfigurationParams'
+   */
+  router.put('/', loginRequiredStrictly, adminRequired, csrf, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
+
+    const requestParams = {
+      'slack:incomingWebhookUrl': req.body.webhookUrl,
+      'slack:isIncomingWebhookPrioritized': req.body.isIncomingWebhookPrioritized,
+      'slack:token': req.body.slackToken,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('notification', requestParams);
+      const responseParams = {
+        webhookUrl: await crowi.configManager.getConfig('notification', 'slack:incomingWebhookUrl'),
+        isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
+        slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
+      };
+      return res.apiv3({ responseParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating slack configuration';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-slackConfiguration-failed'));
+    }
+
+  });
+
+  return router;
+};

+ 30 - 27
packages/app/src/server/routes/apiv3/slack-integration-settings.js

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

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

@@ -94,7 +94,7 @@ module.exports = (crowi) => {
   }
 
   const addSigningSecretToReq = (req, res, next) => {
-    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:signingSecret');
+    req.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
     return next();
   };
 
@@ -111,7 +111,11 @@ module.exports = (crowi) => {
 
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Processing your request ...',
+    });
+
 
     const args = body.text.split(' ');
     const command = args[0];

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

@@ -0,0 +1,21 @@
+module.exports = function(crowi, app) {
+  const actions = {};
+  const api = {};
+  actions.api = api;
+
+  actions.forgotPassword = async function(req, res) {
+    return res.render('forgot-password');
+  };
+
+  actions.resetPassword = async function(req, res) {
+    const { error, passwordResetOrder } = req;
+
+    if (error != null) {
+      return res.render('forgot-password/error', { key: error.key });
+    }
+
+    return res.render('reset-password', { email: passwordResetOrder.email });
+  };
+
+  return actions;
+};

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

@@ -1,5 +1,13 @@
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
+const rateLimit = require('express-rate-limit');
+
+const apiLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 5, // limit each IP to 5 requests per windowMs
+  message:
+    'Too many requests sent from this IP, please try again after 15 minutes',
+});
 
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 
@@ -13,6 +21,7 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
+  const injectResetOrderByTokenMiddleware = require('../middlewares/inject-reset-order-by-token-middleware')(crowi);
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
@@ -28,6 +37,7 @@ module.exports = function(crowi, app) {
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
+  const forgotPassword = require('./forgot-password')(crowi, app);
 
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
@@ -175,6 +185,9 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
+  app.get('/forgot-password', forgotPassword.forgotPassword);
+  app.get('/forgot-password/:token'      ,apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword);
+
   app.get('/share/:linkId', page.showSharedPage);
 
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);

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

@@ -1,6 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+import UpdatePost from '../models/update-post';
+
 const { isCreatablePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
@@ -1118,7 +1120,6 @@ module.exports = function(crowi, app) {
    */
   api.getUpdatePost = function(req, res) {
     const path = req.query.path;
-    const UpdatePost = crowi.model('UpdatePost');
 
     if (!path) {
       return res.json(ApiResponse.error({}));

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

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

+ 37 - 52
packages/app/src/server/service/page.js

@@ -3,6 +3,7 @@ import loggerFactory from '~/utils/logger';
 
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
+const streamToPromise = require('stream-to-promise');
 
 const logger = loggerFactory('growi:models:page');
 const debug = require('debug')('growi:models:page');
@@ -49,6 +50,26 @@ class PageService {
     return this.prepareShoudDeletePagesByRedirectTo(pagePath, redirectToPagePathMapping, pagePaths);
   }
 
+  /**
+   * Generate read stream to operate descendants of the specified page path
+   * @param {string} targetPagePath
+   * @param {User} viewer
+   */
+  async generateReadStreamToOperateOnlyDescendants(targetPagePath, userToOperate) {
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.find())
+      .addConditionToExcludeRedirect()
+      .addConditionToListOnlyDescendants(targetPagePath);
+
+    await Page.addConditionToFilteringByViewerToEdit(builder, userToOperate);
+
+    return builder
+      .query
+      .lean()
+      .cursor({ batchSize: BULK_REINDEX_SIZE });
+  }
 
   async renamePage(page, newPagePath, user, options, isRecursively = false) {
 
@@ -62,6 +83,11 @@ class PageService {
     // sanitize path
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
+    // create descendants first
+    if (isRecursively) {
+      await this.renameDescendantsWithStream(page, newPagePath, user, options);
+    }
+
     const update = {};
     // update Page
     update.path = newPagePath;
@@ -79,10 +105,6 @@ class PageService {
       await Page.create(path, body, user, { redirectTo: newPagePath });
     }
 
-    if (isRecursively) {
-      this.renameDescendantsWithStream(page, newPagePath, user, options);
-    }
-
     this.pageEvent.emit('delete', page, user, socketClientId);
     this.pageEvent.emit('create', renamedPage, user, socketClientId);
 
@@ -147,19 +169,12 @@ class PageService {
    * Create rename stream
    */
   async renameDescendantsWithStream(targetPage, newPagePath, user, options = {}) {
-    const Page = this.crowi.model('Page');
+
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
+
     const newPagePathPrefix = newPagePath;
-    const { PageQueryBuilder } = Page;
     const pathRegExp = new RegExp(`^${escapeStringRegexp(targetPage.path)}`, 'i');
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
-
     const renameDescendants = this.renameDescendants.bind(this);
     const pageEvent = this.pageEvent;
     let count = 0;
@@ -189,6 +204,8 @@ class PageService {
     readStream
       .pipe(createBatchStream(BULK_REINDEX_SIZE))
       .pipe(writeStream);
+
+    await streamToPromise(readStream);
   }
 
 
@@ -348,19 +365,11 @@ class PageService {
   }
 
   async duplicateDescendantsWithStream(page, newPagePath, user) {
-    const Page = this.crowi.model('Page');
-    const newPagePathPrefix = newPagePath;
-    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
 
-    const { PageQueryBuilder } = Page;
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(page.path, user);
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(page.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
 
     const duplicateDescendants = this.duplicateDescendants.bind(this);
     const pageEvent = this.pageEvent;
@@ -486,16 +495,8 @@ class PageService {
    * Create delete stream
    */
   async deleteDescendantsWithStream(targetPage, user, options = {}) {
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
     const deleteDescendants = this.deleteDescendants.bind(this);
     let count = 0;
@@ -562,16 +563,8 @@ class PageService {
    * Create delete completely stream
    */
   async deleteCompletelyDescendantsWithStream(targetPage, user, options = {}) {
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
     const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
     let count = 0;
@@ -688,16 +681,8 @@ class PageService {
    * Create revert stream
    */
   async revertDeletedDescendantsWithStream(targetPage, user, options = {}) {
-    const Page = this.crowi.model('Page');
-    const { PageQueryBuilder } = Page;
 
-    const readStream = new PageQueryBuilder(Page.find())
-      .addConditionToExcludeRedirect()
-      .addConditionToListOnlyDescendants(targetPage.path)
-      .addConditionToFilteringByViewer(user)
-      .query
-      .lean()
-      .cursor({ batchSize: BULK_REINDEX_SIZE });
+    const readStream = await this.generateReadStreamToOperateOnlyDescendants(targetPage.path, user);
 
     const revertDeletedDescendants = this.revertDeletedDescendants.bind(this);
     let count = 0;

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

@@ -2,10 +2,11 @@ import mongoose from 'mongoose';
 
 import { IncomingWebhookSendArguments } from '@slack/webhook';
 import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
-import { generateWebClient, markdownSectionBlock } from '@growi/slack';
 
+import { generateWebClient, markdownSectionBlock, SlackbotType } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
+
 import S2sMessage from '../models/vo/s2s-message';
 
 import ConfigManager from './config-manager';
@@ -99,22 +100,22 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   private isCheckTypeValid(): boolean {
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType == null) {
-      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
+      throw new Error('The config \'SLACKBOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
     }
 
     return true;
   }
 
   /**
-   * generate WebClient instance for 'customBotWithoutProxy' type
+   * generate WebClient instance for CUSTOM_WITHOUT_PROXY type
    */
   async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
     this.isCheckTypeValid();
 
-    const token = this.configManager.getConfig('crowi', 'slackbot:token');
+    const token = this.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
 
     if (token == null) {
-      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:token\') must be set.');
+      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:withoutProxy:botToken\') must be set.');
     }
 
     return generateWebClient(token);
@@ -147,7 +148,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
 
     const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
 
-    if (currentBotType === 'customBotWithoutProxy') {
+    if (currentBotType === SlackbotType.CUSTOM_WITHOUT_PROXY) {
       return this.generateClientForCustomBotWithoutProxy();
     }
 
@@ -170,7 +171,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
     this.isCheckTypeValid();
 
     // connect to proxy
-    const proxyServerUri = this.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const proxyServerUri = this.configManager.getConfig('crowi', 'slackbot:proxyUri');
     const serverUri = new URL('/g2s', proxyServerUri);
     const headers = {
       'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,

+ 1 - 1
packages/app/src/server/views/admin/slack-integration-legacy.html

@@ -7,6 +7,6 @@
 {% endblock %}
 
 {% block content_main %}
-<div id="admin-slack-integration-notification-setting" class="admin-slack-integration-notification-setting"></div>
+<div id="admin-slack-integration-legacy" class="admin-slack-integration-legacy"></div>
 {% endblock content_main %}
 

+ 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 isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
+      {% set isPasswordResetEnabled = getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') %}
 
       <div
         id="login-form"
@@ -119,6 +120,7 @@
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
+        data-is-password-reset-enabled = "{{ isPasswordResetEnabled }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         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">
           <h2>{{ t('login.Registration successful') }}</h2>
         </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 %}
         <div class="alert alert-warning">
             <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 %}

+ 98 - 1
packages/app/src/test/service/page.test.js

@@ -14,6 +14,14 @@ let parentForRename1;
 let parentForRename2;
 let parentForRename3;
 let parentForRename4;
+let parentForRename5;
+let parentForRename6;
+let parentForRename7;
+let parentForRename8;
+let parentForRename9;
+
+let irrelevantPage1;
+let irrelevantPage2;
 
 let childForRename1;
 let childForRename2;
@@ -94,6 +102,48 @@ describe('PageService', () => {
         creator: testUser1,
         lastUpdateUser: testUser1,
       },
+      {
+        path: '/parentForRename5',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename6',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1/level2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1/level2/child',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1/level2/level2',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/parentForRename6-2021H1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
+      {
+        path: '/level1-2021H1',
+        grant: Page.GRANT_PUBLIC,
+        creator: testUser1,
+        lastUpdateUser: testUser1,
+      },
       {
         path: '/parentForRename1/child',
         grant: Page.GRANT_PUBLIC,
@@ -183,6 +233,14 @@ describe('PageService', () => {
     parentForRename2 = await Page.findOne({ path: '/parentForRename2' });
     parentForRename3 = await Page.findOne({ path: '/parentForRename3' });
     parentForRename4 = await Page.findOne({ path: '/parentForRename4' });
+    parentForRename5 = await Page.findOne({ path: '/parentForRename5' });
+    parentForRename6 = await Page.findOne({ path: '/parentForRename6' });
+    parentForRename7 = await Page.findOne({ path: '/level1/level2' });
+    parentForRename8 = await Page.findOne({ path: '/level1/level2/child' });
+    parentForRename9 = await Page.findOne({ path: '/level1/level2/level2' });
+
+    irrelevantPage1 = await Page.findOne({ path: '/parentForRename6-2021H1' });
+    irrelevantPage2 = await Page.findOne({ path: '/level1-2021H1' });
 
     parentForDuplicate = await Page.findOne({ path: '/parentForDuplicate' });
 
@@ -232,6 +290,36 @@ describe('PageService', () => {
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });
 
+  describe('rename page without using renameDescendantsWithStreamSpy', () => {
+    test('rename page with different tree with isRecursively [deeper]', async() => {
+      const resultPage = await crowi.pageService.renamePage(parentForRename6, '/parentForRename6/renamedChild', testUser1, {}, true);
+      const wrongPage = await Page.findOne({ path: '/parentForRename6/renamedChild/renamedChild' });
+      const expectPage1 = await Page.findOne({ path: '/parentForRename6/renamedChild' });
+      const expectPage2 = await Page.findOne({ path: '/parentForRename6-2021H1' });
+
+      expect(resultPage.path).toEqual(expectPage1.path);
+      expect(expectPage2.path).not.toBeNull();
+
+      // Check that pages that are not to be renamed have not been renamed
+      expect(wrongPage).toBeNull();
+    });
+
+    test('rename page with different tree with isRecursively [shallower]', async() => {
+      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();
+
+      // Check that pages that are not to be renamed have not been renamed
+      expect(expectPage4).not.toBeNull();
+    });
+  });
+
   describe('rename page', () => {
     let pageEventSpy;
     let renameDescendantsWithStreamSpy;
@@ -328,6 +416,16 @@ describe('PageService', () => {
         expect(redirectedFromPageRevision).toBeNull();
       });
 
+      test('rename page with different tree with isRecursively', async() => {
+
+        const resultPage = await crowi.pageService.renamePage(parentForRename5, '/parentForRename5/renamedChild', testUser1, {}, true);
+        const wrongPage = await Page.findOne({ path: '/parentForRename5/renamedChild/renamedChild' });
+        const expectPage = await Page.findOne({ path: '/parentForRename5/renamedChild' });
+
+        expect(resultPage.path).toEqual(expectPage.path);
+        expect(wrongPage).toBeNull();
+      });
+
     });
 
     test('renameDescendants without options', async() => {
@@ -396,7 +494,6 @@ describe('PageService', () => {
     });
   });
 
-
   describe('duplicate page', () => {
     let duplicateDescendantsWithStreamSpy;
 

+ 1 - 1
packages/core/package.json

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

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

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

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

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

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

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

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "4.3.3-RC",
+  "version": "4.4.0-RC",
   "license": "MIT",
   "main": "dist/index.js",
   "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/request-between-growi-and-proxy';
 export * from './interfaces/request-from-slack';
+export * from './interfaces/slackbot-types';
 export * from './models/errors';
 export * from './middlewares/verify-growi-to-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',
+}

+ 1 - 0
packages/slack/src/utils/required-scopes.ts

@@ -2,6 +2,7 @@ export const requiredScopes: string[] = [
   'commands',
   'team:read',
   'chat:write',
+  'chat:write.public',
   'channels:join',
   'channels:history',
   'groups:history',

+ 21 - 6
packages/slack/src/utils/webclient-factory.ts

@@ -1,12 +1,27 @@
-import { LogLevel, WebClient } from '@slack/web-api';
+import { LogLevel, WebClient, WebClientOptions } from '@slack/web-api';
 
 const isProduction = process.env.NODE_ENV === 'production';
+const logLevel: LogLevel = isProduction ? LogLevel.DEBUG : LogLevel.INFO;
 
 /**
  * Generate WebClilent instance
- * @param token Slack Bot Token or Proxy Server URI
- * @returns
+ * @param token
+ * @param serverUri Slack Bot Token or Proxy Server URI
+ * @param headers
  */
-export const generateWebClient = (token?: string, serverUri?: string, headers?:{[key:string]:string}): WebClient => {
-  return new WebClient(token, { slackApiUrl: serverUri, logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO, headers });
-};
+export function generateWebClient(token?: string, serverUri?: string, headers?:{[key:string]:string}): WebClient;
+
+/**
+ * Generate WebClilent instance
+ * @param token
+ * @param opts
+ */
+export function generateWebClient(token?: string, opts?: WebClientOptions): WebClient;
+
+export function generateWebClient(token?: string, ...args: any[]): WebClient {
+  if (typeof args[0] === 'string') {
+    return new WebClient(token, { logLevel, slackApiUrl: args[0], headers: args[1] });
+  }
+
+  return new WebClient(token, { logLevel, ...args });
+}

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "1.0.2",
+  "version": "1.0.3",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -24,7 +24,7 @@
   "// comments for dependencies": {},
   "dependencies": {
     "@godaddy/terminus": "^4.8.0",
-    "@growi/slack": "^4.3.3-RC",
+    "@growi/slack": "^4.4.0-RC",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/slackbot-proxy/src/Server.ts

@@ -52,7 +52,7 @@ const helmetOptions = isProduction ? {
     directives: {
       defaultSrc: ['\'self\''],
       styleSrc: ['\'self\'', '\'unsafe-inline\''],
-      imgSrc: ['\'self\'', 'data:', 'validator.swagger.io'],
+      imgSrc: ['\'self\'', 'data:', 'https:'],
       scriptSrc: ['\'self\'', 'https: \'unsafe-inline\''],
     },
   },

+ 20 - 10
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -5,10 +5,10 @@ import axios from 'axios';
 import createError from 'http-errors';
 import { addHours } from 'date-fns';
 
-import { WebAPICallResult } from '@slack/web-api';
+import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 
 import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient, REQUEST_TIMEOUT_FOR_PTOG,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient,
 } from '@growi/slack';
 
 import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
@@ -246,13 +246,13 @@ export class GrowiToSlackCtrl {
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   async callSlackApi(
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
-  ): Promise<void|WebAPICallResult> {
+  ): Promise<WebclientRes> {
     const { tokenGtoPs } = req;
 
     logger.debug('Slack API called: ', { method });
 
     if (tokenGtoPs.length !== 1) {
-      return res.webClientErr('tokenGtoPs is invalid', 'invalid_tokenGtoP');
+      return res.simulateWebAPIPlatformError('tokenGtoPs is invalid', 'invalid_tokenGtoP');
     }
 
     const tokenGtoP = tokenGtoPs[0];
@@ -264,15 +264,18 @@ export class GrowiToSlackCtrl {
       .getOne();
 
     if (relation == null) {
-      return res.webClientErr('relation is invalid', 'invalid_relation');
+      return res.simulateWebAPIPlatformError('relation is invalid', 'invalid_relation');
     }
 
     const token = relation.installation.data.bot?.token;
     if (token == null) {
-      return res.webClientErr('installation is invalid', 'invalid_installation');
+      return res.simulateWebAPIPlatformError('installation is invalid', 'invalid_installation');
     }
 
-    const client = generateWebClient(token);
+    // generate WebClient with no retry because GROWI main side will do
+    const client = generateWebClient(token, {
+      retryConfig: { retries: 0 },
+    });
 
     try {
       this.injectGrowiUri(req, relation.growiUri);
@@ -281,12 +284,19 @@ export class GrowiToSlackCtrl {
       opt.headers = req.headers;
 
       logger.debug({ method, opt });
-      // !! DO NOT REMOVE `await ` or it does not enter catch block even when error occured !! -- 2021.08.22 Yuki Takei
-      return await client.apiCall(method, opt);
+      // !! DO NOT REMOVE `await ` or it does not enter catch block even when axios error occured !! -- 2021.08.22 Yuki Takei
+      const result = await client.apiCall(method, opt);
+
+      return res.send(result);
     }
     catch (err) {
       logger.error(err);
-      return res.webClientErr(`failed to send to slack. err: ${err.message}`, 'fail_api_call');
+
+      if (err.code === ErrorCode.PlatformError) {
+        return res.simulateWebAPIPlatformError(err.message, err.code);
+      }
+
+      return res.simulateWebAPIRequestError(err.message, err.response?.status);
     }
   }
 

+ 25 - 22
packages/slackbot-proxy/src/controllers/slack.ts

@@ -1,5 +1,5 @@
 import {
-  BodyParams, Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
+  Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
 } from '@tsed/common';
 
 import axios from 'axios';
@@ -19,7 +19,10 @@ import { InstallationRepository } from '~/repositories/installation';
 import { RelationRepository } from '~/repositories/relation';
 import { OrderRepository } from '~/repositories/order';
 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 { InstallerService } from '~/services/InstallerService';
 import { SelectGrowiService } from '~/services/SelectGrowiService';
@@ -126,10 +129,6 @@ export class SlackCtrl {
 
     // register
     if (growiCommand.growiCommandType === 'register') {
-      // Send response immediately to avoid opelation_timeout error
-      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-      res.send();
-
       return this.registerService.process(growiCommand, authorizeResult, body as {[key:string]:string});
     }
 
@@ -142,10 +141,6 @@ export class SlackCtrl {
         return 'GROWI Urls must be urls.';
       }
 
-      // Send response immediately to avoid opelation_timeout error
-      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-      res.send();
-
       return this.unregisterService.process(growiCommand, authorizeResult, body as {[key:string]:string});
     }
 
@@ -178,7 +173,10 @@ export class SlackCtrl {
 
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Processing your request ...',
+    });
 
     const baseDate = new Date();
 
@@ -259,10 +257,6 @@ export class SlackCtrl {
 
     const { body, authorizeResult } = req;
 
-    // Send response immediately to avoid opelation_timeout error
-    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
-
     // pass
     if (body.ssl_check != null) {
       return;
@@ -300,10 +294,18 @@ export class SlackCtrl {
 
     // forward to GROWI server
     if (callBackId === 'select_growi') {
+      // Send response immediately to avoid opelation_timeout error
+      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+      res.send();
+
       const selectedGrowiInformation = await this.selectGrowiService.handleSelectInteraction(installation, payload);
       return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
     }
 
+    // Send response immediately to avoid opelation_timeout error
+    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+    res.send();
+
     /*
     * forward to GROWI server
     */
@@ -332,14 +334,15 @@ export class SlackCtrl {
   }
 
   @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> {
+
+    const { authorizeResult } = req;
+    const client = generateWebClient(authorizeResult.botToken);
 
-    logger.info('receive event', body);
+    if (req.body.event.type === 'app_home_opened') {
+      await postWelcomeMessage(client, req.body.event.channel);
+    }
 
     return;
   }

+ 9 - 2
packages/slackbot-proxy/src/middlewares/growi-to-slack/add-webclient-response-to-res.ts

@@ -1,10 +1,12 @@
+import { ErrorCode } from '@slack/web-api';
 import {
   IMiddleware, Middleware, Next, Req, Res,
 } from '@tsed/common';
 
 
 export type WebclientRes = Res & {
-  webClientErr: (message?:string, errorCode?:string) => void
+  simulateWebAPIRequestError: (error: string, statusCode: number) => WebclientRes
+  simulateWebAPIPlatformError: (error: string, errorCode?:string) => WebclientRes
 };
 
 
@@ -13,7 +15,12 @@ export class AddWebclientResponseToRes implements IMiddleware {
 
   use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
 
-    res.webClientErr = (error?:string, errorCode?:string) => {
+    // https://github.com/slackapi/node-slack-sdk/blob/7b95663a9ef31036367c066ccbf0021423278f40/packages/web-api/src/WebClient.ts#L356-L358
+    res.simulateWebAPIRequestError = (error: string, statusCode?: number) => {
+      return res.status(statusCode || 500).send({ error, errorCode: ErrorCode.RequestError });
+    };
+    // https://github.com/slackapi/node-slack-sdk/blob/7b95663a9ef31036367c066ccbf0021423278f40/packages/web-api/src/WebClient.ts#L197-L199
+    res.simulateWebAPIPlatformError = (error: string, errorCode?: string) => {
       return res.send({ ok: false, error, errorCode });
     };
 

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

@@ -6,56 +6,29 @@ import {
 import Logger from 'bunyan';
 
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
-import { InstallationRepository } from '~/repositories/installation';
 import { InstallerService } from '~/services/InstallerService';
 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');
       return res.end();
     }
 
-    // create query from body
-    const query: InstallationQuery<boolean> = {
-      teamId,
-      enterpriseId,
-      isEnterpriseInstall,
-    };
-
     let result: AuthorizeResult;
     try {
-      result = await this.installerService.installer.authorize(query);
+      result = await installerService.installer.authorize(query);
 
       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();
       }
     }
     catch (e) {
-      this.logger.error(e.message);
+      logger.error(e.message);
 
       res.writeHead(500, e.message);
       return res.end();
@@ -63,19 +36,39 @@ export class AuthorizeCommandMiddleware implements IMiddleware {
 
     // set authorized data
     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()
   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;
 
@@ -83,52 +76,61 @@ export class AuthorizeInteractionMiddleware implements IMiddleware {
     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
-      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> = {
       teamId,
       enterpriseId,
       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);
   }
 
 }

+ 20 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/url-verification.ts

@@ -0,0 +1,20 @@
+import {
+  IMiddleware, Middleware, Req, Res,
+} from '@tsed/common';
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+
+
+@Middleware()
+export class UrlVerificationMiddleware implements IMiddleware {
+
+  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void> {
+
+    // 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 (req.body.type === 'url_verification') {
+      res.send(req.body.challenge);
+      return;
+    }
+  }
+
+}

+ 19 - 7
packages/slackbot-proxy/src/views/install-succeeded.ejs

@@ -6,16 +6,28 @@
       <div class="col text-center">
         <h1 class="my-5">Congratulations!</h1>
         <h2 class="my-5">GROWI Bot installation has succeeded!</h2>
-        <p>
-          Access to
-          <a href=<%- appPageUrl %>>
-            Slack App detail page.
-          </a>
+      </div>
+    </div>
+
+    <div class="row justify-content-md-center">
+      <div class="col col-xl-6 col-md-8">
+        <h4>The steps required to configure GROWI Slack app.</h4>
+        <ol>
+          <li>First, <code>/growi register</code> in any slack channel.</li>
+          <img class="w-100" src="https://docs.growi.org/assets/images/slack-bot-growi-register.png" alt="growi-register"/>
+          <li>Looking for additional help? Try <code>/growi help</code></li>
+        </ol>
+        <h4 class="mt-5">Need more infomation?</h4>
+        <p>Please refer to the document below.</p>
+        <p class="text-center">
+          <a class="btn btn-success my-4" href="https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html">Getting started</a>
         </p>
-        <p>
-          <a class="btn btn-outline-success" href="https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html">Getting started</a>
+        <hr>
+        <p class="text-center">
+          <a href=<%- appPageUrl %>>Slack App detail page.</a>
         </p>
       </div>
     </div>
+
   </div>
 </body>

+ 1 - 1
packages/ui/package.json

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

+ 5 - 0
yarn.lock

@@ -7966,6 +7966,11 @@ express-mongo-sanitize@^2.1.0:
   resolved "https://registry.yarnpkg.com/express-mongo-sanitize/-/express-mongo-sanitize-2.1.0.tgz#a8c647787c25ded6e97b5e864d113e7687c5d471"
   integrity sha512-ELGeH/Tx+kJGn3klCzSmOewfN1ezJQrkqzq83dl/K3xhd5PUbvLtiD5CiuYRmQfoZPL4rUEVjANf/YjE2BpTWQ==
 
+express-rate-limit@^5.3.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-5.3.0.tgz#e7b9d3c2e09ece6e0406a869b2ce00d03fe48aea"
+  integrity sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew==
+
 express-session@^1.16.1:
   version "1.16.1"
   resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.16.1.tgz#251ff9776c59382301de6c8c33411af357ed439c"