Browse Source

Merge branch 'feat/growi-bot' into imprv/gw6067-add-accordion-when-button-clicked

# Conflicts:
#	src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
#	src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx
kaori 4 years ago
parent
commit
2e1e438943
25 changed files with 584 additions and 376 deletions
  1. 5 3
      .github/workflows/release-slackbot-proxy.yml
  2. 24 3
      packages/slack/src/utils/check-communicable.ts
  3. 2 2
      packages/slack/src/utils/webclient-factory.ts
  4. 6 5
      packages/slackbot-proxy/docker/Dockerfile
  5. 8 0
      packages/slackbot-proxy/package.json
  6. 83 14
      packages/slackbot-proxy/src/Server.ts
  7. 10 0
      packages/slackbot-proxy/src/config/swagger/config.dev.ts
  8. 5 0
      packages/slackbot-proxy/src/config/swagger/config.prod.ts
  9. 66 7
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  10. 2 2
      resource/locales/en_US/admin/admin.json
  11. 2 2
      resource/locales/ja_JP/admin/admin.json
  12. 2 2
      resource/locales/zh_CN/admin/admin.json
  13. 0 65
      src/client/js/components/Admin/SlackIntegration/AccessTokenSettings.jsx
  14. 24 27
      src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  15. 58 40
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxyIntegrationCard.jsx
  16. 32 0
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  17. 31 47
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  18. 13 1
      src/client/js/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.jsx
  19. 1 1
      src/client/js/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  20. 1 4
      src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx
  21. 42 64
      src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  22. 43 26
      src/server/routes/apiv3/slack-integration-settings.js
  23. 61 11
      src/server/routes/apiv3/slack-integration.js
  24. 16 50
      src/server/service/slackbot.js
  25. 47 0
      yarn.lock

+ 5 - 3
.github/workflows/release-slackbot.yml → .github/workflows/release-slackbot-proxy.yml

@@ -1,9 +1,9 @@
-name: Release Docker Image for growi-slackbot
+name: Release Docker Image for @growi/slackbot-proxy
 
 on:
   push:
     branches:
-      - release/slackbot/**
+      - release/slackbot-proxy/**
 
 jobs:
 
@@ -35,9 +35,10 @@ jobs:
 
     - name: Build and push
       uses: docker/build-push-action@v2
+      working-directory: ./packages/slackbot-proxy
       with:
         context: .
-        file: ./packages/slackbot-proxy/docker/Dockerfile
+        file: ./docker/Dockerfile
         platforms: linux/amd64
         push: true
         tags: |
@@ -48,6 +49,7 @@ jobs:
 
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v2
+      working-directory: ./packages/slackbot-proxy
       with:
         username: wsmoogle
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

+ 24 - 3
packages/slack/src/utils/check-communicable.ts

@@ -33,12 +33,24 @@ export const connectToSlackApiServer = async(): Promise<void|AxiosError> => {
  * Test Slack API
  * @param client
  */
-const testSlackApiServer = async(client: WebClient): Promise<void> => {
+const testSlackApiServer = async(client: WebClient): Promise<any> => {
   const result = await client.api.test();
 
   if (!result.ok) {
     throw new Error(result.error);
   }
+
+  return result;
+};
+
+const checkSlackScopes = (resultTestSlackApiServer: any) => {
+  const slackScopes = resultTestSlackApiServer.response_metadata.scopes;
+  const correctScopes = ['commands', 'team:read', 'chat:write'];
+  const isPassedScopeCheck = correctScopes.every(e => slackScopes.includes(e));
+
+  if (!isPassedScopeCheck) {
+    throw new Error('The scopes is not appropriate. Required scopes is [\'commands\', \'team:read\', \'chat:write\']');
+  }
 };
 
 /**
@@ -94,7 +106,16 @@ export const getConnectionStatuses = async(tokens: string[]): Promise<{[key: str
  * @param token bot OAuth token
  * @returns
  */
-export const relationTestToSlack = async(token:string): Promise<void> => {
+export const testToSlack = async(token:string): Promise<void> => {
+  const client = generateWebClient(token);
+  const res = await testSlackApiServer(client);
+  await checkSlackScopes(res);
+};
+
+export const sendSuccessMessage = async(token:string, channel:string, appSiteUrl:string): Promise<void> => {
   const client = generateWebClient(token);
-  await testSlackApiServer(client);
+  await client.chat.postMessage({
+    channel,
+    text: `Successfully tested with ${appSiteUrl}.`,
+  });
 };

+ 2 - 2
packages/slack/src/utils/webclient-factory.ts

@@ -7,6 +7,6 @@ const isProduction = process.env.NODE_ENV === 'production';
  * @param token Slack Bot Token or Proxy Server URI
  * @returns
  */
-export const generateWebClient = (token: string, serverUri?: string): WebClient => {
-  return new WebClient(token, { slackApiUrl: serverUri, logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+export const generateWebClient = (token: string, serverUri?: string, headers?:{[key:string]:string}): WebClient => {
+  return new WebClient(token, { slackApiUrl: serverUri, logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO, headers });
 };

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

@@ -33,7 +33,7 @@ RUN npx lerna bootstrap
 FROM deps-resolver-base AS deps-resolver-prod
 RUN npx lerna bootstrap -- --production
 # make artifacts
-RUN tar cf node_modules.tar node_modules
+RUN tar cf dependencies.tar node_modules packages/slackbot-proxy/node_modules
 
 
 ##
@@ -79,8 +79,9 @@ ENV NODE_ENV production
 
 ENV appDir /opt
 
+# copy artifacts
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${appDir}/node_modules.tar ${appDir}/
+  ${appDir}/dependencies.tar ${appDir}/
 COPY --from=builder --chown=node:node \
   ${appDir}/packages.tar ${appDir}/
 
@@ -88,11 +89,11 @@ RUN chown node:node ${appDir}
 
 USER node
 
-# extract node_modules.tar
+# extract artifacts
 WORKDIR ${appDir}
-RUN tar xf node_modules.tar
+RUN tar xf dependencies.tar
 RUN tar xf packages.tar
-RUN rm node_modules.tar packages.tar
+RUN rm dependencies.tar packages.tar
 
 WORKDIR ${appDir}/packages/slackbot-proxy
 

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

@@ -17,7 +17,11 @@
     "test:lint:fix": "eslint src --ext .ts --fix",
     "version": "node -p \"require('./package.json').version\""
   },
+  "// comments for dependencies": {
+    "express-graceful-exit": "0.5.2 includes a typings file error: https://github.com/emostar/express-graceful-exit/issues/24"
+  },
   "dependencies": {
+    "@godaddy/terminus": "^4.8.0",
     "@growi/slack": "^0.9.0-RC",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.1.0",
@@ -33,6 +37,9 @@
     "cookie-parser": "^1.4.5",
     "cross-env": "^7.0.0",
     "dotenv-flow": "^3.2.0",
+    "express-bunyan-logger": "^1.3.3",
+    "express-graceful-exit": "=0.5.0",
+    "helmet": "^4.6.0",
     "method-override": "^3.0.0",
     "mysql2": "^2.2.5",
     "typeorm": "^0.2.31",
@@ -47,6 +54,7 @@
     "@typescript-eslint/parser": "^4.18.0",
     "browser-bunyan": "^1.6.3",
     "eslint-import-resolver-typescript": "^2.4.0",
+    "morgan": "^1.10.0",
     "ts-jest": "^26.5.4",
     "ts-node": "^9.1.1",
     "ts-node-dev": "^1.1.6",

+ 83 - 14
packages/slackbot-proxy/src/Server.ts

@@ -1,16 +1,30 @@
 import { Configuration, Inject, InjectorService } from '@tsed/di';
-import { PlatformApplication } from '@tsed/common';
-import '@tsed/platform-express'; // /!\ keep this import
+import { HttpServer, PlatformApplication } from '@tsed/common';
+import '@tsed/platform-express'; // !! DO NOT MODIFY !!
+import '@tsed/typeorm'; // !! DO NOT MODIFY !! -- https://github.com/tsedio/tsed/issues/1332#issuecomment-837840612
+import '@tsed/swagger';
+
 import bodyParser from 'body-parser';
 import compress from 'compression';
 import cookieParser from 'cookie-parser';
 import methodOverride from 'method-override';
-import '@tsed/swagger';
-import { TypeORMService } from '@tsed/typeorm';
+import helmet from 'helmet';
+import { Express } from 'express';
+import expressBunyanLogger from 'express-bunyan-logger';
+import gracefulExit from 'express-graceful-exit';
+
 import { ConnectionOptions } from 'typeorm';
+import { createTerminus } from '@godaddy/terminus';
 
+import swaggerSettingsForDev from '~/config/swagger/config.dev';
+import swaggerSettingsForProd from '~/config/swagger/config.prod';
+import loggerFactory from '~/utils/logger';
 
 export const rootDir = __dirname;
+const isProduction = process.env.NODE_ENV === 'production';
+
+const logger = loggerFactory('slackbot-proxy:server');
+
 
 const connectionOptions: ConnectionOptions = {
   // The 'name' property must be set. Otherwise, the 'name' will be '0' and won't work well. -- 2021.04.05 Yuki Takei
@@ -25,18 +39,34 @@ const connectionOptions: ConnectionOptions = {
   synchronize: true,
 } as ConnectionOptions;
 
+const swaggerSettings = isProduction ? swaggerSettingsForProd : swaggerSettingsForDev;
+const helmetOptions = isProduction ? {} : {
+  contentSecurityPolicy: {
+    directives: {
+      defaultSrc: ['\'self\''],
+      styleSrc: ['\'self\'', '\'unsafe-inline\''],
+      imgSrc: ['\'self\'', 'data:', 'validator.swagger.io'],
+      scriptSrc: ['\'self\'', 'https: \'unsafe-inline\''],
+    },
+  },
+};
 
 @Configuration({
   rootDir,
   acceptMimes: ['application/json'],
   httpPort: process.env.PORT || 8080,
   httpsPort: false, // CHANGE
+  // disable RequestLogger of @tsed/logger
+  logger: { logRequest: false },
   mount: {
     '/': [
       `${rootDir}/controllers/*.ts`,
       `${rootDir}/middlewares/*.ts`,
     ],
   },
+  middlewares: [
+    helmet(helmetOptions),
+  ],
   componentsScan: [
     `${rootDir}/services/*.ts`,
   ],
@@ -54,12 +84,7 @@ const connectionOptions: ConnectionOptions = {
       ],
     } as ConnectionOptions,
   ],
-  swagger: [
-    {
-      path: '/docs',
-      specVersion: '3.0.1',
-    },
-  ],
+  swagger: swaggerSettings,
   exclude: [
     '**/*.spec.ts',
   ],
@@ -67,7 +92,7 @@ const connectionOptions: ConnectionOptions = {
 export class Server {
 
   @Inject()
-  app: PlatformApplication;
+  app: PlatformApplication<Express>;
 
   @Configuration()
   settings: Configuration;
@@ -81,10 +106,19 @@ export class Server {
     if (serverUri === undefined) {
       throw new Error('The environment variable \'SERVER_URI\' must be defined.');
     }
+
+    const server = this.injector.get<HttpServer>(HttpServer);
+
+    // init express-graceful-exit
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    gracefulExit.init(server!);
   }
 
   $beforeRoutesInit(): void {
+    const expressApp = this.app.getApp();
+
     this.app
+      .use(gracefulExit.middleware(expressApp))
       .use(cookieParser())
       .use(compress({}))
       .use(methodOverride())
@@ -92,11 +126,46 @@ export class Server {
       .use(bodyParser.urlencoded({
         extended: true,
       }));
+
+    this.setupLogger();
   }
 
-  async $onReady(): Promise<void> {
-    // for synchromizing when boot
-    this.injector.get<TypeORMService>(TypeORMService);
+  $beforeListen(): void {
+    const expressApp = this.app.getApp();
+    const server = this.injector.get<HttpServer>(HttpServer);
+
+    // init terminus
+    createTerminus(server, {
+      onSignal: async() => {
+        logger.info('server is starting cleanup');
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        gracefulExit.gracefulExitHandler(expressApp, server!);
+      },
+      onShutdown: async() => {
+        logger.info('cleanup finished, server is shutting down');
+      },
+    });
+  }
+
+  /**
+   * Setup logger for requests
+   */
+  private setupLogger(): void {
+    // use bunyan
+    if (isProduction) {
+      const logger = loggerFactory('express');
+
+      this.app.use(expressBunyanLogger({
+        logger,
+        excludes: ['*'],
+      }));
+    }
+    // use morgan
+    else {
+      // eslint-disable-next-line @typescript-eslint/no-var-requires
+      const morgan = require('morgan');
+      this.app.use(morgan('dev'));
+    }
   }
 
 }

+ 10 - 0
packages/slackbot-proxy/src/config/swagger/config.dev.ts

@@ -0,0 +1,10 @@
+import { SwaggerSettings } from '@tsed/swagger';
+
+const settings: SwaggerSettings[] = [
+  {
+    path: '/docs',
+    specVersion: '3.0.1',
+  },
+];
+
+export default settings;

+ 5 - 0
packages/slackbot-proxy/src/config/swagger/config.prod.ts

@@ -0,0 +1,5 @@
+import { SwaggerSettings } from '@tsed/swagger';
+
+const settings: SwaggerSettings[] = [];
+
+export default settings;

+ 66 - 7
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -1,11 +1,13 @@
 import {
-  Controller, Get, Inject, Req, Res, UseBefore,
+  Controller, Get, Post, Inject, Req, Res, UseBefore,
 } from '@tsed/common';
 import axios from 'axios';
 
-import { WebAPICallResult } from '@slack/web-api';
+import { WebAPICallOptions, WebAPICallResult } from '@slack/web-api';
 
-import { verifyGrowiToSlackRequest, getConnectionStatuses, relationTestToSlack } from '@growi/slack';
+import {
+  verifyGrowiToSlackRequest, getConnectionStatuses, testToSlack, generateWebClient,
+} from '@growi/slack';
 
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
 import { InstallationRepository } from '~/repositories/installation';
@@ -105,8 +107,15 @@ export class GrowiToSlackCtrl {
         return res.status(400).send({ message: `failed to request to GROWI. err: ${err.message}` });
       }
 
-      await relationTestToSlack(token);
-      return res.send({ relation });
+      try {
+        await testToSlack(token);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.status(400).send({ message: `failed to test. err: ${err.message}` });
+      }
+
+      return res.send({ relation, slackBotToken: token });
     }
 
     // retrieve latest Order with Installation
@@ -136,7 +145,13 @@ export class GrowiToSlackCtrl {
       return res.status(400).send({ message: 'installation is invalid' });
     }
 
-    await relationTestToSlack(token);
+    try {
+      await testToSlack(token);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.status(400).send({ message: `failed to test. err: ${err.message}` });
+    }
 
     logger.debug('relation test is success', order);
 
@@ -145,7 +160,51 @@ export class GrowiToSlackCtrl {
       installation: order.installation, tokenGtoP: order.tokenGtoP, tokenPtoG: order.tokenPtoG, growiUri: order.growiUrl,
     });
 
-    return res.send({ relation: createdRelation });
+    return res.send({ relation: createdRelation, slackBotToken: token });
+  }
+
+  @Post('/*')
+  @UseBefore(verifyGrowiToSlackRequest)
+  async postResult(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+    const { tokenGtoPs } = req;
+
+    if (tokenGtoPs.length !== 1) {
+      return res.status(400).send({ message: 'tokenGtoPs is invalid' });
+    }
+
+    const tokenGtoP = tokenGtoPs[0];
+
+    // retrieve relation with Installation
+    const relation = await this.relationRepository.createQueryBuilder('relation')
+      .where('tokenGtoP = :token', { token: tokenGtoP })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getOne();
+
+    if (relation == null) {
+      return res.status(400).send({ message: 'relation is invalid' });
+    }
+
+    const token = relation.installation.data.bot?.token;
+    if (token == null) {
+      return res.status(400).send({ message: 'installation is invalid' });
+    }
+
+    const client = generateWebClient(token);
+
+    try {
+      // TODO: GW-6133
+      const opt = req.body as WebAPICallOptions;
+      await client.apiCall('put', opt);
+    }
+    catch (err) {
+      // TODO: GW-6133
+      // logger.error()
+      return res.status(500).send({ message: err.message });
+    }
+
+    logger.debug('postMessage is success');
+
+    return res.end();
   }
 
 }

+ 2 - 2
resource/locales/en_US/admin/admin.json

@@ -291,8 +291,7 @@
     },
     "use_env_var_if_empty": "If the value in the database is empty, the value of the environment variable <code>{{variable}}</code> is used.",
     "access_token_settings": {
-      "discard": "Discard",
-      "generate": "Generate"
+      "regenerate": "Regenerate"
     },
     "delete": "Delete",
     "integration_procedure": "Integration Procedure",
@@ -302,6 +301,7 @@
     "reset_all_settings": "Reset all settings",
     "delete_slackbot_settings": "Reset Slack Bot settings",
     "slackbot_settings_notice": "Reset",
+    "all_settings_of_the_bot_will_be_reset": "All settings of the Bot will be reset.<br>Are you sure?",
     "accordion": {
       "create_bot": "Create Bot",
       "how_to_create_a_bot": "How to create a bot",

+ 2 - 2
resource/locales/ja_JP/admin/admin.json

@@ -289,8 +289,7 @@
     },
     "use_env_var_if_empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
     "access_token_settings": {
-      "discard": "破棄",
-      "generate": "発行"
+      "regenerate": "再発行"
     },
     "delete": "削除",
     "integration_procedure": "連携手順",
@@ -299,6 +298,7 @@
     "reset_all_settings": "全ての設定をリセット",
     "delete_slackbot_settings": "Slack Bot 設定をリセットする",
     "slackbot_settings_notice": "リセットします",
+    "all_settings_of_the_bot_will_be_reset": "Botの全ての設定がリセットされます。<br>よろしいですか?",
     "accordion": {
       "create_bot": "Bot を作成する",
       "how_to_create_a_bot": "作成手順はこちら",

+ 2 - 2
resource/locales/zh_CN/admin/admin.json

@@ -299,8 +299,7 @@
     },
     "use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <code>{{variable}}</code> 启用。",
     "access_token_settings": {
-      "discard": "丢弃",
-      "generate": "生成"
+      "regenerate": "再生"
     },
     "delete": "取消",
     "integration_procedure": "协作程序",
@@ -309,6 +308,7 @@
     "reset_all_settings": "重置所有设置",
     "delete_slackbot_settings": "重置 Slack Bot 设置",
     "slackbot_settings_notice": "重置",
+    "all_settings_of_the_bot_will_be_reset": "bot的所有设置将被重置。<br>你确定吗?",
     "accordion": {
       "create_bot": "创建 Bot",
       "how_to_create_a_bot": "如何创建一个 Bot",

+ 0 - 65
src/client/js/components/Admin/SlackIntegration/AccessTokenSettings.jsx

@@ -1,65 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'react-i18next';
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-import { toastSuccess } from '../../../util/apiNotification';
-
-const AccessTokenSettings = (props) => {
-  const { t } = useTranslation('admin');
-
-  const onClickDiscardButton = () => {
-    if (props.onClickDiscardButton != null) {
-      props.onClickDiscardButton();
-    }
-  };
-
-  const onClickGenerateToken = () => {
-    if (props.onClickGenerateToken != null) {
-      props.onClickGenerateToken();
-    }
-  };
-
-  const accessToken = props.accessToken ? props.accessToken : '';
-
-  return (
-    <div className="row">
-      <div className="col-lg-12">
-
-        <h2 className="admin-setting-header">Access Token</h2>
-
-        <div className="form-group row my-5">
-          <label className="text-left text-md-right col-md-3 col-form-label">Access Token</label>
-          <div className="col-md-6">
-            {accessToken.length === 0 ? (
-              <input className="form-control" type="text" value={accessToken} readOnly />
-            ) : (
-              <CopyToClipboard text={accessToken} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
-                <input className="form-control" type="text" value={accessToken} readOnly />
-              </CopyToClipboard>
-            )}
-          </div>
-        </div>
-
-        <div className="row">
-          <div className="mx-auto">
-            <button type="button" className="btn btn-outline-secondary text-nowrap mx-1" onClick={onClickDiscardButton} disabled={accessToken.length === 0}>
-              {t('slack_integration.access_token_settings.discard')}
-            </button>
-            <button type="button" className="btn btn-primary text-nowrap mx-1" onClick={onClickGenerateToken}>
-              {t('slack_integration.access_token_settings.generate')}
-            </button>
-          </div>
-        </div>
-
-      </div>
-    </div>
-  );
-};
-
-AccessTokenSettings.propTypes = {
-  accessToken: PropTypes.string,
-  onClickDiscardButton: PropTypes.func,
-  onClickGenerateToken: PropTypes.func,
-};
-
-export default AccessTokenSettings;

+ 24 - 27
src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -15,10 +15,9 @@ const CustomBotWithProxySettings = (props) => {
   const {
     appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn,
   } = props;
-  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
-  const { t } = useTranslation();
-
   const [newProxyServerUri, setNewProxyServerUri] = useState();
+  const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
+  const { t } = useTranslation();
 
   useEffect(() => {
     if (proxyServerUri != null) {
@@ -26,6 +25,12 @@ const CustomBotWithProxySettings = (props) => {
     }
   }, [proxyServerUri]);
 
+  const fetchSlackIntegrationData = () => {
+    if (props.fetchSlackIntegrationData != null) {
+      props.fetchSlackIntegrationData();
+    }
+  };
+
   const addSlackAppIntegrationHandler = async() => {
     try {
       await appContainer.apiv3.put('/slack-integration-settings/slack-app-integrations');
@@ -41,34 +46,26 @@ const CustomBotWithProxySettings = (props) => {
     }
   };
 
-  const discardTokenHandler = async(tokenGtoP, tokenPtoG) => {
+  const deleteSlackAppIntegrationHandler = async() => {
     try {
-      await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { tokenGtoP, tokenPtoG });
+      // GW-6068 set new value after this
+      await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { integrationIdToDelete });
+      fetchSlackIntegrationData();
+      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
     }
     catch (err) {
       toastError(err);
-      logger(err);
+      logger.error(err);
     }
   };
 
-  const generateTokenHandler = async() => {
+  const generateAccessTokens = async() => {
     try {
       await appContainer.apiv3.put('/slack-integration-settings/slack-app-integrations');
     }
     catch (err) {
       toastError(err);
-      logger(err);
-    }
-  };
-
-  const deleteSlackAppIntegrationHandler = async() => {
-    try {
-      // TODO GW-5923 delete SlackAppIntegration
-      // await appContainer.apiv3.put('/slack-integration-settings/custom-bot-with-proxy');
-      toastSuccess('success');
-    }
-    catch (err) {
-      toastError(err);
+      logger.error(err);
     }
   };
 
@@ -77,7 +74,7 @@ const CustomBotWithProxySettings = (props) => {
       await appContainer.apiv3.put('/slack-integration-settings/proxy-uri', {
         proxyUri: newProxyServerUri,
       });
-      toastSuccess(t('toaster.update_successed', { target: t('Proxy URL') }));
+      toastSuccess(t('toaster.update_successed', { target: 'Proxy URL' }));
     }
     catch (err) {
       toastError(err);
@@ -128,12 +125,12 @@ const CustomBotWithProxySettings = (props) => {
         {slackAppIntegrations.map((slackAppIntegration) => {
           const { tokenGtoP, tokenPtoG } = slackAppIntegration;
           return (
-            <React.Fragment key={slackAppIntegration.id}>
+            <React.Fragment key={slackAppIntegration._id}>
               <div className="d-flex justify-content-end">
                 <button
                   className="my-3 btn btn-outline-danger"
                   type="button"
-                  onClick={() => setIsDeleteConfirmModalShown(true)}
+                  onClick={() => setIntegrationIdToDelete(slackAppIntegration._id)}
                 >
                   <i className="icon-trash mr-1" />
                   {t('admin:slack_integration.delete')}
@@ -141,8 +138,8 @@ const CustomBotWithProxySettings = (props) => {
               </div>
               <WithProxyAccordions
                 botType="customBotWithProxy"
-                discardTokenHandler={() => discardTokenHandler(tokenGtoP, tokenPtoG)}
-                generateTokenHandler={generateTokenHandler}
+                slackAppIntegrationId={slackAppIntegration._id}
+                onClickGenerateTokenBtn={generateAccessTokens}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
               />
@@ -161,8 +158,8 @@ const CustomBotWithProxySettings = (props) => {
       </div>
       <DeleteSlackBotSettingsModal
         isResetAll={false}
-        isOpen={isDeleteConfirmModalShown}
-        onClose={() => setIsDeleteConfirmModalShown(false)}
+        isOpen={integrationIdToDelete != null}
+        onClose={() => setIntegrationIdToDelete(null)}
         onClickDeleteButton={deleteSlackAppIntegrationHandler}
       />
     </>
@@ -177,10 +174,10 @@ CustomBotWithProxySettings.defaultProps = {
 
 CustomBotWithProxySettings.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
   slackAppIntegrations: PropTypes.array,
   proxyServerUri: PropTypes.string,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  fetchSlackIntegrationData: PropTypes.func,
 };
 
 export default CustomBotWithProxySettingsWrapper;

+ 58 - 40
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxyIntegrationCard.jsx

@@ -4,16 +4,70 @@ import PropTypes from 'prop-types';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
-const CustomBotWithoutProxyIntegrationCard = (props) => {
+const IntegrationSuccess = () => {
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <div className="d-none d-lg-block">
+        <p className="text-success small mt-5">
+          <i className="fa fa-check mr-1" />
+          {t('admin:slack_integration.integration_sentence.integration_successful')}
+        </p>
+        <hr className="align-self-center admin-border-success border-success"></hr>
+      </div>
+      <div id="integration-line-for-tooltip" className="d-block d-lg-none mt-5">
+        <i className="fa fa-check mr-1 text-success" />
+        <hr className="align-self-center admin-border-success border-success"></hr>
+      </div>
+      <UncontrolledTooltip placement="top" fade={false} target="integration-line-for-tooltip">
+        <small>
+          {t('admin:slack_integration.integration_sentence.integration_successful')}
+        </small>
+      </UncontrolledTooltip>
+    </>
+  );
+};
 
+const IntegrationFailed = () => {
   const { t } = useTranslation();
 
+  return (
+    <>
+      <div className="d-none d-lg-block">
+        <p className="mt-4">
+          <small
+            className="text-danger m-0"
+          // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.integration_sentence.integration_is_not_complete') }}
+          />
+        </p>
+        <hr className="align-self-center admin-border-danger border-danger"></hr>
+
+      </div>
+      <div id="integration-line-for-tooltip" className="d-block d-lg-none mt-5">
+        <i className="icon-info text-danger" />
+        <hr className="align-self-center admin-border-danger border-danger"></hr>
+      </div>
+      <UncontrolledTooltip placement="top" fade={false} target="integration-line-for-tooltip">
+        <small
+          className="m-0"
+        // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.integration_sentence.integration_is_not_complete') }}
+        />
+      </UncontrolledTooltip>
+    </>
+  );
+};
+
+const CustomBotWithoutProxyIntegrationCard = (props) => {
+
   return (
     <div className="d-flex justify-content-center my-5 bot-integration">
       <div className="card rounded shadow border-0 w-50 admin-bot-card mb-0">
         <h5 className="card-title font-weight-bold mt-3 ml-4">Slack</h5>
         <div className="card-body p-2 w-50 mx-auto">
-          {props.slackWSNameInWithoutProxy != null && (
+          {props.isIntegrationSuccess && props.slackWSNameInWithoutProxy != null && (
             <div className="card slack-work-space-name-card">
               <div className="m-2 text-center">
                 <h5 className="font-weight-bold">{props.slackWSNameInWithoutProxy}</h5>
@@ -25,32 +79,7 @@ const CustomBotWithoutProxyIntegrationCard = (props) => {
       </div>
 
       <div className="text-center w-25">
-
-        <div className="d-none d-lg-block">
-          {/* TODO GW-5998 switching logic */}
-          {/* <p className="text-success small mt-5">
-            <i className="fa fa-check mr-1" />
-            {t('admin:slack_integration.integration_sentence.integration_successful')}
-          </p> */}
-
-          <p className="mt-4">
-            <small
-              className="text-danger m-0"
-              // eslint-disable-next-line react/no-danger
-              dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.integration_sentence.integration_is_not_complete') }}
-            />
-          </p>
-        </div>
-
-        {/* TODO GW-5998  */}
-        <div id="integration-line-for-tooltip" className="d-block d-lg-none mt-5">
-          {/* <i className="fa fa-check mr-1 text-success" /> */}
-          <i className="icon-info text-danger" />
-        </div>
-
-        {/* TODO GW-5998 */}
-        {/* <hr className="align-self-center admin-border-success border-success"></hr> */}
-        <hr className="align-self-center admin-border-danger border-danger"></hr>
+        {props.isIntegrationSuccess ? <IntegrationSuccess /> : <IntegrationFailed />}
       </div>
 
       <div className="card rounded-lg shadow border-0 w-50 admin-bot-card mb-0">
@@ -59,18 +88,6 @@ const CustomBotWithoutProxyIntegrationCard = (props) => {
           <div className="btn btn-primary">{ props.siteName }</div>
         </div>
       </div>
-
-      <UncontrolledTooltip placement="top" fade={false} target="integration-line-for-tooltip">
-        {/* TODO GW-5998 */}
-        {/* <small>
-          {t('admin:slack_integration.integration_sentence.integration_successful')}
-        </small> */}
-        <small
-          className="m-0"
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.integration_sentence.integration_is_not_complete') }}
-        />
-      </UncontrolledTooltip>
     </div>
   );
 };
@@ -78,6 +95,7 @@ const CustomBotWithoutProxyIntegrationCard = (props) => {
 CustomBotWithoutProxyIntegrationCard.propTypes = {
   siteName: PropTypes.string.isRequired,
   slackWSNameInWithoutProxy: PropTypes.string,
+  isIntegrationSuccess: PropTypes.bool,
 };
 
 export default CustomBotWithoutProxyIntegrationCard;

+ 32 - 0
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -14,6 +14,10 @@ const CustomBotWithoutProxySettings = (props) => {
 
   const [siteName, setSiteName] = useState('');
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
+  const [isIntegrationSuccess, setIsIntegrationSuccess] = useState(false);
+  const [connectionMessage, setConnectionMessage] = useState('');
+  const [connectionErrorCode, setConnectionErrorCode] = useState(null);
+  const [testChannel, setTestChannel] = useState('');
 
   const resetSettings = async() => {
     if (onResetSettings == null) {
@@ -22,6 +26,25 @@ const CustomBotWithoutProxySettings = (props) => {
     onResetSettings();
   };
 
+  const testConnection = async() => {
+    setConnectionErrorCode(null);
+    setConnectionMessage(null);
+    try {
+      await appContainer.apiv3.post('/slack-integration-settings/without-proxy/test', { channel: testChannel });
+      setConnectionMessage('Send the message to slack work space.');
+      setIsIntegrationSuccess(true);
+    }
+    catch (err) {
+      setConnectionErrorCode(err[0].code);
+      setConnectionMessage(err[0].message);
+      setIsIntegrationSuccess(false);
+    }
+  };
+
+  const inputTestChannelHandler = (channel) => {
+    setTestChannel(channel);
+  };
+
   useEffect(() => {
     const siteName = appContainer.config.crowi.title;
     setSiteName(siteName);
@@ -34,6 +57,7 @@ const CustomBotWithoutProxySettings = (props) => {
       <CustomBotWithoutProxyIntegrationCard
         siteName={siteName}
         slackWSNameInWithoutProxy={props.slackWSNameInWithoutProxy}
+        isIntegrationSuccess={isIntegrationSuccess}
       />
 
       <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
@@ -51,6 +75,13 @@ const CustomBotWithoutProxySettings = (props) => {
         <CustomBotWithoutProxySettingsAccordion
           {...props}
           activeStep={botInstallationStep.CREATE_BOT}
+          connectionMessage={connectionMessage}
+          connectionErrorCode={connectionErrorCode}
+          isIntegrationSuccess={isIntegrationSuccess}
+          testChannel={testChannel}
+          onTestFormSubmitted={testConnection}
+          inputTestChannelHandler={inputTestChannelHandler}
+
         />
       </div>
       <DeleteSlackBotSettingsModal
@@ -73,6 +104,7 @@ CustomBotWithoutProxySettings.propTypes = {
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
   isRgisterSlackCredentials: PropTypes.bool,
+  isIntegrationSuccess: PropTypes.bool,
   slackWSNameInWithoutProxy: PropTypes.string,
   onResetSettings: PropTypes.func,
 };

+ 31 - 47
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -16,19 +16,15 @@ export const botInstallationStep = {
 };
 
 const CustomBotWithoutProxySettingsAccordion = ({
-  appContainer, activeStep, fetchSlackIntegrationData,
-  slackSigningSecret, slackSigningSecretEnv, slackBotToken, slackBotTokenEnv,
-  isRegisterSlackCredentials, isSendTestMessage,
-  onSetSlackSigningSecret, onSetSlackBotToken, onSetIsSendTestMessage,
+  appContainer, activeStep,
+  connectionMessage, connectionErrorCode, testChannel, slackSigningSecret, slackSigningSecretEnv, slackBotToken, slackBotTokenEnv,
+  isRegisterSlackCredentials, isIntegrationSuccess,
+  fetchSlackIntegrationData, inputTestChannelHandler, onTestFormSubmitted, onSetSlackSigningSecret, onSetSlackBotToken,
 }) => {
   const { t } = useTranslation();
   // TODO: GW-5644 Store default open accordion
   // eslint-disable-next-line no-unused-vars
   const [defaultOpenAccordionKeys, setDefaultOpenAccordionKeys] = useState(new Set([activeStep]));
-  const [connectionErrorCode, setConnectionErrorCode] = useState(null);
-  const [connectionErrorMessage, setConnectionErrorMessage] = useState(null);
-  const [connectionSuccessMessage, setConnectionSuccessMessage] = useState(null);
-  const [testChannel, setTestChannel] = useState('');
   const currentBotType = 'customBotWithoutProxy';
 
 
@@ -63,42 +59,22 @@ const CustomBotWithoutProxySettingsAccordion = ({
     }
   };
 
-  const testConnection = async() => {
-    setConnectionErrorCode(null);
-    setConnectionErrorMessage(null);
-    setConnectionSuccessMessage(null);
-    // TODO: 5921 Add new Test endpoint
-    try {
-      // eslint-disable-next-line no-console
-      console.log('Test');
-      // const res = await appContainer.apiv3.post('/slack-integration-settings//without-proxy/test', {
-      //   channel: testChannel,
-      // });
-      // setConnectionSuccessMessage(res.data.message);
-      // onSetIsSendTestMessage(true);
-    }
-    catch (err) {
-      onSetIsSendTestMessage(false);
-      setConnectionErrorCode('dummy-error-code');
-      setConnectionErrorMessage('This is a sample error message');
-    }
-  };
-
   const submitForm = (e) => {
     e.preventDefault();
-    testConnection();
-  };
 
-  const inputTestChannelHandler = (channel) => {
-    setTestChannel(channel);
+    if (onTestFormSubmitted == null) {
+      return;
+    }
+    onTestFormSubmitted();
   };
 
+
   let value = '';
-  if (connectionErrorMessage != null) {
-    value = [connectionErrorCode, connectionErrorMessage];
+  if (connectionMessage === 'Send the message to slack work space.' || connectionMessage === '') {
+    value = connectionMessage;
   }
-  if (connectionSuccessMessage != null) {
-    value = connectionSuccessMessage;
+  else {
+    value = [connectionErrorCode, connectionMessage];
   }
 
   return (
@@ -159,7 +135,7 @@ const CustomBotWithoutProxySettingsAccordion = ({
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
-        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.test_connection')}{isSendTestMessage && <i className="ml-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.test_connection')}{isIntegrationSuccess && <i className="ml-3 text-success fa fa-check"></i>}</>}
       >
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <div className="d-flex justify-content-center">
@@ -184,10 +160,17 @@ const CustomBotWithoutProxySettingsAccordion = ({
             </button>
           </form>
         </div>
-        {connectionErrorMessage != null
-          && <p className="text-danger text-center my-4">{t('admin:slack_integration.accordion.error_check_logs_below')}</p>}
-        {connectionSuccessMessage != null
-          && <p className="text-info text-center my-4">{t('admin:slack_integration.accordion.send_message_to_slack_work_space')}</p>}
+        {connectionMessage === ''
+          ? <p></p>
+          : (
+            <>
+              {connectionMessage === 'Send the message to slack work space.'
+                ? <p className="text-info text-center my-4">{t('admin:slack_integration.accordion.send_message_to_slack_work_space')}</p>
+                : <p className="text-danger text-center my-4">{t('admin:slack_integration.accordion.error_check_logs_below')}</p>
+              }
+            </>
+          )
+        }
         <form>
           <div className="row my-3 justify-content-center">
             <div className="form-group slack-connection-log col-md-4">
@@ -214,15 +197,16 @@ CustomBotWithoutProxySettingsAccordion.propTypes = {
   slackSigningSecretEnv: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
+  testChannel: PropTypes.string,
   isRegisterSlackCredentials: PropTypes.bool,
-  isSendTestMessage: PropTypes.bool,
+  isIntegrationSuccess: PropTypes.bool,
   fetchSlackIntegrationData: PropTypes.func,
+  inputTestChannelHandler: PropTypes.func,
+  onTestFormSubmitted: PropTypes.func,
   onSetSlackSigningSecret: PropTypes.func,
   onSetSlackBotToken: PropTypes.func,
-  onSetIsSendTestMessage: PropTypes.func,
-  onSetIsRegisterSlackCredentials: PropTypes.func,
-  setSlackWSNameInWithoutProxy: PropTypes.func,
-
+  connectionMessage: PropTypes.string,
+  connectionErrorCode: PropTypes.string,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
   activeStep: PropTypes.oneOf(Object.values(botInstallationStep)).isRequired,
 };

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

@@ -49,7 +49,19 @@ const DeleteSlackBotSettingsModal = React.memo((props) => {
         </span>
       </ModalHeader>
       <ModalBody>
-        {t('admin:slack_integration.slackbot_settings_notice')}
+        {props.isResetAll && (
+          <>
+            <span
+              // eslint-disable-next-line react/no-danger
+              dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.all_settings_of_the_bot_will_be_reset') }}
+            />
+          </>
+        )}
+        {!props.isResetAll && (
+          <>
+            {t('admin:slack_integration.slackbot_settings_notice')}
+          </>
+        )}
       </ModalBody>
       <ModalFooter>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>

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

@@ -124,7 +124,7 @@ const OfficialBotSettings = (props) => {
         </div>
       </div>
 
-      <h2 className="admin-setting-header">{t('admin:slack_integration.official_bot_settings')}</h2>
+      <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
 
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration) => {

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

@@ -24,7 +24,6 @@ const SlackIntegration = (props) => {
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
   const [isRegisterSlackCredentials, setIsRegisterSlackCredentials] = useState(false);
-  const [isSendTestMessage, setIsSendTestMessage] = useState(false);
   const [slackWSNameInWithoutProxy, setSlackWSNameInWithoutProxy] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
@@ -93,7 +92,6 @@ const SlackIntegration = (props) => {
       setIsRegisterSlackCredentials(false);
       setSlackSigningSecret(null);
       setSlackBotToken(null);
-      setIsSendTestMessage(false);
       setSlackWSNameInWithoutProxy(null);
     }
     catch (err) {
@@ -135,7 +133,6 @@ const SlackIntegration = (props) => {
     case 'customBotWithoutProxy':
       settingsComponent = (
         <CustomBotWithoutProxySettings
-          isSendTestMessage={isSendTestMessage}
           isRegisterSlackCredentials={isRegisterSlackCredentials}
           slackBotTokenEnv={slackBotTokenEnv}
           slackBotToken={slackBotToken}
@@ -144,7 +141,6 @@ const SlackIntegration = (props) => {
           slackWSNameInWithoutProxy={slackWSNameInWithoutProxy}
           onSetSlackSigningSecret={setSlackSigningSecret}
           onSetSlackBotToken={setSlackBotToken}
-          onSetIsSendTestMessage={setIsSendTestMessage}
           onResetSettings={resetWithOutSettings}
           fetchSlackIntegrationData={fetchSlackIntegrationData}
         />
@@ -156,6 +152,7 @@ const SlackIntegration = (props) => {
           slackAppIntegrations={slackAppIntegrations}
           proxyServerUri={proxyServerUri}
           onClickAddSlackWorkspaceBtn={fetchSlackIntegrationData}
+          fetchSlackIntegrationData={fetchSlackIntegrationData}
         />
       );
       break;

+ 42 - 64
src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -1,12 +1,16 @@
+/* eslint-disable react/prop-types */
 import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+import loggerFactory from '@alias/logger';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess } from '../../../util/apiNotification';
 import AppContainer from '../../../services/AppContainer';
 import Accordion from '../Common/Accordion';
 
+const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 
 const BotCreateProcess = () => {
   const { t } = useTranslation();
@@ -73,15 +77,9 @@ const RegisteringProxyUrlProcess = () => {
 const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers((props) => {
   const { t } = useTranslation();
 
-  const generateTokenHandler = () => {
-    if (props.generateTokenHandler != null) {
-      props.generateTokenHandler();
-    }
-  };
-
-  const discardTokenHandler = () => {
-    if (props.discardTokenHandler != null) {
-      props.discardTokenHandler();
+  const onClickGenerateTokenBtn = () => {
+    if (props.onClickGenerateTokenBtn != null) {
+      props.onClickGenerateTokenBtn();
     }
   };
 
@@ -116,23 +114,13 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
       </div>
 
       <div className="row my-3">
-        <div className="mx-auto">
-          <button
-            type="button"
-            className="btn btn-outline-secondary mx-2"
-            onClick={discardTokenHandler}
-            disabled={props.tokenGtoP == null || props.tokenPtoG == null}
-          >
-            { t('admin:slack_integration.access_token_settings.discard') }
-          </button>
-          <button
-            type="button"
-            className="btn btn-primary mx-2"
-            onClick={generateTokenHandler}
-          >
-            { t('admin:slack_integration.access_token_settings.generate') }
-          </button>
-        </div>
+        <button
+          type="button"
+          className="btn btn-primary mx-auto"
+          onClick={onClickGenerateTokenBtn}
+        >
+          { t('admin:slack_integration.access_token_settings.regenerate') }
+        </button>
       </div>
       <p className="font-weight-bold">2. {t('admin:slack_integration.accordion.register_for_growi_official_bot_proxy_service')}</p>
       <div className="d-flex flex-column align-items-center">
@@ -179,34 +167,27 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
   );
 }, [AppContainer]);
 
-const TestProcess = () => {
+const TestProcess = ({ apiv3Post, slackAppIntegrationId }) => {
   const { t } = useTranslation();
   const [testChannel, setTestChannel] = useState('');
-  /* eslint-disable no-unused-vars */
-  // TODO: Add connection Logs
-  const [connectionErrorCode, setConnectionErrorCode] = useState(null);
-  const [connectionErrorMessage, setConnectionErrorMessage] = useState(null);
-  const [connectionSuccessMessage, setConnectionSuccessMessage] = useState(null);
+  const [connectionError, setConnectionError] = useState(null);
 
-  // TODO: Show test logs
   let value = '';
-  if (connectionErrorMessage != null) {
-    value = [connectionErrorCode, connectionErrorMessage];
-  }
-  if (connectionSuccessMessage != null) {
-    value = connectionSuccessMessage;
+  if (connectionError != null) {
+    value = [connectionError.code, connectionError.message];
   }
 
-
-  // TODO: Handle test button
-  const submitForm = (e) => {
+  const submitForm = async(e) => {
     e.preventDefault();
-    // eslint-disable-next-line no-console
-    console.log('Form Submitted');
-  };
+    setConnectionError(null);
 
-  const inputTestChannelHandler = (channel) => {
-    setTestChannel(channel);
+    try {
+      await apiv3Post('/slack-integration-settings/with-proxy/relation-test', { slackAppIntegrationId, channel: testChannel });
+    }
+    catch (error) {
+      setConnectionError(error[0]);
+      logger.error(error);
+    }
   };
 
   return (
@@ -223,22 +204,22 @@ const TestProcess = () => {
               type="text"
               value={testChannel}
               placeholder="Slack Channel"
-            // TODO: Handle test button
-              onChange={e => inputTestChannelHandler(e.target.value)}
+              onChange={e => setTestChannel(e.target.value)}
             />
           </div>
           <button
             type="submit"
             className="btn btn-info mx-3 font-weight-bold"
             disabled={testChannel.trim() === ''}
-          >Test
+          >
+            Test
           </button>
         </form>
       </div>
-      {connectionErrorMessage != null
-      && <p className="text-danger text-center my-4">{t('admin:slack_integration.accordion.error_check_logs_below')}</p>}
-      {connectionSuccessMessage != null
-      && <p className="text-info text-center my-4">{t('admin:slack_integration.accordion.send_message_to_slack_work_space')}</p>}
+      {connectionError == null
+        ? <p className="text-info text-center my-4">{t('admin:slack_integration.accordion.send_message_to_slack_work_space')}</p>
+        : <p className="text-danger text-center my-4">{t('admin:slack_integration.accordion.error_check_logs_below')}</p>
+      }
       <form>
         <div className="row my-3 justify-content-center">
           <div className="form-group slack-connection-log col-md-4">
@@ -246,7 +227,6 @@ const TestProcess = () => {
             <textarea
               className="form-control card border-info slack-connection-log-body rounded-lg"
               rows="5"
-            // TODO: Show test logs
               value={value}
               readOnly
             />
@@ -270,8 +250,7 @@ const WithProxyAccordions = (props) => {
       title: 'register_for_growi_official_bot_proxy_service',
       content: <GeneratingTokensAndRegisteringProxyServiceProcess
         growiUrl={props.appContainer.config.crowi.url}
-        discardTokenHandler={props.discardTokenHandler}
-        generateTokenHandler={props.generateTokenHandler}
+        onClickGenerateTokenBtn={props.onClickGenerateTokenBtn}
         tokenPtoG={props.tokenPtoG}
         tokenGtoP={props.tokenGtoP}
       />,
@@ -299,8 +278,7 @@ const WithProxyAccordions = (props) => {
       title: 'register_for_growi_official_bot_proxy_service',
       content: <GeneratingTokensAndRegisteringProxyServiceProcess
         growiUrl={props.appContainer.config.crowi.url}
-        discardTokenHandler={props.discardTokenHandler}
-        generateTokenHandler={props.generateTokenHandler}
+        onClickGenerateTokenBtn={props.onClickGenerateTokenBtn}
         tokenPtoG={props.tokenPtoG}
         tokenGtoP={props.tokenGtoP}
       />,
@@ -311,7 +289,7 @@ const WithProxyAccordions = (props) => {
     },
     '⑤': {
       title: 'test_connection',
-      content: <TestProcess />,
+      content: <TestProcess apiv3Post={props.appContainer.apiv3.post} slackAppIntegrationId={props.slackAppIntegrationId} />,
     },
   };
 
@@ -322,7 +300,6 @@ const WithProxyAccordions = (props) => {
       className="card border-0 rounded-lg shadow overflow-hidden"
     >
       {Object.entries(integrationProcedureMapping).map(([key, value]) => {
-
         return (
           <Accordion
             title={<><span className="mr-2">{key}</span>{t(`admin:slack_integration.accordion.${value.title}`)}</>}
@@ -340,14 +317,15 @@ const WithProxyAccordions = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const OfficialBotSettingsAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
+const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [AppContainer]);
 WithProxyAccordions.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   botType: PropTypes.string.isRequired,
-  discardTokenHandler: PropTypes.func,
-  generateTokenHandler: PropTypes.func,
+
+  slackAppIntegrationId: PropTypes.string.isRequired,
+  onClickGenerateTokenBtn: PropTypes.func,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
 };
 
-export default OfficialBotSettingsAccordionsWrapper;
+export default WithProxyAccordionsWrapper;

+ 43 - 26
src/server/routes/apiv3/slack-integration-settings.js

@@ -5,7 +5,7 @@ const axios = require('axios');
 const urljoin = require('url-join');
 const loggerFactory = require('@alias/logger');
 
-const { getConnectionStatuses, relationTestToSlack } = require('@growi/slack');
+const { getConnectionStatuses, testToSlack, sendSuccessMessage } = require('@growi/slack');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -59,16 +59,12 @@ module.exports = (crowi) => {
       body('proxyUri').if(value => value !== '').trim().matches(/^(https?:\/\/)/)
         .isURL({ require_tld: false }),
     ],
-    AccessTokens: [
-      query('tokenGtoP').trim().not().isEmpty()
-        .isString()
-        .isLength({ min: 1 }),
-      query('tokenPtoG').trim().not().isEmpty()
-        .isString()
-        .isLength({ min: 1 }),
-    ],
     RelationTest: [
-      body('slackappintegrationsId').isMongoId(),
+      body('slackAppIntegrationId').isMongoId(),
+      body('channel').trim().isString(),
+    ],
+    deleteIntegration: [
+      query('integrationIdToDelete').isMongoId(),
     ],
     SlackChannel: [
       body('channel').trim().not().isEmpty()
@@ -406,11 +402,11 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to delete access tokens for slack
    */
-  router.delete('/slack-app-integration', validator.AccessTokens, apiV3FormValidator, async(req, res) => {
+  router.delete('/slack-app-integration', validator.deleteIntegration, apiV3FormValidator, async(req, res) => {
     const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-    const { tokenGtoP, tokenPtoG } = req.query;
+    const { integrationIdToDelete } = req.query;
     try {
-      const response = await SlackAppIntegration.findOneAndDelete({ tokenGtoP, tokenPtoG });
+      const response = await SlackAppIntegration.findOneAndDelete({ _id: integrationIdToDelete });
       return res.apiv3({ response });
     }
     catch (error) {
@@ -452,7 +448,7 @@ module.exports = (crowi) => {
    *            application/json:
    *              schema:
    *                properties:
-   *                  slackappintegrationsId:
+   *                  slackAppIntegrationId:
    *                    type: string
    *        responses:
    *           200:
@@ -465,23 +461,35 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, 'not-proxy-type'), 400);
     }
 
-    const { slackappintegrationsId } = req.body;
-
+    const { slackAppIntegrationId } = req.body;
+    let slackBotToken;
     try {
-      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: slackappintegrationsId });
+      const slackAppIntegration = await SlackAppIntegration.findOne({ _id: slackAppIntegrationId });
       if (slackAppIntegration == null) {
         const msg = 'Could not find SlackAppIntegration by id';
         return res.apiv3Err(new ErrorV3(msg, 'find-slackAppIntegration-failed'), 400);
       }
-      const response = await postRelationTest(slackAppIntegration.tokenGtoP);
-
-      return res.apiv3({ response });
+      const result = await postRelationTest(slackAppIntegration.tokenGtoP);
+      slackBotToken = result.slackBotToken;
+      if (slackBotToken == null) {
+        const msg = 'Could not find slackBotToken by relation';
+        return res.apiv3Err(new ErrorV3(msg, 'find-slackBotToken-failed'), 400);
+      }
     }
     catch (error) {
-      const msg = 'Error occured in updating Custom bot setting';
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      return res.apiv3Err(new ErrorV3(`Error occured while testing. Cause: ${error.message}`, 'test-failed', error.stack));
+    }
+
+    const { channel } = req.body;
+    const appSiteURL = crowi.configManager.getConfig('crowi', 'app:siteUrl');
+    try {
+      await sendSuccessMessage(slackBotToken, channel, appSiteURL);
+    }
+    catch (error) {
+      return res.apiv3Err(new ErrorV3(`Error occured while sending message. Cause: ${error.message}`, 'send-message-failed', error.stack));
     }
+
   });
 
   /**
@@ -510,17 +518,26 @@ module.exports = (crowi) => {
       const msg = 'Select Without Proxy Type';
       return res.apiv3Err(new ErrorV3(msg, 'select-not-proxy-type'), 400);
     }
-    // TODO impl req.body at GW-5998
-    // const { channel } = req.body;
+
     const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:token');
     try {
-      await relationTestToSlack(slackBotToken);
-      // TODO impl return response after imple 5996, 6002
+      await testToSlack(slackBotToken);
     }
     catch (error) {
       logger.error('Error', error);
       return res.apiv3Err(new ErrorV3(`Error occured while testing. Cause: ${error.message}`, 'test-failed', error.stack));
     }
+
+    const { channel } = req.body;
+    const appSiteURL = crowi.configManager.getConfig('crowi', 'app:siteUrl');
+    try {
+      await sendSuccessMessage(slackBotToken, channel, appSiteURL);
+    }
+    catch (error) {
+      return res.apiv3Err(new ErrorV3(`Error occured while sending message. Cause: ${error.message}`, 'send-message-failed', error.stack));
+    }
+
+    return res.apiv3();
   });
 
   return router;

+ 61 - 11
src/server/routes/apiv3/slack-integration.js

@@ -1,9 +1,10 @@
 const express = require('express');
 const mongoose = require('mongoose');
+const urljoin = require('url-join');
 
 const loggerFactory = require('@alias/logger');
 
-const { verifySlackRequest } = require('@growi/slack');
+const { verifySlackRequest, generateWebClient } = require('@growi/slack');
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
@@ -24,13 +25,13 @@ module.exports = (crowi) => {
       return res.status(400).send({ message });
     }
 
-    const slackAppIntegration = await SlackAppIntegration.estimatedDocumentCount({ tokenPtoG });
+    const slackAppIntegrationCount = await SlackAppIntegration.estimatedDocumentCount({ tokenPtoG });
 
     logger.debug('verifyAccessTokenFromProxy', {
       tokenPtoG,
     });
 
-    if (slackAppIntegration === 0) {
+    if (slackAppIntegrationCount === 0) {
       return res.status(403).send({
         message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`?',
       });
@@ -44,6 +45,31 @@ module.exports = (crowi) => {
     return next();
   };
 
+  const generateClientForResponse = (tokenGtoP) => {
+    const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
+
+    if (currentBotType == null) {
+      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
+    }
+
+    let token;
+
+    // connect directly
+    if (tokenGtoP == null) {
+      token = crowi.configManager.getConfig('crowi', 'slackbot:token');
+      return generateWebClient(token);
+    }
+
+    // connect to proxy
+    const proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyServerUri');
+    const serverUri = urljoin(proxyServerUri, '/g2s');
+    const headers = {
+      'x-growi-gtop-tokens': tokenGtoP,
+    };
+
+    return generateWebClient(token, serverUri, headers);
+  };
+
   async function handleCommands(req, res) {
     const { body } = req;
 
@@ -59,19 +85,31 @@ module.exports = (crowi) => {
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     res.send();
 
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+
+    // generate client
+    let client;
+    if (tokenPtoG == null) {
+      client = generateClientForResponse();
+    }
+    else {
+      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+      client = generateClientForResponse(slackAppIntegration.tokenGtoP);
+    }
+
     const args = body.text.split(' ');
     const command = args[0];
 
     try {
       switch (command) {
         case 'search':
-          await crowi.slackBotService.showEphemeralSearchResults(body, args);
+          await crowi.slackBotService.showEphemeralSearchResults(client, body, args);
           break;
         case 'create':
-          await crowi.slackBotService.createModal(body);
+          await crowi.slackBotService.createModal(client, body);
           break;
         default:
-          await crowi.slackBotService.notCommand(body);
+          await crowi.slackBotService.notCommand(client, body);
           break;
       }
     }
@@ -98,12 +136,12 @@ module.exports = (crowi) => {
   });
 
 
-  const handleBlockActions = async(payload) => {
+  const handleBlockActions = async(client, payload) => {
     const { action_id: actionId } = payload.actions[0];
 
     switch (actionId) {
       case 'shareSearchResults': {
-        await crowi.slackBotService.shareSearchResults(payload);
+        await crowi.slackBotService.shareSearchResults(client, payload);
         break;
       }
       case 'showNextResults': {
@@ -111,7 +149,7 @@ module.exports = (crowi) => {
 
         const { body, args, offset } = parsedValue;
         const newOffset = offset + 10;
-        await crowi.slackBotService.showEphemeralSearchResults(body, args, newOffset);
+        await crowi.slackBotService.showEphemeralSearchResults(client, body, args, newOffset);
         break;
       }
       default:
@@ -137,16 +175,28 @@ module.exports = (crowi) => {
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     res.send();
 
+
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    // generate client
+    let client;
+    if (tokenPtoG == null) {
+      client = generateClientForResponse();
+    }
+    else {
+      const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+      client = generateClientForResponse(slackAppIntegration.tokenGtoP);
+    }
+
     const payload = JSON.parse(req.body.payload);
     const { type } = payload;
 
     try {
       switch (type) {
         case 'block_actions':
-          await handleBlockActions(payload);
+          await handleBlockActions(client, payload);
           break;
         case 'view_submission':
-          await handleViewSubmission(payload);
+          await handleViewSubmission(client, payload);
           break;
         default:
           break;

+ 16 - 50
src/server/service/slackbot.js

@@ -1,8 +1,6 @@
 const logger = require('@alias/logger')('growi:service:SlackBotService');
 const mongoose = require('mongoose');
 
-const { generateWebClient } = require('@growi/slack');
-
 const PAGINGLIMIT = 10;
 
 const S2sMessage = require('../models/vo/s2s-message');
@@ -25,29 +23,6 @@ class SlackBotService extends S2sMessageHandlable {
     this.lastLoadedAt = new Date();
   }
 
-  get client() {
-    const currentBotType = this.crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
-
-    if (currentBotType == null) {
-      throw new Error('The config \'SLACK_BOT_TYPE\'(ns: \'crowi\', key: \'slackbot:currentBotType\') must be set.');
-    }
-
-    let serverUri;
-    let token;
-
-    // connect to proxy
-    if (currentBotType !== 'customBotWithoutProxy') {
-      // TODO: https://youtrack.weseek.co.jp/issue/GW-5896
-      serverUri = 'http://localhost:8080/slack-api-proxy/';
-    }
-    // connect directly
-    else {
-      token = this.crowi.configManager.getConfig('crowi', 'slackbot:token');
-    }
-
-    return generateWebClient(token, serverUri);
-  }
-
   /**
    * @inheritdoc
    */
@@ -87,13 +62,9 @@ class SlackBotService extends S2sMessageHandlable {
     }
   }
 
-  async sendAuthTest() {
-    await this.client.api.test();
-  }
-
-  notCommand(body) {
+  async notCommand(client, body) {
     logger.error('Invalid first argument');
-    this.client.chat.postEphemeral({
+    client.chat.postEphemeral({
       channel: body.channel_id,
       user: body.user_id,
       blocks: [
@@ -109,10 +80,10 @@ class SlackBotService extends S2sMessageHandlable {
     return keywords;
   }
 
-  async getSearchResultPaths(body, args, offset = 0) {
+  async getSearchResultPaths(client, body, args, offset = 0) {
     const firstKeyword = args[1];
     if (firstKeyword == null) {
-      this.client.chat.postEphemeral({
+      client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
         blocks: [
@@ -132,7 +103,7 @@ class SlackBotService extends S2sMessageHandlable {
     // no search results
     if (results.data.length === 0) {
       logger.info(`No page found with "${keywords}"`);
-      this.client.chat.postEphemeral({
+      client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
         blocks: [
@@ -166,22 +137,17 @@ class SlackBotService extends S2sMessageHandlable {
     };
   }
 
-  async getSlackChannelName() {
-    const slackTeamInfo = await this.client.team.info();
-    return slackTeamInfo.team.name;
-  }
-
-  shareSearchResults(payload) {
-    this.client.chat.postMessage({
+  async shareSearchResults(client, payload) {
+    client.chat.postMessage({
       channel: payload.channel.id,
       text: payload.actions[0].value,
     });
   }
 
-  async showEphemeralSearchResults(body, args, offsetNum) {
+  async showEphemeralSearchResults(client, body, args, offsetNum) {
     const {
       resultPaths, offset, resultsTotal,
-    } = await this.getSearchResultPaths(body, args, offsetNum);
+    } = await this.getSearchResultPaths(client, body, args, offsetNum);
 
     const keywords = this.getKeywords(args);
 
@@ -246,7 +212,7 @@ class SlackBotService extends S2sMessageHandlable {
           },
         );
       }
-      await this.client.chat.postEphemeral({
+      await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
         blocks: [
@@ -258,7 +224,7 @@ class SlackBotService extends S2sMessageHandlable {
     }
     catch {
       logger.error('Failed to get search results.');
-      await this.client.chat.postEphemeral({
+      await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
         blocks: [
@@ -269,9 +235,9 @@ class SlackBotService extends S2sMessageHandlable {
     }
   }
 
-  async createModal(body) {
+  async createModal(client, body) {
     try {
-      await this.client.views.open({
+      await client.views.open({
         trigger_id: body.trigger_id,
 
         view: {
@@ -299,7 +265,7 @@ class SlackBotService extends S2sMessageHandlable {
     }
     catch (err) {
       logger.error('Failed to create a page.');
-      await this.client.chat.postEphemeral({
+      await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
         blocks: [
@@ -311,7 +277,7 @@ class SlackBotService extends S2sMessageHandlable {
   }
 
   // Submit action in create Modal
-  async createPageInGrowi(payload) {
+  async createPageInGrowi(client, payload) {
     const Page = this.crowi.model('Page');
     const pathUtils = require('growi-commons').pathUtils;
 
@@ -328,7 +294,7 @@ class SlackBotService extends S2sMessageHandlable {
       await Page.create(path, contentsBody, dummyObjectIdOfUser, {});
     }
     catch (err) {
-      this.client.chat.postMessage({
+      client.chat.postMessage({
         channel: payload.user.id,
         blocks: [
           this.generateMarkdownSectionBlock(`Cannot create new page to existed path\n *Contents* :memo:\n ${contentsBody}`)],

+ 47 - 0
yarn.lock

@@ -1433,6 +1433,13 @@
   resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46"
   integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
 
+"@godaddy/terminus@^4.8.0":
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/@godaddy/terminus/-/terminus-4.8.0.tgz#7f1258abd731adcf5f08d8ff1aa0216a02d65062"
+  integrity sha512-C3u+LTmlhtqsk1Sjw9UDTAXfVngby62dJL71LyhpRcwX3FDeLb0yd1Rmxh1OjH5HouOr0IIuU4dhiJMT6NNXog==
+  dependencies:
+    stoppable "^1.1.0"
+
 "@google-cloud/common@^3.6.0":
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/@google-cloud/common/-/common-3.6.0.tgz#c2f6da5f79279a4a9ac7c71fc02d582beab98e8b"
@@ -4368,6 +4375,13 @@ basic-auth@~2.0.0:
   dependencies:
     safe-buffer "5.1.1"
 
+basic-auth@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a"
+  integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==
+  dependencies:
+    safe-buffer "5.1.2"
+
 batch@0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
@@ -7864,6 +7878,13 @@ express-form@~0.12.0:
     object-additions "^0.5.1"
     validator "^2.1.0"
 
+express-graceful-exit@=0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/express-graceful-exit/-/express-graceful-exit-0.5.0.tgz#fcde6495af84f361e66f119bf0e3eca69955f9f1"
+  integrity sha512-gZkxdmgYz6VVHjZiMTaO59N875ugmBnzopF95q8Fzmws86JLNWMwGjLVG4i+99WO4v9L5wl6vKYFyGoHKoNJ7A==
+  dependencies:
+    underscore "^1.4.4"
+
 express-session@^1.16.1:
   version "1.16.1"
   resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.16.1.tgz#251ff9776c59382301de6c8c33411af357ed439c"
@@ -9352,6 +9373,11 @@ helmet@^3.13.0:
     referrer-policy "1.1.0"
     x-xss-protection "1.1.0"
 
+helmet@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/helmet/-/helmet-4.6.0.tgz#579971196ba93c5978eb019e4e8ec0e50076b4df"
+  integrity sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==
+
 hex-color-regex@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
@@ -12559,6 +12585,17 @@ mongoose@5.10.11:
     sift "7.0.1"
     sliced "1.0.1"
 
+morgan@^1.10.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
+  integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==
+  dependencies:
+    basic-auth "~2.0.1"
+    debug "2.6.9"
+    depd "~2.0.0"
+    on-finished "~2.3.0"
+    on-headers "~1.0.2"
+
 morgan@^1.9.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.9.0.tgz#d01fa6c65859b76fcf31b3cb53a3821a311d8051"
@@ -17382,6 +17419,11 @@ sticky-events@^3.1.3:
   resolved "https://registry.yarnpkg.com/sticky-events/-/sticky-events-3.1.3.tgz#7b6b4091988b87b9f4e711c7c6532de07ab156dd"
   integrity sha512-nTm2bDaYTXFHAyQS59mWDRnnno/D8oj3C4JddOdipq6ZRnLLqjj+PeyCSbHPwMVdfvQoKwmMmAztp+YybDhvtA==
 
+stoppable@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/stoppable/-/stoppable-1.1.0.tgz#32da568e83ea488b08e4d7ea2c3bcc9d75015d5b"
+  integrity sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==
+
 stream-browserify@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"
@@ -18716,6 +18758,11 @@ umask@^1.1.0:
   resolved "https://registry.yarnpkg.com/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d"
   integrity sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0=
 
+underscore@^1.4.4:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1"
+  integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==
+
 unherit@^1.0.4:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.2.tgz#14f1f397253ee4ec95cec167762e77df83678449"