Răsfoiți Sursa

Merge branch 'master' into feat/password-rsettings-by-users

kaori 4 ani în urmă
părinte
comite
9c65f29763
100 a modificat fișierele cu 2453 adăugiri și 1534 ștergeri
  1. 2 0
      .devcontainer/Dockerfile
  2. 4 0
      .devcontainer/docker-compose.yml
  3. 1 1
      .github/workflows/release-rc.yml
  4. 2 2
      .github/workflows/release.yml
  5. 23 3
      CHANGES.md
  6. 1 1
      package.json
  7. 1 0
      packages/app/.env.development
  8. 18 13
      packages/app/docker/Dockerfile
  9. 17 16
      packages/app/package.json
  10. BIN
      packages/app/public/images/slack-integration/growi-register-sentence.png
  11. 15 2
      packages/app/resource/locales/en_US/admin/admin.json
  12. 22 22
      packages/app/resource/locales/en_US/sandbox.md
  13. 0 1
      packages/app/resource/locales/en_US/translation.json
  14. 53 16
      packages/app/resource/locales/en_US/welcome.md
  15. 15 2
      packages/app/resource/locales/ja_JP/admin/admin.json
  16. 0 1
      packages/app/resource/locales/ja_JP/translation.json
  17. 43 10
      packages/app/resource/locales/ja_JP/welcome.md
  18. 15 2
      packages/app/resource/locales/zh_CN/admin/admin.json
  19. 0 1
      packages/app/resource/locales/zh_CN/translation.json
  20. 53 16
      packages/app/resource/locales/zh_CN/welcome.md
  21. 5 3
      packages/app/src/client/admin.jsx
  22. 9 37
      packages/app/src/client/services/AdminNotificationContainer.js
  23. 91 0
      packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  24. 5 1
      packages/app/src/client/util/apiNotification.js
  25. 71 0
      packages/app/src/components/Admin/LegacySlackIntegration/LegacySlackIntegration.jsx
  26. 21 21
      packages/app/src/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx
  27. 111 20
      packages/app/src/components/Admin/Notification/NotificationSetting.jsx
  28. 0 80
      packages/app/src/components/Admin/Notification/SlackIntegrationNotificationSetting.jsx
  29. 39 17
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  30. 7 5
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  31. 36 15
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  32. 53 0
      packages/app/src/components/Admin/SlackIntegration/SlackAppIntegrationControl.tsx
  33. 3 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  34. 41 31
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  35. 2 2
      packages/app/src/components/PageComment/CommentEditor.jsx
  36. 10 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  37. 7 1
      packages/app/src/components/PageEditor/Editor.jsx
  38. 7 9
      packages/app/src/components/PageEditor/EditorIcon.jsx
  39. 3 3
      packages/app/src/components/PageEditor/EditorNavbarBottom.jsx
  40. 6 1
      packages/app/src/server/crowi/express-init.js
  41. 10 45
      packages/app/src/server/crowi/index.js
  42. 1 1
      packages/app/src/server/models/config.ts
  43. 0 1
      packages/app/src/server/models/index.js
  44. 5 0
      packages/app/src/server/models/page.js
  45. 1 0
      packages/app/src/server/models/slack-app-integration.js
  46. 122 0
      packages/app/src/server/models/update-post.ts
  47. 0 153
      packages/app/src/server/models/updatePost.js
  48. 2 2
      packages/app/src/server/routes/admin.js
  49. 2 2
      packages/app/src/server/routes/apiv3/healthcheck.js
  50. 1 0
      packages/app/src/server/routes/apiv3/index.js
  51. 9 71
      packages/app/src/server/routes/apiv3/notification-setting.js
  52. 14 11
      packages/app/src/server/routes/apiv3/page.js
  53. 2 2
      packages/app/src/server/routes/apiv3/search.js
  54. 128 0
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  55. 193 160
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  56. 22 59
      packages/app/src/server/routes/apiv3/slack-integration.js
  57. 2 2
      packages/app/src/server/routes/apiv3/statistics.js
  58. 2 1
      packages/app/src/server/routes/page.js
  59. 1 3
      packages/app/src/server/service/app.ts
  60. 11 7
      packages/app/src/server/service/global-notification/global-notification-slack.js
  61. 37 52
      packages/app/src/server/service/page.js
  62. 1 1
      packages/app/src/server/service/slack-command-handler/help.js
  63. 279 0
      packages/app/src/server/service/slack-integration.ts
  64. 0 22
      packages/app/src/server/service/slack-notification.js
  65. 0 136
      packages/app/src/server/service/slackbot.ts
  66. 0 61
      packages/app/src/server/service/user-notification/index.js
  67. 82 0
      packages/app/src/server/service/user-notification/index.ts
  68. 15 43
      packages/app/src/server/util/slack-legacy.js
  69. 123 182
      packages/app/src/server/util/slack.js
  70. 1 1
      packages/app/src/server/views/admin/slack-integration-legacy.html
  71. 2 0
      packages/app/src/server/views/admin/slack-integration.html
  72. 98 1
      packages/app/src/test/service/page.test.js
  73. 4 7
      packages/app/src/test/utils/slack-legacy.test.js
  74. 9 0
      packages/slack/src/index.ts
  75. 11 1
      packages/slack/src/utils/block-kit-builder.ts
  76. 9 4
      packages/slack/src/utils/check-communicable.ts
  77. 31 0
      packages/slack/src/utils/publish-initial-home-view.ts
  78. 11 0
      packages/slack/src/utils/required-scopes.ts
  79. 4 0
      packages/slack/src/utils/slash-command-parser.ts
  80. 21 6
      packages/slack/src/utils/webclient-factory.ts
  81. 21 0
      packages/slack/src/utils/welcome-message.ts
  82. 15 0
      packages/slackbot-proxy/CHANGES.md
  83. 4 1
      packages/slackbot-proxy/docker/Dockerfile
  84. 2 1
      packages/slackbot-proxy/package.json
  85. 9 1
      packages/slackbot-proxy/src/Server.ts
  86. 1 0
      packages/slackbot-proxy/src/config/logger/config.dev.ts
  87. 23 10
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  88. 144 88
      packages/slackbot-proxy/src/controllers/slack.ts
  89. 2 9
      packages/slackbot-proxy/src/controllers/top.ts
  90. 30 0
      packages/slackbot-proxy/src/middlewares/growi-to-slack/add-webclient-response-to-res.ts
  91. 0 23
      packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts
  92. 32 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts
  93. BIN
      packages/slackbot-proxy/src/public/images/growi-bot.png
  94. 23 8
      packages/slackbot-proxy/src/services/RegisterService.ts
  95. 4 0
      packages/slackbot-proxy/src/services/RelationsService.ts
  96. 21 0
      packages/slackbot-proxy/src/views/install-failed.ejs
  97. 18 0
      packages/slackbot-proxy/src/views/install-succeeded-but-has-problem.ejs
  98. 21 0
      packages/slackbot-proxy/src/views/install-succeeded.ejs
  99. 1 0
      packages/slackbot-proxy/src/views/top.ejs
  100. 4 0
      packages/slackbot-proxy/tsconfig.base.json

+ 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:

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

@@ -55,7 +55,7 @@ jobs:
       uses: docker/build-push-action@v2
       with:
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache

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

@@ -113,7 +113,7 @@ jobs:
       uses: docker/build-push-action@v2
       with:
         context: .
-        file: ./docker/Dockerfile
+        file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         push: true
         cache-from: type=local,src=/tmp/.buildx-cache
@@ -131,7 +131,7 @@ jobs:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         repository: weseek/growi
-        readme-filepath: ./docker/README.md
+        readme-filepath: ./packages/app/docker/README.md
 
     - name: Slack Notification
       uses: weseek/ghaction-release-slack-notification@master

+ 23 - 3
CHANGES.md

@@ -1,17 +1,37 @@
 # CHANGES
 
-## v4.3.3-RC
+## v4.4.0-RC
+
+### BREAKING CHANGES
 
+* Official plugins are now preinstalled
+
+### Updates
+
+* Improvement: Add attachment button in editor navbar
+* Fix: Recursive rename operation from `/parent` to `/parent/child` ([#4101](https://github.com/weseek/growi/pull/4101))
 * Fix: Encode spaces in page path in LinkEditModal
-* 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
 * Support: Include official plugins as sub packages
 * Support: Upgrade libs
     * @slack/web-api
-    * escape-string-regexp
+    * date-fns
+    * helmet
     * morgan
+    * socket.io
+
+## v4.3.3-RC
+
+* Improvement: Welcome page markdown
+* Fix: Some recursive operation exclude descendant pages that are restricted for groups
+    * Rename / Delete / Delete completely / Put back / Duplicate
+* Fix: Layout is broken when editing users page ([#4128](https://github.com/weseek/growi/issues/4128))
+* Support: Upgrade libs
+    * @slack/web-api
+    * date-fns
+    * escape-string-regexp
 
 ## v4.3.2
 

+ 1 - 1
package.json

@@ -31,7 +31,7 @@
   "scripts": {
     "start": "yarn app:server",
     "prestart": "yarn app:build",
-    "app:build": "yarn lerna run build --scope @growi/app --scope @growi/slack --scope @growi/plugin-pukiwiki-like-linker",
+    "app:build": "yarn lerna run build --scope @growi/app --scope @growi/slack --scope @growi/plugin-*",
     "app:server": "yarn lerna run server --scope @growi/app",
     "slackbot-proxy:build": "yarn lerna run build --scope @growi/slackbot-proxy --scope @growi/slack",
     "slackbot-proxy:server": "yarn lerna run start:prod --scope @growi/slackbot-proxy",

+ 1 - 0
packages/app/.env.development

@@ -10,6 +10,7 @@ MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"
 ELASTICSEARCH_URI="http://elasticsearch:9200/growi"
+ELASTICSEARCH_REQUEST_TIMEOUT=15000
 HACKMD_URI="http://localhost:3010"
 HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # DRAWIO_URI="http://localhost:8080/?offline=1&https=0"

+ 18 - 13
packages/app/docker/Dockerfile

@@ -17,18 +17,21 @@ COPY ./package.json .
 COPY ./yarn.lock .
 COPY ./lerna.json .
 COPY ./packages/app/package.json packages/app/
-COPY ./packages/slack/package.json packages/slack/
+COPY ./packages/core/package.json packages/core/
+COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
+COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
 COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
+COPY ./packages/slack/package.json packages/slack/
+COPY ./packages/ui/package.json packages/ui/
 
 # setup
 RUN yarn config set network-timeout 300000
 RUN npx lerna bootstrap
 
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules \
-  packages/plugin-pukiwiki-like-linker/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
@@ -41,10 +44,9 @@ FROM deps-resolver AS deps-resolver-prod
 RUN yarn install --production
 
 # make artifacts
-RUN tar cf node_modules.tar node_modules \
-  packages/app/node_modules \
-  packages/slack/node_modules \
-  packages/plugin-pukiwiki-like-linker/node_modules
+RUN tar cf node_modules.tar \
+  node_modules \
+  packages/*/node_modules
 
 
 
@@ -92,9 +94,13 @@ COPY ./yarn.lock ./
 COPY ./lerna.json ./
 COPY ./tsconfig.base.json ./
 # copy all related packages
-COPY packages/slack packages/slack
 COPY packages/app packages/app
+COPY packages/core packages/core
+COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
+COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker
+COPY packages/slack packages/slack
+COPY packages/ui packages/ui
 
 # build
 RUN yarn lerna run build
@@ -113,9 +119,8 @@ RUN tar cf packages.tar \
   packages/app/.env.production \
   packages/app/tsconfig.base.json \
   packages/app/tsconfig.json \
-  packages/slack/package.json \
-  packages/slack/dist
-
+  packages/*/package.json \
+  packages/*/dist
 
 
 

+ 17 - 16
packages/app/package.json

@@ -6,21 +6,21 @@
     "//// for production": "",
     "start": "yarn build && yarn server",
     "build": "run-p build:*",
-    "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
-    "build:server": "cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
+    "build:client": "yarn cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --bail",
+    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx shx rm -rf dist transpiled",
-    "prebuild": "run-p clean resources:*",
+    "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "postbuild": "npx shx mv transpiled/src dist && npx shx rm -rf transpiled",
-    "server": "cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
+    "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config --expose_gc dist/server/app.js",
     "server:ci": "yarn server --ci",
-    "preserver": "yarn migrate",
+    "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "//// for development": "",
     "dev": "run-p dev:client dev:server",
-    "dev:client": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
-    "dev:client:nowatch": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
-    "dev:server": "cross-env NODE_ENV=development yarn ts-node-dev src/server/app.ts --expose_gc --inspect",
-    "predev:client": "run-p resources:*",
-    "predev:server": "yarn migrate",
+    "dev:client": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js --progress --watch",
+    "dev:client:nowatch": "yarn cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
+    "dev:server": "yarn cross-env NODE_ENV=development yarn ts-node-dev src/server/app.ts --expose_gc",
+    "predev:client": "yarn cross-env NODE_ENV=development run-p resources:*",
+    "predev:server": "yarn cross-env NODE_ENV=development yarn migrate",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
@@ -33,10 +33,10 @@
     "prelint:eslint": "yarn resources:plugin",
     "prelint:swagger2openapi": "yarn openapi:v3",
     "//// misc": "",
-    "console": "cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
+    "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
-    "openapi:v3": "cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
-    "openapi:v1": "cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
+    "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
+    "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "migrate": "yarn migrate:up",
@@ -45,7 +45,7 @@
     "migrate:up": "yarn ts-node node_modules/.bin/migrate-mongo up -f config/migrate.js",
     "migrate:down": "yarn ts-node node_modules/.bin/migrate-mongo down -f config/migrate.js",
     "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --transpile-only"
+    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --inspect --transpile-only"
   },
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
@@ -80,7 +80,7 @@
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "csrf": "^3.1.0",
-    "date-fns": "^2.0.0",
+    "date-fns": "^2.23.0",
     "detect-indent": "^6.0.0",
     "diff": "^5.0.0",
     "elasticsearch": "^16.0.0",
@@ -97,7 +97,8 @@
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
-    "helmet": "^3.13.0",
+    "helmet": "^4.6.0",
+    "nocache": "^3.0.1",
     "http-errors": "~1.6.2",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",

BIN
packages/app/public/images/slack-integration/growi-register-sentence.png


+ 15 - 2
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",
@@ -282,6 +288,9 @@
       "cancel": "Cancel",
       "change": "Change"
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "Succeeded to delete the slack integration procedure"
+    },
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
     "access_token_settings": {
       "regenerate": "Regenerate"
@@ -309,8 +318,8 @@
       "paste_growi_url": "Since a modal is displayed, enter the following URL in <b>GROWI URL</b>.",
       "enter_access_token_for_growi_and_proxy": "Enter <b>Access Token Proxy to GROWI</b> and <b>Access Token GROWI to Proxy</b>",
       "set_proxy_url_on_growi": "Set Proxy URL on GROWI",
-      "copy_proxy_url": "1. When the above step are completed successfully, the Proxy URL will be displayed in the Slack Channel you selected in the modal, so copy it.",
-      "enter_proxy_url_and_update": "2. Enter and update the Proxy URL that you copied in step in the <b>Proxy URL</b>  of the <b>Custom bot with proxy integration</b> on this page.",
+      "copy_proxy_url": "When the above step are completed successfully, the Proxy URL will be displayed in the Slack Channel you selected in the modal, so copy it.",
+      "enter_proxy_url_and_update": "Enter and update the Proxy URL that you copied in the above step in the <b>Proxy URL</b> of the <b>Custom bot with proxy integration</b> on this page.",
       "dont_need_update": "※If the value is already in there, there is no need to update it.",
       "select_install_your_app": "Select \"Install your app\".",
       "select_install_to_workspace": "Select \"Install to Workspace\".",
@@ -346,6 +355,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.",

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

@@ -37,7 +37,7 @@ Add one `#` per level at the start of the line
 
 ## Block paragraph
 
-Pararaphs are created by inserting a newline character
+Paragraphs are created by inserting a newline character
 A paragraph can be created by pressing Enter at the end of the previous paragraph.
 
 ```
@@ -53,17 +53,17 @@ paragraph2
 ## Br new line
 
 Add two spaces before break.
-***This behaviour can be modified in the options menu.***
+***This behavior can be modified in the options menu.***
 
 ```
-hoge
-fuga(two spaces)
-piyo
+foo
+bar(two spaces)
+baz
 ```
 
-hoge
-fuga
-piyo
+foo
+bar
+baz
 
 ## Blockquotes
 
@@ -84,7 +84,7 @@ Add one `>` per level at the start of the line
 Wrap code with three back quotes or tildes.
 
 ```
-print 'hoge'
+print 'foo'
 ```
 
 ### Syntax highlight and file name
@@ -131,16 +131,16 @@ This is  `Inline Code`.
 Code blocks should be preceded by four spaces or one tab.
 
 ```
-    class Hoge
-        def hoge
-            print 'hoge'
+    class Foo
+        def foo
+            print 'foo'
         end
     end
 ```
 
-    class Hoge
-        def hoge
-            print 'hoge'
+    class Foo
+        def foo
+            print 'foo'
         end
     end
 
@@ -166,7 +166,7 @@ ___
 
 ### Italic
 
-To italicize text, add One asterisk or underscores before and after a word or phrase.
+To italicize text, add one asterisk or underscores before and after a word or phrase.
 
 ```
 This is *Italic* .
@@ -178,7 +178,7 @@ This is _Italic_ .
 
 ### Bold
 
-To bold text, add two asterisks or underscores before and after a word or phrase.
+To make text bold, add two asterisks or underscores before and after a word or phrase.
 
 ```
 This is **bold**.
@@ -263,7 +263,7 @@ Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 ## Ul Bulleted list
 
-To create an unordered list, add dashes (-), asterisks (*), or plus signs (+) in front of line items. 
+To create an unordered list, add dashes (-), asterisks (*), or plus signs (+) in front of line items.
 Items can be nested using indentation.
 
 ```
@@ -286,7 +286,7 @@ Items can be nested using indentation.
 
 ## Ol Numbered List
 
-To create an ordered list, add line items with numbers followed by periods. 
+To create an ordered list, add line items with numbers followed by periods.
 The numbers don’t have to be in numerical order, but the list should start with the number one.
 
 ```
@@ -449,9 +449,9 @@ See [emojione](https://www.emojione.com/)
 
 # :heavy_plus_sign: More..
 
-- Try to attach Bootstrap4 Tags?
+- Want to attach Bootstrap4 Tags?
     - :arrow_right: [/Sandbox/Bootstrap4]
-- Try to draw Diagrams?
+- Want to draw Diagrams?
     - :arrow_right: [/Sandbox/Diagrams]
-- Try to write Math Formulas?
+- Want to write Math Formulas?
     - :arrow_right: [/Sandbox/Math]

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

@@ -438,7 +438,6 @@
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
-    "delete_slack_integration_procedure": "Succeeded to delete the slack integration procedure",
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",

+ 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.

+ 15 - 2
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",
@@ -282,6 +288,9 @@
       "cancel": "取消",
       "change": "変更する"
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "Slack 連携手順を削除しました"
+    },
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
     "access_token_settings": {
       "regenerate": "再発行"
@@ -308,8 +317,8 @@
       "paste_growi_url": "モーダルが表示されるので、<b>GROWI URL</b> には下記のURLを入力します。",
       "enter_access_token_for_growi_and_proxy": "上記で発行した<b>Access Token Proxy to GROWI</b> と <b>Access Token GROWI to Proxy</b>を入れる",
       "set_proxy_url_on_growi": "ProxyのURLをGROWIに登録する",
-      "copy_proxy_url": "1. ②が正常に完了すると、モーダル内で選択したSlack ChannelにProxy URLが表示されるので、コピーします。",
-      "enter_proxy_url_and_update": "2. 連携手順③でコピーしたProxy URLを、このページの<b>Custom bot with proxy 連携</b>の<b>Proxy URL</b>に入力、更新します。",
+      "copy_proxy_url": "上の手順が正常に完了すると、モーダル内で選択したSlack ChannelにProxy URLが表示されるので、コピーします。",
+      "enter_proxy_url_and_update": "コピーしたProxy URLを、このページの<b>Custom bot with proxy 連携</b>の<b>Proxy URL</b>に入力、更新します。",
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "select_install_your_app": "Install your app をクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",
@@ -345,6 +354,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": "少なくとも一つはチェックしてください。",

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

@@ -440,7 +440,6 @@
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
-    "delete_slack_integration_procedure": "Slack 連携手順を削除しました",
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",

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

+ 15 - 2
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",
@@ -292,6 +298,9 @@
       "cancel": "取消",
       "change": "改变"
     },
+    "toastr": {
+      "delete_slack_integration_procedure": "删除了 Slack 集成程序"
+    },
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <code>{{variable}}</code> 启用。",
     "access_token_settings": {
       "regenerate": "再生"
@@ -318,8 +327,8 @@
       "paste_growi_url": "由于显示了模式,请在 <b>GROWI URL</b> 中输入以下URL",
       "enter_access_token_for_growi_and_proxy": "插入上面发出的 <b>Access Token Proxy to GROWI</b> 和 <b>Access Token GROWI to Proxy</b>。",
       "set_proxy_url_on_growi": "向GROWI注册Proxy的URL",
-      "copy_proxy_url": "1. 当上述步骤成功完成后,Proxy URL将显示在你在模版中选择的Slack频道中,所以请复制它。",
-      "enter_proxy_url_and_update": "2. 输入并更新你在步骤③中复制的ProxyURL到本页的<b>Custom bot with proxy 一体化</b>的<b>ProxyURL</b>。",
+      "copy_proxy_url": "当上述步骤成功完成后,Proxy URL将显示在你在模版中选择的Slack频道中,所以请复制它。",
+      "enter_proxy_url_and_update": "上述过程中复制的ProxyURL到本页的<b>Custom bot with proxy 一体化</b>的<b>ProxyURL</b>。",
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "select_install_your_app": "选择 \"Install your app\"。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",
@@ -355,6 +364,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": "您应该至少选中一个复选框。",

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

@@ -418,7 +418,6 @@
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
-    "delete_slack_integration_procedure": "删除了 Slack 集成程序",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"remove_user_success": "Succeeded to removing {{username}} ",

+ 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 />,

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

+ 5 - 1
packages/app/src/client/util/apiNotification.js

@@ -34,8 +34,12 @@ const toastrOption = {
 export const toastError = (err, header = 'Error', option = toastrOption.error) => {
   const errs = toArrayIfNot(err);
 
+  if (err.length === 0) {
+    toastr.error('', header);
+  }
+
   for (const err of errs) {
-    toastr.error(err.message, header, option);
+    toastr.error(err.message || err, header, option);
   }
 };
 

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

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

@@ -1,7 +1,12 @@
-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 {
+  Card, CardBody, TabContent, TabPane,
+} from 'reactstrap';
+import { useTranslation } from 'react-i18next';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -19,9 +24,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 === 'officialBot' || currentBotType === 'customBotWithProxy';
+
+  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 +102,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 +136,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;

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

@@ -1,6 +1,7 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
+
 import loggerFactory from '~/utils/logger';
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -8,12 +9,15 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 
-const logger = loggerFactory('growi:SlackBotSettings');
+const logger = loggerFactory('growi:cli:SlackIntegration:CustomBotWithProxySettings');
 
 const CustomBotWithProxySettings = (props) => {
   const {
-    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
+    appContainer, slackAppIntegrations, proxyServerUri,
+    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
+    connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   const [newProxyServerUri, setNewProxyServerUri] = useState();
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
@@ -31,17 +35,36 @@ const CustomBotWithProxySettings = (props) => {
     }
   };
 
+  const isPrimaryChangedHandler = useCallback(async(slackIntegrationToChange, newValue) => {
+    // do nothing when turning off
+    if (!newValue) {
+      return;
+    }
+
+    try {
+      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
+      if (onPrimaryUpdated != null) {
+        onPrimaryUpdated();
+      }
+      toastSuccess(t('toaster.update_successed', { target: 'Primary' }));
+    }
+    catch (err) {
+      toastError(err, 'Failed to change isPrimary');
+      logger.error('Failed to change isPrimary', err);
+    }
+  }, [appContainer.apiv3, t, onPrimaryUpdated]);
+
   const deleteSlackAppIntegrationHandler = async() => {
     try {
-      await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { integrationIdToDelete });
+      await appContainer.apiv3.delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
       }
-      toastSuccess(t('toaster.delete_slack_integration_procedure'));
+      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
     }
     catch (err) {
-      toastError(err);
-      logger.error(err);
+      toastError(err, 'Failed to delete');
+      logger.error('Failed to delete', err);
     }
   };
 
@@ -53,8 +76,8 @@ const CustomBotWithProxySettings = (props) => {
       toastSuccess(t('toaster.update_successed', { target: 'Proxy URL' }));
     }
     catch (err) {
-      toastError(err);
-      logger.error(err);
+      toastError(err, 'Failed to update');
+      logger.error('Failed to update', err);
     }
   };
 
@@ -113,14 +136,12 @@ const CustomBotWithProxySettings = (props) => {
                 <h2 id={_id || `settings-accordions-${i}`}>
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                 </h2>
-                <button
-                  className="btn btn-outline-danger"
-                  type="button"
-                  onClick={() => setIntegrationIdToDelete(slackAppIntegration._id)}
-                >
-                  <i className="icon-trash mr-1" />
-                  {t('admin:slack_integration.delete')}
-                </button>
+                <SlackAppIntegrationControl
+                  slackAppIntegration={slackAppIntegration}
+                  onIsPrimaryChanged={isPrimaryChangedHandler}
+                  // set state to open DeleteSlackBotSettingsModal
+                  onDeleteButtonClicked={saiToDelete => setIntegrationIdToDelete(saiToDelete._id)}
+                />
               </div>
               <WithProxyAccordions
                 botType="customBotWithProxy"
@@ -168,6 +189,7 @@ CustomBotWithProxySettings.propTypes = {
   slackAppIntegrations: PropTypes.array,
   proxyServerUri: PropTypes.string,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  onPrimaryUpdated: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   onSubmitForm: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,

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

@@ -51,7 +51,7 @@ const ManageCommandsProcess = ({
 
   const updateCommandsHandler = async() => {
     try {
-      await apiv3Put(`/slack-integration-settings/${slackAppIntegrationId}/supported-commands`, {
+      await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
         supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
         supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
       });
@@ -75,18 +75,19 @@ const ManageCommandsProcess = ({
           <div className="custom-control custom-checkbox">
             <div className="row mb-5">
               {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
+                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
                 return (
                   <div className="col-sm-6 my-1" key={commandName}>
                     <input
                       type="checkbox"
                       className="custom-control-input"
-                      id={commandName}
+                      id={checkboxId}
                       name={commandName}
                       value={commandName}
                       checked={selectedCommandsForBroadcastUse.has(commandName)}
                       onChange={toggleCheckboxForBroadcast}
                     />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
                       {commandName}
                     </label>
                   </div>
@@ -100,18 +101,19 @@ const ManageCommandsProcess = ({
           <div className="custom-control custom-checkbox">
             <div className="row mb-5">
               {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
+                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
                 return (
                   <div className="col-sm-6 my-1" key={commandName}>
                     <input
                       type="checkbox"
                       className="custom-control-input"
-                      id={commandName}
+                      id={checkboxId}
                       name={commandName}
                       value={commandName}
                       checked={selectedCommandsForSingleUse.has(commandName)}
                       onChange={toggleCheckboxForSingleUse}
                     />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={commandName}>
+                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
                       {commandName}
                     </label>
                   </div>

+ 36 - 15
packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import loggerFactory from '~/utils/logger';
@@ -8,12 +8,15 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import { SlackAppIntegrationControl } from './SlackAppIntegrationControl';
 
-const logger = loggerFactory('growi:SlackBotSettings');
+const logger = loggerFactory('growi:cli:SlackIntegration:OfficialBotSettings');
 
 const OfficialBotSettings = (props) => {
   const {
-    appContainer, slackAppIntegrations, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
+    appContainer, slackAppIntegrations,
+    onClickAddSlackWorkspaceBtn, onPrimaryUpdated,
+    connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   const [siteName, setSiteName] = useState('');
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
@@ -25,17 +28,36 @@ const OfficialBotSettings = (props) => {
     }
   };
 
+  const isPrimaryChangedHandler = useCallback(async(slackIntegrationToChange, newValue) => {
+    // do nothing when turning off
+    if (!newValue) {
+      return;
+    }
+
+    try {
+      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackIntegrationToChange._id}/make-primary`);
+      if (onPrimaryUpdated != null) {
+        onPrimaryUpdated();
+      }
+      toastSuccess(t('toaster.update_successed', { target: 'Primary' }));
+    }
+    catch (err) {
+      toastError(err, 'Failed to change isPrimary');
+      logger.error('Failed to change isPrimary', err);
+    }
+  }, [appContainer.apiv3, t, onPrimaryUpdated]);
+
   const deleteSlackAppIntegrationHandler = async() => {
-    await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { integrationIdToDelete });
+    await appContainer.apiv3.delete(`/slack-integration-settings/slack-app-integrations/${integrationIdToDelete}`);
     try {
       if (props.onDeleteSlackAppIntegration != null) {
         props.onDeleteSlackAppIntegration();
       }
-      toastSuccess(t('toaster.delete_slack_integration_procedure'));
+      toastSuccess(t('admin:slack_integration.toastr.delete_slack_integration_procedure'));
     }
     catch (err) {
-      toastError(err);
-      logger.error(err);
+      toastError('Failed to delete');
+      logger.error('Failed to delete', err);
     }
   };
 
@@ -79,14 +101,12 @@ const OfficialBotSettings = (props) => {
                 <h2 id={_id || `settings-accordions-${i}`}>
                   {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
                 </h2>
-                <button
-                  className="btn btn-outline-danger"
-                  type="button"
-                  onClick={() => setIntegrationIdToDelete(slackAppIntegration._id)}
-                >
-                  <i className="icon-trash mr-1" />
-                  {t('admin:slack_integration.delete')}
-                </button>
+                <SlackAppIntegrationControl
+                  slackAppIntegration={slackAppIntegration}
+                  onIsPrimaryChanged={isPrimaryChangedHandler}
+                  // set state to open DeleteSlackBotSettingsModal
+                  onDeleteButtonClicked={saiToDelete => setIntegrationIdToDelete(saiToDelete._id)}
+                />
               </div>
               <WithProxyAccordions
                 botType="officialBot"
@@ -133,6 +153,7 @@ OfficialBotSettings.propTypes = {
 
   slackAppIntegrations: PropTypes.array,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  onPrimaryUpdated: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,
   onUpdateTokens: PropTypes.func,

+ 53 - 0
packages/app/src/components/Admin/SlackIntegration/SlackAppIntegrationControl.tsx

@@ -0,0 +1,53 @@
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  slackAppIntegration: {
+    _id: string,
+    isPrimary?: boolean,
+  },
+  onIsPrimaryChanged?: (slackAppIntegration: unknown, newValue: boolean) => void,
+  onDeleteButtonClicked?: (slackAppIntegration: unknown) => void,
+}
+
+export const SlackAppIntegrationControl: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const { slackAppIntegration, onIsPrimaryChanged, onDeleteButtonClicked } = props;
+  const inputId = `cb-primary-${slackAppIntegration._id}`;
+  const isPrimary = slackAppIntegration.isPrimary === true;
+
+  return (
+    <div className="d-flex align-items-center">
+      <div className="my-1 custom-control custom-switch">
+        <input
+          className="custom-control-input"
+          id={inputId}
+          type="checkbox"
+          checked={isPrimary}
+          disabled={isPrimary}
+          onChange={(e) => {
+            if (onIsPrimaryChanged != null) {
+              onIsPrimaryChanged(slackAppIntegration, e.target.checked);
+            }
+          }}
+        />
+        <label className="custom-control-label" htmlFor={inputId}>
+          Primary
+        </label>
+      </div>
+      <button
+        className="btn btn-outline-danger ml-3"
+        type="button"
+        onClick={() => {
+          if (onDeleteButtonClicked != null) {
+            onDeleteButtonClicked(slackAppIntegration);
+          }
+        }}
+      >
+        <i className="icon-trash mr-1" />
+        {t('admin:slack_integration.delete')}
+      </button>
+    </div>
+  );
+};

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

@@ -72,7 +72,7 @@ const SlackIntegration = (props) => {
 
   const createSlackIntegrationData = async() => {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/slack-app-integrations');
+      await appContainer.apiv3.post('/slack-integration-settings/slack-app-integrations');
       fetchSlackIntegrationData();
       toastSuccess(t('admin:slack_integration.adding_slack_ws_integration_settings_successful'));
     }
@@ -130,6 +130,7 @@ const SlackIntegration = (props) => {
         <OfficialBotSettings
           slackAppIntegrations={slackAppIntegrations}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
+          onPrimaryUpdated={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
           onUpdateTokens={fetchSlackIntegrationData}
@@ -156,6 +157,7 @@ const SlackIntegration = (props) => {
           slackAppIntegrations={slackAppIntegrations}
           proxyServerUri={proxyServerUri}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
+          onPrimaryUpdated={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
           onUpdateTokens={fetchSlackIntegrationData}

+ 41 - 31
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -86,17 +86,27 @@ const RegisteringProxyUrlProcess = () => {
   const { t } = useTranslation();
   return (
     <div className="container w-75 py-5">
-      <p
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.copy_proxy_url') }}
-      />
-      <img className="mb-5 border border-light img-fluid" src="/images/slack-integration/growi-register-sentence.png" />
-      <span
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.enter_proxy_url_and_update') }}
-      />
-      <p className="text-danger">{t('admin:slack_integration.accordion.dont_need_update')}</p>
-      <img className="mb-3 border border-light img-fluid" src="/images/slack-integration/growi-set-proxy-url.png" />
+      <ol>
+        <li>
+          <p
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.copy_proxy_url') }}
+          />
+          <p>
+            <img className="border border-light img-fluid" src="/images/slack-integration/growi-register-sentence.png" />
+          </p>
+        </li>
+        <li>
+          <p
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.enter_proxy_url_and_update') }}
+          />
+          <p>
+            <img className="border border-light img-fluid" src="/images/slack-integration/growi-set-proxy-url.png" />
+          </p>
+          <p className="text-danger">{t('admin:slack_integration.accordion.dont_need_update')}</p>
+        </li>
+      </ol>
     </div>
   );
 };
@@ -107,7 +117,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
 
   const regenerateTokensHandler = async() => {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/regenerate-tokens', { slackAppIntegrationId });
+      await appContainer.apiv3.put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/regenerate-tokens`);
       if (props.onUpdateTokens != null) {
         props.onUpdateTokens();
       }
@@ -215,7 +225,7 @@ const TestProcess = ({
   const submitForm = async(e) => {
     e.preventDefault();
     try {
-      await apiv3Post('/slack-integration-settings/with-proxy/relation-test', { slackAppIntegrationId, channel: testChannel });
+      await apiv3Post(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/relation-test`, { channel: testChannel });
       const newLogs = addLogs(logsValue, successMessage, null);
       setLogsValue(newLogs);
 
@@ -309,15 +319,6 @@ const WithProxyAccordions = (props) => {
       />,
     },
     '③': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
-    '④': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -327,6 +328,15 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
     },
+    '④': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
   };
 
   const CustomBotIntegrationProcedure = {
@@ -353,15 +363,6 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
-    '⑥': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -371,6 +372,15 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
     },
+    '⑥': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
+        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
+      />,
+    },
   };
 
   const integrationProcedureMapping = props.botType === 'officialBot' ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;

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

@@ -62,7 +62,7 @@ class CommentEditor extends React.Component {
       isUploadable,
       isUploadableFile,
       errorMessage: undefined,
-      hasSlackConfig: config.hasSlackConfig,
+      isSlackConfigured: config.isSlackConfigured,
     };
 
     this.updateState = this.updateState.bind(this);
@@ -354,7 +354,7 @@ class CommentEditor extends React.Component {
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ this.state.errorMessage && errorMessage }</span>
 
-            { this.state.hasSlackConfig
+            { this.state.isSlackConfigured
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification

+ 10 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -801,6 +801,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
       >
         <EditorIcon icon="CheckList" />
       </Button>,
+      <Button
+        key="nav-item-attachment"
+        color={null}
+        size="sm"
+        title="Attachment"
+        onClick={this.props.onAddAttachmentButtonClicked}
+      >
+        <EditorIcon icon="Attachment" />
+      </Button>,
       <Button
         key="nav-item-link"
         color={null}
@@ -947,6 +956,7 @@ CodeMirrorEditor.propTypes = Object.assign({
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
   onMarkdownHelpButtonClicked: PropTypes.func,
+  onAddAttachmentButtonClicked: PropTypes.func,
 }, AbstractEditor.propTypes);
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,

+ 7 - 1
packages/app/src/components/PageEditor/Editor.jsx

@@ -39,6 +39,7 @@ export default class Editor extends AbstractEditor {
     this.dropHandler = this.dropHandler.bind(this);
 
     this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
+    this.addAttachmentHandler = this.addAttachmentHandler.bind(this);
 
     this.getAcceptableType = this.getAcceptableType.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
@@ -187,6 +188,10 @@ export default class Editor extends AbstractEditor {
     this.setState({ isCheatsheetModalShown: true });
   }
 
+  addAttachmentHandler() {
+    this.dropzone.open();
+  }
+
   getDropzoneClassName(isDragAccept, isDragReject) {
     let className = 'dropzone';
     if (!this.props.isUploadable) {
@@ -314,6 +319,7 @@ export default class Editor extends AbstractEditor {
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}
                         onMarkdownHelpButtonClicked={this.showMarkdownHelp}
+                        onAddAttachmentButtonClicked={this.addAttachmentHandler}
                         {...this.props}
                       />
                     )}
@@ -341,7 +347,7 @@ export default class Editor extends AbstractEditor {
             <button
               type="button"
               className="btn btn-outline-secondary btn-block btn-open-dropzone"
-              onClick={() => { this.dropzone.open() }}
+              onClick={this.addAttachmentHandler}
             >
               <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
               Attach files

+ 7 - 9
packages/app/src/components/PageEditor/EditorIcon.jsx

@@ -118,15 +118,13 @@ const EditorIcon = (props) => {
           <path d="M22.12,17H19.75l-3.12-4H18a1,1,0,0,0,1-1V8a1,1,0,0,0-1-1H12a1,1,0,0,0-1,1v4a1,1,0,0,0,1,1h1.38l-2.92,4H7.88A.94.94,0,0,0,7,18v4a.94.94,0,0,0,.88,1h5.24A.94.94,0,0,0,14,22V18a.94.94,0,0,0-.88-1H11.63l3.13-4h.47l3.13,4H16.88A.94.94,0,0,0,16,18v4a.94.94,0,0,0,.88,1h5.24A.94.94,0,0,0,23,22V18A.94.94,0,0,0,22.12,17ZM13,22H8V18h5ZM12,8h6v4H12ZM22,22H17V18h5Z" />
         </svg>
       );
-    // Unused icon
-    // case 'attachment':
-    //   return (
-    //     <svg xmlns="http://www.w3.org/2000/svg" height="30" viewBox="0 0 30 30">
-    //       <rect fillOpacity="0" width="30" height="30" />
-    //       <path d="M9.71,22.5a2.57,2.57,0,0,1-1.85-.79,2.79,2.79,0,0,1,0-4l9-9.23a3.21,3.21,0,0,1,1.59-.87,3.39,3.39,0,0,1,1.81.1,4.38,4.38,0,0,1,1.7,1.05,4.15,4.15,0,0,1,.46.56,3.73,3.73,0,0,1,.35.65,4.25,4.25,0,0,1,.2.72,3.91,3.91,0,0,1,.07.76,3.71,3.71,0,0,1-1.12,2.67l-6.79,7a.48.48,0,0,1-.34.16.51.51,0,0,1-.35-.13.48.48,0,0,1,0-.7l6.78-7a2.8,2.8,0,0,0,.84-2,2.58,2.58,0,0,0-.79-2,3.63,3.63,0,0,0-1.11-.75,2.41,2.41,0,0,0-1.31-.17,2.19,2.19,0,0,0-1.25.62l-9,9.22A1.8,1.8,0,0,0,8,19.69,1.78,1.78,0,0,0,8.58,21a1.81,1.81,0,0,0,.57.39,1.48,1.48,0,0,0,.66.1,2,2,0,0,0,1.28-.62l7.12-7.35.15-.16a1.15,1.15,0,0,0,.15-.2.9.9,0,0,0,.12-.24,1.17,1.17,0,0,0,.07-.25.52.52,0,0,0-.05-.27.75.75,0,0,0-.19-.26.73.73,0,0,0-.58-.27,1.29,1.29,0,0,0-.67.38l-5.36,5.53a.5.5,0,0,1-.22.13.46.46,0,0,1-.26,0,.48.48,0,0,1-.22-.12A.41.41,0,0,1,11,17.5a.5.5,0,0,1,.14-.35L16.5,11.6a2.19,2.19,0,0,1,1.29-.67,1.69,1.69,0,0,1,1.37.55,1.54,1.54,0,0,1,.53,1.31,2.26,2.26,0,0,1-.76,1.42L11.8,21.58a3.06,3.06,0,0,1-2,.91H9.71Z" />
-    //     </svg>
-    // );
-
+    case 'Attachment':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="30" viewBox="0 0 30 30">
+          <rect fillOpacity="0" width="30" height="30" />
+          <path d="M9.71,22.5a2.57,2.57,0,0,1-1.85-.79,2.79,2.79,0,0,1,0-4l9-9.23a3.21,3.21,0,0,1,1.59-.87,3.39,3.39,0,0,1,1.81.1,4.38,4.38,0,0,1,1.7,1.05,4.15,4.15,0,0,1,.46.56,3.73,3.73,0,0,1,.35.65,4.25,4.25,0,0,1,.2.72,3.91,3.91,0,0,1,.07.76,3.71,3.71,0,0,1-1.12,2.67l-6.79,7a.48.48,0,0,1-.34.16.51.51,0,0,1-.35-.13.48.48,0,0,1,0-.7l6.78-7a2.8,2.8,0,0,0,.84-2,2.58,2.58,0,0,0-.79-2,3.63,3.63,0,0,0-1.11-.75,2.41,2.41,0,0,0-1.31-.17,2.19,2.19,0,0,0-1.25.62l-9,9.22A1.8,1.8,0,0,0,8,19.69,1.78,1.78,0,0,0,8.58,21a1.81,1.81,0,0,0,.57.39,1.48,1.48,0,0,0,.66.1,2,2,0,0,0,1.28-.62l7.12-7.35.15-.16a1.15,1.15,0,0,0,.15-.2.9.9,0,0,0,.12-.24,1.17,1.17,0,0,0,.07-.25.52.52,0,0,0-.05-.27.75.75,0,0,0-.19-.26.73.73,0,0,0-.58-.27,1.29,1.29,0,0,0-.67.38l-5.36,5.53a.5.5,0,0,1-.22.13.46.46,0,0,1-.26,0,.48.48,0,0,1-.22-.12A.41.41,0,0,1,11,17.5a.5.5,0,0,1,.14-.35L16.5,11.6a2.19,2.19,0,0,1,1.29-.67,1.69,1.69,0,0,1,1.37.55,1.54,1.54,0,0,1,.53,1.31,2.26,2.26,0,0,1-.76,1.42L11.8,21.58a3.06,3.06,0,0,1-2,.91H9.71Z" />
+        </svg>
+      );
   }
 
 

+ 3 - 3
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx

@@ -19,7 +19,7 @@ const EditorNavbarBottom = (props) => {
   const [isExpanded, setExpanded] = useState(false);
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
-  const hasSlackConfig = props.appContainer.getConfig().hasSlackConfig;
+  const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
   const {
     navigationContainer,
@@ -61,7 +61,7 @@ const EditorNavbarBottom = (props) => {
   return (
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
       {/* Collapsed SlackNotification */}
-      {hasSlackConfig && (
+      {isSlackConfigured && (
         <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
             <SlackNotification
@@ -84,7 +84,7 @@ const EditorNavbarBottom = (props) => {
         <form className="form-inline flex-nowrap ml-auto">
           {/* Responsive Design for the SlackNotification */}
           {/* Button or the normal Slack banner */}
-          {hasSlackConfig && (isDeviceSmallerThanMd ? (
+          {isSlackConfigured && (isDeviceSmallerThanMd ? (
             <Button
               className="grw-btn-slack border mr-2"
               onClick={() => (setSlackExpanded(!isSlackExpanded))}

+ 6 - 1
packages/app/src/server/crowi/express-init.js

@@ -53,7 +53,12 @@ module.exports = function(crowi, app) {
       nsSeparator: '::',
     });
 
-  app.use(helmet());
+  app.use(helmet({
+    contentSecurityPolicy: false,
+    expectCt: false,
+    referrerPolicy: false,
+    permittedCrossDomainPolicies: false,
+  }));
 
   app.use((req, res, next) => {
     const now = new Date();

+ 10 - 45
packages/app/src/server/crowi/index.js

@@ -13,8 +13,11 @@ import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import ConfigManager from '../service/config-manager';
+import AppService from '../service/app';
 import AclService from '../service/acl';
 import AttachmentService from '../service/attachment';
+import { SlackIntegrationService } from '../service/slack-integration';
+import { UserNotificationService } from '../service/user-notification';
 
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
@@ -45,7 +48,6 @@ function Crowi() {
   this.passportService = null;
   this.globalNotificationService = null;
   this.userNotificationService = null;
-  this.slackNotificationService = null;
   this.xssService = null;
   this.aclService = null;
   this.appService = null;
@@ -60,7 +62,7 @@ function Crowi() {
   this.syncPageStatusService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
-  this.slackBotService = null;
+  this.slackIntegrationService = null;
   this.xss = new Xss();
 
   this.tokens = null;
@@ -93,12 +95,10 @@ Crowi.prototype.init = async function() {
 
   // customizeService depends on AppService and XssService
   // passportService depends on appService
-  // slack depends on setUpSlacklNotification
   // export and import depends on setUpGrowiBridge
   await Promise.all([
     this.setUpApp(),
     this.setUpXss(),
-    this.setUpSlacklNotification(),
     this.setUpGrowiBridge(),
   ]);
 
@@ -107,8 +107,7 @@ Crowi.prototype.init = async function() {
     this.setupPassport(),
     this.setupSearcher(),
     this.setupMailer(),
-    this.setupSlack(),
-    this.setupSlackLegacy(),
+    this.setupSlackIntegrationService(),
     this.setupCsrf(),
     this.setUpFileUpload(),
     this.setUpFileUploaderSwitchService(),
@@ -121,7 +120,6 @@ Crowi.prototype.init = async function() {
     this.setupImport(),
     this.setupPageService(),
     this.setupSyncPageStatusService(),
-    this.setupSlackBotService(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -137,11 +135,9 @@ Crowi.prototype.initForTest = async function() {
 
   // // customizeService depends on AppService and XssService
   // // passportService depends on appService
-  // // slack depends on setUpSlacklNotification
   await Promise.all([
     this.setUpApp(),
     this.setUpXss(),
-    // this.setUpSlacklNotification(),
     // this.setUpGrowiBridge(),
   ]);
 
@@ -150,7 +146,7 @@ Crowi.prototype.initForTest = async function() {
     this.setupPassport(),
     // this.setupSearcher(),
     // this.setupMailer(),
-    // this.setupSlack(),
+    // this.setupSlackIntegrationService(),
     // this.setupCsrf(),
     // this.setUpFileUpload(),
     this.setupAttachmentService(),
@@ -383,24 +379,6 @@ Crowi.prototype.setupMailer = async function() {
   }
 };
 
-Crowi.prototype.setupSlack = async function() {
-  const self = this;
-
-  return new Promise(((resolve, reject) => {
-    self.slack = require('../util/slack')(self);
-    resolve();
-  }));
-};
-
-Crowi.prototype.setupSlackLegacy = async function() {
-  const self = this;
-
-  return new Promise(((resolve, reject) => {
-    self.slackLegacy = require('../util/slack-legacy')(self);
-    resolve();
-  }));
-};
-
 Crowi.prototype.setupCsrf = async function() {
   const Tokens = require('csrf');
   this.tokens = new Tokens();
@@ -525,22 +503,11 @@ Crowi.prototype.setUpGlobalNotification = async function() {
  * setup UserNotificationService
  */
 Crowi.prototype.setUpUserNotification = async function() {
-  const UserNotificationService = require('../service/user-notification');
   if (this.userNotificationService == null) {
     this.userNotificationService = new UserNotificationService(this);
   }
 };
 
-/**
- * setup SlackNotificationService
- */
-Crowi.prototype.setUpSlacklNotification = async function() {
-  const SlackNotificationService = require('../service/slack-notification');
-  if (this.slackNotificationService == null) {
-    this.slackNotificationService = new SlackNotificationService(this.configManager);
-  }
-};
-
 /**
  * setup XssService
  */
@@ -581,7 +548,6 @@ Crowi.prototype.setUpCustomize = async function() {
  * setup AppService
  */
 Crowi.prototype.setUpApp = async function() {
-  const AppService = require('../service/app');
   if (this.appService == null) {
     this.appService = new AppService(this);
 
@@ -681,15 +647,14 @@ Crowi.prototype.setupSyncPageStatusService = async function() {
   }
 };
 
-Crowi.prototype.setupSlackBotService = async function() {
-  const SlackBotService = require('../service/slackbot');
-  if (this.slackBotService == null) {
-    this.slackBotService = new SlackBotService(this);
+Crowi.prototype.setupSlackIntegrationService = async function() {
+  if (this.slackIntegrationService == null) {
+    this.slackIntegrationService = new SlackIntegrationService(this);
   }
 
   // add as a message handler
   if (this.s2sMessagingService != null) {
-    this.s2sMessagingService.addMessageHandler(this.slackBotService);
+    this.s2sMessagingService.addMessageHandler(this.slackIntegrationService);
   }
 };
 

+ 1 - 1
packages/app/src/server/models/config.ts

@@ -216,7 +216,7 @@ schema.statics.getLocalconfig = function(crowi) {
     isSavedStatesOfTabChanges: crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
     isEnabledAttachTitleHeader: crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
     customizeScript: crowi.configManager.getConfig('crowi', 'customize:script'),
-    hasSlackConfig: crowi.slackNotificationService.hasSlackConfig(),
+    isSlackConfigured: crowi.slackIntegrationService.isSlackConfigured,
     env: {
       PLANTUML_URI: env.PLANTUML_URI || null,
       BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,

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

@@ -12,7 +12,6 @@ module.exports = {
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),
   Attachment: require('./attachment'),
-  UpdatePost: require('./updatePost'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),

+ 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)
    */

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

@@ -4,6 +4,7 @@ const mongoose = require('mongoose');
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
+  isPrimary: { type: Boolean, unique: true, sparse: true },
   supportedCommandsForBroadcastUse: { type: [String], default: [] },
   supportedCommandsForSingleUse: { type: [String], default: [] },
 });

+ 122 - 0
packages/app/src/server/models/update-post.ts

@@ -0,0 +1,122 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import {
+  Types, Schema, Model, Document,
+} from 'mongoose';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+export interface IUpdatePost {
+  pathPattern: string
+  patternPrefix: string
+  patternPrefix2: string
+  channel: string
+  provider: string
+  creator: Schema.Types.ObjectId
+  createdAt: Date
+}
+
+export interface UpdatePostDocument extends IUpdatePost, Document {}
+
+export interface UpdatePostModel extends Model<UpdatePostDocument> {
+  normalizeChannelName(channel): any
+  createPrefixesByPathPattern(pathPattern): any
+  getRegExpByPattern(pattern): any
+  findSettingsByPath(path): Promise<UpdatePostDocument[]>
+  findAll(offset?: number): Promise<UpdatePostDocument[]>
+  createUpdatePost(pathPattern: string, channel: string, creator: Types.ObjectId): Promise<UpdatePostDocument>
+}
+
+/**
+ * This is the setting for notify to 3rd party tool (like Slack).
+ */
+const updatePostSchema = new Schema<UpdatePostDocument, UpdatePostModel>({
+  pathPattern: { type: String, required: true },
+  patternPrefix: { type: String, required: true },
+  patternPrefix2: { type: String, required: true },
+  channel: { type: String, required: true },
+  provider: { type: String, required: true },
+  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  createdAt: { type: Date, default: Date.now },
+});
+
+updatePostSchema.statics.normalizeChannelName = function(channel) {
+  return channel.replace(/(#|,)/g, '');
+};
+
+updatePostSchema.statics.createPrefixesByPathPattern = function(pathPattern) {
+  const patternPrefix = ['*', '*'];
+
+  // not begin with slash
+  if (!pathPattern.match(/^\/.+/)) {
+    return patternPrefix;
+  }
+
+  const pattern = pathPattern.split('/');
+  pattern.shift();
+  if (pattern[0] && pattern[0] !== '*') {
+    patternPrefix[0] = pattern[0];
+  }
+
+  if (pattern[1] && pattern[1] !== '*') {
+    patternPrefix[1] = pattern[1];
+  }
+  return patternPrefix;
+};
+
+updatePostSchema.statics.getRegExpByPattern = function(pattern) {
+  let reg = pattern;
+  if (!reg.match(/^\/.*/)) {
+    reg = `/*${reg}*`;
+  }
+  reg = `^${reg}`;
+  reg = reg.replace(/\//g, '\\/');
+  reg = reg.replace(/(\*)/g, '.*');
+
+  return new RegExp(reg);
+};
+
+updatePostSchema.statics.findSettingsByPath = async function(path) {
+  const prefixes = this.createPrefixesByPathPattern(path);
+
+  const settings = await this.find({
+    $or: [
+      { patternPrefix: prefixes[0], patternPrefix2: prefixes[1] },
+      { patternPrefix: '*', patternPrefix2: '*' },
+      { patternPrefix: prefixes[0], patternPrefix2: '*' },
+      { patternPrefix: '*', patternPrefix2: prefixes[1] },
+    ],
+  });
+  if (settings.length <= 0) {
+    return settings;
+  }
+
+  const validSettings = settings.filter((setting) => {
+    const patternRegex = this.getRegExpByPattern(setting.pathPattern);
+    return patternRegex.test(path);
+  });
+
+  return validSettings;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+updatePostSchema.statics.findAll = function(offset = 0) {
+  return this.find().sort({ createdAt: 1 }).populate('creator').exec();
+};
+
+updatePostSchema.statics.createUpdatePost = async function(pathPattern, channel, creator) {
+  const provider = 'slack'; // now slack only
+
+  const prefixes = this.createPrefixesByPathPattern(pathPattern);
+
+  return this.create({
+    pathPattern,
+    patternPrefix: prefixes[0],
+    patternPrefix2: prefixes[1],
+    channel: this.normalizeChannelName(channel),
+    provider,
+    creator,
+    createdAt: Date.now(),
+  });
+};
+
+export default getOrCreateModel<UpdatePostDocument, UpdatePostModel>('UpdatePost', updatePostSchema);

+ 0 - 153
packages/app/src/server/models/updatePost.js

@@ -1,153 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-/**
- * This is the setting for notify to 3rd party tool (like Slack).
- */
-module.exports = function(crowi) {
-  const debug = require('debug')('growi:models:updatePost');
-  const mongoose = require('mongoose');
-  const ObjectId = mongoose.Schema.Types.ObjectId;
-
-  // TODO: slack 以外の対応
-  const updatePostSchema = new mongoose.Schema({
-    pathPattern: { type: String, required: true },
-    patternPrefix:  { type: String, required: true },
-    patternPrefix2: { type: String, required: true },
-    channel: { type: String, required: true },
-    provider: { type: String, required: true },
-    creator: { type: ObjectId, ref: 'User', index: true },
-    createdAt: { type: Date, default: Date.now },
-  });
-
-  updatePostSchema.statics.normalizeChannelName = function(channel) {
-    return channel.replace(/(#|,)/g, '');
-  };
-
-  updatePostSchema.statics.createPrefixesByPathPattern = function(pathPattern) {
-    const patternPrefix = ['*', '*'];
-
-    // not begin with slash
-    if (!pathPattern.match(/^\/.+/)) {
-      return patternPrefix;
-    }
-
-    const pattern = pathPattern.split('/');
-    pattern.shift();
-    if (pattern[0] && pattern[0] !== '*') {
-      patternPrefix[0] = pattern[0];
-    }
-
-    if (pattern[1] && pattern[1] !== '*') {
-      patternPrefix[1] = pattern[1];
-    }
-    return patternPrefix;
-  };
-
-  updatePostSchema.statics.getRegExpByPattern = function(pattern) {
-    let reg = pattern;
-    if (!reg.match(/^\/.*/)) {
-      reg = `/*${reg}*`;
-    }
-    reg = `^${reg}`;
-    reg = reg.replace(/\//g, '\\/');
-    reg = reg.replace(/(\*)/g, '.*');
-
-    return new RegExp(reg);
-  };
-
-  updatePostSchema.statics.findSettingsByPath = function(path) {
-    const UpdatePost = this;
-    const prefixes = UpdatePost.createPrefixesByPathPattern(path);
-
-    return new Promise(((resolve, reject) => {
-      UpdatePost.find({
-        $or: [
-          { patternPrefix: prefixes[0], patternPrefix2: prefixes[1] },
-          { patternPrefix: '*', patternPrefix2: '*' },
-          { patternPrefix: prefixes[0], patternPrefix2: '*' },
-          { patternPrefix: '*', patternPrefix2: prefixes[1] },
-        ],
-      })
-        .then((settings) => {
-          if (settings.length <= 0) {
-            return resolve(settings);
-          }
-
-          // eslint-disable-next-line no-param-reassign
-          settings = settings.filter((setting) => {
-            const patternRegex = UpdatePost.getRegExpByPattern(setting.pathPattern);
-            return patternRegex.test(path);
-          });
-
-          return resolve(settings);
-        });
-    }));
-  };
-
-  updatePostSchema.statics.findAll = function(offset) {
-    const UpdatePost = this;
-    // eslint-disable-next-line no-param-reassign
-    offset = offset || 0;
-
-    return new Promise(((resolve, reject) => {
-      UpdatePost
-        .find()
-        .sort({ createdAt: 1 })
-        .populate('creator')
-        .exec((err, data) => {
-          if (err) {
-            return reject(err);
-          }
-
-          if (data.length < 1) {
-            return resolve([]);
-          }
-
-          return resolve(data);
-        });
-    }));
-  };
-
-  updatePostSchema.statics.create = function(pathPattern, channel, user) {
-    const UpdatePost = this;
-    const provider = 'slack'; // now slack only
-
-    const prefixes = UpdatePost.createPrefixesByPathPattern(pathPattern);
-    const notif = new UpdatePost();
-    notif.pathPattern = pathPattern;
-    notif.patternPrefix = prefixes[0];
-    notif.patternPrefix2 = prefixes[1];
-    notif.channel = UpdatePost.normalizeChannelName(channel);
-    notif.provider = provider;
-    notif.creator = user;
-    notif.createdAt = Date.now();
-
-    return new Promise(((resolve, reject) => {
-      notif.save((err, doc) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(doc);
-      });
-    }));
-  };
-
-  updatePostSchema.statics.remove = function(id) {
-    const UpdatePost = this;
-
-    return new Promise(((resolve, reject) => {
-      UpdatePost.findOneAndRemove({ _id: id }, (err, data) => {
-        if (err) {
-          debug('UpdatePost.findOneAndRemove failed', err);
-          return reject(err);
-        }
-
-        return resolve(data);
-      });
-    }));
-  };
-
-  return mongoose.model('UpdatePost', updatePostSchema);
-};

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

@@ -14,7 +14,7 @@ module.exports = function(crowi, app) {
   const {
     configManager,
     aclService,
-    slackNotificationService,
+    slackIntegrationService,
     exportService,
   } = crowi;
 
@@ -160,7 +160,7 @@ module.exports = function(crowi, app) {
     const code = req.query.code;
     const { t } = req;
 
-    if (!code || !slackNotificationService.hasSlackConfig()) {
+    if (!code || !slackIntegrationService.isSlackConfigured()) {
       return res.redirect('/admin/notification');
     }
 

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

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
@@ -122,7 +122,7 @@ module.exports = (crowi) => {
    *                  info:
    *                    $ref: '#/components/schemas/HealthcheckInfo'
    */
-  router.get('/', helmet.noCache(), async(req, res) => {
+  router.get('/', noCache(), async(req, res) => {
     let checkServices = req.query.checkServices || [];
     let isStrictly = req.query.strictly != null;
 

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

@@ -48,6 +48,7 @@ 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));

+ 9 - 71
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,53 +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'),
-      };
-      await crowi.setupSlackLegacy();
-      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
   *
@@ -221,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);
@@ -268,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) {

+ 14 - 11
packages/app/src/server/routes/apiv3/page.js

@@ -185,7 +185,7 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/Page'
    */
   router.put('/likes', accessTokenParser, loginRequiredStrictly, csrf, validator.likes, apiV3FormValidator, async(req, res) => {
-    const { pageId, bool } = req.body;
+    const { pageId, bool: isLiked } = req.body;
 
     let page;
     try {
@@ -193,7 +193,8 @@ module.exports = (crowi) => {
       if (page == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
-      if (bool) {
+
+      if (isLiked) {
         page = await page.like(req.user);
       }
       else {
@@ -205,17 +206,19 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    try {
-      // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
-    }
-    catch (err) {
-      logger.error('Like notification failed', err);
-    }
-
     const result = { page };
     result.seenUser = page.seenUsers;
-    return res.apiv3({ result });
+    res.apiv3({ result });
+
+    if (isLiked) {
+      try {
+        // global notification
+        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
+      }
+      catch (err) {
+        logger.error('Like notification failed', err);
+      }
+    }
   });
 
   /**

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

@@ -7,7 +7,7 @@ const { body } = require('express-validator');
 
 const router = express.Router();
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -41,7 +41,7 @@ module.exports = (crowi) => {
    *                  info:
    *                    type: object
    */
-  router.get('/indices', helmet.noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/indices', noCache(), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
     const { searchService } = crowi;
 
     if (!searchService.isConfigured) {

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

+ 193 - 160
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -7,7 +7,10 @@ const axios = require('axios');
 const urljoin = require('url-join');
 
 const {
-  getConnectionStatus, getConnectionStatuses, sendSuccessMessage, defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+  getConnectionStatus, getConnectionStatuses,
+  sendSuccessMessage,
+  defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
+  REQUEST_TIMEOUT_FOR_GTOP,
 } = require('@growi/slack');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
@@ -16,6 +19,8 @@ const logger = loggerFactory('growi:routes:apiv3:slack-integration-settings');
 
 const router = express.Router();
 
+const OFFICIAL_SLACKBOT_PROXY_URI = 'https://slackbot-proxy.growi.org';
+
 /**
  * @swagger
  *  tags:
@@ -51,10 +56,10 @@ module.exports = (crowi) => {
   const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 
   const validator = {
-    BotType: [
+    botType: [
       body('currentBotType').isString(),
     ],
-    SlackIntegration: [
+    slackIntegration: [
       body('currentBotType')
         .isIn(['officialBot', 'customBotWithoutProxy', 'customBotWithProxy']),
     ],
@@ -62,42 +67,52 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
     ],
+    makePrimary: [
+      param('id').isMongoId().withMessage('id is required'),
+    ],
     updateSupportedCommands: [
       body('supportedCommandsForSingleUse').toArray(),
       body('supportedCommandsForBroadcastUse').toArray(),
       param('id').isMongoId().withMessage('id is required'),
     ],
-    RelationTest: [
-      body('slackAppIntegrationId').isMongoId(),
+    relationTest: [
+      param('id').isMongoId(),
       body('channel').trim().isString(),
     ],
+    regenerateTokens: [
+      param('id').isMongoId(),
+    ],
     deleteIntegration: [
-      query('integrationIdToDelete').isMongoId(),
+      param('id').isMongoId(),
     ],
-    SlackChannel: [
+    slackChannel: [
       body('channel').trim().not().isEmpty()
         .isString(),
     ],
   };
 
-  async function resetAllBotSettings() {
+  async function updateSlackBotSettings(params) {
+    const { configManager } = crowi;
+    // update config without publishing S2sMessage
+    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+  }
+
+  async function resetAllBotSettings(initializedType) {
     await SlackAppIntegration.deleteMany();
 
     const params = {
-      'slackbot:currentBotType': null,
+      'slackbot:currentBotType': initializedType,
       'slackbot:signingSecret': null,
       'slackbot:token': null,
       'slackbot:proxyServerUri': null,
     };
-    const { configManager } = crowi;
-    // update config without publishing S2sMessage
-    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
-  }
 
-  async function updateSlackBotSettings(params) {
-    const { configManager } = crowi;
-    // update config without publishing S2sMessage
-    return configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+    // set url if officialBot is specified
+    if (initializedType === 'officialBot') {
+      params['slackbot:proxyServerUri'] = OFFICIAL_SLACKBOT_PROXY_URI;
+    }
+
+    return updateSlackBotSettings(params);
   }
 
   async function getConnectionStatusesFromProxy(tokens) {
@@ -107,6 +122,7 @@ module.exports = (crowi) => {
     const result = await axios.get(urljoin(proxyUri, '/g2s/connection-status'), {
       headers: {
         'x-growi-gtop-tokens': csv,
+        timeout: REQUEST_TIMEOUT_FOR_GTOP,
       },
     });
 
@@ -119,13 +135,22 @@ module.exports = (crowi) => {
       throw new Error('Proxy URL is not registered');
     }
 
-    const headers = {
-      'x-growi-gtop-tokens': token,
-    };
-
-    const result = await axios[method](urljoin(proxyUri, endpoint), body, { headers });
+    try {
+      const result = await axios[method](
+        urljoin(proxyUri, endpoint),
+        body, {
+          headers: {
+            'x-growi-gtop-tokens': token,
+          },
+          timeout: REQUEST_TIMEOUT_FOR_GTOP,
+        },
+      );
 
-    return result.data;
+      return result.data;
+    }
+    catch (err) {
+      throw new Error(`Requesting to proxy server failed: ${err.message}`);
+    }
   }
 
   /**
@@ -206,7 +231,7 @@ module.exports = (crowi) => {
           });
         }
         catch (e) {
-          errorMsg = 'Incorrect Proxy URL';
+          errorMsg = 'Something went wrong when retrieving information from Proxy Server.';
           errorCode = 'test-connection-failed';
           logger.error(errorMsg, e);
         }
@@ -218,48 +243,15 @@ module.exports = (crowi) => {
     });
   });
 
-  /**
-   * @swagger
-   *
-   *    /slack-integration-settings/:
-   *      put:
-   *        tags: [SlackIntegration]
-   *        operationId: putSlackIntegration
-   *        summary: put /slack-integration
-   *        description: Put SlackIntegration setting.
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/SlackIntegration'
-   *        responses:
-   *           200:
-   *             description: Succeeded to put Slack Integration setting.
-   */
-  router.put('/', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.SlackIntegration, apiV3FormValidator, async(req, res) => {
-    const { currentBotType } = req.body;
-
-    const requestParams = {
-      'slackbot:currentBotType': currentBotType,
-    };
-
-    try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
 
-      const slackIntegrationSettingsParams = {
-        currentBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType'),
-      };
-      return res.apiv3({ slackIntegrationSettingsParams });
-    }
-    catch (error) {
-      const msg = 'Error occured in updating Slack bot setting';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-SlackIntegrationSetting-failed'), 500);
-    }
-  });
+  const handleBotTypeChanging = async(req, res, initializedBotType) => {
+    await resetAllBotSettings(initializedBotType);
+    crowi.slackIntegrationService.publishUpdatedMessage();
 
+    // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
+    const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
+    return res.apiv3({ slackBotTypeParam });
+  };
 
   /**
    * @swagger
@@ -280,23 +272,15 @@ module.exports = (crowi) => {
    *           200:
    *             description: Succeeded to put botType setting.
    */
-  router.put('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.BotType, apiV3FormValidator, async(req, res) => {
+  router.put('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.botType, apiV3FormValidator, async(req, res) => {
     const { currentBotType } = req.body;
 
-    await resetAllBotSettings();
-    const requestParams = { 'slackbot:currentBotType': currentBotType };
-
-    if (currentBotType === 'officialBot') {
-      requestParams['slackbot:proxyServerUri'] = 'https://slackbot-proxy.growi.org';
+    if (currentBotType == null) {
+      return res.apiv3Err(new ErrorV3('The param \'currentBotType\' must be specified.', 'update-CustomBotSetting-failed'), 400);
     }
 
     try {
-      await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
-
-      // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
-      const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
-      return res.apiv3({ slackBotTypeParam });
+      await handleBotTypeChanging(req, res, currentBotType);
     }
     catch (error) {
       const msg = 'Error occured in updating Custom bot setting';
@@ -324,22 +308,13 @@ module.exports = (crowi) => {
    *             description: Succeeded to delete botType setting.
    */
   router.delete('/bot-type', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
-
-    await resetAllBotSettings();
-    const params = { 'slackbot:currentBotType': null };
-
     try {
-      await updateSlackBotSettings(params);
-      crowi.slackBotService.publishUpdatedMessage();
-
-      // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
-      const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
-      return res.apiv3({ slackBotTypeParam });
+      await handleBotTypeChanging(req, res, null);
     }
     catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
+      const msg = 'Error occured in resetting all';
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'resetting-all-failed'), 500);
     }
   });
 
@@ -370,7 +345,7 @@ module.exports = (crowi) => {
     };
     try {
       await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
+      crowi.slackIntegrationService.publishUpdatedMessage();
 
       const customBotWithoutProxySettingParams = {
         slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:signingSecret'),
@@ -390,7 +365,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *    /slack-integration-settings/slack-app-integrations:
-   *      put:
+   *      post:
    *        tags: [SlackIntegration]
    *        operationId: putSlackAppIntegrations
    *        summary: /slack-integration
@@ -399,19 +374,20 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to create slack app integration
    */
-  router.put('/slack-app-integrations', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-    const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
-    if (SlackAppIntegrationRecordsNum >= 10) {
-      const msg = 'Not be able to create more than 10 slack workspace integration settings';
-      logger.error('Error', msg);
-      return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
-    }
-
+  router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
+      const count = await SlackAppIntegration.countDocuments();
+      if (count >= 10) {
+        const msg = 'Not be able to create more than 10 slack workspace integration settings';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
+      }
+
       const slackAppTokens = await SlackAppIntegration.create({
         tokenGtoP,
         tokenPtoG,
+        isPrimary: count === 0 ? true : undefined,
         supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
         supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
       });
@@ -427,37 +403,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /slack-integration-settings/regenerate-tokens:
-   *      put:
-   *        tags: [SlackIntegration]
-   *        operationId: putRegenerateTokens
-   *        summary: /slack-integration
-   *        description: Regenerate SlackAppTokens
-   *        responses:
-   *          200:
-   *            description: Succeeded to regenerate slack app tokens
-   */
-  router.put('/regenerate-tokens', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-
-    const { slackAppIntegrationId } = req.body;
-
-    try {
-      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
-      const slackAppTokens = await SlackAppIntegration.findByIdAndUpdate(slackAppIntegrationId, { tokenGtoP, tokenPtoG });
-
-      return res.apiv3(slackAppTokens, 200);
-    }
-    catch (error) {
-      const msg = 'Error occurred during regenerating slack app tokens';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'regenerating-slackAppTokens-failed'), 500);
-    }
-  });
-
-  /**
-   * @swagger
-   *
-   *    /slack-integration-settings/slack-app-integration:
+   *    /slack-integration-settings/slack-app-integrations/:id:
    *      delete:
    *        tags: [SlackIntegration]
    *        operationId: deleteAccessTokens
@@ -467,11 +413,19 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to delete access tokens for slack
    */
-  router.delete('/slack-app-integration', validator.deleteIntegration, apiV3FormValidator, async(req, res) => {
+  router.delete('/slack-app-integrations/:id', validator.deleteIntegration, apiV3FormValidator, async(req, res) => {
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-    const { integrationIdToDelete } = req.query;
+    const { id } = req.params;
+
     try {
-      const response = await SlackAppIntegration.findOneAndDelete({ _id: integrationIdToDelete });
+      const response = await SlackAppIntegration.findOneAndDelete({ _id: id });
+
+      // update primary
+      const countOfPrimary = await SlackAppIntegration.countDocuments({ isPrimary: true });
+      if (countOfPrimary === 0) {
+        await SlackAppIntegration.updateOne({}, { isPrimary: true });
+      }
+
       return res.apiv3({ response });
     }
     catch (error) {
@@ -488,13 +442,13 @@ module.exports = (crowi) => {
 
     try {
       await updateSlackBotSettings(requestParams);
-      crowi.slackBotService.publishUpdatedMessage();
+      crowi.slackIntegrationService.publishUpdatedMessage();
       return res.apiv3({});
     }
     catch (error) {
       const msg = 'Error occured in updating Custom bot setting';
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-SlackAppIntegration-failed'), 500);
     }
 
   });
@@ -502,7 +456,83 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /slack-integration-settings/:id/supported-commands:
+   *    /slack-integration-settings/slack-app-integrations/:id/makeprimary:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: makePrimary
+   *        summary: /slack-integration
+   *        description: Make SlackAppTokens primary
+   *        responses:
+   *          200:
+   *            description: Succeeded to make it primary
+   */
+  // eslint-disable-next-line max-len
+  router.put('/slack-app-integrations/:id/make-primary', loginRequiredStrictly, adminRequired, csrf, validator.makePrimary, apiV3FormValidator, async(req, res) => {
+
+    const { id } = req.params;
+
+    try {
+      await SlackAppIntegration.bulkWrite([
+        // unset isPrimary for others
+        {
+          updateMany: {
+            filter: { _id: { $ne: id } },
+            update: { $unset: { isPrimary: '' } },
+          },
+        },
+        // set primary
+        {
+          updateOne: {
+            filter: { _id: id },
+            update: { isPrimary: true },
+          },
+        },
+      ]);
+
+      return res.apiv3();
+    }
+    catch (error) {
+      const msg = 'Error occurred during making SlackAppIntegration primary';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'making-primary-failed'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/slack-app-integrations/:id/regenerate-tokens:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: putRegenerateTokens
+   *        summary: /slack-integration
+   *        description: Regenerate SlackAppTokens
+   *        responses:
+   *          200:
+   *            description: Succeeded to regenerate slack app tokens
+   */
+  // eslint-disable-next-line max-len
+  router.put('/slack-app-integrations/:id/regenerate-tokens', loginRequiredStrictly, adminRequired, csrf, validator.regenerateTokens, apiV3FormValidator, async(req, res) => {
+
+    const { id } = req.params;
+
+    try {
+      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
+      const slackAppTokens = await SlackAppIntegration.findByIdAndUpdate(id, { tokenGtoP, tokenPtoG });
+
+      return res.apiv3(slackAppTokens, 200);
+    }
+    catch (error) {
+      const msg = 'Error occurred during regenerating slack app tokens';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'regenerating-slackAppTokens-failed'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/slack-app-integrations/:id/supported-commands:
    *      put:
    *        tags: [SlackIntegration]
    *        operationId: putSupportedCommands
@@ -512,7 +542,8 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to update supported commands
    */
-  router.put('/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/slack-app-integrations/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
     const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
     const { id } = req.params;
 
@@ -523,56 +554,58 @@ module.exports = (crowi) => {
         { new: true },
       );
 
-      await requestToProxyServer(
-        slackAppIntegration.tokenGtoP,
-        'put',
-        '/g2s/supported-commands',
-        {
-          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
-          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
-        },
-      );
+      const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+      if (proxyUri != null) {
+        await requestToProxyServer(
+          slackAppIntegration.tokenGtoP,
+          'put',
+          '/g2s/supported-commands',
+          {
+            supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
+            supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+          },
+        );
+      }
 
       return res.apiv3({ slackAppIntegration });
     }
     catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
+      const msg = `Error occured in updating settings. Cause: ${error.message}`;
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'update-supported-commands-failed'), 500);
     }
   });
 
   /**
    * @swagger
    *
-   *    /slack-integration-settings/with-proxy/relation-test:
+   *    /slack-integration-settings/slack-app-integrations/:id/relation-test:
    *      post:
    *        tags: [botType]
    *        operationId: postRelationTest
-   *        summary: /slack-integration/bot-type
+   *        summary: Test relation
    *        description: Delete botType setting.
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  slackAppIntegrationId:
-   *                    type: string
    *        responses:
    *           200:
    *             description: Succeeded to delete botType setting.
    */
-  router.post('/with-proxy/relation-test', loginRequiredStrictly, adminRequired, csrf, validator.RelationTest, apiV3FormValidator, async(req, res) => {
+  // 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') {
       const msg = 'Not Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
     }
 
-    const { slackAppIntegrationId } = req.body;
+    const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    if (proxyUri == null) {
+      return res.apiv3Err(new ErrorV3('Proxy URL is null.', 'not-proxy-Uri'), 400);
+    }
+
+    const { id } = req.params;
     let slackBotToken;
     try {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: slackAppIntegrationId });
+      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: id });
       if (slackAppIntegration == null) {
         const msg = 'Could not find SlackAppIntegration by id';
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
@@ -631,7 +664,7 @@ module.exports = (crowi) => {
    *           200:
    *             description: Succeeded to connect to slack work space.
    */
-  router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.SlackChannel, apiV3FormValidator, async(req, res) => {
+  router.post('/without-proxy/test', loginRequiredStrictly, adminRequired, csrf, validator.slackChannel, apiV3FormValidator, async(req, res) => {
     const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
     if (currentBotType !== 'customBotWithoutProxy') {
       const msg = 'Select Without Proxy Type';

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

@@ -14,7 +14,7 @@ const { respondIfSlackbotError } = require('../../service/slack-command-handler/
 module.exports = (crowi) => {
   this.app = crowi.express;
 
-  const { configManager } = crowi;
+  const { configManager, slackIntegrationService } = crowi;
 
   // Check if the access token is correct
   async function verifyAccessTokenFromProxy(req, res, next) {
@@ -98,32 +98,7 @@ module.exports = (crowi) => {
     return next();
   };
 
-  const generateClientForResponse = (tokenGtoP) => {
-    const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-
-    if (currentBotType == null) {
-      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
-    }
-
-    let token;
-
-    // connect directly
-    if (tokenGtoP == null) {
-      token = crowi.configManager.getConfig('crowi', 'slackbot:token');
-      return generateWebClient(token);
-    }
-
-    // connect to proxy
-    const proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
-    const serverUri = urljoin(proxyServerUri, '/g2s');
-    const headers = {
-      'x-growi-gtop-tokens': tokenGtoP,
-    };
-
-    return generateWebClient(token, serverUri, headers);
-  };
-
-  async function handleCommands(req, res) {
+  async function handleCommands(req, res, client) {
     const { body } = req;
 
     if (body.text == null) {
@@ -136,25 +111,17 @@ 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();
-
-    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    res.json({
+      response_type: 'ephemeral',
+      text: 'Processing your request ...',
+    });
 
-    // generate client
-    let client;
-    if (tokenPtoG == null) {
-      client = generateClientForResponse();
-    }
-    else {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
-      client = generateClientForResponse(slackAppIntegration.tokenGtoP);
-    }
 
     const args = body.text.split(' ');
     const command = args[0];
 
     try {
-      await crowi.slackBotService.handleCommandRequest(command, client, body, args);
+      await crowi.slackIntegrationService.handleCommandRequest(command, client, body, args);
     }
     catch (err) {
       await respondIfSlackbotError(client, body, err);
@@ -163,7 +130,8 @@ module.exports = (crowi) => {
   }
 
   router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
-    return handleCommands(req, res);
+    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+    return handleCommands(req, res, client);
   });
 
   router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
@@ -175,27 +143,18 @@ module.exports = (crowi) => {
       return res.send({ challenge: body.challenge });
     }
 
-    return handleCommands(req, res);
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+
+    return handleCommands(req, res, client);
   });
 
-  async function handleInteractions(req, res) {
+  async function handleInteractions(req, res, client) {
 
     // 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 tokenPtoG = req.headers['x-growi-ptog-tokens'];
-    // generate client
-    let client;
-    if (tokenPtoG == null) {
-      client = generateClientForResponse();
-    }
-    else {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
-      client = generateClientForResponse(slackAppIntegration.tokenGtoP);
-    }
-
     const payload = JSON.parse(req.body.payload);
     const { type } = payload;
 
@@ -203,7 +162,7 @@ module.exports = (crowi) => {
       switch (type) {
         case 'block_actions':
           try {
-            await crowi.slackBotService.handleBlockActionsRequest(client, payload);
+            await crowi.slackIntegrationService.handleBlockActionsRequest(client, payload);
           }
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
@@ -211,7 +170,7 @@ module.exports = (crowi) => {
           break;
         case 'view_submission':
           try {
-            await crowi.slackBotService.handleViewSubmissionRequest(client, payload);
+            await crowi.slackIntegrationService.handleViewSubmissionRequest(client, payload);
           }
           catch (err) {
             await respondIfSlackbotError(client, req.body, err);
@@ -228,11 +187,15 @@ module.exports = (crowi) => {
   }
 
   router.post('/interactions', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
-    return handleInteractions(req, res);
+    const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
+    return handleInteractions(req, res, client);
   });
 
   router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
-    return handleInteractions(req, res);
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
+
+    return handleInteractions(req, res, client);
   });
 
   router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {

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

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const helmet = require('helmet');
+const noCache = require('nocache');
 
 const USER_STATUS_MASTER = {
   1: 'registered',
@@ -97,7 +97,7 @@ module.exports = (crowi) => {
    *                    type: object
    *                    description: Statistics for all user
    */
-  router.get('/user', helmet.noCache(), async(req, res) => {
+  router.get('/user', noCache(), async(req, res) => {
     const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
     res.status(200).send({ data });
   });

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

+ 1 - 3
packages/app/src/server/service/app.ts

@@ -12,7 +12,7 @@ const logger = loggerFactory('growi:service:AppService');
 /**
  * the service class of AppService
  */
-class AppService implements S2sMessageHandlable {
+export default class AppService implements S2sMessageHandlable {
 
   crowi!: any;
 
@@ -131,5 +131,3 @@ class AppService implements S2sMessageHandlable {
   }
 
 }
-
-module.exports = AppService;

+ 11 - 7
packages/app/src/server/service/global-notification/global-notification-slack.js

@@ -2,6 +2,10 @@ import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 
+import {
+  prepareSlackMessageForGlobalNotification,
+} from '../../util/slack';
+
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 
@@ -14,8 +18,7 @@ class GlobalNotificationSlackService {
 
   constructor(crowi) {
     this.crowi = crowi;
-    this.slack = crowi.getSlack();
-    this.slackLegacy = crowi.getSlackLegacy();
+
     this.type = crowi.model('GlobalNotificationSetting').TYPE.SLACK;
     this.event = crowi.model('GlobalNotificationSetting').EVENT;
   }
@@ -33,20 +36,21 @@ class GlobalNotificationSlackService {
    * @param {{ comment: Comment, oldPath: string }} _ event specific vars
    */
   async fire(event, id, path, triggeredBy, vars) {
+    const { appService, slackIntegrationService } = this.crowi;
+
     const GlobalNotification = this.crowi.model('GlobalNotificationSetting');
     const notifications = await GlobalNotification.findSettingByPathAndEvent(event, path, this.type);
 
     const messageBody = this.generateMessageBody(event, id, path, triggeredBy, vars);
     const attachmentBody = this.generateAttachmentBody(event, id, path, triggeredBy, vars);
 
+    const appTitle = appService.getAppTitle();
+
     await Promise.all(notifications.map((notification) => {
-      return [
-        this.slack.sendGlobalNotification(messageBody, attachmentBody, notification.slackChannels),
-        this.slackLegacy.sendGlobalNotification(messageBody, attachmentBody, notification.slackChannels),
-      ];
+      const messageObj = prepareSlackMessageForGlobalNotification(messageBody, attachmentBody, appTitle, notification.slackChannels);
+      return slackIntegrationService.postMessage(messageObj);
     }));
 
-
   }
 
   /**

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

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

@@ -11,7 +11,7 @@ module.exports = () => {
     message += 'Commands:\n\n';
     message += '`/growi create`                          Create new page\n\n';
     message += '`/growi search [keyword]`       Search pages\n\n';
-    message += '`/growi togetter`                      Create new page with existing slack conversations\n\n';
+    message += '`/growi togetter`                      Create new page with existing slack conversations (Alpha)\n\n';
     client.chat.postEphemeral({
       channel: body.channel_id,
       user: body.user_id,

+ 279 - 0
packages/app/src/server/service/slack-integration.ts

@@ -0,0 +1,279 @@
+import mongoose from 'mongoose';
+
+import { IncomingWebhookSendArguments } from '@slack/webhook';
+import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
+import { generateWebClient, markdownSectionBlock } from '@growi/slack';
+
+
+import loggerFactory from '~/utils/logger';
+import S2sMessage from '../models/vo/s2s-message';
+
+import ConfigManager from './config-manager';
+import { S2sMessagingService } from './s2s-messaging/base';
+import { S2sMessageHandlable } from './s2s-messaging/handlable';
+
+
+const logger = loggerFactory('growi:service:SlackBotService');
+
+
+type S2sMessageForSlackIntegration = S2sMessage & { updatedAt: Date };
+
+export class SlackIntegrationService implements S2sMessageHandlable {
+
+  crowi!: any;
+
+  configManager!: ConfigManager;
+
+  s2sMessagingService!: S2sMessagingService;
+
+  lastLoadedAt?: Date;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
+    this.s2sMessagingService = crowi.s2sMessagingService;
+
+    this.initialize();
+  }
+
+  initialize() {
+    this.lastLoadedAt = new Date();
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage: S2sMessageForSlackIntegration): boolean {
+    const { eventName, updatedAt } = s2sMessage;
+    if (eventName !== 'slackIntegrationServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
+  }
+
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(): Promise<void> {
+    const { configManager } = this.crowi;
+
+    logger.info('Reset slack bot by pubsub notification');
+    await configManager.loadConfigs();
+    this.initialize();
+  }
+
+  async publishUpdatedMessage(): Promise<void> {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('slackIntegrationServiceUpdated', { updatedAt: new Date() });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      }
+      catch (e) {
+        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      }
+    }
+  }
+
+  get isSlackConfigured(): boolean {
+    return this.isSlackbotConfigured || this.isSlackLegacyConfigured;
+  }
+
+  get isSlackbotConfigured(): boolean {
+    const hasSlackbotType = !!this.configManager.getConfig('crowi', 'slackbot:currentBotType');
+    return hasSlackbotType;
+  }
+
+  get isSlackLegacyConfigured(): boolean {
+    // for legacy util
+    const hasSlackToken = !!this.configManager.getConfig('notification', 'slack:token');
+    const hasSlackIwhUrl = !!this.configManager.getConfig('notification', 'slack:incomingWebhookUrl');
+
+    return hasSlackToken || hasSlackIwhUrl;
+  }
+
+  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.');
+    }
+
+    return true;
+  }
+
+  /**
+   * generate WebClient instance for 'customBotWithoutProxy' type
+   */
+  async generateClientForCustomBotWithoutProxy(): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    const token = this.configManager.getConfig('crowi', 'slackbot:token');
+
+    if (token == null) {
+      throw new Error('The config \'SLACK_BOT_TOKEN\'(ns: \'crowi\', key: \'slackbot:token\') must be set.');
+    }
+
+    return generateWebClient(token);
+  }
+
+  /**
+   * generate WebClient instance by tokenPtoG
+   * @param tokenPtoG
+   */
+  async generateClientByTokenPtoG(tokenPtoG: string): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+
+    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+
+    if (slackAppIntegration == null) {
+      throw new Error('No SlackAppIntegration exists that corresponds to the tokenPtoG specified.');
+    }
+
+    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+  }
+
+  /**
+   * generate WebClient instance by tokenPtoG
+   * @param tokenPtoG
+   */
+  async generateClientForPrimaryWorkspace(): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    const currentBotType = this.configManager.getConfig('crowi', 'slackbot:currentBotType');
+
+    if (currentBotType === 'customBotWithoutProxy') {
+      return this.generateClientForCustomBotWithoutProxy();
+    }
+
+    // retrieve primary SlackAppIntegration
+    const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+    const slackAppIntegration = await SlackAppIntegration.findOne({ isPrimary: true });
+
+    if (slackAppIntegration == null) {
+      throw new Error('None of the primary SlackAppIntegration exists.');
+    }
+
+    return this.generateClientBySlackAppIntegration(slackAppIntegration as unknown as { tokenGtoP: string; });
+  }
+
+  /**
+   * generate WebClient instance by SlackAppIntegration
+   * @param slackAppIntegration
+   */
+  async generateClientBySlackAppIntegration(slackAppIntegration: { tokenGtoP: string }): Promise<WebClient> {
+    this.isCheckTypeValid();
+
+    // connect to proxy
+    const proxyServerUri = this.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const serverUri = new URL('/g2s', proxyServerUri);
+    const headers = {
+      'x-growi-gtop-tokens': slackAppIntegration.tokenGtoP,
+    };
+
+    return generateWebClient(undefined, serverUri.toString(), headers);
+  }
+
+  async postMessage(messageArgs: ChatPostMessageArguments, slackAppIntegration?: { tokenGtoP: string; }): Promise<void> {
+    // use legacy slack configuration
+    if (this.isSlackLegacyConfigured && !this.isSlackbotConfigured) {
+      return this.postMessageWithLegacyUtil(messageArgs);
+    }
+
+    const client = slackAppIntegration == null
+      ? await this.generateClientForPrimaryWorkspace()
+      : await this.generateClientBySlackAppIntegration(slackAppIntegration);
+
+    try {
+      await client.chat.postMessage(messageArgs);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageArgs);
+      throw error;
+    }
+  }
+
+  private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
+    const slackLegacyUtil = require('../util/slack-legacy')(this.crowi);
+
+    try {
+      await slackLegacyUtil.postMessage(messageArgs);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageArgs);
+      throw error;
+    }
+  }
+
+  /**
+   * Handle /commands endpoint
+   */
+  async handleCommandRequest(command, client, body, ...opt) {
+    let module;
+    try {
+      module = `./slack-command-handler/${command}`;
+    }
+    catch (err) {
+      await this.notCommand(client, body);
+    }
+
+    try {
+      const handler = require(module)(this.crowi);
+      await handler.handleCommand(client, body, ...opt);
+    }
+    catch (err) {
+      throw err;
+    }
+  }
+
+  async handleBlockActionsRequest(client, payload) {
+    const { action_id: actionId } = payload.actions[0];
+    const commandName = actionId.split(':')[0];
+    const handlerMethodName = actionId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
+    try {
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
+    }
+    catch (err) {
+      throw err;
+    }
+    return;
+  }
+
+  async handleViewSubmissionRequest(client, payload) {
+    const { callback_id: callbackId } = payload.view;
+    const commandName = callbackId.split(':')[0];
+    const handlerMethodName = callbackId.split(':')[1];
+    const module = `./slack-command-handler/${commandName}`;
+    try {
+      const handler = require(module)(this.crowi);
+      await handler.handleBlockActions(client, payload, handlerMethodName);
+    }
+    catch (err) {
+      throw err;
+    }
+    return;
+  }
+
+  async notCommand(client, body) {
+    logger.error('Invalid first argument');
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'No command',
+      blocks: [
+        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
+      ],
+    });
+    return;
+  }
+
+}

+ 0 - 22
packages/app/src/server/service/slack-notification.js

@@ -1,22 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:SlackNotification'); // eslint-disable-line no-unused-vars
-/**
- * the service class of SlackNotificationService
- */
-class SlackNotificationService {
-
-  constructor(configManager) {
-    this.configManager = configManager;
-  }
-
-  hasSlackConfig() {
-    const hasSlackToken = !!this.configManager.getConfig('notification', 'slack:token');
-    const hasSlackIwhUrl = !!this.configManager.getConfig('notification', 'slack:incomingWebhookUrl');
-
-    return hasSlackToken || hasSlackIwhUrl;
-  }
-
-}
-
-module.exports = SlackNotificationService;

+ 0 - 136
packages/app/src/server/service/slackbot.ts

@@ -1,136 +0,0 @@
-import loggerFactory from '~/utils/logger';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
-
-const logger = loggerFactory('growi:service:SlackBotService');
-
-const { markdownSectionBlock } = require('@growi/slack');
-
-const S2sMessage = require('../models/vo/s2s-message');
-
-
-class SlackBotService implements S2sMessageHandlable {
-
-  crowi!: any;
-
-  s2sMessagingService!: S2sMessagingService;
-
-  lastLoadedAt?: Date;
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.s2sMessagingService = crowi.s2sMessagingService;
-
-    this.initialize();
-  }
-
-  initialize() {
-    this.lastLoadedAt = new Date();
-  }
-
-  /**
-   * @inheritdoc
-   */
-  shouldHandleS2sMessage(s2sMessage) {
-    const { eventName, updatedAt } = s2sMessage;
-    if (eventName !== 'slackBotServiceUpdated' || updatedAt == null) {
-      return false;
-    }
-
-    return this.lastLoadedAt == null || this.lastLoadedAt < new Date(s2sMessage.updatedAt);
-  }
-
-
-  /**
-   * @inheritdoc
-   */
-  async handleS2sMessage() {
-    const { configManager } = this.crowi;
-
-    logger.info('Reset slack bot by pubsub notification');
-    await configManager.loadConfigs();
-    this.initialize();
-  }
-
-  async publishUpdatedMessage() {
-    const { s2sMessagingService } = this;
-
-    if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('slackBotServiceUpdated', { updatedAt: new Date() });
-
-      try {
-        await s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
-      }
-    }
-  }
-
-  /**
-   * Handle /commands endpoint
-   */
-  async handleCommandRequest(command, client, body, ...opt) {
-    let module;
-    try {
-      module = `./slack-command-handler/${command}`;
-    }
-    catch (err) {
-      await this.notCommand(client, body);
-    }
-
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleCommand(client, body, ...opt);
-    }
-    catch (err) {
-      throw err;
-    }
-  }
-
-  async handleBlockActionsRequest(client, payload) {
-    const { action_id: actionId } = payload.actions[0];
-    const commandName = actionId.split(':')[0];
-    const handlerMethodName = actionId.split(':')[1];
-    const module = `./slack-command-handler/${commandName}`;
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleBlockActions(client, payload, handlerMethodName);
-    }
-    catch (err) {
-      throw err;
-    }
-    return;
-  }
-
-  async handleViewSubmissionRequest(client, payload) {
-    const { callback_id: callbackId } = payload.view;
-    const commandName = callbackId.split(':')[0];
-    const handlerMethodName = callbackId.split(':')[1];
-    const module = `./slack-command-handler/${commandName}`;
-    try {
-      const handler = require(module)(this.crowi);
-      await handler.handleBlockActions(client, payload, handlerMethodName);
-    }
-    catch (err) {
-      throw err;
-    }
-    return;
-  }
-
-  async notCommand(client, body) {
-    logger.error('Invalid first argument');
-    client.chat.postEphemeral({
-      channel: body.channel_id,
-      user: body.user_id,
-      text: 'No command',
-      blocks: [
-        markdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
-      ],
-    });
-    return;
-  }
-
-}
-
-module.exports = SlackBotService;

+ 0 - 61
packages/app/src/server/service/user-notification/index.js

@@ -1,61 +0,0 @@
-const toArrayFromCsv = require('~/utils/to-array-from-csv');
-
-/**
- * service class of UserNotification
- */
-class UserNotificationService {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    this.Page = this.crowi.model('Page');
-  }
-
-  /**
-   * fire user notification
-   *
-   * @memberof UserNotificationService
-   *
-   * @param {Page} page
-   * @param {User} user
-   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
-   * @param {string} mode 'create' or 'update' or 'comment'
-   * @param {string} previousRevision
-   * @param {Comment} comment
-   */
-  async fire(page, user, slackChannelsStr, mode, option, comment = {}) {
-    const {
-      slackNotificationService, slackLegacy, slack,
-    } = this.crowi;
-
-    const opt = option || {};
-    const previousRevision = opt.previousRevision || '';
-
-    await page.updateSlackChannels(slackChannelsStr);
-
-    if (!slackNotificationService.hasSlackConfig()) {
-      throw new Error('slackNotificationService has not been set up');
-    }
-
-    // "dev,slacktest" => [dev,slacktest]
-    const slackChannels = toArrayFromCsv(slackChannelsStr);
-
-    const promises = slackChannels.map(async(chan) => {
-      let res;
-      if (mode === 'comment') {
-        res = await slack.postComment(comment, user, chan, page.path);
-        res = await slackLegacy.postComment(comment, user, chan, page.path);
-      }
-      else {
-        res = await slack.postPage(page, user, chan, mode, previousRevision);
-        res = await slackLegacy.postPage(page, user, chan, mode, previousRevision);
-      }
-      return res;
-    });
-
-    return Promise.allSettled(promises);
-  }
-
-}
-
-module.exports = UserNotificationService;

+ 82 - 0
packages/app/src/server/service/user-notification/index.ts

@@ -0,0 +1,82 @@
+import UpdatePost from '~/server/models/update-post';
+import { toArrayFromCsv } from '~/utils/to-array-from-csv';
+
+
+import {
+  prepareSlackMessageForPage,
+  prepareSlackMessageForComment,
+} from '../../util/slack';
+
+/**
+ * service class of UserNotification
+ */
+export class UserNotificationService {
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  crowi!: any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * fire user notification
+   *
+   * @memberof UserNotificationService
+   *
+   * @param {Page} page
+   * @param {User} user
+   * @param {string} slackChannelsStr comma separated string. e.g. 'general,channel1,channel2'
+   * @param {string} mode 'create' or 'update' or 'comment'
+   * @param {string} previousRevision
+   * @param {Comment} comment
+   */
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  async fire(page, user, slackChannelsStr, mode, option, comment = {}): Promise<PromiseSettledResult<any>[]> {
+    const {
+      appService, slackIntegrationService,
+    } = this.crowi;
+
+    if (!slackIntegrationService.isSlackConfigured) {
+      throw new Error('slackIntegrationService has not been set up');
+    }
+
+    // update slackChannels attribute asynchronously
+    page.updateSlackChannels(slackChannelsStr);
+
+    const opt = option || {};
+    const previousRevision = opt.previousRevision || '';
+
+    // "dev,slacktest" => [dev,slacktest]
+    const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr);
+    await this.putDefaultChannelIfEmpty(page.path, slackChannels);
+
+    const appTitle = appService.getAppTitle();
+    const siteUrl = appService.getSiteUrl();
+
+    const promises = slackChannels.map(async(chan) => {
+      let messageObj;
+      if (mode === 'comment') {
+        messageObj = prepareSlackMessageForComment(comment, user, appTitle, siteUrl, chan, page.path);
+      }
+      else {
+        messageObj = prepareSlackMessageForPage(page, user, appTitle, siteUrl, chan, mode, previousRevision);
+      }
+
+      return slackIntegrationService.postMessage(messageObj);
+    });
+
+    return Promise.allSettled(promises);
+  }
+
+  private async putDefaultChannelIfEmpty(pagePath:string, slackChannels: (string|null)[]): Promise<void> {
+    const updatePosts = await UpdatePost.findSettingsByPath(pagePath);
+    slackChannels.push(...(updatePosts).map(up => up.channel));
+
+    // insert null if empty to notify once
+    if (slackChannels.length === 0) {
+      slackChannels.push(null);
+    }
+  }
+
+}

+ 15 - 43
packages/app/src/server/util/slack-legacy.js

@@ -1,20 +1,15 @@
-const debug = require('debug')('growi:util:slack');
-// const slack = require('./slack');
+import { IncomingWebhook } from '@slack/webhook';
+import { WebClient } from '@slack/web-api';
 
-/**
- * slack
- */
+import loggerFactory from '~/utils/logger';
 
-/* eslint-disable no-use-before-define */
+const logger = loggerFactory('growi:util:slack-legacy');
 
 module.exports = function(crowi) {
-  const { IncomingWebhook } = require('@slack/webhook');
-  const { WebClient } = require('@slack/web-api');
 
   const { configManager } = crowi;
-  const slack = crowi.getSlack();
 
-  const slackLegacy = {};
+  const slackUtilLegacy = {};
 
   const postWithIwh = async(messageObj) => {
     const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
@@ -22,71 +17,48 @@ module.exports = function(crowi) {
       await webhook.send(messageObj);
     }
     catch (error) {
-      debug('Post error', error);
-      debug('Sent data to slack is:', messageObj);
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
       throw error;
     }
   };
 
   const postWithWebApi = async(messageObj) => {
     const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
-    // stringify attachments
-    if (messageObj.attachments != null) {
-      messageObj.attachments = JSON.stringify(messageObj.attachments);
-    }
     try {
       await client.chat.postMessage(messageObj);
     }
     catch (error) {
-      debug('Post error', error);
-      debug('Sent data to slack is:', messageObj);
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
       throw error;
     }
   };
 
-  // slackLegacy.post = function (channel, message, opts) {
-  slackLegacy.postPage = (page, user, channel, updateType, previousRevision) => {
-    const messageObj = slack.prepareSlackMessageForPage(page, user, channel, updateType, previousRevision);
-
-    return slackPost(messageObj);
-  };
-
-  slackLegacy.postComment = (comment, user, channel, path) => {
-    const messageObj = slack.prepareSlackMessageForComment(comment, user, channel, path);
-
-    return slackPost(messageObj);
-  };
-
-  slackLegacy.sendGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
-    const messageObj = await slack.prepareSlackMessageForGlobalNotification(messageBody, attachmentBody, slackChannel);
-
-    return slackPost(messageObj);
-  };
-
-  const slackPost = (messageObj) => {
+  slackUtilLegacy.postMessage = async(messageObj) => {
     // when incoming Webhooks is prioritized
     if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        debug('posting message with IncomingWebhook');
+        logger.debug('posting message with IncomingWebhook');
         return postWithIwh(messageObj);
       }
       if (configManager.getConfig('notification', 'slack:token')) {
-        debug('posting message with Web API');
+        logger.debug('posting message with Web API');
         return postWithWebApi(messageObj);
       }
     }
     // else
     else {
       if (configManager.getConfig('notification', 'slack:token')) {
-        debug('posting message with Web API');
+        logger.debug('posting message with Web API');
         return postWithWebApi(messageObj);
       }
       if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        debug('posting message with IncomingWebhook');
+        logger.debug('posting message with IncomingWebhook');
         return postWithIwh(messageObj);
       }
     }
   };
 
-  return slackLegacy;
+  return slackUtilLegacy;
 };

+ 123 - 182
packages/app/src/server/util/slack.js

@@ -7,219 +7,160 @@ const urljoin = require('url-join');
 
 /* eslint-disable no-use-before-define */
 
-module.exports = function(crowi) {
-  const { WebClient } = require('@slack/web-api');
+const convertMarkdownToMarkdown = function(body, siteUrl) {
+  return body
+    .replace(/\n\*\s(.+)/g, '\n• $1')
+    .replace(/#{1,}\s?(.+)/g, '\n*$1*')
+    .replace(/(\[(.+)\]\((https?:\/\/.+)\))/g, '<$3|$2>')
+    .replace(/(\[(.+)\]\((\/.+)\))/g, `<${siteUrl}$3|$2>`);
+};
+
+const prepareAttachmentTextForCreate = function(page, siteUrl) {
+  let body = page.revision.body;
+  if (body.length > 2000) {
+    body = `${body.substr(0, 2000)}...`;
+  }
+
+  return convertMarkdownToMarkdown(body, siteUrl);
+};
 
-  const { configManager } = crowi;
-  const slack = {};
+const prepareAttachmentTextForUpdate = function(page, siteUrl, previousRevision) {
+  const diff = require('diff');
+  let diffText = '';
 
-  const postWithWebApi = async(messageObj) => {
-    const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
-    // stringify attachments
-    if (messageObj.attachments != null) {
-      messageObj.attachments = JSON.stringify(messageObj.attachments);
+  diff.diffLines(previousRevision.body, page.revision.body).forEach((line) => {
+    debug('diff line', line);
+    const value = line.value.replace(/\r\n|\r/g, '\n'); // eslint-disable-line no-unused-vars
+    if (line.added) {
+      diffText += `${line.value} ... :lower_left_fountain_pen:`;
     }
-    try {
-      await client.chat.postMessage(messageObj);
+    else if (line.removed) {
+      // diffText += '-' + line.value.replace(/(.+)?\n/g, '- $1\n');
+      // 1以下は無視
+      if (line.count > 1) {
+        diffText += `:wastebasket: ... ${line.count} lines\n`;
+      }
     }
-    catch (error) {
-      debug('Post error', error);
-      debug('Sent data to slack is:', messageObj);
-      throw error;
+    else {
+      // diffText += '...\n';
     }
-  };
+  });
 
-  const convertMarkdownToMarkdown = function(body) {
-    const url = crowi.appService.getSiteUrl();
+  debug('diff is', diffText);
 
-    return body
-      .replace(/\n\*\s(.+)/g, '\n• $1')
-      .replace(/#{1,}\s?(.+)/g, '\n*$1*')
-      .replace(/(\[(.+)\]\((https?:\/\/.+)\))/g, '<$3|$2>')
-      .replace(/(\[(.+)\]\((\/.+)\))/g, `<${url}$3|$2>`);
-  };
+  return diffText;
+};
 
-  const prepareAttachmentTextForCreate = function(page, user) {
-    let body = page.revision.body;
-    if (body.length > 2000) {
-      body = `${body.substr(0, 2000)}...`;
-    }
+const prepareAttachmentTextForComment = function(comment) {
+  let body = comment.comment;
+  if (body.length > 2000) {
+    body = `${body.substr(0, 2000)}...`;
+  }
 
+  if (comment.isMarkdown) {
     return convertMarkdownToMarkdown(body);
-  };
+  }
 
-  const prepareAttachmentTextForUpdate = function(page, user, previousRevision) {
-    const diff = require('diff');
-    let diffText = '';
-
-    diff.diffLines(previousRevision.body, page.revision.body).forEach((line) => {
-      debug('diff line', line);
-      const value = line.value.replace(/\r\n|\r/g, '\n'); // eslint-disable-line no-unused-vars
-      if (line.added) {
-        diffText += `${line.value} ... :lower_left_fountain_pen:`;
-      }
-      else if (line.removed) {
-        // diffText += '-' + line.value.replace(/(.+)?\n/g, '- $1\n');
-        // 1以下は無視
-        if (line.count > 1) {
-          diffText += `:wastebasket: ... ${line.count} lines\n`;
-        }
-      }
-      else {
-        // diffText += '...\n';
-      }
-    });
+  return body;
+};
 
-    debug('diff is', diffText);
+const generateSlackMessageTextForPage = function(path, pageId, user, siteUrl, updateType) {
+  let text;
 
-    return diffText;
-  };
+  const pageUrl = `<${urljoin(siteUrl, pageId)}|${path}>`;
+  if (updateType === 'create') {
+    text = `:rocket: ${user.username} created a new page! ${pageUrl}`;
+  }
+  else {
+    text = `:heavy_check_mark: ${user.username} updated ${pageUrl}`;
+  }
 
-  const prepareAttachmentTextForComment = function(comment) {
-    let body = comment.comment;
-    if (body.length > 2000) {
-      body = `${body.substr(0, 2000)}...`;
-    }
-
-    if (comment.isMarkdown) {
-      return convertMarkdownToMarkdown(body);
-    }
+  return text;
+};
 
-    return body;
+export const prepareSlackMessageForPage = (page, user, appTitle, siteUrl, channel, updateType, previousRevision) => {
+  let body = page.revision.body;
+
+  if (updateType === 'create') {
+    body = prepareAttachmentTextForCreate(page, siteUrl);
+  }
+  else {
+    body = prepareAttachmentTextForUpdate(page, siteUrl, previousRevision);
+  }
+
+  const attachment = {
+    color: '#263a3c',
+    author_name: `@${user.username}`,
+    author_link: urljoin(siteUrl, 'user', user.username),
+    author_icon: user.image,
+    title: page.path,
+    title_link: urljoin(siteUrl, page.id),
+    text: body,
+    mrkdwn_in: ['text'],
   };
-
-  slack.prepareSlackMessageForPage = (page, user, channel, updateType, previousRevision) => {
-    const appTitle = crowi.appService.getAppTitle();
-    const url = crowi.appService.getSiteUrl();
-    let body = page.revision.body;
-
-    if (updateType === 'create') {
-      body = prepareAttachmentTextForCreate(page, user);
-    }
-    else {
-      body = prepareAttachmentTextForUpdate(page, user, previousRevision);
-    }
-
-    const attachment = {
-      color: '#263a3c',
-      author_name: `@${user.username}`,
-      author_link: urljoin(url, 'user', user.username),
-      author_icon: user.image,
-      title: page.path,
-      title_link: urljoin(url, page.id),
-      text: body,
-      mrkdwn_in: ['text'],
-    };
-    if (user.image) {
-      attachment.author_icon = user.image;
-    }
-
-    const message = {
-      channel: (channel != null) ? `#${channel}` : undefined,
-      username: appTitle,
-      text: getSlackMessageTextForPage(page.path, page.id, user, updateType),
-      attachments: [attachment],
-    };
-
-    return message;
+  if (user.image) {
+    attachment.author_icon = user.image;
+  }
+
+  const message = {
+    channel: (channel != null) ? `#${channel}` : undefined,
+    username: appTitle,
+    text: generateSlackMessageTextForPage(page.path, page.id, user, siteUrl, updateType),
+    attachments: [attachment],
   };
 
-  slack.prepareSlackMessageForComment = (comment, user, channel, path) => {
-    const appTitle = crowi.appService.getAppTitle();
-    const url = crowi.appService.getSiteUrl();
-    const body = prepareAttachmentTextForComment(comment);
-
-    const attachment = {
-      color: '#263a3c',
-      author_name: `@${user.username}`,
-      author_link: urljoin(url, 'user', user.username),
-      author_icon: user.image,
-      text: body,
-      mrkdwn_in: ['text'],
-    };
-    if (user.image) {
-      attachment.author_icon = user.image;
-    }
+  return message;
+};
 
-    const message = {
-      channel: (channel != null) ? `#${channel}` : undefined,
-      username: appTitle,
-      text: getSlackMessageTextForComment(path, String(comment.page), user),
-      attachments: [attachment],
-    };
+export const prepareSlackMessageForComment = (comment, user, appTitle, siteUrl, channel, path) => {
+  const body = prepareAttachmentTextForComment(comment);
 
-    return message;
+  const attachment = {
+    color: '#263a3c',
+    author_name: `@${user.username}`,
+    author_link: urljoin(siteUrl, 'user', user.username),
+    author_icon: user.image,
+    text: body,
+    mrkdwn_in: ['text'],
+  };
+  if (user.image) {
+    attachment.author_icon = user.image;
+  }
+
+  const pageUrl = `<${urljoin(siteUrl, String(comment.page))}|${path}>`;
+  const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
+
+  const message = {
+    channel: (channel != null) ? `#${channel}` : undefined,
+    username: appTitle,
+    text,
+    attachments: [attachment],
   };
 
-  /**
+  return message;
+};
+
+/**
    * For GlobalNotification
    *
    * @param {string} messageBody
    * @param {string} attachmentBody
    * @param {string} slackChannel
   */
-  slack.prepareSlackMessageForGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
-    const appTitle = crowi.appService.getAppTitle();
-
-    const attachment = {
-      color: '#263a3c',
-      text: attachmentBody,
-      mrkdwn_in: ['text'],
-    };
-
-    const message = {
-      channel: (slackChannel != null) ? `#${slackChannel}` : undefined,
-      username: appTitle,
-      text: messageBody,
-      attachments: [attachment],
-    };
-
-    return message;
-  };
-
-  const getSlackMessageTextForPage = function(path, pageId, user, updateType) {
-    let text;
-    const url = crowi.appService.getSiteUrl();
-
-    const pageUrl = `<${urljoin(url, pageId)}|${path}>`;
-    if (updateType === 'create') {
-      text = `:rocket: ${user.username} created a new page! ${pageUrl}`;
-    }
-    else {
-      text = `:heavy_check_mark: ${user.username} updated ${pageUrl}`;
-    }
-
-    return text;
-  };
-
-  const getSlackMessageTextForComment = function(path, pageId, user) {
-    const url = crowi.appService.getSiteUrl();
-    const pageUrl = `<${urljoin(url, pageId)}|${path}>`;
-    const text = `:speech_balloon: ${user.username} commented on ${pageUrl}`;
-
-    return text;
-  };
-
-  slack.postPage = (page, user, channel, updateType, previousRevision) => {
-    const messageObj = slack.prepareSlackMessageForPage(page, user, channel, updateType, previousRevision);
-
-    return slackPost(messageObj);
-  };
-
-  slack.postComment = (comment, user, channel, path) => {
-    const messageObj = slack.prepareSlackMessageForComment(comment, user, channel, path);
-
-    return slackPost(messageObj);
-  };
+export const prepareSlackMessageForGlobalNotification = (messageBody, attachmentBody, appTitle, slackChannel) => {
 
-  slack.sendGlobalNotification = async(messageBody, attachmentBody, slackChannel) => {
-    const messageObj = await slack.prepareSlackMessageForGlobalNotification(messageBody, attachmentBody, slackChannel);
-    return slackPost(messageObj);
+  const attachment = {
+    color: '#263a3c',
+    text: attachmentBody,
+    mrkdwn_in: ['text'],
   };
 
-  const slackPost = (messageObj) => {
-    return postWithWebApi(messageObj);
+  const message = {
+    channel: (slackChannel != null) ? `#${slackChannel}` : undefined,
+    username: appTitle,
+    text: messageBody,
+    attachments: JSON.stringify([attachment]),
   };
 
-  return slack;
+  return message;
 };

+ 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 %}
 

+ 2 - 0
packages/app/src/server/views/admin/slack-integration.html

@@ -1,5 +1,7 @@
 {% extends '../layout/admin.html' %}
 
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('slack_integration')) }}{% endblock %}
+
 {% block content_header %}
 <h1 class="title">{{ t('slack_integration') }}</h1>
 {% 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;
 

+ 4 - 7
packages/app/src/test/utils/slack-legacy.test.js

@@ -3,18 +3,15 @@ const { getInstance } = require('../setup-crowi');
 describe('Slack Util', () => {
 
   let crowi;
-  let slackLegacy;
+  let slackLegacyUtil;
 
   beforeEach(async() => {
     crowi = await getInstance();
-    slackLegacy = require('~/server/util/slack-legacy')(crowi);
+    slackLegacyUtil = require('~/server/util/slack-legacy')(crowi);
   });
 
-  test('post comment method exists', () => {
-    expect(slackLegacy.postComment).toBeInstanceOf(Function);
+  test('postMessage method exists', () => {
+    expect(slackLegacyUtil.postMessage).toBeInstanceOf(Function);
   });
 
-  test('post page method exists', () => {
-    expect(slackLegacy.postPage).toBeInstanceOf(Function);
-  });
 });

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

@@ -1,3 +1,7 @@
+export const REQUEST_TIMEOUT_FOR_GTOP = 10000;
+
+export const REQUEST_TIMEOUT_FOR_PTOG = 10000;
+
 export const supportedSlackCommands: string[] = [
   '/growi',
 ];
@@ -5,6 +9,7 @@ export const supportedSlackCommands: string[] = [
 export const supportedGrowiCommands: string[] = [
   'search',
   'create',
+  'togetter',
   'help',
 ];
 
@@ -14,6 +19,7 @@ export const defaultSupportedCommandsNameForBroadcastUse: string[] = [
 
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
   'create',
+  'togetter',
 ];
 
 export * from './interfaces/growi-command';
@@ -26,6 +32,9 @@ export * from './utils/block-kit-builder';
 export * from './utils/check-communicable';
 export * from './utils/get-supported-growi-actions-regexps';
 export * from './utils/post-ephemeral-errors';
+export * from './utils/publish-initial-home-view';
 export * from './utils/reshape-contents-body';
 export * from './utils/slash-command-parser';
 export * from './utils/webclient-factory';
+export * from './utils/welcome-message';
+export * from './utils/required-scopes';

+ 11 - 1
packages/slack/src/utils/block-kit-builder.ts

@@ -1,5 +1,5 @@
 import {
-  SectionBlock, InputBlock, DividerBlock, ActionsBlock,
+  SectionBlock, HeaderBlock, InputBlock, DividerBlock, ActionsBlock,
   Button, Overflow, Datepicker, Select, RadioButtons, Checkboxes, Action, MultiSelect, PlainTextInput, Option,
 } from '@slack/types';
 
@@ -10,6 +10,16 @@ export function divider(): DividerBlock {
   };
 }
 
+export function markdownHeaderBlock(text: string): HeaderBlock {
+  return {
+    type: 'header',
+    text: {
+      type: 'plain_text',
+      text,
+    },
+  };
+}
+
 export function markdownSectionBlock(text: string): SectionBlock {
   return {
     type: 'section',

+ 9 - 4
packages/slack/src/utils/check-communicable.ts

@@ -4,6 +4,8 @@ import { WebClient } from '@slack/web-api';
 
 import { generateWebClient } from './webclient-factory';
 import { ConnectionStatus } from '../interfaces/connection-status';
+import { requiredScopes } from './required-scopes';
+import { markdownSectionBlock } from './block-kit-builder';
 
 /**
  * Check whether the HTTP server responds or not.
@@ -45,11 +47,10 @@ const testSlackApiServer = async(client: WebClient): Promise<any> => {
 
 const checkSlackScopes = (resultTestSlackApiServer: any) => {
   const slackScopes = resultTestSlackApiServer.response_metadata.scopes;
-  const correctScopes = ['commands', 'team:read', 'chat:write'];
-  const isPassedScopeCheck = correctScopes.every(e => slackScopes.includes(e));
+  const isPassedScopeCheck = requiredScopes.every(e => slackScopes.includes(e));
 
   if (!isPassedScopeCheck) {
-    throw new Error('The scopes is not appropriate. Required scopes is [\'commands\', \'team:read\', \'chat:write\']');
+    throw new Error(`The scopes you registered are not appropriate. Required scopes are ${requiredScopes}`);
   }
 };
 
@@ -121,6 +122,10 @@ export const sendSuccessMessage = async(token:string, channel:string, appSiteUrl
   const client = generateWebClient(token);
   await client.chat.postMessage({
     channel,
-    text: `Successfully tested with ${appSiteUrl}.`,
+    text: 'Success',
+    blocks: [
+      markdownSectionBlock(`:tada: Successfully tested with ${appSiteUrl}.`),
+      markdownSectionBlock('Now your GROWI and Slack integration is ready to use :+1:'),
+    ],
   });
 };

+ 31 - 0
packages/slack/src/utils/publish-initial-home-view.ts

@@ -0,0 +1,31 @@
+// Now Home tab is disabled
+// TODO Imple Home tab
+
+import { ViewsPublishResponse, WebClient } from '@slack/web-api';
+
+export const publishInitialHomeView = (client: WebClient, userId: string): Promise<ViewsPublishResponse> => {
+  return client.views.publish({
+    user_id: userId,
+    view: {
+      type: 'home',
+      blocks: [
+        {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: 'Welcome GROWI Official Bot Home',
+          },
+        },
+        {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: 'Learn how to use GROWI Official bot.'
+            // eslint-disable-next-line max-len
+              + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.',
+          },
+        },
+      ],
+    },
+  });
+};

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

@@ -0,0 +1,11 @@
+export const requiredScopes: string[] = [
+  'commands',
+  'team:read',
+  'chat:write',
+  'chat:write.public',
+  'channels:join',
+  'channels:history',
+  'groups:history',
+  'im:history',
+  'mpim:history',
+];

+ 4 - 0
packages/slack/src/utils/slash-command-parser.ts

@@ -2,6 +2,10 @@ import { GrowiCommand } from '../interfaces/growi-command';
 import { InvalidGrowiCommandError } from '../models/errors';
 
 export const parseSlashCommand = (slashCommand:{[key:string]:string}): GrowiCommand => {
+  if (slashCommand.text == null) {
+    throw new InvalidGrowiCommandError('The SlashCommand.text is null');
+  }
+
   const trimmedText = slashCommand.text.trim();
   const splitted = trimmedText.split(' ');
 

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

+ 21 - 0
packages/slack/src/utils/welcome-message.ts

@@ -0,0 +1,21 @@
+import { ChatPostMessageResponse, WebClient } from '@slack/web-api';
+
+export const postWelcomeMessage = (client: WebClient, userId: string): Promise<ChatPostMessageResponse> => {
+  return client.chat.postMessage({
+    channel: userId,
+    user: userId,
+    blocks: [
+      {
+        type: 'section',
+        text: {
+          type: 'mrkdwn',
+          text: ':tada: You have successfully installed GROWI Official bot on this Slack workspace.\n'
+            + 'At first you do `/growi register` in the channel that you want to use.\n'
+            + 'Looking for additional help?'
+            // eslint-disable-next-line max-len
+            + 'See <https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html#official-bot-settings | Docs>.',
+        },
+      },
+    ],
+  });
+};

+ 15 - 0
packages/slackbot-proxy/CHANGES.md

@@ -0,0 +1,15 @@
+# CHANGES
+
+## v1.0.2
+
+* Fix: Label for the install succeeded screen
+
+## v1.0.1
+
+* Improvement: Automatically add GROWI bot user to the channel where the command was executed
+* Improvement: Post a welcome message when the installation is successful
+* Improvement: Post a warning message when none of GROWI permit the command
+* Improvement: Permission scopes
+* Improvement: Top page information
+* Improvement: Add a link to docs
+

+ 4 - 1
packages/slackbot-proxy/docker/Dockerfile

@@ -37,7 +37,10 @@ RUN tar cf node_modules.tar node_modules \
 FROM deps-resolver-base AS deps-resolver-prod
 RUN npx lerna bootstrap -- --production
 # make artifacts
-RUN tar cf dependencies.tar node_modules packages/slackbot-proxy/node_modules
+RUN tar cf dependencies.tar \
+  node_modules \
+  packages/slack/node_modules \
+  packages/slackbot-proxy/node_modules
 
 
 ##

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "1.0.0-RC",
+  "version": "1.0.2",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -37,6 +37,7 @@
     "bunyan": "^1.8.15",
     "compression": "^1.7.4",
     "cookie-parser": "^1.4.5",
+    "date-fns": "^2.23.0",
     "express-bunyan-logger": "^1.3.3",
     "extensible-custom-error": "^0.0.7",
     "helmet": "^4.6.0",

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

@@ -42,7 +42,12 @@ const connectionOptions: ConnectionOptions = {
 } as ConnectionOptions;
 
 const swaggerSettings = isProduction ? swaggerSettingsForProd : swaggerSettingsForDev;
-const helmetOptions = isProduction ? {} : {
+const helmetOptions = isProduction ? {
+  contentSecurityPolicy: false,
+  expectCt: false,
+  referrerPolicy: false,
+  permittedCrossDomainPolicies: false,
+} : {
   contentSecurityPolicy: {
     directives: {
       defaultSrc: ['\'self\''],
@@ -51,6 +56,9 @@ const helmetOptions = isProduction ? {} : {
       scriptSrc: ['\'self\'', 'https: \'unsafe-inline\''],
     },
   },
+  expectCt: false,
+  referrerPolicy: false,
+  permittedCrossDomainPolicies: false,
 };
 
 @Configuration({

+ 1 - 0
packages/slackbot-proxy/src/config/logger/config.dev.ts

@@ -10,6 +10,7 @@ const config: UniversalBunyanConfig = {
    */
   // 'express:*': 'debug',
   // 'slackbot-proxy:*': 'debug',
+  'slackbot-proxy:controllers:growi-to-slack': 'debug',
 
 };
 

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

@@ -5,13 +5,13 @@ 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,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient,
 } from '@growi/slack';
 
-import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/slack-to-growi/add-webclient-response-to-res';
+import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
 
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
 import { InstallationRepository } from '~/repositories/installation';
@@ -61,6 +61,7 @@ export class GrowiToSlackCtrl {
       headers: {
         'x-growi-ptog-tokens': tokenPtoG,
       },
+      timeout: REQUEST_TIMEOUT_FOR_PTOG,
     });
   }
 
@@ -245,13 +246,13 @@ export class GrowiToSlackCtrl {
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
   async callSlackApi(
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
-  ): Promise<void|string|Res|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];
@@ -263,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);
@@ -279,11 +283,20 @@ export class GrowiToSlackCtrl {
       const opt = req.body;
       opt.headers = req.headers;
 
-      return client.apiCall(method, opt);
+      logger.debug({ 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);
     }
   }
 

+ 144 - 88
packages/slackbot-proxy/src/controllers/slack.ts

@@ -1,13 +1,16 @@
 import {
-  BodyParams, Controller, Get, Inject, Post, Req, Res, UseBefore,
+  BodyParams, Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
 } from '@tsed/common';
 
 import axios from 'axios';
 
 import { WebAPICallResult } from '@slack/web-api';
+import { Installation } from '@slack/oauth';
+
 
 import {
   markdownSectionBlock, GrowiCommand, parseSlashCommand, postEphemeralErrors, verifySlackRequest, generateWebClient,
+  InvalidGrowiCommandError, requiredScopes, postWelcomeMessage, REQUEST_TIMEOUT_FOR_PTOG,
 } from '@growi/slack';
 
 import { Relation } from '~/entities/relation';
@@ -25,6 +28,7 @@ import { RelationsService } from '~/services/RelationsService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { InvalidUrlError } from '../models/errors';
 import loggerFactory from '~/utils/logger';
+import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-to-conversation';
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
@@ -80,6 +84,7 @@ export class SlackCtrl {
         headers: {
           'x-growi-ptog-tokens': relation.tokenPtoG,
         },
+        timeout: REQUEST_TIMEOUT_FOR_PTOG,
       });
     });
 
@@ -97,22 +102,30 @@ export class SlackCtrl {
   }
 
   @Post('/commands')
-  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware)
+  @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware, JoinToConversationMiddleware)
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     const { body, authorizeResult } = req;
 
-    if (body.text == null) {
-      return 'No text.';
-    }
+    let growiCommand;
 
-    const growiCommand = parseSlashCommand(body);
+    try {
+      growiCommand = parseSlashCommand(body);
+    }
+    catch (err) {
+      if (err instanceof InvalidGrowiCommandError) {
+        res.json({
+          blocks: [
+            markdownSectionBlock('*Command type is not specified.*'),
+            markdownSectionBlock('Run `/growi help` to check the commands you can use.'),
+          ],
+        });
+      }
+      logger.error(err.message);
+      return;
+    }
 
     // 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});
     }
 
@@ -125,10 +138,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});
     }
 
@@ -161,57 +170,80 @@ 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();
 
-    const relationsForSingleUse:Relation[] = [];
+    const allowedRelationsForSingleUse:Relation[] = [];
+    const allowedRelationsForBroadcastUse:Relation[] = [];
+    const disallowedGrowiUrls: Set<string> = new Set();
+
+    // check permission
     await Promise.all(relations.map(async(relation) => {
-      const isSupported = await this.relationsService.isSupportedGrowiCommandForSingleUse(relation, growiCommand.growiCommandType, baseDate);
-      if (isSupported) {
-        relationsForSingleUse.push(relation);
+      const isSupportedForSingleUse = await this.relationsService.isSupportedGrowiCommandForSingleUse(
+        relation, growiCommand.growiCommandType, baseDate,
+      );
+
+      let isSupportedForBroadcastUse = false;
+      if (!isSupportedForSingleUse) {
+        isSupportedForBroadcastUse = await this.relationsService.isSupportedGrowiCommandForBroadcastUse(
+          relation, growiCommand.growiCommandType, baseDate,
+        );
       }
-    }));
 
-    let isCommandPermitted = false;
-
-    if (relationsForSingleUse.length > 0) {
-      isCommandPermitted = true;
-      body.growiUrisForSingleUse = relationsForSingleUse.map(v => v.growiUri);
-      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
-    }
-
-    const relationsForBroadcastUse:Relation[] = [];
-    await Promise.all(relations.map(async(relation) => {
-      const isSupported = await this.relationsService.isSupportedGrowiCommandForBroadcastUse(relation, growiCommand.growiCommandType, baseDate);
-      if (isSupported) {
-        relationsForBroadcastUse.push(relation);
+      if (isSupportedForSingleUse) {
+        allowedRelationsForSingleUse.push(relation);
+      }
+      else if (isSupportedForBroadcastUse) {
+        allowedRelationsForBroadcastUse.push(relation);
+      }
+      else {
+        disallowedGrowiUrls.add(relation.growiUri);
       }
     }));
 
-    /*
-     * forward to GROWI server
-     */
-    if (relationsForBroadcastUse.length > 0) {
-      isCommandPermitted = true;
-      this.sendCommand(growiCommand, relationsForBroadcastUse, body);
-    }
+    // when all of GROWI disallowed
+    if (relations.length === disallowedGrowiUrls.size) {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const client = generateWebClient(authorizeResult.botToken!);
 
-    if (!isCommandPermitted) {
-      const botToken = relations[0].installation?.data.bot?.token;
+      const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
+        return '\n'
+          + `• ${new URL('/admin/slack-integration', growiUrl).toString()}`;
+      });
 
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const client = generateWebClient(botToken!);
+      const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
 
       return client.chat.postEphemeral({
         text: 'Error occured.',
         channel: body.channel_id,
         user: body.user_id,
         blocks: [
-          markdownSectionBlock(`It is not allowed to run *'${growiCommand.growiCommandType}'* command to this GROWI.`),
+          markdownSectionBlock('*None of GROWI permitted the command.*'),
+          markdownSectionBlock(`*'${growiCommand.growiCommandType}'* command was not allowed.`),
+          markdownSectionBlock(
+            `To use this command, modify settings from following pages: ${linkUrlList}`,
+          ),
+          markdownSectionBlock(
+            `Or, if your GROWI version is 4.3.0 or below, upgrade GROWI to use commands and permission settings: ${growiDocsLink}`,
+          ),
         ],
       });
     }
+
+    // select GROWI
+    if (allowedRelationsForSingleUse.length > 0) {
+      body.growiUrisForSingleUse = allowedRelationsForSingleUse.map(v => v.growiUri);
+      return this.selectGrowiService.process(growiCommand, authorizeResult, body);
+    }
+
+    // forward to GROWI server
+    if (allowedRelationsForBroadcastUse.length > 0) {
+      return this.sendCommand(growiCommand, allowedRelationsForBroadcastUse, body);
+    }
   }
 
   @Post('/interactions')
@@ -222,10 +254,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;
@@ -263,10 +291,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
     */
@@ -286,6 +322,7 @@ export class SlackCtrl {
         headers: {
           'x-growi-ptog-tokens': relation.tokenPtoG,
         },
+        timeout: REQUEST_TIMEOUT_FOR_PTOG,
       });
     }
     catch (err) {
@@ -307,50 +344,69 @@ export class SlackCtrl {
   }
 
   @Get('/oauth_redirect')
-  async handleOauthRedirect(@Req() req: Req, @Res() res: Res): Promise<void> {
-
-    if (req.query.state === '') {
-      res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
-      res.end('<html>'
-      + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
-      + '<body style="text-align:center; padding-top:20%;">'
-      + '<h1>Illegal state, try it again.</h1>'
-      + '<a href="/">'
-      + 'Go to install page'
-      + '</a>'
-      + '</body></html>');
+  async handleOauthRedirect(@Req() req: Req, @Res() serverRes: Res, @Res() platformRes: PlatformResponse): Promise<void|string> {
+
+    // create 'Add to Slack' url
+    const addToSlackUrl = await this.installerService.installer.generateInstallUrl({
+      scopes: requiredScopes,
+    });
+
+    const state = req.query.state;
+    if (state == null || state === '') {
+      return platformRes.status(400).render('install-failed.ejs', { url: addToSlackUrl });
     }
 
-    await this.installerService.installer.handleCallback(req, res, {
-      success: (installation, metadata, req, res) => {
-        logger.info('Success to install', { installation, metadata });
+    // promisify
+    const installPromise = new Promise<Installation>((resolve, reject) => {
+      this.installerService.installer.handleCallback(req, serverRes, {
+        success: async(installation, metadata) => {
+          logger.info('Success to install', { installation, metadata });
+          resolve(installation);
+        },
+        failure: async(error) => {
+          reject(error); // go to catch block
+        },
+      });
+    });
+
+    let httpStatus = 200;
+    let httpBody;
+    try {
+      const installation = await installPromise;
 
+      // check whether bot is not null
+      if (installation.bot == null) {
+        logger.warn('Success to install but something wrong. `installation.bot` is null.');
+        httpStatus = 500;
+        httpBody = await platformRes.render('install-succeeded-but-has-problem.ejs', { reason: '`installation.bot` is null' });
+      }
+      // MAIN PATH: everything is fine
+      else {
         const appPageUrl = `https://slack.com/apps/${installation.appId}`;
+        httpBody = await platformRes.render('install-succeeded.ejs', { appPageUrl });
 
-        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
-        res.end('<html>'
-        + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
-        + '<body style="text-align:center; padding-top:20%;">'
-        + '<h1>Congratulations!</h1>'
-        + '<p>GROWI Bot installation has succeeded.</p>'
-        + `<a href="${appPageUrl}">`
-        + 'Access to Slack App detail page.'
-        + '</a>'
-        + '</body></html>');
-      },
-      failure: (error, installOptions, req, res) => {
-        res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
-        res.end('<html>'
-        + '<head><meta name="viewport" content="width=device-width,initial-scale=1"></head>'
-        + '<body style="text-align:center; padding-top:20%;">'
-        + '<h1>GROWI Bot installation failed</h1>'
-        + '<p>Please contact administrators of your workspace</p>'
-        + 'Reference: <a href="https://slack.com/help/articles/222386767-Manage-app-installation-settings-for-your-workspace">'
-        + 'Manage app installation settings for your workspace'
-        + '</a>'
-        + '</body></html>');
-      },
-    });
+        // generate client
+        const client = generateWebClient(installation.bot.token);
+
+        const userId = installation.user.id;
+
+        await Promise.all([
+          // post message
+          postWelcomeMessage(client, userId),
+          // publish home
+          // TODO When Home tab show off, use bellow.
+          // publishInitialHomeView(client, userId),
+        ]);
+      }
+    }
+    catch (error) {
+      logger.error(error);
+      httpStatus = 500;
+      httpBody = await platformRes.status(400).render('install-failed.ejs', { url: addToSlackUrl });
+    }
+
+    platformRes.status(httpStatus);
+    return httpBody;
   }
 
 }

+ 2 - 9
packages/slackbot-proxy/src/controllers/top.ts

@@ -2,6 +2,7 @@ import {
   Controller, Get, Inject, View,
 } from '@tsed/common';
 
+import { requiredScopes } from '@growi/slack';
 import { InstallerService } from '~/services/InstallerService';
 
 const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
@@ -18,15 +19,7 @@ export class TopCtrl {
   async getTopPage(): Promise<any> {
     const url = await this.installerService.installer.generateInstallUrl({
       // Add the scopes your app needs
-      scopes: [
-        'channels:history',
-        'commands',
-        'groups:history',
-        'im:history',
-        'mpim:history',
-        'chat:write',
-        'team:read',
-      ],
+      scopes: requiredScopes,
     });
 
     return { url, isOfficialMode };

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

@@ -0,0 +1,30 @@
+import { ErrorCode } from '@slack/web-api';
+import {
+  IMiddleware, Middleware, Next, Req, Res,
+} from '@tsed/common';
+
+
+export type WebclientRes = Res & {
+  simulateWebAPIRequestError: (error: string, statusCode: number) => WebclientRes
+  simulateWebAPIPlatformError: (error: string, errorCode?:string) => WebclientRes
+};
+
+
+@Middleware()
+export class AddWebclientResponseToRes implements IMiddleware {
+
+  use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
+
+    // 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 });
+    };
+
+    next();
+  }
+
+}

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

@@ -1,23 +0,0 @@
-import {
-  IMiddleware, Middleware, Next, Req, Res,
-} from '@tsed/common';
-
-
-export type WebclientRes = Res & {
-  webClientErr: (message?:string, errorCode?:string) => void
-};
-
-
-@Middleware()
-export class AddWebclientResponseToRes implements IMiddleware {
-
-  use(@Req() req: Req, @Res() res: WebclientRes, @Next() next: Next): void {
-
-    res.webClientErr = (error?:string, errorCode?:string) => {
-      return res.send({ ok: false, error, errorCode });
-    };
-
-    next();
-  }
-
-}

+ 32 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/join-to-conversation.ts

@@ -0,0 +1,32 @@
+import { generateWebClient } from '@growi/slack';
+import {
+  IMiddleware, Middleware, Req,
+} from '@tsed/common';
+
+import Logger from 'bunyan';
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+
+import loggerFactory from '~/utils/logger';
+
+const logger: Logger = loggerFactory('slackbot-proxy:middlewares:JoinToConversationsMiddleware');
+
+
+/**
+ * This middleware should be processed after AuthorizeCommandMiddleware or AuthorizeInteractionMiddleware
+ */
+@Middleware()
+export class JoinToConversationMiddleware implements IMiddleware {
+
+  async use(@Req() req: SlackOauthReq): Promise<void> {
+    const { body, authorizeResult } = req;
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const client = generateWebClient(authorizeResult.botToken!);
+
+    const joinResult = await client.conversations.join({ channel: body.channel_id });
+    if (!joinResult.ok) {
+      logger.error(joinResult.error, joinResult);
+    }
+  }
+
+}

BIN
packages/slackbot-proxy/src/public/images/growi-bot.png


+ 23 - 8
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -1,6 +1,8 @@
 import { Inject, Service } from '@tsed/di';
 import { WebClient, LogLevel, Block } from '@slack/web-api';
-import { markdownSectionBlock, inputSectionBlock, GrowiCommand } from '@growi/slack';
+import {
+  markdownSectionBlock, markdownHeaderBlock, inputSectionBlock, GrowiCommand,
+} from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { OrderRepository } from '~/repositories/order';
@@ -103,19 +105,32 @@ export class RegisterService implements GrowiCommandProcessor {
 
     const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
 
+    const blocks: Block[] = [];
+
     if (isOfficialMode) {
-      const blocks = [
-        markdownSectionBlock('Successfully registered with the proxy! Please check test connection in your GROWI'),
-      ];
+      blocks.push(markdownHeaderBlock(':white_check_mark: 1. Install Official bot to Slack'));
+      blocks.push(markdownHeaderBlock(':white_check_mark: 2. Register for GROWI Official Bot Proxy Service'));
+      blocks.push(markdownSectionBlock('The request has been successfully accepted. However, registration has *NOT been completed* yet.'));
+      blocks.push(markdownHeaderBlock(':arrow_right: 3. Test Connection'));
+      blocks.push(markdownSectionBlock('*Test Connection* to complete the registration in your GROWI.'));
+      blocks.push(markdownHeaderBlock(':white_large_square: 4. (Opt) Manage GROWI commands'));
+      blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
       await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
       return;
 
     }
 
-    const blocks = [
-      markdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
-      markdownSectionBlock(`Proxy URL: ${serverUri}`),
-    ];
+    blocks.push(markdownHeaderBlock(':white_check_mark: 1. Create Bot'));
+    blocks.push(markdownHeaderBlock(':white_check_mark: 2. Install bot to Slack'));
+    blocks.push(markdownHeaderBlock(':white_check_mark: 3. Register for your GROWI Custom Bot Proxy'));
+    blocks.push(markdownSectionBlock('The request has been successfully accepted. However, registration has *NOT been completed* yet.'));
+    blocks.push(markdownHeaderBlock(':arrow_right: 4. Set Proxy URL on GROWI'));
+    blocks.push(markdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'));
+    blocks.push(markdownSectionBlock(`Proxy URL: ${serverUri}`));
+    blocks.push(markdownHeaderBlock(':arrow_right: 5. Test Connection'));
+    blocks.push(markdownSectionBlock('And *Test Connection* to complete the registration in your GROWI.'));
+    blocks.push(markdownHeaderBlock(':white_large_square: 6. (Opt) Manage GROWI commands'));
+    blocks.push(markdownSectionBlock('Modify permission settings if you need.'));
     await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
     return;
   }

+ 4 - 0
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -1,7 +1,10 @@
 import { Inject, Service } from '@tsed/di';
+
 import axios from 'axios';
 import { addHours } from 'date-fns';
 
+import { REQUEST_TIMEOUT_FOR_PTOG } from '@growi/slack';
+
 import { Relation } from '~/entities/relation';
 import { RelationRepository } from '~/repositories/relation';
 
@@ -22,6 +25,7 @@ export class RelationsService {
       headers: {
         'x-growi-ptog-tokens': relation.tokenPtoG,
       },
+      timeout: REQUEST_TIMEOUT_FOR_PTOG,
     });
   }
 

+ 21 - 0
packages/slackbot-proxy/src/views/install-failed.ejs

@@ -0,0 +1,21 @@
+<%- include('commons/head'); %>
+
+<body>
+  <div class="container">
+    <div class="row">
+      <div class="col text-center">
+        <h1 class="my-5">GROWI Bot installation failed..</h1>
+        <p>
+          Retry from
+          <a href=<%- url %>>
+            <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
+          </a>
+        </p>
+        <p>
+          Or, please contact administrators of your workspace<br>
+          Reference: <a href="https://slack.com/help/articles/222386767-Manage-app-installation-settings-for-your-workspace">Manage app installation settings for your workspace</a>
+        </p>
+      </div>
+    </div>
+  </div>
+</body>

+ 18 - 0
packages/slackbot-proxy/src/views/install-succeeded-but-has-problem.ejs

@@ -0,0 +1,18 @@
+<%- include('commons/head'); %>
+
+<body>
+  <div class="container">
+    <div class="row">
+      <div class="col text-center">
+        <h1 class="my-5">GROWI Bot installation has succeeded, but something went wrong..</h1>
+        <p>
+          Reason: <%- reason%>
+        </p>
+        <p>
+          Please contact administrators of your workspace<br>
+          Reference: <a href="https://slack.com/help/articles/222386767-Manage-app-installation-settings-for-your-workspace">Manage app installation settings for your workspace</a>
+        </p>
+      </div>
+    </div>
+  </div>
+</body>

+ 21 - 0
packages/slackbot-proxy/src/views/install-succeeded.ejs

@@ -0,0 +1,21 @@
+<%- include('commons/head'); %>
+
+<body>
+  <div class="container">
+    <div class="row">
+      <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>
+        </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>
+        </p>
+      </div>
+    </div>
+  </div>
+</body>

+ 1 - 0
packages/slackbot-proxy/src/views/top.ejs

@@ -14,6 +14,7 @@
           <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
         </a>
       </div>
+      <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>
       <div class="d-flex justify-content-evenly my-3">
         <% if (isOfficialMode) { %>
           <a href="/privacy">

+ 4 - 0
packages/slackbot-proxy/tsconfig.base.json

@@ -2,10 +2,14 @@
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
   },
+  "include": [
+    "src"
+  ],
   "exclude": [
     "node_modules",
     "config",
     "dist",
+    "src/public/**",
     "**/*.test.ts"
   ]
 }

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff