Quellcode durchsuchen

Merge branch 'imprv/GW-5883-error-handling-using-http-errors' into imprv/GW-5883-error-handling-using-http-errors-implementation

hakumizuki vor 4 Jahren
Ursprung
Commit
a71c6ae35c
44 geänderte Dateien mit 701 neuen und 362 gelöschten Zeilen
  1. 1 0
      .github/workflows/ci-slackbot-proxy.yml
  2. 4 7
      .github/workflows/ci.yml
  3. 37 33
      .github/workflows/release-rc.yml
  4. 31 12
      .github/workflows/release-slackbot-proxy.yml
  5. 47 38
      .github/workflows/release.yml
  6. 5 0
      CHANGES.md
  7. 1 17
      bin/generate-plugin-definitions-source.js
  8. 0 5
      config/env.dev.js
  9. 3 0
      docker/Dockerfile
  10. 2 8
      package.json
  11. 4 3
      packages/app/package.json
  12. 1 0
      packages/slackbot-proxy/.gitignore
  13. 13 6
      packages/slackbot-proxy/docker/Dockerfile
  14. 8 4
      packages/slackbot-proxy/package.json
  15. 2 2
      packages/slackbot-proxy/src/controllers/slack.ts
  16. 20 0
      packages/slackbot-proxy/src/controllers/term.ts
  17. BIN
      packages/slackbot-proxy/src/public/images/add-to-slack.png
  18. 32 24
      packages/slackbot-proxy/src/services/RegisterService.ts
  19. 9 0
      packages/slackbot-proxy/src/views/commons/head.ejs
  20. 1 3
      packages/slackbot-proxy/src/views/privacy.ejs
  21. 79 0
      packages/slackbot-proxy/src/views/term.ejs
  22. 42 17
      packages/slackbot-proxy/src/views/top.ejs
  23. 6 3
      resource/locales/en_US/admin/admin.json
  24. 6 3
      resource/locales/ja_JP/admin/admin.json
  25. 6 3
      resource/locales/zh_CN/admin/admin.json
  26. 2 2
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  27. 11 9
      src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  28. 1 38
      src/client/js/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  29. 1 1
      src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx
  30. 3 7
      src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  31. 54 0
      src/client/js/components/Admin/Users/SendInvitationEmailButton.jsx
  32. 52 5
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  33. 29 15
      src/client/js/components/Admin/Users/UserMenu.jsx
  34. 2 4
      src/client/js/components/Page/RevisionRenderer.jsx
  35. 1 2
      src/client/js/services/AdminUsersContainer.js
  36. 12 0
      src/client/js/util/apiNotification.js
  37. 6 0
      src/client/styles/scss/_user.scss
  38. 27 45
      src/server/models/user.js
  39. 0 19
      src/server/plugins/plugin.service.js
  40. 7 0
      src/server/routes/apiv3/slack-integration-settings.js
  41. 2 1
      src/server/routes/apiv3/slack-integration.js
  42. 111 6
      src/server/routes/apiv3/users.js
  43. 2 2
      src/server/service/slackbot.js
  44. 18 18
      yarn.lock

+ 1 - 0
.github/workflows/ci-slackbot-proxy.yml

@@ -4,6 +4,7 @@ on:
   push:
   push:
     branches-ignore:
     branches-ignore:
       - release/**
       - release/**
+      - rc/**
       - tmp/**
       - tmp/**
     paths:
     paths:
       - .github/workflows/ci-slackbot-proxy.yml
       - .github/workflows/ci-slackbot-proxy.yml

+ 4 - 7
.github/workflows/ci.yml

@@ -4,11 +4,11 @@ on:
   push:
   push:
     branches-ignore:
     branches-ignore:
       - release/**
       - release/**
+      - rc/**
       - tmp/**
       - tmp/**
     paths:
     paths:
       - .github/workflows/ci.yml
       - .github/workflows/ci.yml
       - packages/app/**
       - packages/app/**
-      - packages/app-for-hoisting/**
       - .eslint*
       - .eslint*
       - .prettier*
       - .prettier*
       - .stylelint*
       - .stylelint*
@@ -198,7 +198,6 @@ jobs:
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
-        yarn lerna add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion --scope @growi/app --scope @growi/app-for-hoisting
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
@@ -271,17 +270,15 @@ jobs:
     - name: Install dependencies
     - name: Install dependencies
       run: |
       run: |
         npx lerna bootstrap
         npx lerna bootstrap
-        yarn lerna add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs --scope @growi/app --scope @growi/app-for-hoisting
-        yarn lerna add -D react-images@1.0.0 react-motion --scope @growi/app --scope @growi/app-for-hoisting
-        yarn lerna run build --scope @growi/slack
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
-    - name: yarn build:prod:analyze
+    - name: Build
       run: |
       run: |
-        yarn build:prod:analyze
+        yarn lerna run build --scope @growi/slack
+        yarn lerna run build --scope @growi/app
     - name: lerna bootstrap --production
     - name: lerna bootstrap --production
       run: |
       run: |
         npx lerna bootstrap -- --production
         npx lerna bootstrap -- --production

+ 37 - 33
.github/workflows/release-rc.yml

@@ -14,34 +14,24 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
 
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v1
-
-    - name: Login to docker.io registry
-      run: |
-        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
-
-    - name: Build Docker Image
-      run: |
-        CACHE_REF=weseek/growi-cache:4
-        docker buildx build \
-          --tag growi \
-          --platform linux/amd64 \
-          --load \
-          --file ./docker/Dockerfile .
-
-    - name: Get SemVer
+    - name: Setup semver
+      id: semver
       run: |
       run: |
         semver=`npm run version --silent`
         semver=`npm run version --silent`
-        echo "SEMVER=$semver" >> $GITHUB_ENV
+        echo "::set-output name=SEMVER::$semver"
 
 
-    - name: Docker Tags by SemVer
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+    - name: Docker meta
+      id: meta
+      uses: docker/metadata-action@v3
       with:
       with:
-        source: growi
-        target: weseek/growi
-        semver: ${{ env.SEMVER }}
-        publish: true
+        images: weseek/growi,ghcr.io/weseek/growi
+        tags: |
+          type=raw,value=${{ steps.semver.outputs.SEMVER }}
+          type=raw,value=${{ steps.semver.outputs.SEMVER }}.{{sha}}
+
+    - name: Login to docker.io registry
+      run: |
+        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
 
     - name: Login to GitHub Container Registry
     - name: Login to GitHub Container Registry
       uses: docker/login-action@v1
       uses: docker/login-action@v1
@@ -50,15 +40,29 @@ jobs:
         username: wsmoogle
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
         password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
 
-    - name: Docker Tags by SemVer in Github Container Registry
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.os }}-buildx-app-${{ github.sha }}
+        restore-keys: |
+          ${{ runner.os }}-buildx-app-
+
+    - name: Build and push
+      uses: docker/build-push-action@v2
       with:
       with:
-        source: growi
-        target: ghcr.io/weseek/growi
-        semver: ${{ env.SEMVER }}
-        publish: true
+        context: .
+        file: ./docker/Dockerfile
+        platforms: linux/amd64
+        push: true
+        cache-from: type=local,src=/tmp/.buildx-cache
+        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        tags: ${{ steps.meta.outputs.tags }}
 
 
-    - name: Check whether workspace is clean
+    - name: Move cache
       run: |
       run: |
-        STATUS=`git status --porcelain`
-        if [ -z "$STATUS" ]; then exit 0; else exit 1; fi
+        rm -rf /tmp/.buildx-cache
+        mv /tmp/.buildx-cache-new /tmp/.buildx-cache

+ 31 - 12
.github/workflows/release-slackbot-proxy.yml

@@ -13,14 +13,21 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v2
     - uses: actions/checkout@v2
 
 
-    - name: Get version
+    - name: Setup semver
+      id: semver
       working-directory: ./packages/slackbot-proxy
       working-directory: ./packages/slackbot-proxy
       run: |
       run: |
-        export RELEASE_VERSION=`npm run version --silent`
-        echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV
+        semver=`npm run version --silent`
+        echo "::set-output name=SEMVER::$semver"
 
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v1
+    - name: Docker meta
+      id: meta
+      uses: docker/metadata-action@v3
+      with:
+        images: weseek/growi-slackbot-proxy,ghcr.io/weseek/growi-slackbot-proxy,asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy
+        tags: |
+          type=raw,value=latest
+          type=raw,value=${{ steps.semver.outputs.SEMVER }}
 
 
     - name: Login to docker.io registry
     - name: Login to docker.io registry
       run: |
       run: |
@@ -44,6 +51,17 @@ jobs:
       run: |
       run: |
         gcloud auth configure-docker --quiet
         gcloud auth configure-docker --quiet
 
 
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.os }}-buildx-slackbot-proxy-${{ github.sha }}
+        restore-keys: |
+          ${{ runner.os }}-buildx-slackbot-proxy-
+
     - name: Build and push
     - name: Build and push
       uses: docker/build-push-action@v2
       uses: docker/build-push-action@v2
       with:
       with:
@@ -51,13 +69,14 @@ jobs:
         file: ./packages/slackbot-proxy/docker/Dockerfile
         file: ./packages/slackbot-proxy/docker/Dockerfile
         platforms: linux/amd64
         platforms: linux/amd64
         push: true
         push: true
-        tags: |
-          weseek/growi-slackbot-proxy:latest
-          weseek/growi-slackbot-proxy:${{ env.RELEASE_VERSION }}
-          ghcr.io/weseek/growi-slackbot-proxy:latest
-          ghcr.io/weseek/growi-slackbot-proxy:${{ env.RELEASE_VERSION }}
-          asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy:latest
-          asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy:${{ env.RELEASE_VERSION }}
+        cache-from: type=local,src=/tmp/.buildx-cache
+        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        tags: ${{ steps.meta.outputs.tags }}
+
+    - name: Move cache
+      run: |
+        rm -rf /tmp/.buildx-cache
+        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
 
     - name: Update Docker Hub Description
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v2
       uses: peter-evans/dockerhub-description@v2

+ 47 - 38
.github/workflows/release.yml

@@ -67,36 +67,62 @@ jobs:
         git fetch --tags
         git fetch --tags
         git checkout refs/tags/v${{ needs.github-release.outputs.RELEASE_VERSION }}
         git checkout refs/tags/v${{ needs.github-release.outputs.RELEASE_VERSION }}
 
 
-    - name: Determine suffix
+    - name: Setup suffix
+      id: suffix
       run: |
       run: |
         [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
         [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
-        echo "SUFFIX=$suffix" >> $GITHUB_ENV
+        echo "::set-output name=SUFFIX::$suffix"
 
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v1
+    - name: Docker meta
+      id: meta
+      uses: docker/metadata-action@v3
+      with:
+        images: weseek/growi,ghcr.io/weseek/growi
+        flavor: |
+          suffix=${{ steps.suffix.outputs.SUFFIX }}
+        tags: |
+          type=raw,value=latest
+          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.github-release.outputs.RELEASE_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
 
     - name: Login to docker.io registry
     - name: Login to docker.io registry
       run: |
       run: |
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
 
-    - name: Build Docker Image
-      run: |
-        docker buildx build \
-          --tag growi${{ env.SUFFIX }} \
-          --build-arg flavor=${{ matrix.flavor }} \
-          --platform linux/amd64 \
-          --load \
-          --file ./docker/Dockerfile .
-
-    - name: Docker Tags by SemVer
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.5
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@v1
+      with:
+        registry: ghcr.io
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@v1
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.os }}-buildx-app-${{ github.sha }}
+        restore-keys: |
+          ${{ runner.os }}-buildx-app-
+
+    - name: Build and push
+      uses: docker/build-push-action@v2
       with:
       with:
-        source: growi${{ env.SUFFIX }}
-        target: weseek/growi
-        semver: ${{ needs.github-release.outputs.RELEASE_VERSION }}
-        suffix: ${{ env.SUFFIX }}
-        additional-tags: 'latest'
-        publish: true
+        context: .
+        file: ./docker/Dockerfile
+        platforms: linux/amd64
+        push: true
+        cache-from: type=local,src=/tmp/.buildx-cache
+        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        tags: ${{ steps.meta.outputs.tags }}
+
+    - name: Move cache
+      run: |
+        rm -rf /tmp/.buildx-cache
+        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
 
     - name: Update Docker Hub Description
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v2
       uses: peter-evans/dockerhub-description@v2
@@ -106,23 +132,6 @@ jobs:
         repository: weseek/growi
         repository: weseek/growi
         readme-filepath: ./docker/README.md
         readme-filepath: ./docker/README.md
 
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v1
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
-
-    - name: Docker Tags by SemVer in Github Container Registry
-      uses: weseek/ghaction-docker-tags-by-semver@v1.0.3
-      with:
-        source: growi${{ env.SUFFIX }}
-        target: ghcr.io/weseek/growi
-        semver: ${{ needs.github-release.outputs.RELEASE_VERSION }}
-        suffix: ${{ env.SUFFIX }}
-        additional-tags: 'latest'
-        publish: true
-
     - name: Slack Notification
     - name: Slack Notification
       uses: weseek/ghaction-release-slack-notification@master
       uses: weseek/ghaction-release-slack-notification@master
       with:
       with:

+ 5 - 0
CHANGES.md

@@ -2,6 +2,9 @@
 
 
 ## v4.3.0-RC
 ## v4.3.0-RC
 
 
+* Support: Upgrade libs
+    * striptags
+
 ### BREAKING CHANGES
 ### BREAKING CHANGES
 
 
 None.
 None.
@@ -31,6 +34,8 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/43x.html>
     * nodemailer
     * nodemailer
     * i18next-express-middleware
     * i18next-express-middleware
     * growi-commons
     * growi-commons
+    * growi-plugin-attachment-refs
+    * growi-plugin-lsx
 
 
 ## v4.2.20
 ## v4.2.20
 
 

+ 1 - 17
bin/generate-plugin-definitions-source.js

@@ -20,25 +20,9 @@ const OUT = helpers.root('tmp/plugins/plugin-definitions.js');
 
 
 
 
 // list plugin names
 // list plugin names
-let pluginNames = pluginUtils.listPluginNames(helpers.root());
+const pluginNames = pluginUtils.listPluginNames(helpers.root());
 logger.info('Detected plugins: ', pluginNames);
 logger.info('Detected plugins: ', pluginNames);
 
 
-// add from PLUGIN_NAMES_TOBE_LOADED when development
-if (process.env.NODE_ENV === 'development'
-    && process.env.PLUGIN_NAMES_TOBE_LOADED !== undefined
-    && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
-  const pluginNamesDev = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
-
-  logger.info('Detected plugins from PLUGIN_NAMES_TOBE_LOADED: ', pluginNamesDev);
-
-  // merge and remove duplicates
-  if (pluginNamesDev.length > 0) {
-    pluginNames = pluginNames.concat(pluginNamesDev);
-    pluginNames = Array.from(new Set(pluginNames));
-  }
-}
-
-
 // get definitions
 // get definitions
 const definitions = pluginNames
 const definitions = pluginNames
   .map((name) => {
   .map((name) => {

+ 0 - 5
config/env.dev.js

@@ -12,11 +12,6 @@ module.exports = {
   HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   // S2SMSG_PUBSUB_SERVER_TYPE: 'nchan',
   // S2SMSG_PUBSUB_SERVER_TYPE: 'nchan',
-  PLUGIN_NAMES_TOBE_LOADED: [
-    // 'growi-plugin-lsx',
-    // 'growi-plugin-pukiwiki-like-linker',
-    // 'growi-plugin-attachment-refs',
-  ],
   // PUBLISH_OPEN_API: true,
   // PUBLISH_OPEN_API: true,
   // USER_UPPER_LIMIT: 0,
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // DEV_HTTPS: true,

+ 3 - 0
docker/Dockerfile

@@ -85,6 +85,7 @@ ENV appDir /opt/growi
 WORKDIR ${appDir}
 WORKDIR ${appDir}
 
 
 COPY ./package.json ./
 COPY ./package.json ./
+COPY ./yarn.lock ./
 COPY ./lerna.json ./
 COPY ./lerna.json ./
 COPY ./tsconfig.base.json ./
 COPY ./tsconfig.base.json ./
 COPY ./babel.config.js ./
 COPY ./babel.config.js ./
@@ -104,11 +105,13 @@ RUN yarn lerna run build
 # make artifacts
 # make artifacts
 RUN tar cf packages.tar \
 RUN tar cf packages.tar \
   package.json \
   package.json \
+  yarn.lock \
   config \
   config \
   public \
   public \
   resource \
   resource \
   src \
   src \
   tmp \
   tmp \
+  packages/app/package.json \
   packages/slack/package.json \
   packages/slack/package.json \
   packages/slack/dist
   packages/slack/dist
 
 

+ 2 - 8
package.json

@@ -21,10 +21,8 @@
   },
   },
   "private": true,
   "private": true,
   "workspaces": {
   "workspaces": {
-    "packages": [
-      "packages/*"
-    ],
-    "nohoist": []
+    "packages": ["packages/*"],
+    "nohoist": ["**/slackbot-proxy/bootstrap"]
   },
   },
   "scripts": {
   "scripts": {
     "build:api:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "build:api:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -37,15 +35,12 @@
     "build:dev:watch:poll": "npm run build:dev:app:watch:poll",
     "build:dev:watch:poll": "npm run build:dev:app:watch:poll",
     "build:dev": "yarn build:dev:app",
     "build:dev": "yarn build:dev:app",
     "build:slack": "lerna run build --scope @growi/slack",
     "build:slack": "lerna run build --scope @growi/slack",
-    "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
-    "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
     "build": "npm run build:dev:watch",
     "build:poll": "npm run build:dev:watch:poll",
     "build:poll": "npm run build:dev:watch:poll",
     "clean:app": "rimraf -- public/js public/styles",
     "clean:app": "rimraf -- public/js public/styles",
     "clean:report": "rimraf -- report",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
     "clean": "npm-run-all -p clean:*",
     "console": "env-cmd -f config/env.dev.js node --experimental-repl-await src/server/console.js",
     "console": "env-cmd -f config/env.dev.js node --experimental-repl-await src/server/console.js",
-    "heroku-postbuild": "sh bin/heroku/install-packages.sh && npm run build:prod",
     "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
     "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
     "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:styles:fix": "stylelint --fix src/client/styles/scss/**/*.scss",
     "lint:styles:fix": "stylelint --fix src/client/styles/scss/**/*.scss",
@@ -60,7 +55,6 @@
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "plugin:def": "node bin/generate-plugin-definitions-source.js",
     "prebuild:dev:watch": "npm run prebuild:dev",
     "prebuild:dev:watch": "npm run prebuild:dev",
     "prebuild:dev": "npm run clean:app && env-cmd -f config/env.dev.js npm run plugin:def && env-cmd -f config/env.dev.js npm run resource && yarn build:slack",
     "prebuild:dev": "npm run clean:app && env-cmd -f config/env.dev.js npm run plugin:def && env-cmd -f config/env.dev.js npm run resource && yarn build:slack",
-    "prebuild:prod": "npm run clean && env-cmd -f config/env.prod.js npm run plugin:def && env-cmd -f config/env.prod.js npm run resource",
     "prelint:swagger2openapi": "npm run build:apiv3:jsdoc",
     "prelint:swagger2openapi": "npm run build:apiv3:jsdoc",
     "preserver:prod": "npm run migrate",
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "prestart": "npm run build:prod",

+ 4 - 3
packages/app/package.json

@@ -3,7 +3,8 @@
   "version": "0.9.0-RC",
   "version": "0.9.0-RC",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
-    "build": "cd ../../ && env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail"
+    "build": "cd ../../ && env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
+    "prebuild": "cd ../../ && yarn plugin:def && yarn resource"
   },
   },
   "// comments for dependencies": {
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
@@ -52,8 +53,8 @@
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
     "growi-commons": "^5.0.4",
-    "growi-plugin-attachment-refs": "^2.0.1",
-    "growi-plugin-lsx": "^4.0.2",
+    "growi-plugin-attachment-refs": "^2.0.2",
+    "growi-plugin-lsx": "^4.0.3",
     "growi-plugin-pukiwiki-like-linker": "^3.1.0",
     "growi-plugin-pukiwiki-like-linker": "^3.1.0",
     "helmet": "^3.13.0",
     "helmet": "^3.13.0",
     "i18next": "^20.3.2",
     "i18next": "^20.3.2",

+ 1 - 0
packages/slackbot-proxy/.gitignore

@@ -0,0 +1 @@
+src/public/bootstrap

+ 13 - 6
packages/slackbot-proxy/docker/Dockerfile

@@ -25,6 +25,10 @@ RUN yarn config set network-timeout 300000
 FROM deps-resolver-base AS deps-resolver-dev
 FROM deps-resolver-base AS deps-resolver-dev
 RUN npx lerna bootstrap
 RUN npx lerna bootstrap
 
 
+# make artifacts
+RUN tar cf node_modules.tar node_modules \
+  packages/slackbot-proxy/node_modules \
+  packages/slack/node_modules
 
 
 
 
 ##
 ##
@@ -45,17 +49,20 @@ ENV appDir /opt
 
 
 WORKDIR ${appDir}
 WORKDIR ${appDir}
 
 
-COPY --from=deps-resolver-dev ${appDir}/node_modules node_modules
+# copy dependent packages
+COPY --from=deps-resolver-dev \
+  ${appDir}/node_modules.tar ${appDir}/
 
 
-# copy all related packages
-COPY packages/slack packages/slack
-COPY packages/slackbot-proxy packages/slackbot-proxy
+# extract node_modules.tar
+RUN tar xf node_modules.tar
+RUN rm node_modules.tar
 
 
 COPY ./package.json ./
 COPY ./package.json ./
 COPY ./lerna.json ./
 COPY ./lerna.json ./
 COPY ./tsconfig.base.json ./
 COPY ./tsconfig.base.json ./
-COPY ./packages/slack ./packages/slack
-COPY ./packages/slackbot-proxy ./packages/slackbot-proxy
+# copy all related packages
+COPY packages/slack packages/slack
+COPY packages/slackbot-proxy packages/slackbot-proxy
 
 
 # build
 # build
 RUN yarn lerna run build
 RUN yarn lerna run build

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

@@ -3,16 +3,19 @@
   "version": "0.9.1-RC",
   "version": "0.9.1-RC",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
-    "build": "yarn tsc && tsc-alias -p tsconfig.build.json && yarn postbuild",
-    "postbuild": "yarn cp:public && yarn cp:views",
-    "cp:public": "mkdir -p ./dist/public && cp -r ./src/public ./dist",
-    "cp:views": "mkdir -p ./dist/views && cp -r ./src/views ./dist",
+    "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
+    "cp:public": "cp -RT ./src/public ./dist/public",
+    "cp:views": "cp -RT ./src/views ./dist/views",
+    "cp:bootstrap": "cp -RT ./node_modules/bootstrap/dist ./dist/public/bootstrap",
+    "cp:bootstrap:dev": "cp -RT ./node_modules/bootstrap/dist ./src/public/bootstrap",
     "tsc": "tsc -p tsconfig.build.json",
     "tsc": "tsc -p tsconfig.build.json",
     "tsc:w": "yarn tsc -w",
     "tsc:w": "yarn tsc -w",
     "dev:ci": "yarn dev --ci",
     "dev:ci": "yarn dev --ci",
     "dev": "cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config src/index.ts",
     "dev": "cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config src/index.ts",
     "start:prod:ci": "yarn start:prod --ci",
     "start:prod:ci": "yarn start:prod --ci",
     "start:prod": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/index.js",
     "start:prod": "cross-env NODE_ENV=production node -r dotenv-flow/config dist/index.js",
+    "postbuild": "yarn cp:public && yarn cp:views && yarn cp:bootstrap",
+    "predev": "yarn cp:bootstrap:dev",
     "test": "yarn test:lint && yarn test:coverage",
     "test": "yarn test:lint && yarn test:coverage",
     "test:unit": "cross-env NODE_ENV=test jest --passWithNoTests",
     "test:unit": "cross-env NODE_ENV=test jest --passWithNoTests",
     "test:coverage": "yarn test:unit",
     "test:coverage": "yarn test:unit",
@@ -57,6 +60,7 @@
     "@tsed/schema": "^6.43.0",
     "@tsed/schema": "^6.43.0",
     "@typescript-eslint/eslint-plugin": "^4.18.0",
     "@typescript-eslint/eslint-plugin": "^4.18.0",
     "@typescript-eslint/parser": "^4.18.0",
     "@typescript-eslint/parser": "^4.18.0",
+    "bootstrap": "^5.0.2",
     "browser-bunyan": "^1.6.3",
     "browser-bunyan": "^1.6.3",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",

+ 2 - 2
packages/slackbot-proxy/src/controllers/slack.ts

@@ -151,7 +151,7 @@ export class SlackCtrl {
       return res.json({
       return res.json({
         blocks: [
         blocks: [
           generateMarkdownSectionBlock('*Found Relations to GROWI.*'),
           generateMarkdownSectionBlock('*Found Relations to GROWI.*'),
-          ...relations.map(relation => generateMarkdownSectionBlock(`GROWI url: ${relation.growiUri}.`)),
+          ...relations.map(relation => generateMarkdownSectionBlock(`GROWI url: ${relation.growiUri}`)),
         ],
         ],
       });
       });
     }
     }
@@ -180,8 +180,8 @@ export class SlackCtrl {
   @Post('/interactions')
   @Post('/interactions')
   @UseBefore(AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
   @UseBefore(AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
   async handleInteraction(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
   async handleInteraction(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
-    logger.info('receive interaction', req.body);
     logger.info('receive interaction', req.authorizeResult);
     logger.info('receive interaction', req.authorizeResult);
+    logger.debug('receive interaction', req.body);
 
 
     const { body, authorizeResult } = req;
     const { body, authorizeResult } = req;
 
 

+ 20 - 0
packages/slackbot-proxy/src/controllers/term.ts

@@ -0,0 +1,20 @@
+import { Controller, PlatformRouter } from '@tsed/common';
+import { Request, Response } from 'express';
+
+const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
+
+@Controller('/term')
+export class TermCtrl {
+
+  constructor(router: PlatformRouter) {
+    if (isOfficialMode) {
+      router.get('/', this.getTerm);
+    }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  getTerm(req: Request, res: Response): string|void {
+    res.render('term.ejs');
+  }
+
+}

BIN
packages/slackbot-proxy/src/public/images/add-to-slack.png


+ 32 - 24
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -1,5 +1,5 @@
 import { Inject, Service } from '@tsed/di';
 import { Inject, Service } from '@tsed/di';
-import { WebClient, LogLevel } from '@slack/web-api';
+import { WebClient, LogLevel, Block } from '@slack/web-api';
 import { generateInputSectionBlock, GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
 import { generateInputSectionBlock, GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
 import { AuthorizeResult } from '@slack/oauth';
 import { AuthorizeResult } from '@slack/oauth';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
@@ -8,6 +8,7 @@ import { Installation } from '~/entities/installation';
 import { InvalidUrlError } from '../models/errors';
 import { InvalidUrlError } from '../models/errors';
 
 
 const isProduction = process.env.NODE_ENV === 'production';
 const isProduction = process.env.NODE_ENV === 'production';
+const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
 
 
 @Service()
 @Service()
 export class RegisterService implements GrowiCommandProcessor {
 export class RegisterService implements GrowiCommandProcessor {
@@ -47,6 +48,18 @@ export class RegisterService implements GrowiCommandProcessor {
     });
     });
   }
   }
 
 
+  async replyToSlack(client: WebClient, channel: string, user: string, text: string, blocks: Array<Block>): Promise<void> {
+    await client.chat.postEphemeral({
+      channel,
+      user,
+      // Recommended including 'text' to provide a fallback when using blocks
+      // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
+      text,
+      blocks,
+    });
+    return;
+  }
+
   async insertOrderRecord(
   async insertOrderRecord(
       installation: Installation | undefined,
       installation: Installation | undefined,
       // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
       // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -67,18 +80,10 @@ export class RegisterService implements GrowiCommandProcessor {
     }
     }
     catch (error) {
     catch (error) {
       const invalidErrorMsg = 'Please enter a valid URL';
       const invalidErrorMsg = 'Please enter a valid URL';
-
-      await client.chat.postEphemeral({
-        channel,
-        user: payload.user.id,
-        // Recommended including 'text' to provide a fallback when using blocks
-        // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
-        text: 'Invalid URL',
-        blocks: [
-          generateMarkdownSectionBlock(invalidErrorMsg),
-        ],
-      });
-
+      const blocks = [
+        generateMarkdownSectionBlock(invalidErrorMsg),
+      ];
+      await this.replyToSlack(client, channel, payload.user.id, 'Invalid URL', blocks);
       throw new InvalidUrlError(growiUrl);
       throw new InvalidUrlError(growiUrl);
     }
     }
 
 
@@ -98,17 +103,20 @@ export class RegisterService implements GrowiCommandProcessor {
 
 
     const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
     const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
 
 
-    await client.chat.postEphemeral({
-      channel,
-      user: payload.user.id,
-      // Recommended including 'text' to provide a fallback when using blocks
-      // refer to https://api.slack.com/methods/chat.postEphemeral#text_usage
-      text: 'Proxy URL',
-      blocks: [
-        generateMarkdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
-        generateMarkdownSectionBlock(`Proxy URL: ${serverUri}`),
-      ],
-    });
+    if (isOfficialMode) {
+      const blocks = [
+        generateMarkdownSectionBlock('Successfully registered with the proxy! Please check test connection in your GROWI'),
+      ];
+      await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
+      return;
+
+    }
+
+    const blocks = [
+      generateMarkdownSectionBlock('Please enter and update the following Proxy URL to slack bot setting form in your GROWI'),
+      generateMarkdownSectionBlock(`Proxy URL: ${serverUri}`),
+    ];
+    await this.replyToSlack(client, channel, payload.user.id, 'Proxy URL', blocks);
     return;
     return;
   }
   }
 
 

+ 9 - 0
packages/slackbot-proxy/src/views/commons/head.ejs

@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html lang="en">
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width,initial-scale=1">
+  <meta name="description" content="GROWI is a wiki that can be written in Markdown and easy to share information with everyone.">
+  <link rel="shortcut icon" href="/images/growi-bot.png">
+  <title>GROWI Official Bot</title>
+  <link href="/bootstrap/css/bootstrap.min.css" rel="stylesheet" >
+</html>

+ 1 - 3
packages/slackbot-proxy/src/views/privacy.ejs

@@ -1,6 +1,4 @@
-<head>
-  <meta name="viewport" content="width=device-width,initial-scale=1">
-</head>
+<%- include('commons/head'); %>
 
 
 <body style="max-width: 600px; padding-top:100px; margin: 0 auto;">
 <body style="max-width: 600px; padding-top:100px; margin: 0 auto;">
   <h1 style="text-align:center;">Privacy Policy</h1>
   <h1 style="text-align:center;">Privacy Policy</h1>

+ 79 - 0
packages/slackbot-proxy/src/views/term.ejs

@@ -0,0 +1,79 @@
+<%- include('commons/head'); %>
+
+<body class="pt-5 m-3 w-50 mx-auto d-flex flex-column">
+  <h1 class="text-center">Terms of Service </h1>
+    <h2 class="text-center">At First</h2>
+      <p>This Terms of Use Agreement(hereinafter referred to as the "Agreement") stipulates the terms and conditions of use for the services provided by WESEEK,Inc. on GROWI Slack-Bot(hereinafter referred to as the "Services"). All registered users(hereinafter referred to as "Users") are required to follow these Terms of Service.</p>
+
+  <h2 class="text-center">Application of Terms</h2>
+    <p>This Agreement shall apply to all relationships between the User and WESEEK,Inc. regarding the use of the Service.  In addition to this Agreement, WESEEK,Inc. may make various provisions regarding the Service, such as rules for use (hereinafter referred to as "Individual Provisions"). These individual regulations may be called by any name. These Individual Regulations, regardless of their names, shall constitute a part of these Terms. In the event that the provisions of these Terms of Use conflict with the provisions of the Individual Provisions of the preceding article, the provisions of the Individual Provisions shall take precedence unless otherwise specified in the Individual Provisions.</p>
+
+  <h2 class="text-center">User Registration</h2>
+   <p>Registration for this service shall be completed when the applicant agrees to these Terms of Use, applies for registration in accordance with the method specified by WESEEK,Inc. approves the application. If WESEEK,Inc. determines that the applicant has any of the following reasons, WESEEK,Inc. may not approve the application for registration, and WESEEK,Inc. shall not be obligated to disclose any of the reasons.</p>
+    <ul>
+      <li>If the applicant has provided false information in the application for registration.</li>
+      <li>If the application is from a person who has violated this agreement.</li>
+      <li>In addition, when WESEEK,Inc. deems that the registration is not appropriate.</li>
+    </ul>
+
+  <h2 class="text-center">Account</h2>
+    <p>Users are responsible for maintaining the privacy and security of their accounts. We will not be liable for any damage or loss caused by your failure to protect your login information, including your password.</p>
+
+  <h2 class="text-center">Prohibited Matters</h2>
+    <p>Users shall not commit any of the following acts when using the Service.</p>
+    <ul>
+      <li>Acts that violate laws or public order and morals</li>
+      <li>Acts related to criminal acts</li>
+      <li>Acts that destroy or interfere with the functions of the servers or networks of WESEEK,Inc., other users, or other third parties.</li>
+      <li>Acts that may interfere with the operation of WESEEK,Inc.'s services.</li>
+      <li>Unauthorized access or attempts to do so.</li>
+      <li>Acts that collect or accumulate personal information about other users.</li>
+      <li>Acts of using this service for illegal purposes.</li>
+      <li>Actions that cause disadvantage, damage, or discomfort to other users of this service or other third parties.</li>
+      <li>Act of impersonating other users.</li>
+      <li>Advertising, solicitation, or business activities on this service that are not authorized by WESEEK,Inc..</li>
+      <li>Directly or indirectly providing benefits to antisocial forces in relation to WESEEK,Inc.'s services.</li>
+      <li>Any other acts that WESEEK,Inc. deems inappropriate.</li>
+    </ul>
+
+  <h2 class="text-center">Suspension of Provision of the Service, etc.</h2>
+    <p>WESEEK,Inc. may suspend or interrupt the provision of all or part of the Service without prior notice to the User if WESEEK,Inc. deems any of the following to be the case When performing maintenance, inspection, or updating of the computer system for this service</p>
+    <ul>
+      <li>In the event that the provision of the Service becomes difficult due to force majeure such as earthquakes, lightning, fire, power outages, or natural disasters.</li>
+      <li>When a computer or communication line is stopped due to an accident</li>
+      <li>In any other cases where WESEEK,Inc. deems it difficult to provide the Service.</li>
+    </ul>
+    <p>WESEEK,Inc. shall not be liable for any disadvantage or damage incurred by the User or any third party due to the suspension or interruption of the provision of the Service.</p>
+
+  <h2 class="text-center">Restriction of Use and Cancellation of Registration</h2>
+    <p>In the event that a User falls under any of the following, WESEEK,Inc. may, without prior notice, restrict the User from using all or part of the Service, or cancel the User's registration.</P>
+    <ul>
+      <li>If the user violates any of the provisions of this agreement.</li>
+      <li>When it is found that there is a false fact in the registered information.</li>
+      <li>When there is no response to communication from WESEEK,Inc. for a certain period of time.</li>
+      <li>When there is no use of this service for a certain period of time after the last use.</li>
+      <li>In any other cases where WESEEK,Inc. deems the use of the Service to be inappropriate.</li>
+    </ul>
+    <p>WESEEK,Inc. shall not be liable for any damages incurred by the User as a result of any action taken by WESEEK,Inc. in accordance with this Article.</p>
+
+  <h2 class="text-center">No Warranty and Disclaimer</h2>
+    <p>WESEEK,Inc does not warrant, expressly or impliedly, that the Service is free from defects in fact or in law (including defects in safety, reliability, accuracy, completeness, effectiveness, fitness for a particular purpose, security, errors or bugs, infringement of rights, etc.).
+      WESEEK,Inc shall not be liable for any damages incurred by the User due to the Service. WESEEK,Inc shall not be liable for any transactions, communications, or disputes that may arise between the User and other users or third parties in relation to the Service.</p>
+  <h2 class="text-center">Modification of Service Contents, etc.</h2>
+    <p>WESEEK,Inc. may change the contents of the Service or discontinue the provision of the Service without notice to the User, and shall not be liable for any damages incurred by the User as a result of such changes.</p>
+
+  <h2 class="text-center">Modification of the Terms of Service, etx.</h2>
+    <p>WESEEK,Inc. may change the Terms of Use at any time without notice to the User, if WESEEK,Inc. deems it necessary. In the event that a User begins to use the Service after a change to the Terms, the User shall be deemed to have agreed to the changed Terms.</p>
+
+  <h2 class="text-center">Handling of Personal Information</h2>
+    <p>WESEEK,Inc. shall handle personal information obtained through the use of the Service in an appropriate manner in accordance with WESEEK,Inc.'s "Privacy Policy".</p>
+
+  <h2 class="text-center">Notification or Communication</h2>
+    <p>Notification or communication between the User and WESEEK,Inc. shall be conducted in a manner determined by WESEEK,Inc.. Unless the User notifies WESEEK,Inc. of a change in the method specified separately by WESEEK,Inc., WESEEK,Inc. will assume that the currently registered contact information is valid and send notifications or communications to said contact information, and these notifications or communications will be deemed to have reached the User at the time they are sent.</p>
+
+  <h2 class="text-center">Prohibition of Transfer of Rights and Obligations</h2>
+    <p>The User may not assign or pledge to a third party his/her position in the User Agreement or rights or obligations under this Agreement without the prior written consent of WESEEK,Inc..</p>
+
+  <h2 class="text-center">Governing Law</h2>
+    <p>These Terms of Use shall be governed by and construed in accordance with the laws of Japan. In the event of a dispute regarding the Service, the court having jurisdiction over the location of the head office of WESEEK,Inc. shall have exclusive jurisdiction.</p>
+</body>

+ 42 - 17
packages/slackbot-proxy/src/views/top.ejs

@@ -1,20 +1,45 @@
-<head>
-  <meta name="viewport" content="width=device-width,initial-scale=1">
-</head>
+<%- include('commons/head'); %>
 
 
-<body style="padding-top:100px; text-align:center;">
-  <h1 >GROWI Bot</h1>
-  <div>
-    <img height="300" width="300" alt="GROWi Bot" src="/images/growi-bot.png" />
-  </div>
-  <div style="display:flex; justify-content: space-around; max-width: 500px; margin:30px auto;">
-    <a href=<%- url %>>
-      <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
-    </a>
-    <% if (isOfficialMode) { %>
-      <a href="/privacy">
-        Privacy Policy
-      </a>
-    <% } %>
+<body class="pt-3">
+  <div class="container border p-5">
+    <div class="text-center">
+      <h1>GROWI Bot</h1>
+      <h4>GROWI is a wiki that can be written in Markdown and easy to share information with everyone.</h4>
+
+      <div>
+        <img height="300" width="300" alt="GROWi Bot" src="/images/growi-bot.png" />
+      </div>
+      <div class="m-3">
+        <a href=<%- url %>>
+          <img alt="Add to Slack" height="40" width="139" src="/images/add-to-slack.png"/>
+        </a>
+      </div>
+      <div class="d-flex justify-content-evenly my-3">
+        <% if (isOfficialMode) { %>
+          <a href="/privacy">
+            Privacy Policy
+          </a>
+          <a href="/term">
+            Term of Service
+          </a>
+        <% } %>
+      </div>
+    </div>
+    <div class="m-3">
+      <div class="d-flex flex-column align-items-center justify-content-center">
+          <ul>
+            With GROWI for Slack, you can:
+            <ul>
+              <li>Search pages for multiple GROWI apps.</li>
+              <li>Create pages easily.</li>
+            </ul>
+            </li>
+          </ul>
+        <div class="mt-3">
+          GROWI is open-source software developed by WESEEK, Inc and we are looking for contributors who can work with us.<br>
+          Please <a href="https://growi-slackin.weseek.co.jp/">join</a> Slack and feel free to talk to WESEEK members!
+        </div>
+      </div>
+    </div>
   </div>
   </div>
 </body>
 </body>

+ 6 - 3
resource/locales/en_US/admin/admin.json

@@ -311,6 +311,7 @@
       "install_now": "Install now",
       "install_now": "Install now",
       "generate_access_token": "Generate Access Token",
       "generate_access_token": "Generate Access Token",
       "register_for_growi_official_bot_proxy_service": "Register for GROWI Official Bot Proxy Service",
       "register_for_growi_official_bot_proxy_service": "Register for GROWI Official Bot Proxy Service",
+      "register_for_growi_custom_bot_proxy": "Register for your GROWI Custom Bot Proxy",
       "enter_growi_register_on_slack": "Enter <b>/growi register</b> on slack",
       "enter_growi_register_on_slack": "Enter <b>/growi register</b> on slack",
       "paste_growi_url": "Since a modal is displayed, enter the following URL in <b>GROWI URL</b>.",
       "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>",
       "enter_access_token_for_growi_and_proxy": "Enter <b>Access Token Proxy to GROWI</b> and <b>Access Token GROWI to Proxy</b>",
@@ -320,7 +321,6 @@
       "dont_need_update": "※If the value is already in there, there is no need to update it.",
       "dont_need_update": "※If the value is already in there, there is no need to update it.",
       "select_install_your_app": "Select \"Install your app\".",
       "select_install_your_app": "Select \"Install your app\".",
       "select_install_to_workspace": "Select \"Install to Workspace\".",
       "select_install_to_workspace": "Select \"Install to Workspace\".",
-      "register_official_bot_proxy_service": "Issue Access Token / Register GROWI Official Bot Proxy Service",
       "register_proxy_url": "Register Proxy URL with GROWI",
       "register_proxy_url": "Register Proxy URL with GROWI",
       "click_allow": "Select \"Allow\".",
       "click_allow": "Select \"Allow\".",
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
       "install_complete_if_checked": "Confirm that \"Install your app\" is checked.",
@@ -353,7 +353,8 @@
       "valid_email": "Valid email address is required",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",
       "send_new_password": "Please send the new password to the user.",
-      "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
+      "send_temporary_password": "If you have not sent an invitation email, copy the temporary password on this screen and contact the inviter.",
+      "send_email": "You can also send or resend the invitation email from the drop-down in the users table.",
       "existing_email": "The following emails already exist",
       "existing_email": "The following emails already exist",
       "issue": "Issue"
       "issue": "Issue"
     },
     },
@@ -367,7 +368,9 @@
       "your_own": "You cannot deactivate your own account",
       "your_own": "You cannot deactivate your own account",
       "remove_admin_access": "Remove admin access",
       "remove_admin_access": "Remove admin access",
       "cannot_remove": "You cannot remove yourself from administrator",
       "cannot_remove": "You cannot remove yourself from administrator",
-      "give_admin_access": "Give admin access"
+      "give_admin_access": "Give admin access",
+      "send_invitation_email": "Send invitation email",
+      "resend_invitation_email": "Resend invitation email"
     },
     },
     "reset_password": "Reset Password",
     "reset_password": "Reset Password",
     "reset_password_modal": {
     "reset_password_modal": {

+ 6 - 3
resource/locales/ja_JP/admin/admin.json

@@ -308,6 +308,7 @@
       "install_now": "今すぐインストール",
       "install_now": "今すぐインストール",
       "generate_access_token": "Access Tokenの発行",
       "generate_access_token": "Access Tokenの発行",
       "register_for_growi_official_bot_proxy_service": "GROWI Official Bot Proxy サービスへの登録",
       "register_for_growi_official_bot_proxy_service": "GROWI Official Bot Proxy サービスへの登録",
+      "register_for_growi_custom_bot_proxy": "GROWI Custom Bot Proxy への登録",
       "enter_growi_register_on_slack": "Slack上で <b>/growi register</b> と打ちます。",
       "enter_growi_register_on_slack": "Slack上で <b>/growi register</b> と打ちます。",
       "paste_growi_url": "モーダルが表示されるので、<b>GROWI URL</b> には下記のURLを入力します。",
       "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>を入れる",
       "enter_access_token_for_growi_and_proxy": "上記で発行した<b>Access Token Proxy to GROWI</b> と <b>Access Token GROWI to Proxy</b>を入れる",
@@ -317,7 +318,6 @@
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "dont_need_update": "※既に値が入っている場合は更新する必要はありません",
       "select_install_your_app": "Install your app をクリックします。",
       "select_install_your_app": "Install your app をクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",
       "select_install_to_workspace": "Install to Workspace をクリックします。",
-      "register_official_bot_proxy_service": "アクセストークンの発行 / GROWI Official Bot Proxy サービスへの登録",
       "register_proxy_url": "Proxy の URLをGROWIに登録する",
       "register_proxy_url": "Proxy の URLをGROWIに登録する",
       "click_allow": "遷移先の画面にて、Allowをクリックします。",
       "click_allow": "遷移先の画面にて、Allowをクリックします。",
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
       "install_complete_if_checked": "Install your app の右側に緑色のチェックがつけばワークスペースへのインストール完了です。",
@@ -351,7 +351,8 @@
       "valid_email": "メールアドレスを入力してください。",
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
-      "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
+      "send_temporary_password": "招待メールを送っていない場合、この画面で仮パスワードをコピーし、招待者へ連絡してください。",
+      "send_email": "ユーザーテーブルのドロップダウンから招待メールの送信または再送信を行うこともできます。",
       "existing_email": "以下のEmailはすでに存在しています。",
       "existing_email": "以下のEmailはすでに存在しています。",
       "issue": "発行"
       "issue": "発行"
     },
     },
@@ -365,7 +366,9 @@
       "your_own": "自分自身のアカウントを停止することはできません",
       "your_own": "自分自身のアカウントを停止することはできません",
       "remove_admin_access": "管理者から外す",
       "remove_admin_access": "管理者から外す",
       "cannot_remove": "自分自身を管理者から外すことはできません",
       "cannot_remove": "自分自身を管理者から外すことはできません",
-      "give_admin_access": "管理者にする"
+      "give_admin_access": "管理者にする",
+      "send_invitation_email": "招待メールの送信",
+      "resend_invitation_email": "招待メールの再送信"
     },
     },
     "reset_password": "パスワードのリセット",
     "reset_password": "パスワードのリセット",
     "reset_password_modal": {
     "reset_password_modal": {

+ 6 - 3
resource/locales/zh_CN/admin/admin.json

@@ -318,6 +318,7 @@
       "install_now": "现在安装",
       "install_now": "现在安装",
       "generate_access_token": "生成Access Token",
       "generate_access_token": "生成Access Token",
       "register_for_growi_official_bot_proxy_service": "注册 GROWI Official Bot Proxy Service",
       "register_for_growi_official_bot_proxy_service": "注册 GROWI Official Bot Proxy Service",
+      "register_for_growi_custom_bot_proxy": "注册 GROWI Custom Bot Proxy",
       "enter_growi_register_on_slack": "在Slack中,输入 <b>/growi register</b>",
       "enter_growi_register_on_slack": "在Slack中,输入 <b>/growi register</b>",
       "paste_growi_url": "由于显示了模式,请在 <b>GROWI URL</b> 中输入以下URL",
       "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>。",
       "enter_access_token_for_growi_and_proxy": "插入上面发出的 <b>Access Token Proxy to GROWI</b> 和 <b>Access Token GROWI to Proxy</b>。",
@@ -327,7 +328,6 @@
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "dont_need_update": "※如果值已经在里面了,就不需要再更新。",
       "select_install_your_app": "选择 \"Install your app\"。",
       "select_install_your_app": "选择 \"Install your app\"。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",
       "select_install_to_workspace": "选择 \"Install to Workspace\"。",
-      "register_official_bot_proxy_service": "发行访问令牌 / 注册 GROWI 官方 Bot 代理服务",
       "register_proxy_url": "向 GROWI 注册代理 URL",
       "register_proxy_url": "向 GROWI 注册代理 URL",
       "click_allow": "选择 \"Allow\"。",
       "click_allow": "选择 \"Allow\"。",
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
       "install_complete_if_checked": "确认已选中 \"Install your app\"。",
@@ -360,7 +360,8 @@
 			"invite_thru_email": "发送邀请电子邮件",
 			"invite_thru_email": "发送邀请电子邮件",
 			"temporary_password": "创建的用户具有临时密码",
 			"temporary_password": "创建的用户具有临时密码",
 			"send_new_password": "请将新密码发送给用户。",
 			"send_new_password": "请将新密码发送给用户。",
-			"send_temporary_password": "请确保复制此屏幕上的临时密码并将其发送给用户。",
+			"send_temporary_password": "如果你没有发送电子邮件邀请,请复制此屏幕上的临时密码并联系邀请人。",
+      "send_email": "你也可以从用户表中的下拉菜单中发送或重新发送邀请邮件。",
 			"existing_email": "以下电子邮件已存在",
 			"existing_email": "以下电子邮件已存在",
       "issue": "Issue"
       "issue": "Issue"
 		},
 		},
@@ -374,7 +375,9 @@
 			"your_own": "您不能停用自己的帐户",
 			"your_own": "您不能停用自己的帐户",
 			"remove_admin_access": "删除管理员访问权限",
 			"remove_admin_access": "删除管理员访问权限",
 			"cannot_remove": "您不能从管理员中删除自己",
 			"cannot_remove": "您不能从管理员中删除自己",
-			"give_admin_access": "授予管理员访问权限"
+			"give_admin_access": "授予管理员访问权限",
+      "send_invitation_email": "发送邀请邮件",
+      "resend_invitation_email": "重发邀请函"
 		},
 		},
 		"reset_password": "重置密码",
 		"reset_password": "重置密码",
 		"reset_password_modal": {
 		"reset_password_modal": {

+ 2 - 2
src/client/js/components/Admin/Common/AdminNavigation.jsx

@@ -28,8 +28,8 @@ const AdminNavigation = (props) => {
       case 'importer':                 return <><i className="icon-fw icon-cloud-upload"></i>    { t('Import Data') }</>;
       case 'importer':                 return <><i className="icon-fw icon-cloud-upload"></i>    { t('Import Data') }</>;
       case 'export':                   return <><i className="icon-fw icon-cloud-download"></i>  { t('Export Archive Data') }</>;
       case 'export':                   return <><i className="icon-fw icon-cloud-download"></i>  { t('Export Archive Data') }</>;
       case 'notification':             return <><i className="icon-fw icon-bell"></i>            { t('External_Notification')}</>;
       case 'notification':             return <><i className="icon-fw icon-bell"></i>            { t('External_Notification')}</>;
-      case 'slack-integration-legacy': return <><i className="fa fa-slack mr-2"></i>             { t('Legacy_Slack_Integration')}</>;
-      case 'slack-integration':        return <><i className="fa fa-slack mr-2"></i>             { t('slack_integration') }</>;
+      case 'slack-integration':        return <><i className="icon-fw icon-shuffle"></i>         { t('slack_integration') }</>;
+      case 'slack-integration-legacy': return <><i className="icon-fw icon-shuffle"></i>         { t('Legacy_Slack_Integration')}</>;
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;

+ 11 - 9
src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -127,15 +127,17 @@ const CustomBotWithProxySettings = (props) => {
             </React.Fragment>
             </React.Fragment>
           );
           );
         })}
         })}
-        <div className="row justify-content-center my-5">
-          <button
-            type="button"
-            className="btn btn-outline-primary"
-            onClick={addSlackAppIntegrationHandler}
-          >
-            {`+ ${t('admin:slack_integration.accordion.add_slack_workspace')}`}
-          </button>
-        </div>
+        {slackAppIntegrations.length < 10 && (
+          <div className="row justify-content-center my-5">
+            <button
+              type="button"
+              className="btn btn-outline-primary"
+              onClick={addSlackAppIntegrationHandler}
+            >
+              {`+ ${t('admin:slack_integration.accordion.add_slack_workspace')}`}
+            </button>
+          </div>
+        )}
       </div>
       </div>
       <DeleteSlackBotSettingsModal
       <DeleteSlackBotSettingsModal
         isResetAll={false}
         isResetAll={false}

+ 1 - 38
src/client/js/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -13,19 +13,12 @@ const logger = loggerFactory('growi:SlackBotSettings');
 
 
 const OfficialBotSettings = (props) => {
 const OfficialBotSettings = (props) => {
   const {
   const {
-    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
+    appContainer, slackAppIntegrations, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   } = props;
   const [siteName, setSiteName] = useState('');
   const [siteName, setSiteName] = useState('');
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const [newProxyServerUri, setNewProxyServerUri] = useState();
-
-  // componentDidUpdate
-  useEffect(() => {
-    setNewProxyServerUri(proxyServerUri);
-  }, [proxyServerUri, slackAppIntegrations]);
-
   const addSlackAppIntegrationHandler = async() => {
   const addSlackAppIntegrationHandler = async() => {
     if (onClickAddSlackWorkspaceBtn != null) {
     if (onClickAddSlackWorkspaceBtn != null) {
       onClickAddSlackWorkspaceBtn();
       onClickAddSlackWorkspaceBtn();
@@ -46,19 +39,6 @@ const OfficialBotSettings = (props) => {
     }
     }
   };
   };
 
 
-  const updateProxyUri = async() => {
-    try {
-      await appContainer.apiv3.put('/slack-integration-settings/proxy-uri', {
-        proxyUri: newProxyServerUri,
-      });
-      toastSuccess(t('toaster.update_successed', { target: t('Proxy URL') }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  };
-
   useEffect(() => {
   useEffect(() => {
     const siteName = appContainer.config.crowi.title;
     const siteName = appContainer.config.crowi.title;
     setSiteName(siteName);
     setSiteName(siteName);
@@ -78,22 +58,6 @@ const OfficialBotSettings = (props) => {
             connectionStatuses={connectionStatuses}
             connectionStatuses={connectionStatuses}
           />
           />
 
 
-          <div className="form-group row my-4">
-            <label className="text-left text-md-right col-md-3 col-form-label mt-3">Proxy URL</label>
-            <div className="col-md-6 mt-3">
-              <input
-                className="form-control"
-                type="text"
-                name="settingForm[proxyUrl]"
-                defaultValue={newProxyServerUri}
-                onChange={(e) => { setNewProxyServerUri(e.target.value) }}
-              />
-            </div>
-            <div className="col-md-2 mt-3 text-center text-md-left">
-              <button type="button" className="btn btn-primary" onClick={updateProxyUri}>{ t('Update') }</button>
-            </div>
-          </div>
-
           <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
           <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
         </>
         </>
       )}
       )}
@@ -159,7 +123,6 @@ OfficialBotSettings.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
   slackAppIntegrations: PropTypes.array,
   slackAppIntegrations: PropTypes.array,
-  proxyServerUri: PropTypes.string,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   onDeleteSlackAppIntegration: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,
   connectionStatuses: PropTypes.object.isRequired,

+ 1 - 1
src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -15,6 +15,7 @@ import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 const botTypes = ['officialBot', 'customBotWithoutProxy', 'customBotWithProxy'];
 const botTypes = ['officialBot', 'customBotWithoutProxy', 'customBotWithProxy'];
 
 
 const SlackIntegration = (props) => {
 const SlackIntegration = (props) => {
+
   const { appContainer } = props;
   const { appContainer } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [currentBotType, setCurrentBotType] = useState(null);
   const [currentBotType, setCurrentBotType] = useState(null);
@@ -124,7 +125,6 @@ const SlackIntegration = (props) => {
       settingsComponent = (
       settingsComponent = (
         <OfficialBotSettings
         <OfficialBotSettings
           slackAppIntegrations={slackAppIntegrations}
           slackAppIntegrations={slackAppIntegrations}
-          proxyServerUri={proxyServerUri}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
           connectionStatuses={connectionStatuses}

+ 3 - 7
src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -169,8 +169,8 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
                 // eslint-disable-next-line react/no-danger
                 // eslint-disable-next-line react/no-danger
               dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.paste_growi_url') }}
               dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.accordion.paste_growi_url') }}
             />
             />
-            <div className="input-group align-items-center ml-2 mb-3">
-              <div className="input-group-prepend mx-1">
+            <div className="input-group align-items-center pl-2 mb-3">
+              <div className="input-group-prepend w-75">
                 <input className="form-control" type="text" value={props.growiUrl} readOnly />
                 <input className="form-control" type="text" value={props.growiUrl} readOnly />
                 <CopyToClipboard text={props.growiUrl} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
                 <CopyToClipboard text={props.growiUrl} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
                   <div className="btn input-group-text">
                   <div className="btn input-group-text">
@@ -303,10 +303,6 @@ const WithProxyAccordions = (props) => {
       />,
       />,
     },
     },
     '③': {
     '③': {
-      title: 'set_proxy_url_on_growi',
-      content: <RegisteringProxyUrlProcess />,
-    },
-    '④': {
       title: 'test_connection',
       title: 'test_connection',
       content: <TestProcess
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
         apiv3Post={props.appContainer.apiv3.post}
@@ -328,7 +324,7 @@ const WithProxyAccordions = (props) => {
       content: <BotInstallProcessForCustomBotWithProxy />,
       content: <BotInstallProcessForCustomBotWithProxy />,
     },
     },
     '③': {
     '③': {
-      title: 'register_for_growi_official_bot_proxy_service',
+      title: 'register_for_growi_custom_bot_proxy',
       content: <GeneratingTokensAndRegisteringProxyServiceProcess
       content: <GeneratingTokensAndRegisteringProxyServiceProcess
         growiUrl={props.appContainer.config.crowi.url}
         growiUrl={props.appContainer.config.crowi.url}
         slackAppIntegrationId={props.slackAppIntegrationId}
         slackAppIntegrationId={props.slackAppIntegrationId}

+ 54 - 0
src/client/js/components/Admin/Users/SendInvitationEmailButton.jsx

@@ -0,0 +1,54 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+const SendInvitationEmailButton = (props) => {
+  const {
+    appContainer, user, isInvitationEmailSended, onSuccessfullySentInvitationEmail,
+  } = props;
+  const { t } = useTranslation();
+
+  const textColor = !isInvitationEmailSended ? 'text-danger' : '';
+
+  const onClickSendInvitationEmailButton = async() => {
+    try {
+      const res = await appContainer.apiv3Put('users/send-invitation-email', { id: user._id });
+      const { failedToSendEmail } = res.data;
+      if (failedToSendEmail == null) {
+        const msg = `Email has been sent<br>・${user.email}`;
+        toastSuccess(msg);
+        onSuccessfullySentInvitationEmail();
+      }
+      else {
+        const msg = { message: `email: ${failedToSendEmail.email}<br>reason: ${failedToSendEmail.reason}` };
+        toastError(msg);
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <button className={`dropdown-item ${textColor}`} type="button" onClick={() => { onClickSendInvitationEmailButton() }}>
+      <i className="icon-fw icon-envelope"></i>
+      {isInvitationEmailSended && (<>{t('admin:user_management.user_table.resend_invitation_email')}</>)}
+      {!isInvitationEmailSended && (<>{t('admin:user_management.user_table.send_invitation_email')}</>)}
+    </button>
+  );
+};
+
+const SendInvitationEmailButtonWrapper = withUnstatedContainers(SendInvitationEmailButton, [AppContainer, AdminUsersContainer]);
+
+SendInvitationEmailButton.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  user: PropTypes.object.isRequired,
+  isInvitationEmailSended: PropTypes.bool.isRequired,
+  onSuccessfullySentInvitationEmail: PropTypes.func.isRequired,
+};
+
+export default SendInvitationEmailButtonWrapper;

+ 52 - 5
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -9,7 +9,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastSuccess, toastError, toastWarning } from '../../../util/apiNotification';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import AppContainer from '../../../services/AppContainer';
@@ -24,6 +24,7 @@ class UserInviteModal extends React.Component {
       emailInputValue: '',
       emailInputValue: '',
       sendEmail: false,
       sendEmail: false,
       invitedEmailList: null,
       invitedEmailList: null,
+      isCreateUserButtonPushed: false,
     };
     };
 
 
     this.handleSubmit = this.handleSubmit.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
@@ -41,6 +42,26 @@ class UserInviteModal extends React.Component {
     toastSuccess('Copied Mail and Password');
     toastSuccess('Copied Mail and Password');
   }
   }
 
 
+  showToasterByEmailList(emailList, toast) {
+    let msg = '';
+    emailList.forEach((email) => {
+      msg += `・${email}<br>`;
+    });
+    switch (toast) {
+      case 'success':
+        msg = `User has been created<br>${msg}`;
+        toastSuccess(msg);
+        break;
+      case 'warning':
+        msg = `Existing email<br>${msg}`;
+        toastWarning(msg);
+        break;
+      case 'error':
+        toastError({ message: msg });
+        break;
+    }
+  }
+
   renderModalBody() {
   renderModalBody() {
     const { t } = this.props;
     const { t } = this.props;
 
 
@@ -80,6 +101,7 @@ class UserInviteModal extends React.Component {
 
 
   renderModalFooter() {
   renderModalFooter() {
     const { t, appContainer } = this.props;
     const { t, appContainer } = this.props;
+    const { isCreateUserButtonPushed } = this.state;
     const { isMailerSetup } = appContainer.config;
     const { isMailerSetup } = appContainer.config;
 
 
     return (
     return (
@@ -116,7 +138,7 @@ class UserInviteModal extends React.Component {
             type="button"
             type="button"
             className="btn btn-primary"
             className="btn btn-primary"
             onClick={this.handleSubmit}
             onClick={this.handleSubmit}
-            disabled={!this.validEmail()}
+            disabled={!this.validEmail() || isCreateUserButtonPushed}
           >
           >
             {t('admin:user_management.invite_modal.issue')}
             {t('admin:user_management.invite_modal.issue')}
           </button>
           </button>
@@ -130,8 +152,9 @@ class UserInviteModal extends React.Component {
 
 
     return (
     return (
       <>
       <>
-        <label className="mr-3 text-left text-danger" style={{ flex: 1 }}>
-          {t('admin:user_management.invite_modal.send_temporary_password')}
+        <label className="mr-3 text-left" style={{ flex: 1 }}>
+          <text className="text-danger">{t('admin:user_management.invite_modal.send_temporary_password')}</text>
+          <text>{t('admin:user_management.invite_modal.send_email')}</text>
         </label>
         </label>
         <button
         <button
           type="button"
           type="button"
@@ -186,6 +209,10 @@ class UserInviteModal extends React.Component {
 
 
   async handleSubmit() {
   async handleSubmit() {
     const { adminUsersContainer } = this.props;
     const { adminUsersContainer } = this.props;
+    // eslint-disable-next-line no-unused-vars
+    const { isCreateUserButtonPushed } = this.state;
+
+    this.setState({ isCreateUserButtonPushed: true });
 
 
     const array = this.state.emailInputValue.split('\n');
     const array = this.state.emailInputValue.split('\n');
     const emailList = array.filter((element) => { return element.match(/.+@.+\..+/) });
     const emailList = array.filter((element) => { return element.match(/.+@.+\..+/) });
@@ -195,11 +222,31 @@ class UserInviteModal extends React.Component {
       const emailList = await adminUsersContainer.createUserInvited(shapedEmailList, this.state.sendEmail);
       const emailList = await adminUsersContainer.createUserInvited(shapedEmailList, this.state.sendEmail);
       this.setState({ emailInputValue: '' });
       this.setState({ emailInputValue: '' });
       this.setState({ invitedEmailList: emailList });
       this.setState({ invitedEmailList: emailList });
-      toastSuccess('Inviting user success');
+
+      if (emailList.createdUserList.length > 0) {
+        const createdEmailList = emailList.createdUserList.map((user) => { return user.email });
+        this.showToasterByEmailList(createdEmailList, 'success');
+      }
+      if (emailList.existingEmailList.length > 0) {
+        this.showToasterByEmailList(emailList.existingEmailList, 'warning');
+      }
+      if (emailList.failedEmailList.length > 0) {
+        const failedEmailList = emailList.failedEmailList.map((failed, index) => {
+          let messgage = `email: ${failed.email}<br>・reason: ${failed.reason}`;
+          if (index !== emailList.failedEmailList.length - 1) {
+            messgage += '<br>';
+          }
+          return messgage;
+        });
+        this.showToasterByEmailList(failedEmailList, 'error');
+      }
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
+    finally {
+      this.setState({ isCreateUserButtonPushed: false });
+    }
   }
   }
 
 
   handleInput(event) {
   handleInput(event) {

+ 29 - 15
src/client/js/components/Admin/Users/UserMenu.jsx

@@ -7,9 +7,10 @@ import {
 
 
 import StatusActivateButton from './StatusActivateButton';
 import StatusActivateButton from './StatusActivateButton';
 import StatusSuspendedButton from './StatusSuspendedButton';
 import StatusSuspendedButton from './StatusSuspendedButton';
-import RemoveUserButton from './UserRemoveButton';
+import UserRemoveButton from './UserRemoveButton';
 import RemoveAdminButton from './RemoveAdminButton';
 import RemoveAdminButton from './RemoveAdminButton';
 import GiveAdminButton from './GiveAdminButton';
 import GiveAdminButton from './GiveAdminButton';
+import SendInvitationEmailButton from './SendInvitationEmailButton';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import AppContainer from '../../../services/AppContainer';
@@ -21,16 +22,21 @@ class UserMenu extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-
+      isInvitationEmailSended: this.props.user.isInvitationEmailSended,
     };
     };
 
 
     this.onPasswordResetClicked = this.onPasswordResetClicked.bind(this);
     this.onPasswordResetClicked = this.onPasswordResetClicked.bind(this);
+    this.onSuccessfullySentInvitationEmail = this.onSuccessfullySentInvitationEmail.bind(this);
   }
   }
 
 
   onPasswordResetClicked() {
   onPasswordResetClicked() {
     this.props.adminUsersContainer.showPasswordResetModal(this.props.user);
     this.props.adminUsersContainer.showPasswordResetModal(this.props.user);
   }
   }
 
 
+  onSuccessfullySentInvitationEmail() {
+    this.setState({ isInvitationEmailSended: true });
+  }
+
   renderEditMenu() {
   renderEditMenu() {
     const { t } = this.props;
     const { t } = this.props;
 
 
@@ -49,6 +55,7 @@ class UserMenu extends React.Component {
 
 
   renderStatusMenu() {
   renderStatusMenu() {
     const { t, user } = this.props;
     const { t, user } = this.props;
+    const { isInvitationEmailSended } = this.state;
 
 
     return (
     return (
       <Fragment>
       <Fragment>
@@ -57,7 +64,14 @@ class UserMenu extends React.Component {
         <li>
         <li>
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
           {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
           {user.status === 2 && <StatusSuspendedButton user={user} />}
           {user.status === 2 && <StatusSuspendedButton user={user} />}
-          {(user.status === 1 || user.status === 3 || user.status === 5) && <RemoveUserButton user={user} />}
+          {user.status === 5 && (
+          <SendInvitationEmailButton
+            user={user}
+            isInvitationEmailSended={isInvitationEmailSended}
+            onSuccessfullySentInvitationEmail={this.onSuccessfullySentInvitationEmail}
+          />
+          )}
+          {(user.status === 1 || user.status === 3 || user.status === 5) && <UserRemoveButton user={user} />}
         </li>
         </li>
       </Fragment>
       </Fragment>
     );
     );
@@ -80,20 +94,20 @@ class UserMenu extends React.Component {
 
 
   render() {
   render() {
     const { user } = this.props;
     const { user } = this.props;
+    const { isInvitationEmailSended } = this.state;
 
 
     return (
     return (
-      <Fragment>
-        <UncontrolledDropdown id="userMenu" size="sm">
-          <DropdownToggle caret color="secondary" outline>
-            <i className="icon-settings"></i>
-          </DropdownToggle>
-          <DropdownMenu positionFixed>
-            {this.renderEditMenu()}
-            {user.status !== 4 && this.renderStatusMenu()}
-            {user.status === 2 && this.renderAdminMenu()}
-          </DropdownMenu>
-        </UncontrolledDropdown>
-      </Fragment>
+      <UncontrolledDropdown id="userMenu" size="sm">
+        <DropdownToggle caret color="secondary" outline>
+          <i className="icon-settings" />
+          {(user.status === 5 && !isInvitationEmailSended) && <i className="fa fa-circle text-danger grw-usermenu-notification-icon" />}
+        </DropdownToggle>
+        <DropdownMenu positionFixed>
+          {this.renderEditMenu()}
+          {user.status !== 4 && this.renderStatusMenu()}
+          {user.status === 2 && this.renderAdminMenu()}
+        </DropdownMenu>
+      </UncontrolledDropdown>
     );
     );
   }
   }
 
 

+ 2 - 4
src/client/js/components/Page/RevisionRenderer.jsx

@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
 import NavigationContainer from '../../services/NavigationContainer';
 import NavigationContainer from '../../services/NavigationContainer';
 import GrowiRenderer from '../../util/GrowiRenderer';
 import GrowiRenderer from '../../util/GrowiRenderer';
 
 
@@ -25,7 +24,7 @@ class RevisionRenderer extends React.PureComponent {
   initCurrentRenderingContext() {
   initCurrentRenderingContext() {
     this.currentRenderingContext = {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
       markdown: this.props.markdown,
-      currentPagePath: this.props.pageContainer.state.path,
+      currentPagePath: decodeURIComponent(window.location.pathname),
     };
     };
   }
   }
 
 
@@ -121,11 +120,10 @@ class RevisionRenderer extends React.PureComponent {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, PageContainer, NavigationContainer]);
+const RevisionRendererWrapper = withUnstatedContainers(RevisionRenderer, [AppContainer, NavigationContainer]);
 
 
 RevisionRenderer.propTypes = {
 RevisionRenderer.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,

+ 1 - 2
src/client/js/services/AdminUsersContainer.js

@@ -164,8 +164,7 @@ export default class AdminUsersContainer extends Container {
       sendEmail,
       sendEmail,
     });
     });
     await this.retrieveUsersByPagingNum(this.state.activePage);
     await this.retrieveUsersByPagingNum(this.state.activePage);
-    const { invitedUserList } = response.data;
-    return invitedUserList;
+    return response.data;
   }
   }
 
 
   /**
   /**

+ 12 - 0
src/client/js/util/apiNotification.js

@@ -20,6 +20,14 @@ const toastrOption = {
     hideDuration: '100',
     hideDuration: '100',
     timeOut: '3000',
     timeOut: '3000',
   },
   },
+  warning: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '6000',
+  },
 };
 };
 
 
 // accepts both a single error and an array of errors
 // accepts both a single error and an array of errors
@@ -35,3 +43,7 @@ export const toastError = (err, header = 'Error', option = toastrOption.error) =
 export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
 export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
   toastr.success(body, header, option);
   toastr.success(body, header, option);
 };
 };
+
+export const toastWarning = (body, header = 'Warning', option = toastrOption.warning) => {
+  toastr.warning(body, header, option);
+};

+ 6 - 0
src/client/styles/scss/_user.scss

@@ -34,6 +34,12 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
   }
   }
 }
 }
 
 
+.grw-usermenu-notification-icon {
+  position: absolute;
+  top: -4px;
+  left: 30px;
+}
+
 .draft-list-item {
 .draft-list-item {
   .icon-container {
   .icon-container {
     .icon-copy,
     .icon-copy,

+ 27 - 45
src/server/models/user.js

@@ -4,7 +4,6 @@ const debug = require('debug')('growi:models:user');
 const logger = require('@alias/logger')('growi:models:user');
 const logger = require('@alias/logger')('growi:models:user');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const mongoosePaginate = require('mongoose-paginate-v2');
-const path = require('path');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
 const md5 = require('md5');
 const md5 = require('md5');
 
 
@@ -64,6 +63,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
     createdAt: { type: Date, default: Date.now },
     lastLoginAt: { type: Date },
     lastLoginAt: { type: Date },
     admin: { type: Boolean, default: 0, index: true },
     admin: { type: Boolean, default: 0, index: true },
+    isInvitationEmailSended: { type: Boolean, default: false },
   }, {
   }, {
     toObject: {
     toObject: {
       transform: (doc, ret, opt) => {
       transform: (doc, ret, opt) => {
@@ -566,57 +566,24 @@ module.exports = function(crowi) {
     const creationEmailList = emailList.filter((email) => { return existingEmailList.indexOf(email) === -1 });
     const creationEmailList = emailList.filter((email) => { return existingEmailList.indexOf(email) === -1 });
 
 
     const createdUserList = [];
     const createdUserList = [];
-    await Promise.all(creationEmailList.map(async(email) => {
-      const createdEmail = await this.createUserByEmail(email);
-      createdUserList.push(createdEmail);
-    }));
-
-    return { existingEmailList, createdUserList };
-  };
-
-  userSchema.statics.sendEmailbyUserList = async function(userList) {
-    const { appService, mailService } = crowi;
-    const appTitle = appService.getAppTitle();
-
-    await Promise.all(userList.map(async(user) => {
-      if (user.password == null) {
-        return;
-      }
+    const failedToCreateUserEmailList = [];
 
 
+    for (const email of creationEmailList) {
       try {
       try {
-        return mailService.send({
-          to: user.email,
-          subject: `Invitation to ${appTitle}`,
-          template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),
-          vars: {
-            email: user.email,
-            password: user.password,
-            url: crowi.appService.getSiteUrl(),
-            appTitle,
-          },
-        });
+        // eslint-disable-next-line no-await-in-loop
+        const createdUser = await this.createUserByEmail(email);
+        createdUserList.push(createdUser);
       }
       }
       catch (err) {
       catch (err) {
-        return debug('fail to send email: ', err);
+        logger.error(err);
+        failedToCreateUserEmailList.push({
+          email,
+          reason: err.message,
+        });
       }
       }
-    }));
-
-  };
-
-  userSchema.statics.createUsersByInvitation = async function(emailList, toSendEmail) {
-    validateCrowi();
-
-    if (!Array.isArray(emailList)) {
-      debug('emailList is not array');
     }
     }
 
 
-    const afterWorkEmailList = await this.createUsersByEmailList(emailList);
-
-    if (toSendEmail) {
-      await this.sendEmailbyUserList(afterWorkEmailList.createdUserList);
-    }
-
-    return afterWorkEmailList;
+    return { createdUserList, existingEmailList, failedToCreateUserEmailList };
   };
   };
 
 
   userSchema.statics.createUserByEmailAndPasswordAndStatus = async function(name, username, email, password, lang, status, callback) {
   userSchema.statics.createUserByEmailAndPasswordAndStatus = async function(name, username, email, password, lang, status, callback) {
@@ -709,6 +676,21 @@ module.exports = function(crowi) {
     return username;
     return username;
   };
   };
 
 
+  userSchema.statics.updateIsInvitationEmailSended = async function(id) {
+    const user = await this.findById(id);
+
+    if (user == null) {
+      throw new Error('User not found');
+    }
+
+    if (user.status !== 5) {
+      throw new Error('The status of the user is not "invited"');
+    }
+
+    user.isInvitationEmailSended = true;
+    user.save();
+  };
+
   class UserUpperLimitException {
   class UserUpperLimitException {
 
 
     constructor() {
     constructor() {

+ 0 - 19
src/server/plugins/plugin.service.js

@@ -16,29 +16,10 @@ class PluginService {
     if (isEnabledPlugins) {
     if (isEnabledPlugins) {
       logger.debug('Plugins are enabled');
       logger.debug('Plugins are enabled');
       this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
       this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
-
-      // when dev
-      if (this.crowi.node_env === 'development') {
-        this.autoDetectAndLoadPluginsForDev();
-      }
     }
     }
 
 
   }
   }
 
 
-  autoDetectAndLoadPluginsForDev() {
-    if (process.env.PLUGIN_NAMES_TOBE_LOADED !== undefined
-      && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
-
-      const pluginNames = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
-      logger.debug('[development] loading Plugins', pluginNames);
-
-      // merge and remove duplicates
-      if (pluginNames.length > 0) {
-        this.crowi.pluginService.loadPlugins(pluginNames);
-      }
-    }
-  }
-
   /**
   /**
    * load plugins
    * load plugins
    *
    *

+ 7 - 0
src/server/routes/apiv3/slack-integration-settings.js

@@ -107,6 +107,9 @@ module.exports = (crowi) => {
 
 
   async function postRelationTest(token) {
   async function postRelationTest(token) {
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
     const proxyUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    if (proxyUri == null) {
+      throw new Error('Proxy URL is not registered');
+    }
 
 
     const result = await axios.get(urljoin(proxyUri, '/g2s/relation-test'), {
     const result = await axios.get(urljoin(proxyUri, '/g2s/relation-test'), {
       headers: {
       headers: {
@@ -273,6 +276,10 @@ module.exports = (crowi) => {
     await resetAllBotSettings();
     await resetAllBotSettings();
     const requestParams = { 'slackbot:currentBotType': currentBotType };
     const requestParams = { 'slackbot:currentBotType': currentBotType };
 
 
+    if (currentBotType === 'officialBot') {
+      requestParams['slackbot:proxyServerUri'] = 'https://slackbot-proxy.growi.org';
+    }
+
     try {
     try {
       await updateSlackBotSettings(requestParams);
       await updateSlackBotSettings(requestParams);
       crowi.slackBotService.publishUpdatedMessage();
       crowi.slackBotService.publishUpdatedMessage();

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

@@ -25,10 +25,11 @@ module.exports = (crowi) => {
       return res.status(400).send({ message });
       return res.status(400).send({ message });
     }
     }
 
 
-    const slackAppIntegrationCount = await SlackAppIntegration.estimatedDocumentCount({ tokenPtoG });
+    const slackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
 
 
     logger.debug('verifyAccessTokenFromProxy', {
     logger.debug('verifyAccessTokenFromProxy', {
       tokenPtoG,
       tokenPtoG,
+      slackAppIntegrationCount,
     });
     });
 
 
     if (slackAppIntegrationCount === 0) {
     if (slackAppIntegrationCount === 0) {

+ 111 - 6
src/server/routes/apiv3/users.js

@@ -6,6 +6,8 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 
 
+const path = require('path');
+
 const { body, query } = require('express-validator');
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 const { isEmail } = require('validator');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
@@ -116,6 +118,40 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
   ];
   ];
 
 
+  const sendEmailByUserList = async(userList) => {
+    const { appService, mailService } = crowi;
+    const appTitle = appService.getAppTitle();
+    const failedToSendEmailList = [];
+
+    for (const user of userList) {
+      try {
+        // eslint-disable-next-line no-await-in-loop
+        await mailService.send({
+          to: user.email,
+          subject: `Invitation to ${appTitle}`,
+          template: path.join(crowi.localeDir, 'en_US/admin/userInvitation.txt'),
+          vars: {
+            email: user.email,
+            password: user.password,
+            url: crowi.appService.getSiteUrl(),
+            appTitle,
+          },
+        });
+        // eslint-disable-next-line no-await-in-loop
+        await User.updateIsInvitationEmailSended(user.user.id);
+      }
+      catch (err) {
+        logger.error(err);
+        failedToSendEmailList.push({
+          email: user.email,
+          reason: err.message,
+        });
+      }
+    }
+
+    return { failedToSendEmailList };
+  };
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -355,16 +391,35 @@ module.exports = (crowi) => {
    *                    existingEmailList:
    *                    existingEmailList:
    *                      type: object
    *                      type: object
    *                      description: Users email that already exists
    *                      description: Users email that already exists
+   *                    failedEmailList:
+   *                      type: object
+   *                      description: Users email that failed to create or send email
    */
    */
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
   router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
-    try {
-      const invitedUserList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
-      return res.apiv3({ invitedUserList }, 201);
+
+    // Delete duplicate email addresses
+    const emailList = Array.from(new Set(req.body.shapedEmailList));
+    let failedEmailList = [];
+
+    // Create users
+    const createUser = await User.createUsersByEmailList(emailList);
+    if (createUser.failedToCreateUserEmailList.length > 0) {
+      failedEmailList = failedEmailList.concat(createUser.failedToCreateUserEmailList);
     }
     }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
+
+    // Send email
+    if (req.body.sendEmail) {
+      const sendEmail = await sendEmailByUserList(createUser.createdUserList);
+      if (sendEmail.failedToSendEmailList.length > 0) {
+        failedEmailList = failedEmailList.concat(sendEmail.failedToSendEmailList);
+      }
     }
     }
+
+    return res.apiv3({
+      createdUserList: createUser.createdUserList,
+      existingEmailList: createUser.existingEmailList,
+      failedEmailList,
+    }, 201);
   });
   });
 
 
   /**
   /**
@@ -761,5 +816,55 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/send-invitation-email:
+   *      put:
+   *        tags: [Users]
+   *        operationId: sendInvitationEmail
+   *        summary: /users/send-invitation-email
+   *        description: send invitation email
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  id:
+   *                    type: string
+   *                    description: user id for send invitation email
+   *        responses:
+   *          200:
+   *            description: success send invitation email
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    failedToSendEmail:
+   *                      type: object
+   *                      description: email and reasons for email sending failure
+   */
+  router.put('/send-invitation-email', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const { id } = req.body;
+
+    try {
+      const user = await User.findById(id);
+      const newPassword = await User.resetPasswordByRandomString(id);
+      const userList = [{
+        email: user.email,
+        password: newPassword,
+        user: { id },
+      }];
+      const sendEmail = await sendEmailByUserList(userList);
+      // return null if absent
+      return res.apiv3({ failedToSendEmail: sendEmail.failedToSendEmailList[0] });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
   return router;
   return router;
 };
 };

+ 2 - 2
src/server/service/slackbot.js

@@ -246,8 +246,8 @@ class SlackBotService extends S2sMessageHandlable {
         ],
         ],
       });
       });
     }
     }
-    catch {
-      logger.error('Failed to get search results.');
+    catch (err) {
+      logger.error('Failed to get search results.', err);
       await client.chat.postEphemeral({
       await client.chat.postEphemeral({
         channel: body.channel_id,
         channel: body.channel_id,
         user: body.user_id,
         user: body.user_id,

+ 18 - 18
yarn.lock

@@ -4639,6 +4639,11 @@ bootstrap@^4.5.0:
   resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.5.0.tgz#97d9dbcb5a8972f8722c9962483543b907d9b9ec"
   resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.5.0.tgz#97d9dbcb5a8972f8722c9962483543b907d9b9ec"
   integrity sha512-Z93QoXvodoVslA+PWNdk23Hze4RBYIkpb5h8I2HY2Tu2h7A0LpAgLcyrhrSUyo2/Oxm2l1fRZPs1e5hnxnliXA==
   integrity sha512-Z93QoXvodoVslA+PWNdk23Hze4RBYIkpb5h8I2HY2Tu2h7A0LpAgLcyrhrSUyo2/Oxm2l1fRZPs1e5hnxnliXA==
 
 
+bootstrap@^5.0.2:
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.0.2.tgz#aff23d5e0e03c31255ad437530ee6556e78e728e"
+  integrity sha512-1Ge963tyEQWJJ+8qtXFU6wgmAVj9gweEjibUdbmcCEYsn38tVwRk8107rk2vzt6cfQcRr3SlZ8aQBqaD8aqf+Q==
+
 bowser@^1.7.3:
 bowser@^1.7.3:
   version "1.9.4"
   version "1.9.4"
   resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a"
   resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a"
@@ -9228,29 +9233,24 @@ grapheme-splitter@^1.0.4:
   resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
   resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
   integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
   integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
 
 
-growi-commons@^5.0.2, growi-commons@^5.0.3:
-  version "5.0.3"
-  resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-5.0.3.tgz#8ec745dcb2d3d003e3acd7d6fbec85cf0967354b"
-  integrity sha512-lV2jmqxWPiuzuVaetSY1uq7viwgRCNwLMnU78GaYix4jfXNcYzdrfedTlU3ZRiVIjLggSFb87EhYqtM/4RfjmA==
-
 growi-commons@^5.0.4:
 growi-commons@^5.0.4:
   version "5.0.4"
   version "5.0.4"
   resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-5.0.4.tgz#1235b7955a3f492803e8c714fef5c4a797e442b7"
   resolved "https://registry.yarnpkg.com/growi-commons/-/growi-commons-5.0.4.tgz#1235b7955a3f492803e8c714fef5c4a797e442b7"
   integrity sha512-K282Pe97SnJgbZWAuMz9pNDTmvmw4JYPf/oYQaPmBsUjaxG4FDwd7+p5UFc5GqZUWcLwXvtJZQZMZEH/xpg+nA==
   integrity sha512-K282Pe97SnJgbZWAuMz9pNDTmvmw4JYPf/oYQaPmBsUjaxG4FDwd7+p5UFc5GqZUWcLwXvtJZQZMZEH/xpg+nA==
 
 
-growi-plugin-attachment-refs@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/growi-plugin-attachment-refs/-/growi-plugin-attachment-refs-2.0.1.tgz#118c4928a6df93afd0c5fab7a7eab569977dcd55"
-  integrity sha512-Kyi5YQoc/5cuan9ldEvnjzh9rYi9hKjKyy75xHn0/4BZZ3aMz9AzzUcln99rqldOGwig3fuobYCCH9YnW/voZw==
+growi-plugin-attachment-refs@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/growi-plugin-attachment-refs/-/growi-plugin-attachment-refs-2.0.2.tgz#341d95e6cf2b91ebcff5f9573518e15fa12ccb46"
+  integrity sha512-7BTH6ERpybPKAEy0+rLylF9Rap1jHod9d0mPF62HXJmycynGlS66hyqpM20mKq5rSKxEWucmbUdtceQjs139+Q==
   dependencies:
   dependencies:
-    growi-commons "^5.0.3"
+    growi-commons "^5.0.4"
 
 
-growi-plugin-lsx@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/growi-plugin-lsx/-/growi-plugin-lsx-4.0.2.tgz#cdf9caf6bb74a50c2eae0a64ea7b413160c32057"
-  integrity sha512-qF3Yx5sbXnf5C9cZshrtd3irPjs4SC5K3CXEhFiQuo6SZN66+uqQ6twuIgMthkTrBw5boHSoevmIiVqfIUsdIw==
+growi-plugin-lsx@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/growi-plugin-lsx/-/growi-plugin-lsx-4.0.3.tgz#feaf5a8687ac009f0f15ee76f7f0cba4efe6a1d3"
+  integrity sha512-x9N50WQRyFL9xY8FJ8ajF3v67eHma5brEASdsxI0t2CSzPlRhqJti3jTrbauevCNb8/na8Woq6B5rr6kPOFwIQ==
   dependencies:
   dependencies:
-    growi-commons "^5.0.2"
+    growi-commons "^5.0.4"
     url "^0.11.0"
     url "^0.11.0"
 
 
 growi-plugin-pukiwiki-like-linker@^3.1.0:
 growi-plugin-pukiwiki-like-linker@^3.1.0:
@@ -18028,9 +18028,9 @@ strip-json-comments@^2.0.0, strip-json-comments@^2.0.1, strip-json-comments@~2.0
   integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
   integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
 
 
 striptags@>=3.1.1:
 striptags@>=3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/striptags/-/striptags-3.1.1.tgz#c8c3e7fdd6fb4bb3a32a3b752e5b5e3e38093ebd"
-  integrity sha1-yMPn/db7S7OjKjt1LltePjgJPr0=
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/striptags/-/striptags-3.2.0.tgz#cc74a137db2de8b0b9a370006334161f7dd67052"
+  integrity sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==
 
 
 strong-log-transformer@^2.1.0:
 strong-log-transformer@^2.1.0:
   version "2.1.0"
   version "2.1.0"