Просмотр исходного кода

Merge pull request #3914 from weseek/feat/growi-bot

release
itizawa 5 лет назад
Родитель
Сommit
2a5608cc77
89 измененных файлов с 1897 добавлено и 1379 удалено
  1. 31 2
      CHANGES.md
  2. 2 0
      config/env.dev.js
  3. 1 1
      package.json
  4. 10 8
      packages/app-for-hoisting/package.json
  5. 10 8
      packages/app/package.json
  6. 1 0
      packages/slack/package.json
  7. 1 0
      packages/slack/src/index.ts
  8. 2 8
      packages/slack/src/models/errors.ts
  9. 33 28
      packages/slack/src/utils/check-communicable.ts
  10. 1 0
      packages/slackbot-proxy/package.json
  11. 65 28
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  12. 68 25
      packages/slackbot-proxy/src/controllers/slack.ts
  13. 1 0
      packages/slackbot-proxy/src/interfaces/slack-to-growi/slack-oauth-req.ts
  14. 28 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts
  15. 40 0
      packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts
  16. 9 0
      packages/slackbot-proxy/src/models/errors.ts
  17. 31 4
      packages/slackbot-proxy/src/services/RegisterService.ts
  18. 72 0
      packages/slackbot-proxy/src/services/UnregisterService.ts
  19. 19 0
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectionButtonDelegator.ts
  20. 7 0
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjector.ts
  21. 18 0
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectorFactory.ts
  22. 10 0
      packages/slackbot-proxy/src/utils/extractGrowiUriFromView.ts
  23. 7 0
      packages/slackbot-proxy/src/utils/injectGrowiUriToView.ts
  24. 30 11
      resource/certs/localhost/cert.pem
  25. 0 11
      resource/certs/localhost/csr.pem
  26. 52 15
      resource/certs/localhost/key.pem
  27. 1 2
      resource/locales/en_US/admin/admin.json
  28. 2 1
      resource/locales/en_US/meta.json
  29. 8 3
      resource/locales/en_US/translation.json
  30. 3 4
      resource/locales/ja_JP/admin/admin.json
  31. 2 1
      resource/locales/ja_JP/meta.json
  32. 8 2
      resource/locales/ja_JP/translation.json
  33. 2 3
      resource/locales/zh_CN/admin/admin.json
  34. 3 2
      resource/locales/zh_CN/meta.json
  35. 9 4
      resource/locales/zh_CN/translation.json
  36. 21 3
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  37. 4 4
      src/client/js/components/Admin/ImportData/ImportDataPageContents.jsx
  38. 32 9
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  39. 1 2
      src/client/js/components/Admin/SlackIntegration/BotTypeCard.jsx
  40. 97 0
      src/client/js/components/Admin/SlackIntegration/Bridge.jsx
  41. 0 22
      src/client/js/components/Admin/SlackIntegration/ConductionStatusHr.jsx
  42. 57 0
      src/client/js/components/Admin/SlackIntegration/CustomBotWithProxyConnectionStatus.jsx
  43. 0 55
      src/client/js/components/Admin/SlackIntegration/CustomBotWithProxyIntegrationCard.jsx
  44. 50 65
      src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  45. 57 0
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxyConnectionStatus.jsx
  46. 0 103
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxyIntegrationCard.jsx
  47. 46 26
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx
  48. 28 70
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  49. 51 77
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  50. 8 9
      src/client/js/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.jsx
  51. 0 176
      src/client/js/components/Admin/SlackIntegration/IntegrationStatus.jsx
  52. 26 0
      src/client/js/components/Admin/SlackIntegration/MessageBasedOnConnection.jsx
  53. 58 74
      src/client/js/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  54. 24 49
      src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx
  55. 74 28
      src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  56. 20 0
      src/client/js/components/Admin/SlackIntegration/slak-integration-util.js
  57. 5 1
      src/client/js/components/InstallerForm.jsx
  58. 4 4
      src/client/js/components/PageCreateModal.jsx
  59. 10 0
      src/client/js/services/AdminGeneralSecurityContainer.js
  60. 24 4
      src/client/js/util/i18n.js
  61. 53 20
      src/client/styles/scss/_admin.scss
  62. 20 1
      src/lib/service/xss/index.js
  63. 33 0
      src/lib/util/path-utils.js
  64. 2 0
      src/server/crowi/express-init.js
  65. 5 5
      src/server/crowi/index.js
  66. 1 1
      src/server/form/invited.js
  67. 1 1
      src/server/form/register.js
  68. 1 1
      src/server/middlewares/access-token-parser.js
  69. 2 0
      src/server/models/config.js
  70. 3 33
      src/server/models/page.js
  71. 19 1
      src/server/models/slack-app-integration.js
  72. 5 0
      src/server/routes/admin.js
  73. 2 1
      src/server/routes/apiv3/pages.js
  74. 7 3
      src/server/routes/apiv3/security-setting.js
  75. 61 9
      src/server/routes/apiv3/share-links.js
  76. 56 23
      src/server/routes/apiv3/slack-integration-settings.js
  77. 5 2
      src/server/routes/apiv3/slack-integration.js
  78. 2 0
      src/server/routes/index.js
  79. 2 2
      src/server/routes/installer.js
  80. 2 121
      src/server/routes/page.js
  81. 10 0
      src/server/service/config-loader.js
  82. 34 3
      src/server/service/slackbot.js
  83. 3 1
      src/server/util/createGrowiPagesFromImports.js
  84. 7 0
      src/server/views/admin/not_found.html
  85. 1 1
      src/server/views/widget/alert_siteurl_undefined.html
  86. 1 1
      src/server/views/widget/page_content.html
  87. 0 50
      src/test/models/page.test.js
  88. 51 1
      src/test/util/path-utils.test.js
  89. 224 146
      yarn.lock

+ 31 - 2
CHANGES.md

@@ -1,8 +1,37 @@
 # CHANGES
 
-## v4.2.18-RC
+## v4.2.21-RC
 
-* Feature: Cobvertible page contents width.
+* Improvement: Upgrade mongodb driver to fix [NODE-2784](https://jira.mongodb.org/browse/NODE-2784)
+* Support: Upgrade libs
+    * connect-mongo
+    * migrate-mongo
+    * mongoose
+    * stream-to-promise
+
+
+## v4.2.20
+
+* Improvement: Error message when the password is too short
+* Improvement: Repeat XSS processing as a countermeasure against nesting 
+* Fix: NoSQL injection of access-token-parser
+* Fix: Checking permission when operating share links
+* Fix: Invalid NaN label is shown when deletedAt of the page is undefined
+    * Introduced by v4.2.8
+
+## v4.2.19
+
+* Feature: Set max-age of the user's cookie with the env var `SESSION_MAX_AGE`
+* Feature: Set max-age of the user's cookie in admin page
+* Improvement: Change the first accessing page after installation to the top page
+* Support: Upgrade libs
+    * string-width
+    * diff
+    * archiver
+
+## v4.2.18
+
+* Feature: Convertible page contents width
 * Fix: Group selector of User Group Delete Modal does not show all groups
 * Fix: Global notification to Slack does not encode spaces of page path
 * Support: Upgrade libs

+ 2 - 0
config/env.dev.js

@@ -26,4 +26,6 @@ module.exports = {
   // SLACK_BOT_TOKEN: '',
   SALT_FOR_GTOP_TOKEN: 'proxy',
   SALT_FOR_PTOG_TOKEN: 'growi',
+  // GROWI_CLOUD_URI: 'http://growi.cloud',
+  // GROWI_APP_ID_FOR_GROWI_CLOUD: '012345',
 };

+ 1 - 1
package.json

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

+ 10 - 8
packages/app-for-hoisting/package.json

@@ -4,7 +4,8 @@
   "license": "MIT",
   "scripts": {},
   "// 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.",
+    "string-width": "5.0.0 or above uses ESM."
   },
   "dependencies": {
     "@google-cloud/storage": "^5.8.5",
@@ -15,7 +16,7 @@
     "@slack/web-api": "^6.2.3",
     "@slack/webhook": "^6.0.0",
     "JSONStream": "^1.3.5",
-    "archiver": "^3.1.1",
+    "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
@@ -26,14 +27,14 @@
     "bunyan-format": "^0.2.1",
     "check-node-version": "^4.1.0",
     "connect-flash": "~0.1.1",
-    "connect-mongo": "^3.2.0",
+    "connect-mongo": "^4.4.1",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "detect-indent": "^6.0.0",
-    "diff": "^4.0.1",
+    "diff": "^5.0.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "env-cmd": "^10.0.1",
@@ -42,6 +43,7 @@
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
+    "express-mongo-sanitize": "^2.1.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
@@ -56,10 +58,10 @@
     "lucene-query-parser": "^1.2.0",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^8.1.4",
+    "migrate-mongo": "^8.2.2",
     "mkdirp": "^1.0.3",
     "module-alias": "^2.0.6",
-    "mongoose": "5.10.11",
+    "mongoose": "5.12.13",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
@@ -85,8 +87,8 @@
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "socket.io": "^2.3.0",
-    "stream-to-promise": "^2.2.0",
-    "string-width": "^4.1.0",
+    "stream-to-promise": "^3.0.0",
+    "string-width": "=4.2.2",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",

+ 10 - 8
packages/app/package.json

@@ -4,7 +4,8 @@
   "license": "MIT",
   "scripts": {},
   "// 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.",
+    "string-width": "5.0.0 or above uses ESM."
   },
   "dependencies": {
     "@google-cloud/storage": "^5.8.5",
@@ -15,7 +16,7 @@
     "@slack/web-api": "^6.2.3",
     "@slack/webhook": "^6.0.0",
     "JSONStream": "^1.3.5",
-    "archiver": "^3.1.1",
+    "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
@@ -26,14 +27,14 @@
     "bunyan-format": "^0.2.1",
     "check-node-version": "^4.1.0",
     "connect-flash": "~0.1.1",
-    "connect-mongo": "^3.2.0",
+    "connect-mongo": "^4.4.1",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "detect-indent": "^6.0.0",
-    "diff": "^4.0.1",
+    "diff": "^5.0.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "env-cmd": "^10.0.1",
@@ -42,6 +43,7 @@
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-form": "~0.12.0",
+    "express-mongo-sanitize": "^2.1.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
@@ -56,10 +58,10 @@
     "lucene-query-parser": "^1.2.0",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
-    "migrate-mongo": "^8.1.4",
+    "migrate-mongo": "^8.2.2",
     "mkdirp": "^1.0.3",
     "module-alias": "^2.0.6",
-    "mongoose": "5.10.11",
+    "mongoose": "5.12.13",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
@@ -85,8 +87,8 @@
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",
     "socket.io": "^2.3.0",
-    "stream-to-promise": "^2.2.0",
-    "string-width": "^4.1.0",
+    "stream-to-promise": "^3.0.0",
+    "string-width": "=4.2.2",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",

+ 1 - 0
packages/slack/package.json

@@ -19,6 +19,7 @@
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "dotenv-flow": "^3.2.0",
+    "extensible-custom-error": "^0.0.7",
     "universal-bunyan": "^0.9.2"
   },
   "devDependencies": {

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

@@ -5,6 +5,7 @@ export const supportedSlackCommands: string[] = [
 export const supportedGrowiCommands: string[] = [
   'search',
   'create',
+  'help',
 ];
 
 export * from './interfaces/growi-command';

+ 2 - 8
packages/slack/src/models/errors.ts

@@ -1,9 +1,3 @@
-export class InvalidGrowiCommandError extends Error {
+import ExtensibleCustomError from 'extensible-custom-error';
 
-  constructor(e?: string) {
-    super(e);
-    this.name = new.target.name;
-    Object.setPrototypeOf(this, new.target.prototype);
-  }
-
-}
+export class InvalidGrowiCommandError extends ExtensibleCustomError {}

+ 33 - 28
packages/slack/src/utils/check-communicable.ts

@@ -67,32 +67,47 @@ const retrieveWorkspaceName = async(client: WebClient): Promise<string> => {
   return (result as any).team?.name;
 };
 
+/**
+ * @param token bot OAuth token
+ * @returns
+ */
+export const getConnectionStatus = async(token:string): Promise<ConnectionStatus> => {
+  const client = generateWebClient(token);
+  const status: ConnectionStatus = {};
+
+  try {
+    // try to connect
+    const resultTestSlackApiServer = await testSlackApiServer(client);
+    // check scope
+    await checkSlackScopes(resultTestSlackApiServer);
+    // retrieve workspace name
+    status.workspaceName = await retrieveWorkspaceName(client);
+  }
+  catch (err) {
+    status.error = err;
+  }
+
+  return status;
+};
+
 /**
  * Get token string to ConnectionStatus map
- * @param tokens Array of bot OAuth token
+ * @param keys Array of bot OAuth token or specific key
+ * @param botTokenResolver function to convert from key to token
  * @returns
  */
-export const getConnectionStatuses = async(tokens: string[]): Promise<{[key: string]: ConnectionStatus}> => {
-  const map = tokens
+export const getConnectionStatuses = async(keys: string[], botTokenResolver?: (key: string) => string): Promise<{[key: string]: ConnectionStatus}> => {
+  const map = keys
     .reduce<Promise<Map<string, ConnectionStatus>>>(
-      async(acc, token) => {
-        const client = generateWebClient(token);
-
-        const status: ConnectionStatus = {};
-        try {
-          // try to connect
-          await testSlackApiServer(client);
-          // retrieve workspace name
-          status.workspaceName = await retrieveWorkspaceName(client);
-        }
-        catch (err) {
-          status.error = err;
+      async(acc, key) => {
+        let token = key;
+        if (botTokenResolver != null) {
+          token = botTokenResolver(key);
         }
+        const status: ConnectionStatus = await getConnectionStatus(token);
 
-        (await acc).set(token, status);
-
+        (await acc).set(key, status);
         return acc;
-
       },
       // define initial accumulator
       Promise.resolve(new Map<string, ConnectionStatus>()),
@@ -102,16 +117,6 @@ export const getConnectionStatuses = async(tokens: string[]): Promise<{[key: str
   return Object.fromEntries(await map);
 };
 
-/**
- * @param token bot OAuth token
- * @returns
- */
-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 client.chat.postMessage({

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

@@ -39,6 +39,7 @@
     "dotenv-flow": "^3.2.0",
     "express-bunyan-logger": "^1.3.3",
     "express-graceful-exit": "=0.5.0",
+    "extensible-custom-error": "^0.0.7",
     "helmet": "^4.6.0",
     "method-override": "^3.0.0",
     "mysql2": "^2.2.5",

+ 65 - 28
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -6,9 +6,11 @@ import axios from 'axios';
 import { WebAPICallOptions, WebAPICallResult } from '@slack/web-api';
 
 import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, testToSlack, generateWebClient,
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient,
 } from '@growi/slack';
 
+import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/slack-to-growi/add-webclient-response-to-res';
+
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
 import { InstallationRepository } from '~/repositories/installation';
 import { RelationRepository } from '~/repositories/relation';
@@ -16,6 +18,8 @@ import { OrderRepository } from '~/repositories/order';
 
 import { InstallerService } from '~/services/InstallerService';
 import loggerFactory from '~/utils/logger';
+import { findInjectorByType } from '~/services/growi-uri-injector/GrowiUriInjectorFactory';
+import { injectGrowiUriToView } from '~/utils/injectGrowiUriToView';
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
@@ -63,13 +67,17 @@ export class GrowiToSlackCtrl {
 
     logger.debug(`${relations.length} relations found`, relations);
 
-    // extract bot token
-    const tokens: string[] = relations
-      .map(relation => relation.installation?.data?.bot?.token)
-      .filter((v): v is string => v != null); // filter out null values
+    // key: tokenGtoP, value: botToken
+    const botTokenResolverMapping: {[tokenGtoP:string]:string} = {};
 
-    const connectionStatuses = await getConnectionStatuses(tokens);
+    relations.forEach((relation) => {
+      const botToken = relation.installation?.data?.bot?.token;
+      if (botToken != null) {
+        botTokenResolverMapping[relation.tokenGtoP] = botToken;
+      }
+    });
 
+    const connectionStatuses = await getConnectionStatuses(Object.keys(botTokenResolverMapping), (tokenGtoP:string) => botTokenResolverMapping[tokenGtoP]);
     return res.send({ connectionStatuses });
   }
 
@@ -107,12 +115,9 @@ export class GrowiToSlackCtrl {
         return res.status(400).send({ message: `failed to request to GROWI. err: ${err.message}` });
       }
 
-      try {
-        await testToSlack(token);
-      }
-      catch (err) {
-        logger.error(err);
-        return res.status(400).send({ message: `failed to test. err: ${err.message}` });
+      const status = await getConnectionStatus(token);
+      if (status.error != null) {
+        return res.status(400).send({ message: `failed to get connection. err: ${status.error}` });
       }
 
       return res.send({ relation, slackBotToken: token });
@@ -145,12 +150,9 @@ export class GrowiToSlackCtrl {
       return res.status(400).send({ message: 'installation is invalid' });
     }
 
-    try {
-      await testToSlack(token);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.status(400).send({ message: `failed to test. err: ${err.message}` });
+    const status = await getConnectionStatus(token);
+    if (status.error != null) {
+      return res.status(400).send({ message: `failed to get connection. err: ${status.error}` });
     }
 
     logger.debug('relation test is success', order);
@@ -163,13 +165,47 @@ export class GrowiToSlackCtrl {
     return res.send({ relation: createdRelation, slackBotToken: token });
   }
 
+  injectGrowiUri(req:GrowiReq, growiUri:string):WebAPICallOptions {
+
+    if (req.body.view != null) {
+      injectGrowiUriToView(req.body, growiUri);
+    }
+
+    if (req.body.blocks != null) {
+      const parsedBlocks = JSON.parse(req.body.blocks as string);
+
+      parsedBlocks.forEach((parsedBlock) => {
+        if (parsedBlock.type !== 'actions') {
+          return;
+        }
+        parsedBlock.elements.forEach((element) => {
+          const growiUriInjector = findInjectorByType(element.type);
+          if (growiUriInjector != null) {
+            growiUriInjector.inject(element, growiUri);
+          }
+        });
+
+        return;
+      });
+
+      req.body.blocks = JSON.stringify(parsedBlocks);
+    }
+
+    const opt = req.body;
+    opt.headers = req.headers;
+
+    return opt;
+  }
+
   @Post('/:method')
-  @UseBefore(verifyGrowiToSlackRequest)
-  async postResult(@PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
+  @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
+  async postResult(
+    @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
+  ): Promise<void|string|Res|WebAPICallResult> {
     const { tokenGtoPs } = req;
 
     if (tokenGtoPs.length !== 1) {
-      return res.status(400).send({ message: 'tokenGtoPs is invalid' });
+      return res.webClientErr('tokenGtoPs is invalid', 'invalid_tokenGtoP');
     }
 
     const tokenGtoP = tokenGtoPs[0];
@@ -181,29 +217,30 @@ export class GrowiToSlackCtrl {
       .getOne();
 
     if (relation == null) {
-      return res.status(400).send({ message: 'relation is invalid' });
+      return res.webClientErr('relation is invalid', 'invalid_relation');
     }
 
     const token = relation.installation.data.bot?.token;
     if (token == null) {
-      return res.status(400).send({ message: 'installation is invalid' });
+      return res.webClientErr('installation is invalid', 'invalid_installation');
     }
 
     const client = generateWebClient(token);
 
     try {
-      const opt = req.body as WebAPICallOptions;
-      opt.headers = req.headers;
+      const opt = this.injectGrowiUri(req, relation.growiUri);
+
       await client.apiCall(method, opt);
     }
     catch (err) {
       logger.error(err);
-      return res.status(400).send({ message: `failed to send to slack. err: ${err.message}` });
+      return res.webClientErr(`failed to send to slack. err: ${err.message}`, 'fail_api_call');
     }
 
-    logger.debug('postMessage is success');
+    logger.debug('send to slack is success');
 
-    return res.end();
+    // required to return ok for apiCall
+    return res.webClient();
   }
 
 }

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

@@ -17,8 +17,11 @@ import { RelationRepository } from '~/repositories/relation';
 import { OrderRepository } from '~/repositories/order';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
 import { AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware } from '~/middlewares/slack-to-growi/authorizer';
+import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { InstallerService } from '~/services/InstallerService';
 import { RegisterService } from '~/services/RegisterService';
+import { UnregisterService } from '~/services/UnregisterService';
+import { InvalidUrlError } from '../models/errors';
 import loggerFactory from '~/utils/logger';
 
 
@@ -43,6 +46,9 @@ export class SlackCtrl {
   @Inject()
   registerService: RegisterService;
 
+  @Inject()
+  unregisterService: UnregisterService;
+
   @Get('/install')
   async install(): Promise<string> {
     const url = await this.installerService.installer.generateInstallUrl({
@@ -54,6 +60,7 @@ export class SlackCtrl {
         'im:history',
         'mpim:history',
         'chat:write',
+        'team:read',
       ],
     });
 
@@ -83,9 +90,22 @@ export class SlackCtrl {
       return this.registerService.process(growiCommand, authorizeResult, body as {[key:string]:string});
     }
 
-    /*
-     * forward to GROWI server
-     */
+    // unregister
+    if (growiCommand.growiCommandType === 'unregister') {
+      if (growiCommand.growiCommandArgs.length === 0) {
+        return 'GROWI Urls is required.';
+      }
+      if (!growiCommand.growiCommandArgs.every(v => v.match(/^(https?:\/\/)/))) {
+        return 'GROWI Urls must be urls.';
+      }
+
+      // Send response immediately to avoid opelation_timeout error
+      // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
+      res.send();
+
+      return this.unregisterService.process(growiCommand, authorizeResult, body as {[key:string]:string});
+    }
+
     const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
@@ -100,10 +120,23 @@ export class SlackCtrl {
       });
     }
 
+    // status
+    if (growiCommand.growiCommandType === 'status') {
+      return res.json({
+        blocks: [
+          generateMarkdownSectionBlock('*Found Relations to GROWI.*'),
+          ...relations.map(relation => generateMarkdownSectionBlock(`GROWI url: ${relation.growiUri}.`)),
+        ],
+      });
+    }
+
     // Send response immediately to avoid opelation_timeout error
     // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
     res.send();
 
+    /*
+     * forward to GROWI server
+     */
     const promises = relations.map((relation: Relation) => {
       // generate API URL
       const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
@@ -132,7 +165,7 @@ export class SlackCtrl {
   }
 
   @Post('/interactions')
-  @UseBefore(AuthorizeInteractionMiddleware)
+  @UseBefore(AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
   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);
@@ -153,41 +186,51 @@ export class SlackCtrl {
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
 
     const payload = JSON.parse(body.payload);
-    const { type } = payload;
+    const callBackId = payload?.view?.callback_id;
 
     // register
-    // response_urls is an array but the element included is only one.
-    if (type === 'view_submission') {
-      await this.registerService.upsertOrderRecord(this.orderRepository, installation, payload);
-      await this.registerService.notifyServerUriToSlack(authorizeResult, payload);
+    if (callBackId === 'register') {
+      try {
+        await this.registerService.insertOrderRecord(this.orderRepository, installation, authorizeResult.botToken, payload);
+      }
+      catch (err) {
+        if (err instanceof InvalidUrlError) {
+          logger.info(err.message);
+          return;
+        }
+        logger.error(err);
+      }
+
+      await this.registerService.notifyServerUriToSlack(authorizeResult.botToken, payload);
+      return;
+    }
+
+    // unregister
+    if (callBackId === 'unregister') {
+      await this.unregisterService.unregister(this.relationRepository, installation, authorizeResult, payload);
       return;
     }
 
     /*
-     * forward to GROWI server
-     */
-    const relations = await this.relationRepository.find({ installation });
+    * forward to GROWI server
+    */
+    const relation = await this.relationRepository.findOne({ installation, growiUri: req.growiUri });
 
-    const promises = relations.map((relation: Relation) => {
+    if (relation == null) {
+      logger.error('*No relation found.*');
+      return;
+    }
+
+    try {
       // generate API URL
-      const url = new URL('/_api/v3/slack-integration/proxied/interactions', relation.growiUri);
-      return axios.post(url.toString(), {
+      const url = new URL('/_api/v3/slack-integration/proxied/interactions', req.growiUri);
+      await axios.post(url.toString(), {
         ...body,
       }, {
         headers: {
           'x-growi-ptog-tokens': relation.tokenPtoG,
         },
       });
-    });
-
-    // pickup PromiseRejectedResult only
-    const results = await Promise.allSettled(promises);
-    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
-    const botToken = installation?.data.bot?.token;
-
-    try {
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      return postEphemeralErrors(rejectedResults, body.channel_id, body.user_id, botToken!);
     }
     catch (err) {
       logger.error(err);

+ 1 - 0
packages/slackbot-proxy/src/interfaces/slack-to-growi/slack-oauth-req.ts

@@ -3,4 +3,5 @@ import { Req } from '@tsed/common';
 
 export type SlackOauthReq = Req & {
   authorizeResult: AuthorizeResult,
+  growiUri?: string,
 };

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

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

+ 40 - 0
packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts

@@ -0,0 +1,40 @@
+import {
+  IMiddleware, Middleware, Next, Req, Res,
+} from '@tsed/common';
+import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
+import { growiUriInjectorFactory } from '~/services/growi-uri-injector/GrowiUriInjectorFactory';
+import { extractGrowiUriFromView } from '~/utils/extractGrowiUriFromView';
+
+@Middleware()
+export class ExtractGrowiUriFromReq implements IMiddleware {
+
+  use(@Req() req: Req & SlackOauthReq, @Res() res: Res, @Next() next: Next): void {
+
+    const payload = JSON.parse(req.body.payload);
+
+    // extract for modal
+    if (payload.view != null) {
+      const extractedValues = extractGrowiUriFromView(payload.view);
+      req.growiUri = extractedValues.growiUri;
+      payload.view.private_metadata = extractedValues.originalData;
+    }
+    else {
+      // break when uri is found
+      for (const type of Object.keys(growiUriInjectorFactory)) {
+        const growiUriInjector = growiUriInjectorFactory[type]();
+        const extractedValues = growiUriInjector.extract(payload.actions[0]);
+
+        if (extractedValues.growiUri != null) {
+          req.growiUri = extractedValues.growiUri;
+          payload.actions[0].value = JSON.stringify(extractedValues.originalData);
+          break;
+        }
+      }
+    }
+
+    req.body.payload = JSON.stringify(payload);
+
+    return next();
+  }
+
+}

+ 9 - 0
packages/slackbot-proxy/src/models/errors.ts

@@ -0,0 +1,9 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export class InvalidUrlError extends ExtensibleCustomError {
+
+  constructor(url: string) {
+    super(`Invalid URL: ${url}`);
+  }
+
+}

+ 31 - 4
packages/slackbot-proxy/src/services/RegisterService.ts

@@ -5,6 +5,7 @@ import { AuthorizeResult } from '@slack/oauth';
 import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
 import { OrderRepository } from '~/repositories/order';
 import { Installation } from '~/entities/installation';
+import { InvalidUrlError } from '../models/errors';
 
 const isProduction = process.env.NODE_ENV === 'production';
 
@@ -19,6 +20,7 @@ export class RegisterService implements GrowiCommandProcessor {
       trigger_id: body.trigger_id,
       view: {
         type: 'modal',
+        callback_id: 'register',
         title: {
           type: 'plain_text',
           text: 'Register Credentials',
@@ -42,15 +44,41 @@ export class RegisterService implements GrowiCommandProcessor {
     });
   }
 
-  async upsertOrderRecord(
+  async insertOrderRecord(
+      orderRepository: OrderRepository, installation: Installation | undefined,
       // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-      orderRepository: OrderRepository, installation: Installation | undefined, payload: any,
+      botToken: string | undefined, payload: any,
   ): Promise<void> {
     const inputValues = payload.view.state.values;
     const growiUrl = inputValues.growiUrl.contents_input.value;
     const tokenPtoG = inputValues.tokenPtoG.contents_input.value;
     const tokenGtoP = inputValues.tokenGtoP.contents_input.value;
 
+    const { channel } = JSON.parse(payload.view.private_metadata);
+
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      const url = new URL(growiUrl);
+    }
+    catch (error) {
+      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),
+        ],
+      });
+
+      throw new InvalidUrlError(growiUrl);
+    }
+
     orderRepository.save({
       installation, growiUrl, tokenPtoG, tokenGtoP,
     });
@@ -58,10 +86,9 @@ export class RegisterService implements GrowiCommandProcessor {
 
   async notifyServerUriToSlack(
       // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-      authorizeResult:AuthorizeResult, payload: any,
+      botToken: string | undefined, payload: any,
   ): Promise<void> {
 
-    const { botToken } = authorizeResult;
     const { channel } = JSON.parse(payload.view.private_metadata);
 
     const serverUri = process.env.SERVER_URI;

+ 72 - 0
packages/slackbot-proxy/src/services/UnregisterService.ts

@@ -0,0 +1,72 @@
+import { Service } from '@tsed/di';
+import { WebClient, LogLevel } from '@slack/web-api';
+import { GrowiCommand, generateMarkdownSectionBlock } from '@growi/slack';
+import { AuthorizeResult } from '@slack/oauth';
+import { GrowiCommandProcessor } from '~/interfaces/slack-to-growi/growi-command-processor';
+import { RelationRepository } from '~/repositories/relation';
+import { Installation } from '~/entities/installation';
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+@Service()
+export class UnregisterService implements GrowiCommandProcessor {
+
+  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string}): Promise<void> {
+    const { botToken } = authorizeResult;
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+    const growiUrls = growiCommand.growiCommandArgs;
+    await client.views.open({
+      trigger_id: body.trigger_id,
+      view: {
+        type: 'modal',
+        callback_id: 'unregister',
+        title: {
+          type: 'plain_text',
+          text: 'Unregister Credentials',
+        },
+        submit: {
+          type: 'plain_text',
+          text: 'Submit',
+        },
+        close: {
+          type: 'plain_text',
+          text: 'Close',
+        },
+        private_metadata: JSON.stringify({ channel: body.channel_name, growiUrls }),
+
+        blocks: [
+          ...growiUrls.map(growiCommandArg => generateMarkdownSectionBlock(`GROWI url: ${growiCommandArg}.`)),
+        ],
+      },
+    });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  async unregister(relationRepository:RelationRepository, installation:Installation | undefined, authorizeResult: AuthorizeResult, payload: any):Promise<void> {
+    const { botToken } = authorizeResult;
+    const { channel, growiUrls } = JSON.parse(payload.view.private_metadata);
+    const client = new WebClient(botToken, { logLevel: isProduction ? LogLevel.DEBUG : LogLevel.INFO });
+
+    const deleteResult = await relationRepository.createQueryBuilder('relation')
+      .where('relation.growiUri IN (:uris)', { uris: growiUrls })
+      .andWhere('relation.installationId = :installationId', { installationId: installation?.id })
+      .delete()
+      .execute();
+
+    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: 'Delete Relations',
+      blocks: [
+        generateMarkdownSectionBlock(`Deleted ${deleteResult.affected} Relations.`),
+      ],
+    });
+
+    return;
+
+  }
+
+
+}

+ 19 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectionButtonDelegator.ts

@@ -0,0 +1,19 @@
+import { GrowiUriInjector } from './GrowiUriInjector';
+
+export class GrowiUriInjectionButtonDelegator implements GrowiUriInjector {
+
+  inject(element: {value:string}, growiUri:string): void {
+    const parsedValue = JSON.parse(element.value);
+    const originalData = JSON.stringify(parsedValue);
+    element.value = JSON.stringify({ growiUri, originalData });
+  }
+
+  extract(action: {value:string}): {growiUri?:string, originalData:any} {
+    const parsedValues = JSON.parse(action.value);
+    if (parsedValues.originalData != null) {
+      parsedValues.originalData = JSON.parse(parsedValues.originalData);
+    }
+    return parsedValues;
+  }
+
+}

+ 7 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjector.ts

@@ -0,0 +1,7 @@
+
+export interface GrowiUriInjector {
+
+  inject(body: any, growiUri:string): void;
+
+  extract(body: any):any;
+}

+ 18 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectorFactory.ts

@@ -0,0 +1,18 @@
+import { GrowiUriInjector } from './GrowiUriInjector';
+import { GrowiUriInjectionButtonDelegator } from './GrowiUriInjectionButtonDelegator';
+
+/**
+ * Instanciate GrowiUriInjector
+ */
+export const growiUriInjectorFactory = {
+  button: (): GrowiUriInjector => {
+    return new GrowiUriInjectionButtonDelegator();
+  },
+};
+
+export const findInjectorByType = (type:string): null|GrowiUriInjector => {
+  if (!Object.keys(growiUriInjectorFactory).includes(type)) {
+    return null;
+  }
+  return growiUriInjectorFactory[type]();
+};

+ 10 - 0
packages/slackbot-proxy/src/utils/extractGrowiUriFromView.ts

@@ -0,0 +1,10 @@
+export const extractGrowiUriFromView = (view:{'private_metadata': string}): {growiUri?:string, originalData:{[key:string]:any}} => {
+  const parsedValues = JSON.parse(view.private_metadata);
+  if (parsedValues.originalData != null) {
+    parsedValues.originalData = JSON.parse(parsedValues.originalData);
+  }
+  else {
+    parsedValues.originalData = view.private_metadata;
+  }
+  return parsedValues;
+};

+ 7 - 0
packages/slackbot-proxy/src/utils/injectGrowiUriToView.ts

@@ -0,0 +1,7 @@
+export const injectGrowiUriToView = (body: {view:string}, growiUri:string): void => {
+  const parsedView = JSON.parse(body.view);
+  const originalData = JSON.stringify(parsedView.private_metadata);
+
+  parsedView.private_metadata = JSON.stringify({ growiUri, originalData });
+  body.view = JSON.stringify(parsedView);
+};

+ 30 - 11
resource/certs/localhost/cert.pem

@@ -1,13 +1,32 @@
 -----BEGIN CERTIFICATE-----
-MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
-UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
-AwwJbG9jYWxob3N0MB4XDTE4MDkxMjEwMjIzNFoXDTE4MTAxMjEwMjIzNFowSDEL
-MAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMRUwEwYDVQQKDAxXRVNFRUssIElu
-Yy4xEjAQBgNVBAMMCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC
-gYEAy0FKOlCQynbo5wYyMCL5LW27Re9dB14wXPDT+zd7wDdYrCbAKKZ1r+7Sj1e7
-638lnn7n4WkhkgsQi/mTxF7W9PHYF00Dh2X0qGf9t+LocNeLVQBHMGNi7HXh8X3j
-iM7w9FffdlfBvuYxPIdDXP12x9JmRhr59Tpv1aaMcRxAY1cCAwEAATANBgkqhkiG
-9w0BAQsFAAOBgQBa/PwnEeFCQ5G4SS6IcL6QVh3KLfeVMCfYVk1o0iJVmJTvfdrq
-crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
-pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
+MIIFcTCCA1mgAwIBAgIUPCyJHVo85uXE84ITTVrxFDflhv4wDQYJKoZIhvcNAQEL
+BQAwSDELMAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMRUwEwYDVQQKDAxXRVNF
+RUssIEluYy4xEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMTA2MDcxNTIzMDFaFw0z
+MTA2MDUxNTIzMDFaMEgxCzAJBgNVBAYTAkpQMQ4wDAYDVQQIDAVUb2t5bzEVMBMG
+A1UECgwMV0VTRUVLLCBJbmMuMRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqG
+SIb3DQEBAQUAA4ICDwAwggIKAoICAQDJwKo3/75jQ7gFO6ejC/3hTptDvZpCnKpI
+TFJ5We2jZpbxULS2lQqFiVj2ezGvXRq+IHO2lUYFI/kLLOmSruM2u/Wrot3F/iZ8
+M31kZQiOoOwIZKrD1TZiEa848q0Gk3yU20d2mZmUB1ddIWl37o7MDeLaqrr825qc
+2Nf8jNm7AdEBPogXcwxI3MNphkEjICm8xOBhZh4IYlmRicweogebOFpUK4oQNdUA
+HejX5tyZboadoBRhXeZxnbQchkMv2HpV0JY31rgpJWKcuK7kTz3BZouOCUgI18Zi
+4nNzRr589IigpDYqDJWcnuz22XeimQJe+T/eEshrhyCFHIFeGi6UEH7CIcZYjigH
+Ha7nommeApjqWultgPZuNjUoQsNW8c8Y/e8rIyQg8WwevcmUbL/PDSKj/kx8In02
+7lR2NETJvRnHwpPuj9/MggfA8jsmnqVaNu9zShUjri03KqLsdkW0mVr3q4jtazsA
+xukAWXGlA+VqRBGwvpwaY2NEG1t9s/NX5FsUQpWmw75caQndxrx9oAr/59NtUeg2
+mLyI9avyZFMhMIa3lnabp0efcII3HeiouPRNJGsR2PrD418dUSwlAXftu4yEPENs
+nlKf8pMP0PCfGCQ2NWxDe0Yp1YvtIwXX9ZoFLrkxEZ4tzVpvSSxOC3YX+IY9E4Q2
+GI5ofJoO4wIDAQABo1MwUTAdBgNVHQ4EFgQUscss6LD5V0Bla8YKtb/mBiVXBR0w
+HwYDVR0jBBgwFoAUscss6LD5V0Bla8YKtb/mBiVXBR0wDwYDVR0TAQH/BAUwAwEB
+/zANBgkqhkiG9w0BAQsFAAOCAgEAiLpFrI7aGlJsPSuarroTPCvSz6vaFFg+QlIR
+MSwITiZ/kNj6c4bh+rzADdsiYU0wOC1ufq841IdHgxnMw7l33hyL1HMc/wrUVCHO
+yE/pz6w2ybxipIOfw21fgwYwYlrdvvlLtqTC/zf92nsuEmMhIjqm8oIhUfRlzX0b
+A1hzMXzvPwxUxNyTX/TSeTvAViDgXTGwstBfN/iiJr8RnX+4cGcyibu+80wa/8As
+T8ryOqdJWC1VoOTupfs5TCbQ8Cc/UDO580ytSHUb8r5RqpB1wl35ui7d6S/6TwwB
+VSKigcbq080oFk+8J0AFAXxYkdAInuDXxvmdSULttQR6H7++kNBiHFObbFOyK4gf
+YDl64bp+q4xAXnKCCmafdpfvMhDlsM91IBy+1utCQd5PeQ6rE5Tv7cz9DPec9X/v
+3tVch3rYYHUHxdPv2ZkXEPTKaMiTFEuO5daV/j5qSvZIwaDofHOEpEojyRGpopxt
+FA9A1oPA/yBFjGpOOQummAKiVooYJUdDX2gSRODNGfXyfYOFh2x1Czdl9VJuK4BW
+qeKTMKJHSVAquOJdk879fF9/Cx4C7iBIaNgFwSCkKnhYIRvYVEarX0QEHBJmiSgG
++0P2AR5mOiUhJqGlRLOWfjEQd+pYfqTi1KOkRMP4xEJdBNCp07zBWmOMXGlRwzQ2
+giuw184=
 -----END CERTIFICATE-----

+ 0 - 11
resource/certs/localhost/csr.pem

@@ -1,11 +0,0 @@
------BEGIN CERTIFICATE REQUEST-----
-MIIBhzCB8QIBADBIMQswCQYDVQQGEwJKUDEOMAwGA1UECAwFVG9reW8xFTATBgNV
-BAoMDFdFU0VFSywgSW5jLjESMBAGA1UEAwwJbG9jYWxob3N0MIGfMA0GCSqGSIb3
-DQEBAQUAA4GNADCBiQKBgQDLQUo6UJDKdujnBjIwIvktbbtF710HXjBc8NP7N3vA
-N1isJsAopnWv7tKPV7vrfyWefufhaSGSCxCL+ZPEXtb08dgXTQOHZfSoZ/234uhw
-14tVAEcwY2LsdeHxfeOIzvD0V992V8G+5jE8h0Nc/XbH0mZGGvn1Om/VpoxxHEBj
-VwIDAQABoAAwDQYJKoZIhvcNAQELBQADgYEAd49hz4IoQO55tr62OFlZr254ZPBX
-SXxCtSWawBWLFij8QLl1B8JkHARrMKdM1jBCy5UXcH05DrxGwIOXIcRW7mfrIQDH
-pGs+BQCHMHuYnssg/z2aDhafkmPaLBwh0KWPypVIStxUwLcKxA1xk5VBoP/q+Lgk
-h/mCVJ7JY40BlLA=
------END CERTIFICATE REQUEST-----

+ 52 - 15
resource/certs/localhost/key.pem

@@ -1,15 +1,52 @@
------BEGIN RSA PRIVATE KEY-----
-MIICXAIBAAKBgQDLQUo6UJDKdujnBjIwIvktbbtF710HXjBc8NP7N3vAN1isJsAo
-pnWv7tKPV7vrfyWefufhaSGSCxCL+ZPEXtb08dgXTQOHZfSoZ/234uhw14tVAEcw
-Y2LsdeHxfeOIzvD0V992V8G+5jE8h0Nc/XbH0mZGGvn1Om/VpoxxHEBjVwIDAQAB
-AoGALsf8OafJa5Aq0uGOM54ZE+eprtME6mk3YGzdnXiLtxYGBrl0iOanN7MUK4HZ
-8r30/qHe5Pa5j0+Uo2LyK8RYXOcT77CeSSQiSGlBgGj7US7ZmyTqsOwaUKVnbFcy
-Bf/bTJl4EjZREy7kdfCWVO1yY98tV4XrZ2CBDSEDyI8UiiECQQDw47YBMy2duuS6
-z5Ui0xRr8MYwRlCNOt6xKQxlKEhnnRA0vdNB6VMiSzvZ9Bt5nPWI/B3ugUupo6Hn
-FXi1tOgTAkEA2AE7RTICcGPpogOtq/5g7pPNofH524hN26qtUT2kKjUy316JYjrU
-t+N6Ck867w4juVbDcVOTB2Nbj+2+t2EILQJAfo1CyvKWHm1XSQVRNlBqRCLkG+x0
-2R16bNxB1MsK7tRG9U5ctB3ePQAFW4WxAX0CSYsaNnjaxS5gGkTfe6ak3QJAWVlh
-EAVYtu7NRKQq4btOk0F2TOfQB7xBIH1gRfuufXsV+Qmc4JIfTZV99OfDJAGAS3kV
-TTpZ1jOGO2oHeslbXQJBAM4xX8hUueQMIllpBNjlAx1xTqptOHa4elaaPZi7HcDj
-olRU0OP/wPOoEJRvHGP8+LAerx5CEYbadnukQAnNPLA=
------END RSA PRIVATE KEY-----
+-----BEGIN PRIVATE KEY-----
+MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDJwKo3/75jQ7gF
+O6ejC/3hTptDvZpCnKpITFJ5We2jZpbxULS2lQqFiVj2ezGvXRq+IHO2lUYFI/kL
+LOmSruM2u/Wrot3F/iZ8M31kZQiOoOwIZKrD1TZiEa848q0Gk3yU20d2mZmUB1dd
+IWl37o7MDeLaqrr825qc2Nf8jNm7AdEBPogXcwxI3MNphkEjICm8xOBhZh4IYlmR
+icweogebOFpUK4oQNdUAHejX5tyZboadoBRhXeZxnbQchkMv2HpV0JY31rgpJWKc
+uK7kTz3BZouOCUgI18Zi4nNzRr589IigpDYqDJWcnuz22XeimQJe+T/eEshrhyCF
+HIFeGi6UEH7CIcZYjigHHa7nommeApjqWultgPZuNjUoQsNW8c8Y/e8rIyQg8Wwe
+vcmUbL/PDSKj/kx8In027lR2NETJvRnHwpPuj9/MggfA8jsmnqVaNu9zShUjri03
+KqLsdkW0mVr3q4jtazsAxukAWXGlA+VqRBGwvpwaY2NEG1t9s/NX5FsUQpWmw75c
+aQndxrx9oAr/59NtUeg2mLyI9avyZFMhMIa3lnabp0efcII3HeiouPRNJGsR2PrD
+418dUSwlAXftu4yEPENsnlKf8pMP0PCfGCQ2NWxDe0Yp1YvtIwXX9ZoFLrkxEZ4t
+zVpvSSxOC3YX+IY9E4Q2GI5ofJoO4wIDAQABAoICAEfK6Iee2QgHu/8YuFGb3c8E
+ItobKT6U8z9g8WQ/bQHdas8acgQayzCzrFWhhw+pKPAJKkQJoG4J3ABvDfrlgvMi
+NYzLGvHuu5Es3W0RStc+Sl++OsoPIBgLxPeV255gc0+hLC/2/qCfcCYy0o4b9PKF
+6nj3kiySlNVFkK5NDNmwDsjLkLAPf4qaDGuLm7g9JL0IrfPa28sN/GN+zoUEEYLZ
+rcMP70RPc6lj+0yA2XU70Glc4oO3X16EyOfxmwz2kqng3OOn48a4h/vO+GVEEWy1
+6ek0Qk7rQaxEeuLFhNmARvIrHR8fdUnTf+1je1//5MkZFvIWav1gi2DywNJUrMff
+QzNrGTzQba4W/9T4u1Qi49unmvV1TGzKgzLGPGwdqpWB2PJepOheYKKOrIr8yf+Q
+0S6f2djIn3bUvrNOCzKacxG5WobVbUjDQXs0/ws9jB//MFTb99q6kIgdhuWUSv9w
+5PCdPPdWEOWUPcln6xWLYErM86+i1RhJEkDgF4FPbwT3qdYrF8cTjVK09znUC2Uh
+W0ODnUTJS6jp3L2cYUaNl9MsehKAbRFjhPykLg080QJvoqmUJ+S1U8+6htM6Gpsl
+JWo5gC0qnXkINpRQPKSrd7IwoiCOFOmU7I0RfErsfx+ZpaD/Nq1crbYxlGKvFHac
+BckEbh9y9wIJWD0zFpZZAoIBAQD2iYrgU+qR1m7aB/cxwmKh6bM4ZYu6LWE1nn6U
+Oz7sMGs+R6dX+ZyvPEj2HIHnFLlGZI8fKbqC4s81ah9uHZXKZ10CBAjwc7jSoWj/
+jLhnU0ifmxBRyiuyFkghWbf5DGx8/EX2xU1GjAB5aAEgCbPJJMEwZbJ1p0rzCQv1
+4E0DG4DlSsIur/WJAWjC4IPG+CCbKAnHDvWseU9Zfr4MoSwezyorzmb9+DksRrGj
+ekfwLkU+CMCKRBJEq8/GsyAecGiW7x5FxQUrCT9ocd8KSgndSag6byrEaVhXKTFO
+NDMwI+MSXn73eEQUKaXBb30DxN7wBXkULoFrwl48JxJol6CFAoIBAQDRfxJH8wST
+LzKKspYc+0ObsrYMaUiNsa3lcSO1+6W0P7lH7oNeuA6Rio03k6y81DwcYO9UQfUo
+CVM5vQNwDZ5uWRq2apgijgyhwJ1+lVsexX/SL5nzmQUnHhKMBk8CEFm6/2f99waP
+fU+n161TjGSB4DfSc2YzY5SLUUugkJHGKxNlhDK3b8XlXSDwHam+5m9MhQfbiVQg
+8bHZGh9Vupj4Tvgq8KOk2Qlk0IR+l1B11bVJUBv6TsU2Ah+RMGYsmVHUeDKhGh1Z
+q4Jof4tcyX2lckcB1zyYXuDj33n4xwUfhsolEklnWTN4kGSerU5re4D5CG9yLY9R
+OspSeMMZtYJHAoIBAQCoNYIOutLB/GeX1XWAettnE5fXc9lZBttbhS9iHeY5qnOR
+g7wW11VjbqNtYFf1fXtIHxZTsdnysaOlEyosrHzmI44e8PfUb3B8LesA6VdsEDPt
+yhPhaipUEMXYE2nlv+dJg6qMy2OFLcKpEyApZtVG+Q/i4JBZm/IUTmjdstN4ukxA
+pbhFuIKznsWGqT7gA63jjvbU3U0cB4zvR/2Nim4a+0gPqX017SW9IuLS4nyw0DEo
+OTp9XCKGvh8+uDrC6flCehXSjrGPAnlD3uXKFGgUlsv0SqMIbcP7fDyK2izOvL2S
+a+z/FgyFIUFZ8KKAbtOZOnK5JV3iUzSOoC38NPAtAoIBAQDPYIROKKf8spnLZiDm
+q2svcuZ2vrmNWbcUCr0Y5fQPy4yzQc1VM8loFMjZnRs/CLBAP0xqEaiGa0SxQQNo
+JnMQNLAWpsnWWtryO2Mr7Nj4SvTOsUQqOO063a/qYExRdLH+Q9lDMwaRf7AcK4lt
+zOmpDrTzZDxk3kwq5NyCItnKWtWmhghWQRSaiWeTiSmAyUafbohqrzRSMC6nBA7E
+pCPMmQ9cIj07b4S3ARpG3a5OFrCP0/b9/n+FiPCKnmhl+ZdhRWKIyvsGypoaqzbf
+MK1p2wejMNbQ1gq0R4HL2+acYKc1rzZuUYR78iNIV7Hq/6SZ39nCIoteE1436Ubn
+6h79AoIBAGndPh3t/xqlVRDexW6XNJvVjCDblsk62WkWuIPECCLL6prdAjjfBCEn
+5J1OY9VcdqFDz5Xit3D05VV3noZAIGZz37+l4v0pI2m7FP+WpSem0woHmKceTqUl
+MaEaqBSg/tBF6A1ueVaR1Ot5KDzvwiPyguCNjOxw1m7xxfNdkhADs6CJTThNNkwN
+RSuYYueYmD6NKJVBVLLwym+oY59fUeYMXvfDVYf0MI2UJNWYOvjm0HeYD/nRjed3
+9NXEIRfM9siZhzwXo4z+vX/FrAAz7uw3UI/r9awt1PMcWLjgj5fwvKw2GYnvjv8Q
++13WXbYYm6udwoFKrodRDCRDcDAlCoI=
+-----END PRIVATE KEY-----

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

@@ -263,7 +263,6 @@
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "detailed_explanation": "Detailed explanation",
-      "selecting_bot_type": "・Select bot type",
       "official_bot": "Official bot",
       "custom_bot": "Custom bot",
       "without_proxy": "without proxy",
@@ -302,7 +301,7 @@
     "reset": "Reset",
     "reset_all_settings": "Reset all settings",
     "delete_slackbot_settings": "Delete Slack Bot settings",
-    "slackbot_settings_notice": "Delete",
+    "slackbot_settings_notice": "The Slack workspace integration procedure will be deleted. <br> Are you sure?",
     "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",

+ 2 - 1
resource/locales/en_US/meta.json

@@ -1,4 +1,5 @@
 {
   "id": "en_US",
-  "displayName": "English"
+  "displayName": "English",
+  "aliases": ["en"]
 }

+ 8 - 3
resource/locales/en_US/translation.json

@@ -128,13 +128,11 @@
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
-  "page_list_and_search_results": "Page list / Search results",
   "scope_of_page_disclosure": "Scope of page disclosure",
   "set_point": "Set point",
   "always_displayed": "Always displayed",
   "always_hidden": "Always hidden",
   "displayed_or_hidden": "Displayed / Hidden",
-  "page_access_and_delete_rights": "Page access / Delete rights",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -438,6 +436,7 @@
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "delete_slack_integration_procedure": "Succeeded to delete the slack integration procedure",
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",
@@ -531,7 +530,6 @@
     "delete_completely": "Delete completely"
   },
   "security_setting": {
-    "Security settings": "Security settings",
     "Guest Users Access": "Guest users access",
     "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
     "Register limitation": "Register limitation",
@@ -543,15 +541,21 @@
     "for_example": " For example, if you would like to restrict registration to users within the growi.org domain, you can write ",
     "in_this_case": "; in this case, only users within the growi.org domain would be able to register, and all other users would be rejected.",
     "insert_single": "Please insert single e-mail address per line.",
+    "page_list_and_search_results": "Page list / Search results",
     "page_listing_1": "Page listing/searching<br>restricted by 'Only me'",
     "page_listing_1_desc": "Show pages that are restricted by 'Only me' option when listing/searching",
     "page_listing_2": "Page listing/searching<br>restricted by User group",
     "page_listing_2_desc": "Show pages that are restricted by User group when listing/searching",
+    "page_access_and_delete_rights": "Page access / Delete rights",
     "complete_deletion": "Restrict complete deletion of pages",
     "complete_deletion_explain": "Restricts users who can completely delete pages.",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
     "anyone": "Anyone",
+    "session": "Session",
+    "max_age": "Max age (msec)",
+    "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
+    "max_age_caution": "Restarting the server is required after you modify this value.",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
@@ -788,6 +792,7 @@
     "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
     "rebuild_description_2": "This may take a while."
   },
+  "to_cloud_settings": "Open GROWI.cloud Settings",
   "login": {
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",

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

@@ -261,7 +261,6 @@
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "detailed_explanation": "詳しい説明はこちら",
-      "selecting_bot_type": "・Botタイプを選択する",
       "official_bot": "Official bot",
       "custom_bot": "Custom bot",
       "without_proxy": "without proxy",
@@ -294,12 +293,12 @@
     },
     "delete": "削除",
     "integration_procedure": "連携手順",
-    "custom_bot_without_proxy_settings": "Custom Bot (Without-Proxy) 設定",
+    "custom_bot_without_proxy_settings": "Custom Bot without proxy 設定",
     "integration_failed":"連携に失敗しました",
     "reset": "リセット",
     "reset_all_settings": "全ての設定をリセット",
     "delete_slackbot_settings": "Slack Bot 設定を削除する",
-    "slackbot_settings_notice": "削除します",
+    "slackbot_settings_notice": "Slak ワークスペースの連携手順が削除されます。<br>よろしいですか?",
     "all_settings_of_the_bot_will_be_reset": "Botの全ての設定がリセットされます。<br>よろしいですか?",
     "accordion": {
       "create_bot": "Bot を作成する",
@@ -332,7 +331,7 @@
     },
     "custom_bot_without_proxy_integration": "Custom bot without proxy 連携",
     "integration_sentence": {
-      "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
+      "integration_is_not_complete": "連携は完了していません<br>下の連携手順を進めてください",
       "integration_successful": "連携は完了しています。",
       "integration_some_ws_is_not_complete": "連携に失敗している ワークスペースがあります。"
 

+ 2 - 1
resource/locales/ja_JP/meta.json

@@ -1,4 +1,5 @@
 {
   "id": "ja_JP",
-  "displayName": "日本語"
+  "displayName": "日本語",
+  "aliases": ["ja"]
 }

+ 8 - 2
resource/locales/ja_JP/translation.json

@@ -128,13 +128,11 @@
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "page_list": "ページリスト",
-  "page_list_and_search_results": "ページリスト・検索結果",
   "scope_of_page_disclosure": "ページの公開範囲",
   "set_point": "設定値",
   "always_displayed": "表示 (固定)",
   "always_hidden": "非表示 (固定)",
   "displayed_or_hidden": "表示 / 非表示",
-  "page_access_and_delete_rights": "ページの閲覧・削除権限",
   "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
@@ -440,6 +438,7 @@
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
+    "delete_slack_integration_procedure": "Slack 連携手順を削除しました",
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
@@ -544,15 +543,21 @@
     "for_example": "例えば、",
     "in_this_case": "と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
     "insert_single": "1行に1メールアドレス入力してください。",
+    "page_list_and_search_results": "ページリスト・検索結果",
     "page_listing_1": "ページのリスト表示と検索<br>'自分のみ'に閲覧制限しているページ",
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
     "page_listing_2_desc": "ページのリスト表示や検索結果において、特定グループにのみ閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
+    "page_access_and_delete_rights": "ページの閲覧・削除権限",
     "complete_deletion": "ページの完全削除",
     "complete_deletion_explain": "ページを完全に削除できるユーザーを制限します。",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
+    "session": "セッション",
+    "max_age": "有効期間 (ミリ秒)",
+    "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
+    "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
@@ -781,6 +786,7 @@
     "rebuild_description_1": "全てのページのインデックスを削除し、作り直します。",
     "rebuild_description_2": "この作業には数秒かかります。"
   },
+  "to_cloud_settings": "GROWI.cloud の管理画面へ",
   "login": {
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",

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

@@ -271,7 +271,6 @@
     "selecting_bot_types": {
       "slack_bot": "Slack bot",
       "detailed_explanation": "详细说明",
-      "selecting_bot_type": "・选择机器人类型",
       "official_bot": "Official bot",
       "custom_bot": "Custom bot",
       "without_proxy": "without proxy",
@@ -304,12 +303,12 @@
     },
     "delete": "取消",
     "integration_procedure": "协作程序",
-    "custom_bot_without_proxy_settings": "Custom Bot (Without-Proxy) 设置",
+    "custom_bot_without_proxy_settings": "Custom Bot without proxy 设置",
     "integration_failed":"联动失败",
     "reset":"重置",
     "reset_all_settings": "重置所有设置",
     "delete_slackbot_settings": "删除 Slack Bot 设置",
-    "slackbot_settings_notice": "删除",
+    "slackbot_settings_notice": "Slak 工作区集成过程已被删除。 <br> 你确定吗?",
     "all_settings_of_the_bot_will_be_reset": "bot的所有设置将被重置。<br>你确定吗?",
     "accordion": {
       "create_bot": "创建 Bot",

+ 3 - 2
resource/locales/zh_CN/meta.json

@@ -1,4 +1,5 @@
 {
 	"id": "zh_CN",
-	"displayName": "简体中文"
-}
+	"displayName": "简体中文",
+  "aliases": ["zh","zh-HK","zh-CN","zh-TW","zh-hk","zh-cn","zh-tw"]
+}

+ 9 - 4
resource/locales/zh_CN/translation.json

@@ -137,13 +137,11 @@
 	"Only me": "只有我",
   "Only inside the group": "仅组内",
   "page_list": "Page List",
-	"page_list_and_search_results": "页面列表/搜索结果",
 	"scope_of_page_disclosure": "页面公开范围",
 	"set_point": "设定值",
 	"always_displayed": "始终显示",
 	"always_hidden": "总是隐藏",
 	"displayed_or_hidden": "显示/隐藏",
-	"page_access_and_delete_rights": "页面访问/删除权限",
 	"Reselect the group": "重新选择组",
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
@@ -417,7 +415,8 @@
 		"update_successed": "Succeeded to update {{target}}",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
-		"remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "delete_slack_integration_procedure": "删除了 Slack 集成程序",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"remove_user_success": "Succeeded to removing {{username}} ",
@@ -519,7 +518,6 @@
     "Invalid_Number_of_Date" : "You entered invalid value"
   },
 	"security_setting": {
-		"Security settings": "安全设置",
 		"Guest Users Access": "来宾用户访问",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
 		"Register limitation": "注册限制",
@@ -531,15 +529,21 @@
 		"for_example": " 例如,如果要将注册限制为growi.org网站域,你可以写",
 		"in_this_case": ";在这种情况下,只有growi.org网站域将能够注册,所有其他用户将被拒绝。",
 		"insert_single": "请每行插入一个电子邮件地址。",
+    "page_list_and_search_results": "页面列表/搜索结果",
 		"page_listing_1": "页面列表/搜索<br>受“仅限我”限制",
 		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
 		"page_listing_2": "页面列表/搜索<br>受用户组限制",
 		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
+    "page_access_and_delete_rights": "页面访问/删除权限",
 		"complete_deletion": "限制完全删除页面",
 		"complete_deletion_explain": "限制可以完全删除页面的用户。",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
+    "session": "会议",
+    "max_age": "有效期间  (msec)",
+    "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
+    "max_age_caution": "修改该值后需要重启服务器。",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
@@ -793,6 +797,7 @@
 		"deletion_modal_header": "删除页",
 		"delete_completely": "完全删除"
 	},
+	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"login": {
 		"Sign in error": "登录错误",
 		"Registration successful": "注册成功",

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

@@ -8,10 +8,16 @@ import urljoin from 'url-join';
 
 import { pathUtils } from 'growi-commons';
 
+import AppContainer from '../../../services/AppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 const AdminNavigation = (props) => {
-  const { t } = props;
+  const { t, appContainer } = props;
   const pathname = window.location.pathname;
 
+  const growiCloudUri = appContainer.config.env.GROWI_CLOUD_URI;
+  const growiAppIdForGrowiCloud = appContainer.config.env.GROWI_APP_ID_FOR_GROWI_CLOUD;
+
   // eslint-disable-next-line react/prop-types
   const MenuLabel = ({ menu }) => {
     switch (menu) {
@@ -27,6 +33,7 @@ const AdminNavigation = (props) => {
       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 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
+      case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
     }
   };
@@ -75,6 +82,16 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
+        {growiCloudUri != null && growiAppIdForGrowiCloud != null
+          && (
+          <a
+            href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
+            className="list-group-item list-group-item-action border-0 round-corner"
+          >
+            <MenuLabel menu="cloud" />
+          </a>
+          )
+        }
       </>
     );
   };
@@ -121,10 +138,11 @@ const AdminNavigation = (props) => {
   );
 };
 
+const AdminNavigationWrapper = withUnstatedContainers(AdminNavigation, [AppContainer]);
 
 AdminNavigation.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default withTranslation()(AdminNavigation);
+export default withTranslation()(AdminNavigationWrapper);

+ 4 - 4
src/client/js/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -69,7 +69,7 @@ class ImportDataPageContents extends React.Component {
                   className="form-control"
                   type="text"
                   name="esaTeamName"
-                  value={adminImportContainer.state.esaTeamName}
+                  value={adminImportContainer.state.esaTeamName || ''}
                   onChange={adminImportContainer.handleInputValue}
                 />
               </div>
@@ -85,7 +85,7 @@ class ImportDataPageContents extends React.Component {
                   className="form-control"
                   type="password"
                   name="esaAccessToken"
-                  value={adminImportContainer.state.esaAccessToken}
+                  value={adminImportContainer.state.esaAccessToken || ''}
                   onChange={adminImportContainer.handleInputValue}
                 />
               </div>
@@ -174,7 +174,7 @@ class ImportDataPageContents extends React.Component {
                   className="form-control"
                   type="text"
                   name="qiitaTeamName"
-                  value={adminImportContainer.state.qiitaTeamName}
+                  value={adminImportContainer.state.qiitaTeamName || ''}
                   onChange={adminImportContainer.handleInputValue}
                 />
               </div>
@@ -189,7 +189,7 @@ class ImportDataPageContents extends React.Component {
                   className="form-control"
                   type="password"
                   name="qiitaAccessToken"
-                  value={adminImportContainer.state.qiitaAccessToken}
+                  value={adminImportContainer.state.qiitaAccessToken || ''}
                   onChange={adminImportContainer.handleInputValue}
                 />
               </div>

+ 32 - 9
src/client/js/components/Admin/Security/SecuritySetting.jsx

@@ -37,15 +37,14 @@ class SecuritySetting extends React.Component {
         <h2 className="alert-anchor border-bottom">
           {t('security_settings')}
         </h2>
+
         {adminGeneralSecurityContainer.retrieveError != null && (
-        <div className="alert alert-danger">
-          <p>{t('Error occurred')} : {adminGeneralSecurityContainer.retrieveError}</p>
-        </div>
-          )}
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {adminGeneralSecurityContainer.retrieveError}</p>
+          </div>
+        )}
 
-        <h4 className="mt-4">
-          { t('page_list_and_search_results') }
-        </h4>
+        <h4 className="mt-4">{ t('security_setting.page_list_and_search_results') }</h4>
         <table className="table table-bordered col-lg-9 mb-5">
           <thead>
             <tr>
@@ -98,7 +97,8 @@ class SecuritySetting extends React.Component {
             </tr>
           </tbody>
         </table>
-        <h4>{t('page_access_and_delete_rights')}</h4>
+
+        <h4>{t('security_setting.page_access_and_delete_rights')}</h4>
         <div className="row mb-4">
           <div className="col-md-3 text-md-right py-2">
             <strong>{t('security_setting.Guest Users Access')}</strong>
@@ -142,7 +142,6 @@ class SecuritySetting extends React.Component {
             )}
           </div>
         </div>
-
         <div className="row mb-4">
           <div className="col-md-3 text-md-right mb-2">
             <strong>{t('security_setting.complete_deletion')}</strong>
@@ -189,6 +188,30 @@ class SecuritySetting extends React.Component {
             </div>
           </div>
         </div>
+
+        <h4>{t('security_setting.session')}</h4>
+        <div className="form-group row">
+          <label className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.max_age')}</label>
+          <div className="col-md-6">
+            <input
+              className="form-control col-md-3"
+              type="text"
+              defaultValue={adminGeneralSecurityContainer.state.sessionMaxAge || ''}
+              onChange={(e) => {
+                adminGeneralSecurityContainer.setSessionMaxAge(e.target.value);
+              }}
+              placeholder="2592000000"
+            />
+            {/* eslint-disable-next-line react/no-danger */}
+            <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('security_setting.max_age_desc') }} />
+            <p className="card well">
+              <span className="text-warning">
+                <i className="icon-info"></i> {t('security_setting.max_age_caution')}
+              </span>
+            </p>
+          </div>
+        </div>
+
         <div className="row my-3">
           <div className="text-center text-md-left offset-md-3 col-md-5">
             <button type="button" className="btn btn-primary" disabled={adminGeneralSecurityContainer.retrieveError != null} onClick={this.putSecuritySetting}>

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

@@ -60,8 +60,7 @@ const BotTypeCard = (props) => {
             </span>
           )}
 
-          {/* TODO: add an appropriate links by GW-5614 */}
-          <i className={`fa fa-external-link btn-link ${props.isActive ? 'grw-botcard-title-active' : ''}`} aria-hidden="true"></i>
+          <i className={props.isActive ? 'grw-botcard-title-active' : ''} aria-hidden="true"></i>
         </h3>
       </div>
       <div className="card-body p-4">

+ 97 - 0
src/client/js/components/Admin/SlackIntegration/Bridge.jsx

@@ -0,0 +1,97 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { UncontrolledTooltip } from 'reactstrap';
+
+const ProxyCircle = () => (
+  <div className="grw-bridge-proxy-circle">
+    <div className="circle position-absolute bg-primary border-light rounded-circle">
+      <p className="circle-inner text-light font-weight-bold d-none d-lg-inline">Proxy Server</p>
+      <p className="circle-inner grw-proxy-server-name d-block d-lg-none">Proxy Server</p>
+    </div>
+  </div>
+);
+
+const BridgeCore = (props) => {
+  const {
+    description, iconClass, hrClass, withProxy,
+  } = props;
+
+  return (
+    <>
+      <div id="grw-bridge-container" className={`grw-bridge-container ${withProxy ? 'with-proxy' : ''}`}>
+        <p className="label">
+          <i className={iconClass} />
+          <small
+            className="ml-2 d-none d-lg-inline"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: description }}
+          />
+        </p>
+        <div className="hr-container">
+          { withProxy && <ProxyCircle /> }
+          <hr className={`align-self-center ${hrClass}`} />
+        </div>
+      </div>
+      <UncontrolledTooltip placement="top" fade={false} target="grw-bridge-container" className="d-block d-lg-none">
+        <small
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: description }}
+        />
+      </UncontrolledTooltip>
+    </>
+  );
+};
+
+BridgeCore.propTypes = {
+  description: PropTypes.string.isRequired,
+  iconClass: PropTypes.string.isRequired,
+  hrClass: PropTypes.string.isRequired,
+  withProxy: PropTypes.bool,
+};
+
+
+const Bridge = (props) => {
+  const { t } = useTranslation();
+  const { errorCount, totalCount, withProxy } = props;
+
+  let description;
+  let iconClass;
+  let hrClass;
+
+  // empty or all failed
+  if (totalCount === 0 || errorCount === totalCount) {
+    description = t('admin:slack_integration.integration_sentence.integration_is_not_complete');
+    iconClass = 'icon-info text-danger';
+    hrClass = 'border-danger admin-border-failed';
+  }
+  // all green
+  else if (errorCount === 0) {
+    description = t('admin:slack_integration.integration_sentence.integration_successful');
+    iconClass = 'fa fa-check text-success';
+    hrClass = 'border-success admin-border-success';
+  }
+  // some of them failed
+  else {
+    description = t('admin:slack_integration.integration_sentence.integration_some_ws_is_not_complete');
+    iconClass = 'fa fa-check text-warning';
+    hrClass = 'border-warning admin-border-failed';
+  }
+
+  return (
+    <BridgeCore
+      description={description}
+      iconClass={iconClass}
+      hrClass={hrClass}
+      withProxy={withProxy}
+    />
+  );
+};
+
+Bridge.propTypes = {
+  errorCount: PropTypes.number.isRequired,
+  totalCount: PropTypes.number.isRequired,
+  withProxy: PropTypes.bool,
+};
+
+export default Bridge;

+ 0 - 22
src/client/js/components/Admin/SlackIntegration/ConductionStatusHr.jsx

@@ -1,22 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-
-const ConductionStatusHr = (props) => {
-  const { errorCount, totalCount } = props;
-
-  return (
-    <>
-      {errorCount === 0 && totalCount !== 0 && <hr className="align-self-center border-success admin-border-success"></hr>}
-      {errorCount === totalCount && <hr className="align-self-center border-danger admin-border-danger"></hr>}
-      {errorCount >= 1 && errorCount < totalCount && <hr className="align-self-center border-warning admin-border-danger"></hr>}
-    </>
-  );
-};
-
-ConductionStatusHr.propTypes = {
-  errorCount: PropTypes.number.isRequired,
-  totalCount: PropTypes.number.isRequired,
-};
-
-export default ConductionStatusHr;

+ 57 - 0
src/client/js/components/Admin/SlackIntegration/CustomBotWithProxyConnectionStatus.jsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Bridge from './Bridge';
+
+const CustomBotWithProxyConnectionStatus = (props) => {
+  const { siteName, connectionStatuses } = props;
+
+  const connectionStatusValues = Object.values(connectionStatuses); // type: ConnectionStatus[]
+
+  const totalCount = connectionStatusValues.length;
+  const errorCount = connectionStatusValues.filter(connectionStatus => connectionStatusValues.error != null).length;
+
+  return (
+    <div className="d-flex justify-content-center my-5 bot-integration">
+
+      <div className="card rounded shadow border-0 w-50 admin-bot-card">
+        <h5 className="card-title font-weight-bold mt-3 ml-3">Slack</h5>
+        <div className="card-body px-5">
+          {connectionStatusValues.map((connectionStatus, i) => {
+            const workspaceName = connectionStatus.workspaceName || `Settings #${i}`;
+
+            return (
+              <div key={workspaceName} className="card slack-work-space-name-card">
+                <div className="m-2 text-center">
+                  <h5 className="font-weight-bold">{workspaceName}</h5>
+                  <img width={20} height={20} src="/images/slack-integration/growi-bot-kun-icon.png" />
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      <div className="text-center w-25 mt-3">
+        <Bridge errorCount={errorCount} totalCount={totalCount} withProxy />
+      </div>
+
+      <div className="card rounded-lg shadow border-0 w-50 admin-bot-card">
+        <h5 className="card-title font-weight-bold mt-3 ml-3">GROWI App
+          <a className="icon-fw fa fa-repeat float-md-right ml-2 mr-sm-3 fa-sm-lg"></a>
+        </h5>
+        <div className="card-body text-center">
+          <div className="mx-md-3 my-4 my-lg-5 p-2 border bg-primary text-light">
+            {siteName}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+CustomBotWithProxyConnectionStatus.propTypes = {
+  siteName: PropTypes.string.isRequired,
+  connectionStatuses: PropTypes.object.isRequired,
+};
+
+export default CustomBotWithProxyConnectionStatus;

+ 0 - 55
src/client/js/components/Admin/SlackIntegration/CustomBotWithProxyIntegrationCard.jsx

@@ -1,55 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import IntegrationStatus from './IntegrationStatus';
-
-const CustomBotWithProxyIntegrationCard = (props) => {
-  const { workspaceNames } = 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">
-        <h5 className="card-title font-weight-bold mt-3 ml-4">Slack</h5>
-        <div className="card-body px-5">
-          {props.slackWorkSpaces.map((slackWorkSpaceName) => {
-            return (
-              <div key={slackWorkSpaceName.name} className={slackWorkSpaceName.active ? 'card slack-work-space-name-card' : ''}>
-                <div className="m-2 text-center">
-                  <h5 className="font-weight-bold">{slackWorkSpaceName.name}</h5>
-                  <img width={20} height={20} src="/images/slack-integration/growi-bot-kun-icon.png" />
-                </div>
-              </div>
-            );
-          })}
-        </div>
-      </div>
-
-      <div className="text-center w-25 mt-3">
-        <IntegrationStatus workspaceNames={workspaceNames} />
-      </div>
-
-      <div className="card rounded-lg shadow border-0 w-50 admin-bot-card">
-        <div className="row">
-          <h5 className="card-title font-weight-bold mt-3 ml-4 col">GROWI App</h5>
-          <div className="pull-right mt-3 mr-3">
-            <a className="icon-fw fa fa-repeat fa-2x"></a>
-          </div>
-        </div>
-        <div className="card-body text-center">
-          <div className="mt-5 border p-2 mx-3 bg-primary text-light">
-            {props.siteName}
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-};
-
-CustomBotWithProxyIntegrationCard.propTypes = {
-  siteName: PropTypes.string.isRequired,
-  slackWorkSpaces: PropTypes.array,
-  isSlackScopeSet: PropTypes.bool,
-  workspaceNames: PropTypes.array.isRequired,
-};
-
-export default CustomBotWithProxyIntegrationCard;

+ 50 - 65
src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -5,7 +5,7 @@ import loggerFactory from '@alias/logger';
 import AppContainer from '../../../services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
-import CustomBotWithProxyIntegrationCard from './CustomBotWithProxyIntegrationCard';
+import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 
@@ -13,30 +13,18 @@ const logger = loggerFactory('growi:SlackBotSettings');
 
 const CustomBotWithProxySettings = (props) => {
   const {
-    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses,
+    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   const [newProxyServerUri, setNewProxyServerUri] = useState();
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [siteName, setSiteName] = useState('');
   const { t } = useTranslation();
 
-  const workspaceNameObjects = Object.values(connectionStatuses);
-  const workspaceNames = workspaceNameObjects.map((w) => {
-    return w.workspaceName;
-  });
-
+  // componentDidUpdate
   useEffect(() => {
-    if (proxyServerUri != null) {
-      setNewProxyServerUri(proxyServerUri);
-    }
+    setNewProxyServerUri(proxyServerUri);
   }, [proxyServerUri]);
 
-  const fetchSlackIntegrationData = () => {
-    if (props.fetchSlackIntegrationData != null) {
-      props.fetchSlackIntegrationData();
-    }
-  };
-
   const addSlackAppIntegrationHandler = async() => {
     if (onClickAddSlackWorkspaceBtn != null) {
       onClickAddSlackWorkspaceBtn();
@@ -45,20 +33,11 @@ const CustomBotWithProxySettings = (props) => {
 
   const deleteSlackAppIntegrationHandler = async() => {
     try {
-      // 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.error(err);
-    }
-  };
-
-  const generateAccessTokens = async() => {
-    try {
-      //  TODO: imprement regenerating tokens by GW-6068
+      if (props.onDeleteSlackAppIntegration != null) {
+        props.onDeleteSlackAppIntegration();
+      }
+      toastSuccess(t('toaster.delete_slack_integration_procedure'));
     }
     catch (err) {
       toastError(err);
@@ -86,45 +65,50 @@ const CustomBotWithProxySettings = (props) => {
 
   return (
     <>
-      <h2 className="admin-setting-header mb-2">{t('admin:slack_integration.custom_bot_with_proxy_integration')}</h2>
-
-      {/* TODO delete tmp props */}
-      <CustomBotWithProxyIntegrationCard
-        siteName={siteName}
-        slackWorkSpaces={
-          [
-            { name: 'wsName1', active: true },
-            { name: 'wsName2', active: false },
-          ]
-        }
-        workspaceNames={workspaceNames}
-      />
-
-      <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) }}
+      <h2 className="admin-setting-header mb-2">{t('admin:slack_integration.custom_bot_with_proxy_integration')}
+        {/* TODO: add an appropriate links by GW-5614 */}
+        <i className="fa fa-external-link btn-link ml-2" aria-hidden="true"></i>
+      </h2>
+
+      {slackAppIntegrations.length !== 0 && (
+        <>
+          <CustomBotWithProxyConnectionStatus
+            siteName={siteName}
+            connectionStatuses={connectionStatuses}
           />
-        </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>
+          <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>
+        </>
+      )}
+
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
-          const { tokenGtoP, tokenPtoG } = slackAppIntegration;
+          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
             <React.Fragment key={slackAppIntegration._id}>
-              <div className="d-flex justify-content-end">
+              <div className="my-3 d-flex align-items-center justify-content-between">
+                <h2 id={_id || `settings-accordions-${i}`}>
+                  {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
+                </h2>
                 <button
-                  className="my-3 btn btn-outline-danger"
+                  className="btn btn-outline-danger"
                   type="button"
                   onClick={() => setIntegrationIdToDelete(slackAppIntegration._id)}
                 >
@@ -132,14 +116,13 @@ const CustomBotWithProxySettings = (props) => {
                   {t('admin:slack_integration.delete')}
                 </button>
               </div>
-              {proxyServerUri != null && workspaceNames[i] == null
-              && (<>Settings #{i + 1} <span className="text-danger">{t('admin:slack_integration.integration_failed')}</span></>)}
               <WithProxyAccordions
                 botType="customBotWithProxy"
                 slackAppIntegrationId={slackAppIntegration._id}
-                onClickGenerateTokenBtn={generateAccessTokens}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
+                onUpdateTokens={onUpdateTokens}
+                onSubmitForm={onSubmitForm}
               />
             </React.Fragment>
           );
@@ -175,8 +158,10 @@ CustomBotWithProxySettings.propTypes = {
   slackAppIntegrations: PropTypes.array,
   proxyServerUri: PropTypes.string,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
-  fetchSlackIntegrationData: PropTypes.func,
+  onDeleteSlackAppIntegration: PropTypes.func,
+  onSubmitForm: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,
+  onUpdateTokens: PropTypes.func,
 };
 
 export default CustomBotWithProxySettingsWrapper;

+ 57 - 0
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxyConnectionStatus.jsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Bridge from './Bridge';
+
+const CustomBotWithoutProxyConnectionStatus = (props) => {
+  const { siteName, connectionStatuses } = props;
+
+  const connectionStatusValues = Object.values(connectionStatuses); // type: ConnectionStatus[]
+
+  const totalCount = connectionStatusValues.length;
+  const errorCount = connectionStatusValues.filter(connectionStatus => connectionStatus.error != null).length;
+
+  let workspaceName;
+  if (totalCount > 0) {
+    workspaceName = connectionStatusValues[0].workspaceName;
+  }
+
+  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 px-4 text-center mx-md-5">
+          {totalCount > 0 ? (
+            <div className="card slack-work-space-name-card">
+              <div className="m-2 text-center">
+                <h5 className="font-weight-bold">
+                  {workspaceName != null ? workspaceName : 'Settings #1'}
+                </h5>
+                <img width={20} height={20} src="/images/slack-integration/growi-bot-kun-icon.png" />
+              </div>
+            </div>
+         ) : ''}
+        </div>
+      </div>
+
+      <div className="text-center w-25">
+        <Bridge errorCount={errorCount} totalCount={totalCount} />
+      </div>
+
+      <div className="card rounded-lg shadow border-0 w-50 admin-bot-card mb-0">
+        <h5 className="card-title font-weight-bold mt-3 ml-4">GROWI App</h5>
+        <div className="card-body p-4 text-center">
+          <div className="border p-2 bg-primary text-light mx-md-5">
+            {siteName}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+CustomBotWithoutProxyConnectionStatus.propTypes = {
+  siteName: PropTypes.string.isRequired,
+  connectionStatuses: PropTypes.object.isRequired,
+};
+
+export default CustomBotWithoutProxyConnectionStatus;

+ 0 - 103
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxyIntegrationCard.jsx

@@ -1,103 +0,0 @@
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-import PropTypes from 'prop-types';
-
-import { UncontrolledTooltip } from 'reactstrap';
-
-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.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>
-                <img width={20} height={20} src="/images/slack-integration/growi-bot-kun-icon.png" />
-              </div>
-            </div>
-          )}
-        </div>
-      </div>
-
-      <div className="text-center w-25">
-        {props.isIntegrationSuccess ? <IntegrationSuccess /> : <IntegrationFailed />}
-      </div>
-
-      <div className="card rounded-lg shadow border-0 w-50 admin-bot-card mb-0">
-        <h5 className="card-title font-weight-bold mt-3 ml-4">GROWI App</h5>
-        <div className="card-body p-4 mb-5 text-center">
-          <div className="border p-2 bg-primary text-light mx-5">
-            {props.siteName}
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-};
-
-CustomBotWithoutProxyIntegrationCard.propTypes = {
-  siteName: PropTypes.string.isRequired,
-  slackWSNameInWithoutProxy: PropTypes.string,
-  isIntegrationSuccess: PropTypes.bool,
-};
-
-export default CustomBotWithoutProxyIntegrationCard;

+ 46 - 26
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySecretTokenSection.jsx

@@ -1,26 +1,44 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import AppContainer from '../../../services/AppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
 
 const CustomBotWithoutProxySecretTokenSection = (props) => {
+  const {
+    appContainer, slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, onUpdatedSecretToken,
+  } = props;
   const { t } = useTranslation();
 
-  const onChangeSigningSecretHandler = (signingSecretInput) => {
-    if (props.onChangeSigningSecretHandler != null) {
-      props.onChangeSigningSecretHandler(signingSecretInput);
-    }
-  };
-
-  const onChangeBotTokenHandler = (botTokenInput) => {
-    if (props.onChangeBotTokenHandler != null) {
-      props.onChangeBotTokenHandler(botTokenInput);
+  const [inputSigningSecret, setInputSigningSecret] = useState(slackSigningSecret || '');
+  const [inputBotToken, setInputBotToken] = useState(slackBotToken || '');
+
+  // update states when props are updated
+  useEffect(() => {
+    setInputSigningSecret(slackSigningSecret || '');
+  }, [slackSigningSecret]);
+  useEffect(() => {
+    setInputBotToken(slackBotToken || '');
+  }, [slackBotToken]);
+
+  const updatedSecretToken = async() => {
+    try {
+      await appContainer.apiv3.put('/slack-integration-settings/without-proxy/update-settings', {
+        slackSigningSecret: inputSigningSecret,
+        slackBotToken: inputBotToken,
+      });
+
+      if (onUpdatedSecretToken != null) {
+        onUpdatedSecretToken(inputSigningSecret, inputBotToken);
+      }
+
+      toastSuccess(t('toaster.update_successed', { target: t('admin:slack_integration.custom_bot_without_proxy_settings') }));
     }
-  };
-
-  const updateSecretTokenHandler = () => {
-    if (props.updateSecretTokenHandler != null) {
-      props.updateSecretTokenHandler();
+    catch (err) {
+      toastError(err);
     }
   };
 
@@ -35,8 +53,8 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           <input
             className="form-control"
             type="text"
-            value={props.slackSigningSecret || ''}
-            onChange={e => onChangeSigningSecretHandler(e.target.value)}
+            value={inputSigningSecret}
+            onChange={e => setInputSigningSecret(e.target.value)}
           />
         </div>
 
@@ -45,7 +63,7 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           <input
             className="form-control"
             type="text"
-            value={props.slackSigningSecretEnv || ''}
+            defaultValue={slackSigningSecretEnv}
             readOnly
           />
           <p className="form-text text-muted">
@@ -64,8 +82,8 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           <input
             className="form-control"
             type="text"
-            value={props.slackBotToken || ''}
-            onChange={e => onChangeBotTokenHandler(e.target.value)}
+            value={inputBotToken}
+            onChange={e => setInputBotToken(e.target.value)}
           />
         </div>
 
@@ -74,7 +92,7 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
           <input
             className="form-control"
             type="text"
-            value={props.slackBotTokenEnv || ''}
+            defaultValue={slackBotTokenEnv}
             readOnly
           />
           <p className="form-text text-muted">
@@ -85,20 +103,22 @@ const CustomBotWithoutProxySecretTokenSection = (props) => {
 
       </div>
 
-      <AdminUpdateButtonRow onClick={updateSecretTokenHandler} disabled={false} />
+      <AdminUpdateButtonRow onClick={updatedSecretToken} disabled={false} />
 
     </div>
   );
 };
 
+const CustomBotWithoutProxySecretTokenSectionWrapper = withUnstatedContainers(CustomBotWithoutProxySecretTokenSection, [AppContainer]);
+
 CustomBotWithoutProxySecretTokenSection.propTypes = {
-  updateSecretTokenHandler: PropTypes.func,
-  onChangeSigningSecretHandler: PropTypes.func,
-  onChangeBotTokenHandler: PropTypes.func,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  onUpdatedSecretToken: PropTypes.func,
   slackSigningSecret: PropTypes.string,
   slackSigningSecretEnv: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
 };
 
-export default CustomBotWithoutProxySecretTokenSection;
+export default CustomBotWithoutProxySecretTokenSectionWrapper;

+ 28 - 70
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -2,111 +2,69 @@ import React, { useState, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 import AppContainer from '../../../services/AppContainer';
-import AdminAppContainer from '../../../services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './CustomBotWithoutProxySettingsAccordion';
-import CustomBotWithoutProxyIntegrationCard from './CustomBotWithoutProxyIntegrationCard';
-import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
+import CustomBotWithoutProxyConnectionStatus from './CustomBotWithoutProxyConnectionStatus';
 
 const CustomBotWithoutProxySettings = (props) => {
-  const { appContainer, onResetSettings } = props;
+  const { appContainer, connectionStatuses } = props;
   const { t } = useTranslation();
-
   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) {
-      return;
-    }
-    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);
   }, [appContainer]);
 
+  const workspaceName = connectionStatuses[props.slackBotToken]?.workspaceName;
+
   return (
     <>
-      <h2 className="admin-setting-header">{t('admin:slack_integration.custom_bot_without_proxy_integration')}</h2>
+      <h2 className="admin-setting-header">{t('admin:slack_integration.custom_bot_without_proxy_integration')}
+        {/* TODO: add an appropriate links by GW-5614 */}
+        <i className="fa fa-external-link btn-link ml-2" aria-hidden="true"></i>
+      </h2>
 
-      <CustomBotWithoutProxyIntegrationCard
+      <CustomBotWithoutProxyConnectionStatus
         siteName={siteName}
-        slackWSNameInWithoutProxy={props.slackWSNameInWithoutProxy}
-        isIntegrationSuccess={isIntegrationSuccess}
+        connectionStatuses={connectionStatuses}
       />
 
       <h2 className="admin-setting-header">{t('admin:slack_integration.integration_procedure')}</h2>
 
-      {(props.slackSigningSecret || props.slackBotToken) && (
-      <button
-        className="mx-3 pull-right btn text-danger border-danger"
-        type="button"
-        onClick={() => setIsDeleteConfirmModalShown(true)}
-      >{t('admin:slack_integration.reset')}
-      </button>
-      )}
-
-      <div className="my-5 mx-3">
+      <div className="px-3">
+        <div className="my-3 d-flex align-items-center justify-content-between">
+          <h2 id={props.slackBotToken || 'settings-accordions'}>
+            {(workspaceName != null) ? `${workspaceName} Work Space` : 'Settings'}
+          </h2>
+        </div>
         <CustomBotWithoutProxySettingsAccordion
-          {...props}
           activeStep={botInstallationStep.CREATE_BOT}
-          connectionMessage={connectionMessage}
-          connectionErrorCode={connectionErrorCode}
-          isIntegrationSuccess={isIntegrationSuccess}
-          testChannel={testChannel}
-          onTestFormSubmitted={testConnection}
-          inputTestChannelHandler={inputTestChannelHandler}
-
+          slackBotTokenEnv={props.slackBotTokenEnv}
+          slackBotToken={props.slackBotToken}
+          slackSigningSecretEnv={props.slackSigningSecretEnv}
+          slackSigningSecret={props.slackSigningSecret}
+          onTestConnectionInvoked={props.onTestConnectionInvoked}
+          onUpdatedSecretToken={props.onUpdatedSecretToken}
         />
       </div>
-      <DeleteSlackBotSettingsModal
-        isResetAll={false}
-        isOpen={isDeleteConfirmModalShown}
-        onClose={() => setIsDeleteConfirmModalShown(false)}
-        onClickDeleteButton={resetSettings}
-      />
     </>
   );
 };
 
-const CustomBotWithoutProxySettingsWrapper = withUnstatedContainers(CustomBotWithoutProxySettings, [AppContainer, AdminAppContainer]);
+const CustomBotWithoutProxySettingsWrapper = withUnstatedContainers(CustomBotWithoutProxySettings, [AppContainer]);
 
 CustomBotWithoutProxySettings.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+
   slackSigningSecret: PropTypes.string,
   slackSigningSecretEnv: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
-  isRgisterSlackCredentials: PropTypes.bool,
-  isIntegrationSuccess: PropTypes.bool,
-  slackWSNameInWithoutProxy: PropTypes.string,
-  onResetSettings: PropTypes.func,
+
+  onUpdatedSecretToken: PropTypes.func.isRequired,
+  onTestConnectionInvoked: PropTypes.func.isRequired,
+  connectionStatuses: PropTypes.object.isRequired,
 };
 
 export default CustomBotWithoutProxySettingsWrapper;

+ 51 - 77
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -1,12 +1,13 @@
 import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
+import Accordion from '../Common/Accordion';
 import AppContainer from '../../../services/AppContainer';
-import AdminAppContainer from '../../../services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import Accordion from '../Common/Accordion';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import MessageBasedOnConnection from './MessageBasedOnConnection';
 import CustomBotWithoutProxySecretTokenSection from './CustomBotWithoutProxySecretTokenSection';
+import { addLogs } from './slak-integration-util';
+
 
 export const botInstallationStep = {
   CREATE_BOT: 'create-bot',
@@ -15,67 +16,54 @@ export const botInstallationStep = {
   CONNECTION_TEST: 'connection-test',
 };
 
-const CustomBotWithoutProxySettingsAccordion = ({
-  appContainer, activeStep,
-  connectionMessage, connectionErrorCode, testChannel, slackSigningSecret, slackSigningSecretEnv, slackBotToken, slackBotTokenEnv,
-  isRegisterSlackCredentials, isIntegrationSuccess,
-  fetchSlackIntegrationData, inputTestChannelHandler, onTestFormSubmitted, onSetSlackSigningSecret, onSetSlackBotToken,
-}) => {
+
+const CustomBotWithoutProxySettingsAccordion = (props) => {
+  const {
+    appContainer, activeStep, onTestConnectionInvoked,
+    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv,
+  } = props;
+  const successMessage = 'Successfully sent to Slack workspace.';
+
   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 currentBotType = 'customBotWithoutProxy';
-
+  const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] = useState(false);
+  const [testChannel, setTestChannel] = useState('');
+  const [logsValue, setLogsValue] = useState('');
 
-  const updateSecretTokenHandler = async() => {
+  const testConnection = async() => {
     try {
-      await appContainer.apiv3.put('/slack-integration-settings/without-proxy/update-settings', {
-        slackSigningSecret,
-        slackBotToken,
-        currentBotType,
-      });
-
-      if (fetchSlackIntegrationData == null) {
-        return null;
+      await appContainer.apiv3.post('/slack-integration-settings/without-proxy/test', { channel: testChannel });
+      setIsLatestConnectionSuccess(true);
+      if (onTestConnectionInvoked != null) {
+        onTestConnectionInvoked();
+        const newLogs = addLogs(logsValue, successMessage, null);
+        setLogsValue(newLogs);
       }
-      fetchSlackIntegrationData();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:slack_integration.custom_bot_without_proxy_settings') }));
     }
     catch (err) {
-      toastError(err);
+      setIsLatestConnectionSuccess(false);
+      const newLogs = addLogs(logsValue, err[0].message, err[0].code);
+      setLogsValue(newLogs);
     }
   };
 
-  const onChangeSigningSecretHandler = (signingSecretInput) => {
-    if (onSetSlackSigningSecret != null) {
-      onSetSlackSigningSecret(signingSecretInput);
-    }
-  };
-
-  const onChangeBotTokenHandler = (botTokenInput) => {
-    if (onSetSlackBotToken != null) {
-      onSetSlackBotToken(botTokenInput);
-    }
+  const inputTestChannelHandler = (channel) => {
+    setTestChannel(channel);
   };
 
   const submitForm = (e) => {
     e.preventDefault();
-
-    if (onTestFormSubmitted == null) {
-      return;
-    }
-    onTestFormSubmitted();
+    testConnection();
   };
 
 
-  let value = '';
-  if (connectionMessage === 'Send the message to slack work space.' || connectionMessage === '') {
-    value = connectionMessage;
-  }
-  else {
-    value = [connectionErrorCode, connectionMessage];
-  }
+  const slackSigningSecretCombined = slackSigningSecret || slackSigningSecretEnv;
+  const slackBotTokenCombined = slackBotToken || slackBotTokenEnv;
+  const isEnterdSecretAndToken = (
+    (slackSigningSecretCombined != null && slackSigningSecretCombined.length > 0)
+    && (slackBotTokenCombined != null && slackBotTokenCombined.length > 0)
+  );
 
   return (
     <div className="card border-0 rounded-lg shadow overflow-hidden">
@@ -120,12 +108,10 @@ const CustomBotWithoutProxySettingsAccordion = ({
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.REGISTER_SLACK_CONFIGURATION)}
         // eslint-disable-next-line max-len
-        title={<><span className="mr-2">③</span>{t('admin:slack_integration.accordion.register_secret_and_token')}{isRegisterSlackCredentials && <i className="ml-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="mr-2">③</span>{t('admin:slack_integration.accordion.register_secret_and_token')}{isEnterdSecretAndToken && <i className="ml-3 text-success fa fa-check"></i>}</>}
       >
         <CustomBotWithoutProxySecretTokenSection
-          updateSecretTokenHandler={updateSecretTokenHandler}
-          onChangeSigningSecretHandler={onChangeSigningSecretHandler}
-          onChangeBotTokenHandler={onChangeBotTokenHandler}
+          onUpdatedSecretToken={props.onUpdatedSecretToken}
           slackSigningSecret={slackSigningSecret}
           slackSigningSecretEnv={slackSigningSecretEnv}
           slackBotToken={slackBotToken}
@@ -135,7 +121,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')}{isIntegrationSuccess && <i className="ml-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <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">
@@ -155,22 +141,14 @@ const CustomBotWithoutProxySettingsAccordion = ({
             <button
               type="submit"
               className="btn btn-info mx-3 font-weight-bold"
-              disabled={testChannel.trim() === ''}
+              disabled={testChannel.trim().length === 0}
             >Test
             </button>
           </form>
         </div>
-        {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>
-              }
-            </>
-          )
-        }
+
+        <MessageBasedOnConnection isLatestConnectionSuccess={isLatestConnectionSuccess} logsValue={logsValue} />
+
         <form>
           <div className="row my-3 justify-content-center">
             <div className="form-group slack-connection-log col-md-4">
@@ -178,7 +156,7 @@ const CustomBotWithoutProxySettingsAccordion = ({
               <textarea
                 className="form-control card border-info slack-connection-log-body rounded-lg"
                 rows="5"
-                value={value}
+                value={logsValue}
                 readOnly
               />
             </div>
@@ -189,26 +167,22 @@ const CustomBotWithoutProxySettingsAccordion = ({
   );
 };
 
-const CustomBotWithoutProxySettingsAccordionWrapper = withUnstatedContainers(CustomBotWithoutProxySettingsAccordion, [AppContainer, AdminAppContainer]);
+
+const CustomBotWithoutProxySettingsAccordionWrapper = withUnstatedContainers(CustomBotWithoutProxySettingsAccordion, [AppContainer]);
+
 
 CustomBotWithoutProxySettingsAccordion.propTypes = {
+  activeStep: PropTypes.oneOf(Object.values(botInstallationStep)).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  onUpdatedSecretToken: PropTypes.func,
+  onTestConnectionInvoked: PropTypes.func,
+
   slackSigningSecret: PropTypes.string,
   slackSigningSecretEnv: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
-  testChannel: PropTypes.string,
-  isRegisterSlackCredentials: PropTypes.bool,
-  isIntegrationSuccess: PropTypes.bool,
-  fetchSlackIntegrationData: PropTypes.func,
-  inputTestChannelHandler: PropTypes.func,
-  onTestFormSubmitted: PropTypes.func,
-  onSetSlackSigningSecret: PropTypes.func,
-  onSetSlackBotToken: PropTypes.func,
-  connectionMessage: PropTypes.string,
-  connectionErrorCode: PropTypes.string,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-  activeStep: PropTypes.oneOf(Object.values(botInstallationStep)).isRequired,
+
 };
 
 export default CustomBotWithoutProxySettingsAccordionWrapper;

+ 8 - 9
src/client/js/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.jsx

@@ -51,17 +51,16 @@ const DeleteSlackBotSettingsModal = React.memo((props) => {
       </ModalHeader>
       <ModalBody>
         {props.isResetAll && (
-          <>
-            <span
-              // eslint-disable-next-line react/no-danger
-              dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.all_settings_of_the_bot_will_be_reset') }}
-            />
-          </>
+          <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')}
-          </>
+          <span
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.slackbot_settings_notice') }}
+          />
         )}
       </ModalBody>
       <ModalFooter>

+ 0 - 176
src/client/js/components/Admin/SlackIntegration/IntegrationStatus.jsx

@@ -1,176 +0,0 @@
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import ConductionStatusHr from './ConductionStatusHr';
-
-const IntegrationSuccess = (props) => {
-  const { t } = useTranslation();
-  const { errorCount, totalCount } = props;
-
-  return (
-    <>
-      <div className="d-none d-lg-block">
-        <p className="text-success small">
-          <i className="fa fa-check mr-1" />
-          {t('admin:slack_integration.integration_sentence.integration_successful')}
-        </p>
-        <div className="pt-2">
-          <div className="position-relative mt-5">
-            <div className="circle position-absolute bg-primary border-light">
-              <p className="circle-inner text-light font-weight-bold">Proxy Server</p>
-            </div>
-          </div>
-        </div>
-        <ConductionStatusHr errorCount={errorCount} totalCount={totalCount} />
-      </div>
-      <div id="integration-line-for-tooltip" className="d-block d-lg-none mt-5">
-        <i className="fa fa-check mr-1 text-success" />
-        <ConductionStatusHr errorCount={errorCount} totalCount={totalCount} />
-      </div>
-      <UncontrolledTooltip placement="top" fade={false} target="integration-line-for-tooltip">
-        <small>
-          {t('admin:slack_integration.integration_sentence.integration_successful')}
-        </small>
-      </UncontrolledTooltip>
-    </>
-  );
-};
-
-IntegrationSuccess.propTypes = {
-  errorCount: PropTypes.number.isRequired,
-  totalCount: PropTypes.number.isRequired,
-};
-
-
-const IntegrationFailed = (props) => {
-  const { t } = useTranslation();
-  const { errorCount, totalCount } = props;
-
-  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>
-        <div className="pt-2">
-          <div className="position-relative mt-5">
-            <div className="circle position-absolute bg-primary border-light">
-              <p className="circle-inner text-light font-weight-bold">Proxy Server</p>
-            </div>
-          </div>
-        </div>
-        <ConductionStatusHr errorCount={errorCount} totalCount={totalCount} />
-      </div>
-      <div id="integration-line-for-tooltip" className="d-block d-lg-none mt-5">
-        <i className="icon-info text-danger" />
-        <ConductionStatusHr errorCount={errorCount} totalCount={totalCount} />
-      </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>
-    </>
-  );
-};
-
-IntegrationFailed.propTypes = {
-  errorCount: PropTypes.number.isRequired,
-  totalCount: PropTypes.number.isRequired,
-
-};
-
-
-const SomeWorkSpacesNotIntegration = (props) => {
-  const { t } = useTranslation();
-  const { errorCount, totalCount } = props;
-
-  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_some_ws_is_not_complete') }}
-          />
-        </p>
-        <div className="pt-2">
-          <div className="position-relative mt-5">
-            <div className="circle position-absolute bg-primary border-light">
-              <p className="circle-inner text-light font-weight-bold">Proxy Server</p>
-            </div>
-          </div>
-        </div>
-        <ConductionStatusHr errorCount={errorCount} totalCount={totalCount} />
-      </div>
-      <div id="integration-line-for-tooltip" className="d-block d-lg-none mt-5">
-        <i className="icon-info text-danger" />
-        <ConductionStatusHr errorCount={errorCount} totalCount={totalCount} />
-      </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_some_ws_is_not_complete') }}
-        />
-      </UncontrolledTooltip>
-    </>
-  );
-};
-
-SomeWorkSpacesNotIntegration.propTypes = {
-  errorCount: PropTypes.number.isRequired,
-  totalCount: PropTypes.number.isRequired,
-
-};
-
-
-const IntegrationStatus = (props) => {
-  const { workspaceNames } = props;
-
-  let errorCount = 0;
-  workspaceNames.forEach((w) => {
-    if (w == null) {
-      errorCount++;
-    }
-  });
-
-  return (
-    <>
-      {errorCount === 0 && workspaceNames.length !== 0 && (
-      <IntegrationSuccess
-        errorCount={errorCount}
-        totalCount={workspaceNames.length}
-      />
-      )}
-      {errorCount === workspaceNames.length && (
-      <IntegrationFailed
-        errorCount={errorCount}
-        totalCount={workspaceNames.length}
-      />
-      )}
-
-      {errorCount >= 1 && errorCount < workspaceNames.length && (
-      <SomeWorkSpacesNotIntegration
-        errorCount={errorCount}
-        totalCount={workspaceNames.length}
-      />
-      )}
-    </>
-  );
-};
-
-IntegrationStatus.propTypes = {
-  workspaceNames: PropTypes.array.isRequired,
-};
-
-export default IntegrationStatus;

+ 26 - 0
src/client/js/components/Admin/SlackIntegration/MessageBasedOnConnection.jsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
+
+const MessageBasedOnConnection = (props) => {
+  const { isLatestConnectionSuccess, logsValue } = props;
+  const { t } = useTranslation();
+
+  if (isLatestConnectionSuccess) {
+    return <p className="text-info text-center my-4">{t('admin:slack_integration.accordion.send_message_to_slack_work_space')}</p>;
+  }
+
+  if (logsValue === '') {
+    return <p></p>;
+  }
+
+  return <p className="text-danger text-center my-4">{t('admin:slack_integration.accordion.error_check_logs_below')}</p>;
+};
+
+MessageBasedOnConnection.propTypes = {
+  isLatestConnectionSuccess: PropTypes.bool.isRequired,
+  logsValue: PropTypes.string.isRequired,
+};
+
+export default MessageBasedOnConnection;

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

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 import AppContainer from '../../../services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
-import CustomBotWithProxyIntegrationCard from './CustomBotWithProxyIntegrationCard';
+import CustomBotWithProxyConnectionStatus from './CustomBotWithProxyConnectionStatus';
 import WithProxyAccordions from './WithProxyAccordions';
 import DeleteSlackBotSettingsModal from './DeleteSlackBotSettingsModal';
 
@@ -13,23 +13,17 @@ const logger = loggerFactory('growi:SlackBotSettings');
 
 const OfficialBotSettings = (props) => {
   const {
-    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses,
+    appContainer, slackAppIntegrations, proxyServerUri, onClickAddSlackWorkspaceBtn, connectionStatuses, onUpdateTokens, onSubmitForm,
   } = props;
   const [siteName, setSiteName] = useState('');
-  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
+  const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const { t } = useTranslation();
 
   const [newProxyServerUri, setNewProxyServerUri] = useState();
 
-  const workspaceNameObjects = Object.values(connectionStatuses);
-  const workspaceNames = workspaceNameObjects.map((w) => {
-    return w.workspaceName;
-  });
-
+  // componentDidUpdate
   useEffect(() => {
-    if (proxyServerUri != null) {
-      setNewProxyServerUri(proxyServerUri);
-    }
+    setNewProxyServerUri(proxyServerUri);
   }, [proxyServerUri, slackAppIntegrations]);
 
   const addSlackAppIntegrationHandler = async() => {
@@ -38,35 +32,17 @@ const OfficialBotSettings = (props) => {
     }
   };
 
-  const discardTokenHandler = async(tokenGtoP, tokenPtoG) => {
-    try {
-      // GW-6068 set new value after this
-      await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { tokenGtoP, tokenPtoG });
-    }
-    catch (err) {
-      toastError(err);
-      logger(err);
-    }
-  };
-
-  const generateTokenHandler = async() => {
-    try {
-      //  TODO: imprement regenerating tokens by GW-6068
-    }
-    catch (err) {
-      toastError(err);
-      logger(err);
-    }
-  };
-
   const deleteSlackAppIntegrationHandler = async() => {
+    await appContainer.apiv3.delete('/slack-integration-settings/slack-app-integration', { integrationIdToDelete });
     try {
-      // TODO GW-5923 delete SlackAppIntegration
-      // await appContainer.apiv3.put('/slack-integration-settings/custom-bot-with-proxy');
-      toastSuccess('success');
+      if (props.onDeleteSlackAppIntegration != null) {
+        props.onDeleteSlackAppIntegration();
+      }
+      toastSuccess(t('toaster.delete_slack_integration_procedure'));
     }
     catch (err) {
       toastError(err);
+      logger.error(err);
     }
   };
 
@@ -90,47 +66,52 @@ const OfficialBotSettings = (props) => {
 
   return (
     <>
-      <h2 className="admin-setting-header">{t('admin:slack_integration.official_bot_integration')}</h2>
-      {/* TODO delete tmp props */}
-      <CustomBotWithProxyIntegrationCard
-        siteName={siteName}
-        slackWorkSpaces={
-          [
-            { name: 'wsName1', active: true },
-            { name: 'wsName2', active: false },
-          ]
-        }
-        workspaceNames={workspaceNames}
-      />
-
-      <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) }}
+      <h2 className="admin-setting-header">{t('admin:slack_integration.official_bot_integration')}
+        {/* TODO: add an appropriate links by GW-5614 */}
+        <i className="fa fa-external-link btn-link ml-2" aria-hidden="true"></i>
+      </h2>
+
+      {slackAppIntegrations.length !== 0 && (
+        <>
+          <CustomBotWithProxyConnectionStatus
+            siteName={siteName}
+            connectionStatuses={connectionStatuses}
           />
-        </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>
+          <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>
+        </>
+      )}
 
       <div className="mx-3">
-        {slackAppIntegrations.map((slackAppIntegration) => {
-          const { tokenGtoP, tokenPtoG } = slackAppIntegration;
+        {slackAppIntegrations.map((slackAppIntegration, i) => {
+          const { tokenGtoP, tokenPtoG, _id } = slackAppIntegration;
+          const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
-            <React.Fragment key={slackAppIntegration.id}>
-              <div className="d-flex justify-content-end">
+            <React.Fragment key={slackAppIntegration._id}>
+              <div className="my-3 d-flex align-items-center justify-content-between">
+                <h2 id={_id || `settings-accordions-${i}`}>
+                  {(workspaceName != null) ? `${workspaceName} Work Space` : `Settings #${i}`}
+                </h2>
                 <button
-                  className="my-3 btn btn-outline-danger"
+                  className="btn btn-outline-danger"
                   type="button"
-                  onClick={() => setIsDeleteConfirmModalShown(true)}
+                  onClick={() => setIntegrationIdToDelete(slackAppIntegration._id)}
                 >
                   <i className="icon-trash mr-1" />
                   {t('admin:slack_integration.delete')}
@@ -138,10 +119,11 @@ const OfficialBotSettings = (props) => {
               </div>
               <WithProxyAccordions
                 botType="officialBot"
-                discardTokenHandler={() => discardTokenHandler(tokenGtoP, tokenPtoG)}
-                generateTokenHandler={generateTokenHandler}
+                slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
+                onUpdateTokens={onUpdateTokens}
+                onSubmitForm={onSubmitForm}
               />
             </React.Fragment>
           );
@@ -158,8 +140,8 @@ const OfficialBotSettings = (props) => {
       </div>
       <DeleteSlackBotSettingsModal
         isResetAll={false}
-        isOpen={isDeleteConfirmModalShown}
-        onClose={() => setIsDeleteConfirmModalShown(false)}
+        isOpen={integrationIdToDelete != null}
+        onClose={() => setIntegrationIdToDelete(null)}
         onClickDeleteButton={deleteSlackAppIntegrationHandler}
       />
     </>
@@ -179,8 +161,10 @@ OfficialBotSettings.propTypes = {
   slackAppIntegrations: PropTypes.array,
   proxyServerUri: PropTypes.string,
   onClickAddSlackWorkspaceBtn: PropTypes.func,
+  onDeleteSlackAppIntegration: PropTypes.func,
   connectionStatuses: PropTypes.object.isRequired,
-
+  onUpdateTokens: PropTypes.func,
+  onSubmitForm: PropTypes.func,
 };
 
 export default OfficialBotSettingsWrapper;

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

@@ -23,12 +23,10 @@ const SlackIntegration = (props) => {
   const [slackBotToken, setSlackBotToken] = useState(null);
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
-  const [isRegisterSlackCredentials, setIsRegisterSlackCredentials] = useState(false);
-  const [slackWSNameInWithoutProxy, setSlackWSNameInWithoutProxy] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
-  const [connectionStatuses, setConnectionStatuses] = useState(null);
+  const [connectionStatuses, setConnectionStatuses] = useState({});
 
 
   const fetchSlackIntegrationData = useCallback(async() => {
@@ -38,15 +36,6 @@ const SlackIntegration = (props) => {
         slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri,
       } = data.settings;
 
-      if (data.connectionStatuses == null) {
-        data.connectionStatuses = {};
-      }
-      // if (data.connectionStatuses != null) {
-      // TODO fix
-      // const { workspaceName } = data.connectionStatuses[slackBotToken];
-      // setSlackWSNameInWithoutProxy(workspaceName);
-      // setConnectionStatuses(data.connectionStatuses);
-      // }
       setConnectionStatuses(data.connectionStatuses);
       setCurrentBotType(data.currentBotType);
       setSlackSigningSecret(slackSigningSecret);
@@ -72,17 +61,6 @@ const SlackIntegration = (props) => {
     }
   };
 
-  const resetWithOutSettings = async() => {
-    try {
-      await appContainer.apiv3.put('/slack-integration-settings/bot-type', { currentBotType: 'customBotWithoutProxy' });
-      fetchSlackIntegrationData();
-      toastSuccess(t('admin:slack_integration.bot_reset_successful'));
-    }
-    catch (error) {
-      toastError(error);
-    }
-  };
-
   const createSlackIntegrationData = async() => {
     try {
       await appContainer.apiv3.put('/slack-integration-settings/slack-app-integrations');
@@ -94,21 +72,22 @@ const SlackIntegration = (props) => {
     }
   };
 
+  const changeSecretAndToken = (secret, token) => {
+    setSlackSigningSecret(secret);
+    setSlackBotToken(token);
+  };
+
   useEffect(() => {
     fetchSlackIntegrationData();
   }, [fetchSlackIntegrationData]);
 
   const changeCurrentBotSettings = async(botType) => {
     try {
-      const res = await appContainer.apiv3.put('/slack-integration-settings/bot-type', {
+      await appContainer.apiv3.put('/slack-integration-settings/bot-type', {
         currentBotType: botType,
       });
-      setCurrentBotType(res.data.slackBotTypeParam.slackBotType);
       setSelectedBotType(null);
-      setIsRegisterSlackCredentials(false);
-      setSlackSigningSecret(null);
-      setSlackBotToken(null);
-      setSlackWSNameInWithoutProxy(null);
+      fetchSlackIntegrationData();
     }
     catch (err) {
       toastError(err);
@@ -143,23 +122,23 @@ const SlackIntegration = (props) => {
           slackAppIntegrations={slackAppIntegrations}
           proxyServerUri={proxyServerUri}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
+          onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
+          onUpdateTokens={fetchSlackIntegrationData}
+          onSubmitForm={fetchSlackIntegrationData}
         />
       );
       break;
     case 'customBotWithoutProxy':
       settingsComponent = (
         <CustomBotWithoutProxySettings
-          isRegisterSlackCredentials={isRegisterSlackCredentials}
           slackBotTokenEnv={slackBotTokenEnv}
           slackBotToken={slackBotToken}
           slackSigningSecretEnv={slackSigningSecretEnv}
           slackSigningSecret={slackSigningSecret}
-          slackWSNameInWithoutProxy={slackWSNameInWithoutProxy}
-          onSetSlackSigningSecret={setSlackSigningSecret}
-          onSetSlackBotToken={setSlackBotToken}
-          onResetSettings={resetWithOutSettings}
-          fetchSlackIntegrationData={fetchSlackIntegrationData}
+          onTestConnectionInvoked={fetchSlackIntegrationData}
+          onUpdatedSecretToken={changeSecretAndToken}
+          connectionStatuses={connectionStatuses}
         />
       );
       break;
@@ -169,8 +148,10 @@ const SlackIntegration = (props) => {
           slackAppIntegrations={slackAppIntegrations}
           proxyServerUri={proxyServerUri}
           onClickAddSlackWorkspaceBtn={createSlackIntegrationData}
-          fetchSlackIntegrationData={fetchSlackIntegrationData}
+          onDeleteSlackAppIntegration={fetchSlackIntegrationData}
           connectionStatuses={connectionStatuses}
+          onUpdateTokens={fetchSlackIntegrationData}
+          onSubmitForm={fetchSlackIntegrationData}
         />
       );
       break;
@@ -201,19 +182,13 @@ const SlackIntegration = (props) => {
           </a>
         </h2>
 
-        <div className="d-flex justify-content">
-          <div className="mr-auto">
-            {t('admin:slack_integration.selecting_bot_types.selecting_bot_type')}
-          </div>
-
-          {(currentBotType != null) && (
-            <button
-              className="mx-3 btn btn-outline-danger flex-end"
-              type="button"
-              onClick={() => setIsDeleteConfirmModalShown(true)}
-            >{t('admin:slack_integration.reset_all_settings')}
-            </button>
-          )}
+        <div className="d-flex justify-content-end">
+          <button
+            className="btn btn-outline-danger"
+            type="button"
+            onClick={() => setIsDeleteConfirmModalShown(true)}
+          >{t('admin:slack_integration.reset_all_settings')}
+          </button>
         </div>
 
         <div className="row my-5 flex-wrap-reverse justify-content-center">

+ 74 - 28
src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -6,9 +6,11 @@ import { CopyToClipboard } from 'react-copy-to-clipboard';
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess } from '../../../util/apiNotification';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
 import AppContainer from '../../../services/AppContainer';
 import Accordion from '../Common/Accordion';
+import { addLogs } from './slak-integration-util';
+import MessageBasedOnConnection from './MessageBasedOnConnection';
 
 const logger = loggerFactory('growi:SlackIntegration:WithProxyAccordionsWrapper');
 
@@ -76,10 +78,19 @@ const RegisteringProxyUrlProcess = () => {
 
 const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers((props) => {
   const { t } = useTranslation();
+  const { appContainer, slackAppIntegrationId } = props;
 
-  const onClickGenerateTokenBtn = () => {
-    if (props.onClickGenerateTokenBtn != null) {
-      props.onClickGenerateTokenBtn();
+  const regenerateTokensHandler = async() => {
+    try {
+      await appContainer.apiv3.put('/slack-integration-settings/regenerate-tokens', { slackAppIntegrationId });
+      if (props.onUpdateTokens != null) {
+        props.onUpdateTokens();
+      }
+      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
     }
   };
 
@@ -117,12 +128,12 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
         <button
           type="button"
           className="btn btn-primary mx-auto"
-          onClick={onClickGenerateTokenBtn}
+          onClick={regenerateTokensHandler}
         >
           { 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>
+      <p className="font-weight-bold mt-5">2. {t('admin:slack_integration.accordion.register_for_growi_official_bot_proxy_service')}</p>
       <div className="d-flex flex-column align-items-center">
         <ol className="p-0">
           <li>
@@ -167,26 +178,33 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
   );
 }, [AppContainer]);
 
-const TestProcess = ({ apiv3Post, slackAppIntegrationId }) => {
+const TestProcess = ({
+  apiv3Post, slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,
+}) => {
+
   const { t } = useTranslation();
   const [testChannel, setTestChannel] = useState('');
-  const [connectionError, setConnectionError] = useState(null);
-
-  let value = '';
-  if (connectionError != null) {
-    value = [connectionError.code, connectionError.message];
-  }
+  const [logsValue, setLogsValue] = useState('');
+  const successMessage = 'Successfully sent to Slack workspace.';
 
   const submitForm = async(e) => {
     e.preventDefault();
-    setConnectionError(null);
-
     try {
       await apiv3Post('/slack-integration-settings/with-proxy/relation-test', { slackAppIntegrationId, channel: testChannel });
+      const newLogs = addLogs(logsValue, successMessage, null);
+      setLogsValue(newLogs);
+
+      if (onSubmitForm != null) {
+        onSubmitForm();
+      }
     }
     catch (error) {
-      setConnectionError(error[0]);
+      const newLogs = addLogs(logsValue, error[0].message, error[0].code);
+      setLogsValue(newLogs);
       logger.error(error);
+      if (onSubmitFormFailed != null) {
+        onSubmitFormFailed();
+      }
     }
   };
 
@@ -216,10 +234,7 @@ const TestProcess = ({ apiv3Post, slackAppIntegrationId }) => {
           </button>
         </form>
       </div>
-      {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>
-      }
+      <MessageBasedOnConnection isLatestConnectionSuccess={isLatestConnectionSuccess} logsValue={logsValue} />
       <form>
         <div className="row my-3 justify-content-center">
           <div className="form-group slack-connection-log col-md-4">
@@ -227,7 +242,7 @@ const TestProcess = ({ apiv3Post, slackAppIntegrationId }) => {
             <textarea
               className="form-control card border-info slack-connection-log-body rounded-lg"
               rows="5"
-              value={value}
+              value={logsValue}
               readOnly
             />
           </div>
@@ -240,6 +255,19 @@ const TestProcess = ({ apiv3Post, slackAppIntegrationId }) => {
 
 const WithProxyAccordions = (props) => {
   const { t } = useTranslation();
+  const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] = useState(false);
+
+  const submitForm = () => {
+    setIsLatestConnectionSuccess(true);
+    if (props.onSubmitForm != null) {
+      props.onSubmitForm();
+    }
+  };
+
+  const submitFormFailed = () => {
+    setIsLatestConnectionSuccess(false);
+  };
+
 
   const officialBotIntegrationProcedure = {
     '①': {
@@ -250,9 +278,10 @@ const WithProxyAccordions = (props) => {
       title: 'register_for_growi_official_bot_proxy_service',
       content: <GeneratingTokensAndRegisteringProxyServiceProcess
         growiUrl={props.appContainer.config.crowi.url}
-        onClickGenerateTokenBtn={props.onClickGenerateTokenBtn}
+        slackAppIntegrationId={props.slackAppIntegrationId}
         tokenPtoG={props.tokenPtoG}
         tokenGtoP={props.tokenGtoP}
+        onUpdateTokens={props.onUpdateTokens}
       />,
     },
     '③': {
@@ -261,7 +290,13 @@ const WithProxyAccordions = (props) => {
     },
     '④': {
       title: 'test_connection',
-      content: <TestProcess />,
+      content: <TestProcess
+        apiv3Post={props.appContainer.apiv3.post}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        onSubmitForm={submitForm}
+        onSubmitFormFailed={submitFormFailed}
+        isLatestConnectionSuccess={isLatestConnectionSuccess}
+      />,
     },
   };
 
@@ -278,9 +313,10 @@ const WithProxyAccordions = (props) => {
       title: 'register_for_growi_official_bot_proxy_service',
       content: <GeneratingTokensAndRegisteringProxyServiceProcess
         growiUrl={props.appContainer.config.crowi.url}
-        onClickGenerateTokenBtn={props.onClickGenerateTokenBtn}
+        slackAppIntegrationId={props.slackAppIntegrationId}
         tokenPtoG={props.tokenPtoG}
         tokenGtoP={props.tokenGtoP}
+        onUpdateTokens={props.onUpdateTokens}
       />,
     },
     '④': {
@@ -289,7 +325,13 @@ const WithProxyAccordions = (props) => {
     },
     '⑤': {
       title: 'test_connection',
-      content: <TestProcess apiv3Post={props.appContainer.apiv3.post} slackAppIntegrationId={props.slackAppIntegrationId} />,
+      content: <TestProcess
+        apiv3Post={props.appContainer.apiv3.post}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        onSubmitForm={submitForm}
+        onSubmitFormFailed={submitFormFailed}
+        isLatestConnectionSuccess={isLatestConnectionSuccess}
+      />,
     },
   };
 
@@ -302,7 +344,13 @@ const WithProxyAccordions = (props) => {
       {Object.entries(integrationProcedureMapping).map(([key, value]) => {
         return (
           <Accordion
-            title={<><span className="mr-2">{key}</span>{t(`admin:slack_integration.accordion.${value.title}`)}</>}
+            title={(
+              <>
+                <span className="mr-2">{key}</span>
+                {t(`admin:slack_integration.accordion.${value.title}`)}
+                {value.title === 'test_connection' && isLatestConnectionSuccess && <i className="ml-3 text-success fa fa-check"></i>}
+              </>
+)}
             key={key}
           >
             {value.content}
@@ -321,9 +369,7 @@ const WithProxyAccordionsWrapper = withUnstatedContainers(WithProxyAccordions, [
 WithProxyAccordions.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   botType: PropTypes.string.isRequired,
-
   slackAppIntegrationId: PropTypes.string.isRequired,
-  onClickGenerateTokenBtn: PropTypes.func,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
 };

+ 20 - 0
src/client/js/components/Admin/SlackIntegration/slak-integration-util.js

@@ -0,0 +1,20 @@
+const addLogs = (log, newLogMessage, newLogCode = undefined) => {
+
+  let newLog;
+  if (newLogCode == null) {
+    newLog = `${new Date()} - ${newLogMessage}\n\n`;
+  }
+  else {
+    newLog = `${new Date()} - ${newLogCode}, ${newLogMessage}\n\n`;
+  }
+
+  if (log == null) {
+    return newLog;
+  }
+  return `${newLog}${log}`;
+};
+
+export {
+  // eslint-disable-next-line import/prefer-default-export
+  addLogs,
+};

+ 5 - 1
src/client/js/components/InstallerForm.jsx

@@ -22,7 +22,11 @@ class InstallerForm extends React.Component {
   }
 
   componentWillMount() {
-    this.changeLanguage(localeMetadatas[0]);
+    const meta = localeMetadatas.find(v => v.id === i18next.language);
+    if (meta == null) {
+      return this.setState({ selectedLang: localeMetadatas[0] });
+    }
+    this.setState({ selectedLang: meta });
   }
 
   // checkUserName(event) {

+ 4 - 4
src/client/js/components/PageCreateModal.jsx

@@ -8,7 +8,7 @@ import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 import urljoin from 'url-join';
 
-import { userPageRoot } from '@commons/util/path-utils';
+import { userPageRoot, isCreatablePage } from '@commons/util/path-utils';
 import { pathUtils } from 'growi-commons';
 
 import AppContainer from '../services/AppContainer';
@@ -24,12 +24,12 @@ const PageCreateModal = (props) => {
   const isReachable = config.isSearchServiceReachable;
   const pathname = decodeURI(window.location.pathname);
   const userPageRootPath = userPageRoot(appContainer.currentUser);
-  const parentPath = pathUtils.addTrailingSlash(pathname);
+  const pageNameInputInitialValue = isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
 
   const [todayInput1, setTodayInput1] = useState(t('Memo'));
   const [todayInput2, setTodayInput2] = useState('');
-  const [pageNameInput, setPageNameInput] = useState(parentPath);
+  const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
 
   function transitBySubmitEvent(e, transitHandler) {
@@ -162,7 +162,7 @@ const PageCreateModal = (props) => {
               {isReachable
                 ? (
                   <PagePathAutoComplete
-                    initializedPath={pathname}
+                    initializedPath={pageNameInput}
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onInputChange={ppacInputChangeHandler}

+ 10 - 0
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -18,6 +18,7 @@ export default class AdminGeneralSecurityContainer extends Container {
 
     this.state = {
       retrieveError: null,
+      sessionMaxAge: null,
       wikiMode: '',
       // set dummy value tile for using suspense
       currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
@@ -51,6 +52,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
+      sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       isLocalEnabled: generalAuth.isLocalEnabled,
       isLdapEnabled: generalAuth.isLdapEnabled,
@@ -79,6 +81,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     return this.state.wikiMode === 'public' || this.state.wikiMode === 'private';
   }
 
+  /**
+   * setter for sessionMaxAge
+   */
+  setSessionMaxAge(sessionMaxAge) {
+    this.setState({ sessionMaxAge });
+  }
+
   /**
    * Change restrictGuestMode
    */
@@ -115,6 +124,7 @@ export default class AdminGeneralSecurityContainer extends Container {
   async updateGeneralSecuritySetting() {
 
     let requestParams = {
+      sessionMaxAge: this.state.sessionMaxAge,
       restrictGuestMode: this.state.currentRestrictGuestMode,
       pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,

+ 24 - 4
src/client/js/util/i18n.js

@@ -1,13 +1,22 @@
 import i18n from 'i18next';
 import LanguageDetector from 'i18next-browser-languagedetector';
 import { initReactI18next } from 'react-i18next';
-
 import locales from '@root/resource/locales';
 
+const aliasesMapping = {};
+Object.values(locales).forEach((locale) => {
+  if (locale.meta.aliases == null) {
+    return;
+  }
+  locale.meta.aliases.forEach((alias) => {
+    aliasesMapping[alias] = locale.meta.id;
+  });
+});
+
 // extract metadata list from 'resource/locales/${locale}/meta.json'
 export const localeMetadatas = Object.values(locales).map(locale => locale.meta);
 
-export const i18nFactory = (userLocaleId = 'en_US') => {
+export const i18nFactory = (userLocaleId) => {
   // setup LanguageDetector
   const langDetector = new LanguageDetector();
   langDetector.addDetector({
@@ -15,7 +24,18 @@ export const i18nFactory = (userLocaleId = 'en_US') => {
     lookup(options) {
       return userLocaleId;
     },
-    cacheUserlanguage(lng, options) {
+  });
+  // Wrapper to convert lang after detected from browser
+  langDetector.addDetector({
+    name: 'navigatorWrapperToConvertByAlias',
+    lookup(options) {
+      const results = langDetector.detectors.navigator.lookup(options);
+      const lang = results[0];
+      if (lang == null) {
+        return;
+      }
+
+      return aliasesMapping[lang] || lang;
     },
   });
 
@@ -29,7 +49,7 @@ export const i18nFactory = (userLocaleId = 'en_US') => {
 
       fallbackLng: 'en_US',
       detection: {
-        order: ['userSettingDetector', 'querystring', 'localStorage'],
+        order: ['userSettingDetector', 'navigatorWrapperToConvertByAlias', 'querystring'],
       },
 
       interpolation: {

+ 53 - 20
src/client/styles/scss/_admin.scss

@@ -90,9 +90,6 @@ $slack-work-space-name-card-border: #efc1f6;
   Slack Integration
   */
   .selecting-bot-type {
-    .btn-link {
-      font-size: 1rem;
-    }
     .supplementary-bot-name {
       font-size: 1rem;
     }
@@ -118,35 +115,71 @@ $slack-work-space-name-card-border: #efc1f6;
     }
   }
 
+  .admin-slack-integration {
+    .admin-setting-header {
+      .btn-link {
+        font-size: 1rem;
+      }
+    }
+  }
+
   .bot-integration {
     .admin-bot-card {
       border-radius: 8px !important;
     }
-    .admin-border-danger {
+    .admin-border-failed {
       border-style: dashed;
       border-width: 2px;
     }
     .admin-border-success {
       border-width: 3px;
     }
-    .circle {
-      top: 50%;
-      left: 50%;
-      width: 100px;
-      height: 100px;
-      border: 13px solid;
-      border-radius: 50%;
-      -webkit-transform: translate(-50%, -50%);
-      -ms-transform: translate(-50%, -50%);
-      transform: translate(-50%, -50%);
+
+    .grw-bridge-proxy-circle {
+      .circle {
+        left: 50%;
+        width: 100px;
+        height: 100px;
+        border: 13px solid;
+        transform: translate(-50%, -50%);
+        @include media-breakpoint-down(md) {
+          width: 50px;
+          height: 50px;
+          border: 8px solid;
+        }
+      }
+
+      .circle-inner {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+      }
+      .circle-inner.grw-proxy-server-name {
+        margin-top: 55px;
+      }
     }
-    .circle-inner {
-      position: absolute;
-      top: 50%;
-      left: 50%;
-      -webkit-transform: translate(-50%, -50%);
-      transform: translate(-50%, -50%);
+
+    // switch layout for Bridge component
+    .grw-bridge-container {
+      .label {
+        @extend .mt-5;
+      }
+
+      // with ProxyCircle
+      &.with-proxy {
+        .label {
+          @extend .mt-0;
+        }
+        .hr-container {
+          margin-top: 40px;
+          @include media-breakpoint-up(lg) {
+            margin-top: 65px;
+          }
+        }
+      }
     }
+
     .slack-work-space-name-card {
       background-color: $slack-work-space-name-card-background;
       border: 1px solid $slack-work-space-name-card-border;

+ 20 - 1
src/lib/service/xss/index.js

@@ -1,6 +1,9 @@
 const xss = require('xss');
 const commonmarkSpec = require('./commonmark-spec');
 
+
+const REPETITIONS_NUM = 50;
+
 class Xss {
 
   constructor(xssOption) {
@@ -36,7 +39,23 @@ class Xss {
   }
 
   process(document) {
-    return this.myxss.process(document);
+    let count = 0;
+    let currDoc = document;
+    let prevDoc = document;
+
+    do {
+      count += 1;
+      // stop running infinitely
+      if (count > REPETITIONS_NUM) {
+        return '--filtered--';
+      }
+
+      prevDoc = currDoc;
+      currDoc = this.myxss.process(currDoc);
+    }
+    while (currDoc !== prevDoc);
+
+    return currDoc;
   }
 
 }

+ 33 - 0
src/lib/util/path-utils.js

@@ -37,6 +37,38 @@ const isUserPage = (path) => {
   return false;
 };
 
+const forbiddenPages = [
+  /\^|\$|\*|\+|#|%/,
+  /^\/-\/.*/,
+  /^\/_r\/.*/,
+  /^\/_apix?(\/.*)?/,
+  /^\/?https?:\/\/.+$/, // avoid miss in renaming
+  /\/{2,}/, // avoid miss in renaming
+  /\s+\/\s+/, // avoid miss in renaming
+  /.+\/edit$/,
+  /.+\.md$/,
+  /^(\.\.)$/, // see: https://github.com/weseek/growi/issues/3582
+  /(\/\.\.)\/?/, // see: https://github.com/weseek/growi/issues/3582
+  /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
+];
+
+/**
+ * Whether path can be created
+ * @param {string} path
+ * @returns {boolean}
+ */
+const isCreatablePage = (path) => {
+  let isCreatable = true;
+  forbiddenPages.forEach((page) => {
+    const pageNameReg = new RegExp(page);
+    if (path.match(pageNameReg)) {
+      isCreatable = false;
+    }
+  });
+
+  return isCreatable;
+};
+
 /**
  * return user path
  * @param {Object} user
@@ -83,6 +115,7 @@ module.exports = {
   isTopPage,
   isTrashPage,
   isUserPage,
+  isCreatablePage,
   userPageRoot,
   convertToNewAffiliationPath,
   encodeSpaces,

+ 2 - 0
src/server/crowi/express-init.js

@@ -11,6 +11,7 @@ module.exports = function(crowi, app) {
   const passport = require('passport');
   const expressSession = require('express-session');
   const flash = require('connect-flash');
+  const mongoSanitize = require('express-mongo-sanitize');
   const swig = require('swig-templates');
   const webpackAssets = require('express-webpack-assets');
   const i18next = require('i18next');
@@ -116,6 +117,7 @@ module.exports = function(crowi, app) {
   app.use(passport.session());
 
   app.use(flash());
+  app.use(mongoSanitize());
 
   app.use(promster);
   app.use(registerSafeRedirect);

+ 5 - 5
src/server/crowi/index.js

@@ -81,8 +81,8 @@ function Crowi(rootdir) {
 Crowi.prototype.init = async function() {
   await this.setupDatabase();
   await this.setupModels();
-  await this.setupSessionConfig();
   await this.setupConfigManager();
+  await this.setupSessionConfig();
 
   // setup messaging services
   await this.setupS2sMessagingService();
@@ -220,7 +220,7 @@ Crowi.prototype.setupDatabase = function() {
 
 Crowi.prototype.setupSessionConfig = async function() {
   const session = require('express-session');
-  const sessionAge = (1000 * 3600 * 24 * 30);
+  const sessionMaxAge = this.configManager.getConfig('crowi', 'security:sessionMaxAge') || 2592000000; // default: 30days
   const redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null;
   const uid = require('uid-safe').sync;
 
@@ -233,7 +233,7 @@ Crowi.prototype.setupSessionConfig = async function() {
     resave: false,
     saveUninitialized: true,
     cookie: {
-      maxAge: sessionAge,
+      maxAge: sessionMaxAge,
     },
     genid(req) {
       // return pre-defined uid when healthcheck
@@ -257,8 +257,8 @@ Crowi.prototype.setupSessionConfig = async function() {
   }
   // use MongoDB for session store
   else {
-    const MongoStore = require('connect-mongo')(session);
-    sessionConfig.store = new MongoStore({ mongooseConnection: mongoose.connection });
+    const MongoStore = require('connect-mongo');
+    sessionConfig.store = MongoStore.create({ client: mongoose.connection.getClient() });
   }
 
   this.sessionConfig = sessionConfig;

+ 1 - 1
src/server/form/invited.js

@@ -5,5 +5,5 @@ const field = form.field;
 module.exports = form(
   field('invitedForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
   field('invitedForm.name').required(),
-  field('invitedForm.password').required().is(/^[\x20-\x7F]{6,}$/),
+  field('invitedForm.password').required().is(/^[\x20-\x7F]*$/).minLength(6),
 );

+ 1 - 1
src/server/form/register.js

@@ -6,6 +6,6 @@ module.exports = form(
   field('registerForm.username').required().is(/^[\da-zA-Z\-_.]+$/),
   field('registerForm.name').required(),
   field('registerForm.email').required(),
-  field('registerForm.password').required().is(/^[\x20-\x7F]{6,}$/),
+  field('registerForm.password').required().is(/^[\x20-\x7F]*$/).minLength(6),
   field('registerForm[app:globalLang]'),
 );

+ 1 - 1
src/server/middlewares/access-token-parser.js

@@ -8,7 +8,7 @@ module.exports = (crowi) => {
   return async(req, res, next) => {
     // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
     const accessToken = req.query.access_token || req.body.access_token || null;
-    if (!accessToken) {
+    if (accessToken == null || typeof accessToken !== 'string') {
       return next();
     }
 

+ 2 - 0
src/server/models/config.js

@@ -222,6 +222,8 @@ module.exports = function(crowi) {
         HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
         NO_CDN: env.NO_CDN || null,
+        GROWI_CLOUD_URI: env.GROWI_CLOUD_URI || null,
+        GROWI_APP_ID_FOR_GROWI_CLOUD: env.GROWI_APP_ID_FOR_GROWI_CLOUD || null,
       },
       isEnabledStaleNotification: crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAclEnabled: crowi.aclService.isAclEnabled(),

+ 3 - 33
src/server/models/page.js

@@ -522,33 +522,6 @@ module.exports = function(crowi) {
     return true;
   };
 
-  pageSchema.statics.isCreatableName = function(name) {
-    const forbiddenPages = [
-      /\^|\$|\*|\+|#|%/,
-      /^\/-\/.*/,
-      /^\/_r\/.*/,
-      /^\/_apix?(\/.*)?/,
-      /^\/?https?:\/\/.+$/, // avoid miss in renaming
-      /\/{2,}/, // avoid miss in renaming
-      /\s+\/\s+/, // avoid miss in renaming
-      /.+\/edit$/,
-      /.+\.md$/,
-      /^(\.\.)$/, // see: https://github.com/weseek/growi/issues/3582
-      /(\/\.\.)\/?/, // see: https://github.com/weseek/growi/issues/3582
-      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share)(\/.*|$)/,
-    ];
-
-    let isCreatable = true;
-    forbiddenPages.forEach((page) => {
-      const pageNameReg = new RegExp(page);
-      if (name.match(pageNameReg)) {
-        isCreatable = false;
-      }
-    });
-
-    return isCreatable;
-  };
-
   pageSchema.statics.fixToCreatableName = function(path) {
     return path
       .replace(/\/\//g, '/');
@@ -1012,9 +985,8 @@ module.exports = function(crowi) {
     savedPage = await this.findByPath(revision.path);
     await savedPage.populateDataToShowRevision();
 
-    if (socketClientId != null) {
-      pageEvent.emit('create', savedPage, user, socketClientId);
-    }
+    pageEvent.emit('create', savedPage, user, socketClientId);
+
     return savedPage;
   };
 
@@ -1041,9 +1013,7 @@ module.exports = function(crowi) {
       savedPage = await this.syncRevisionToHackmd(savedPage);
     }
 
-    if (socketClientId != null) {
-      pageEvent.emit('update', savedPage, user, socketClientId);
-    }
+    pageEvent.emit('update', savedPage, user, socketClientId);
 
     return savedPage;
   };

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

@@ -7,7 +7,7 @@ const schema = new mongoose.Schema({
 });
 class SlackAppIntegration {
 
-  static generateAccessToken() {
+  static generateAccessTokens() {
     const hasher1 = crypto.createHash('sha512');
     const hasher2 = crypto.createHash('sha512');
     const tokenGtoP = hasher1.update(new Date().getTime().toString() + process.env.SALT_FOR_GTOP_TOKEN);
@@ -15,6 +15,24 @@ class SlackAppIntegration {
     return [tokenGtoP.digest('base64'), tokenPtoG.digest('base64')];
   }
 
+  static async generateUniqueAccessTokens() {
+    let duplicateTokens;
+    let tokenGtoP;
+    let tokenPtoG;
+    let generateTokens;
+
+    do {
+      generateTokens = this.generateAccessTokens();
+      tokenGtoP = generateTokens[0];
+      tokenPtoG = generateTokens[1];
+      // eslint-disable-next-line no-await-in-loop
+      duplicateTokens = await this.findOne({ $or: [{ tokenGtoP }, { tokenPtoG }] });
+    } while (duplicateTokens != null);
+
+
+    return { tokenGtoP, tokenPtoG };
+  }
+
 }
 
 module.exports = function(crowi) {

+ 5 - 0
src/server/routes/admin.js

@@ -498,5 +498,10 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
   };
 
+  actions.notFound = {};
+  actions.notFound.index = function(req, res) {
+    return res.render('admin/not_found');
+  };
+
   return actions;
 };

+ 2 - 1
src/server/routes/apiv3/pages.js

@@ -6,6 +6,7 @@ const pathUtils = require('growi-commons').pathUtils;
 
 const { body } = require('express-validator');
 const { query } = require('express-validator');
+const { isCreatablePage } = require('@commons/util/path-utils');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();
@@ -378,7 +379,7 @@ module.exports = (crowi) => {
       socketClientId: +req.body.socketClientId || undefined,
     };
 
-    if (!Page.isCreatableName(newPagePath)) {
+    if (!isCreatablePage(newPagePath)) {
       return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath})'`, 'invalid_path'), 409);
     }
 

+ 7 - 3
src/server/routes/apiv3/security-setting.js

@@ -12,6 +12,7 @@ const removeNullPropertyFromObject = require('../../../lib/util/removeNullProper
 
 const validator = {
   generalSetting: [
+    body('sessionMaxAge').optional({ checkFalsy: true }).trim().isInt(),
     body('restrictGuestMode').if(value => value != null).isString().isIn([
       'Deny', 'Readonly',
     ]),
@@ -361,6 +362,7 @@ module.exports = (crowi) => {
         hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         wikiMode: await crowi.configManager.getConfig('crowi', 'security:wikiMode'),
+        sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
       localSetting: {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
@@ -566,7 +568,8 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/GeneralSetting'
    */
   router.put('/general-setting', loginRequiredStrictly, adminRequired, csrf, validator.generalSetting, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
+    const updateData = {
+      'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
       'security:restrictGuestMode': req.body.restrictGuestMode,
       'security:pageCompleteDeletionAuthority': req.body.pageCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
@@ -575,11 +578,12 @@ module.exports = (crowi) => {
     const wikiMode = await crowi.configManager.getConfig('crowi', 'security:wikiMode');
     if (wikiMode === 'private' || wikiMode === 'public') {
       logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');
-      delete requestParams['security:restrictGuestMode'];
+      delete updateData['security:restrictGuestMode'];
     }
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
       const securitySettingParams = {
+        sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
         restrictGuestMode: await crowi.configManager.getConfig('crowi', 'security:restrictGuestMode'),
         pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
         hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),

+ 61 - 9
src/server/routes/apiv3/share-links.js

@@ -8,7 +8,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body } = require('express-validator');
+const { body, query, param } = require('express-validator');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -28,14 +28,19 @@ module.exports = (crowi) => {
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const ShareLink = crowi.model('ShareLink');
+  const Page = crowi.model('Page');
 
+  validator.getShareLinks = [
+    // validate the page id is MongoId
+    query('relatedPage').isMongoId().withMessage('Page Id is required'),
+  ];
 
   /**
    * @swagger
    *
    *  paths:
    *    /share-links/:
-   *      post:
+   *      get:
    *        tags: [ShareLink]
    *        description: get share links
    *        parameters:
@@ -49,10 +54,19 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to get share links
    */
-  router.get('/', loginRequired, async(req, res) => {
+  router.get('/', loginRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
+
+    const page = await Page.findByIdAndViewer(relatedPage, req.user);
+
+    if (page == null) {
+      const msg = 'Page is not found or forbidden';
+      logger.error('Error', msg);
+      return res.apiv3Err(new ErrorV3(msg, 'get-shareLink-failed'));
+    }
+
     try {
-      const shareLinksResult = await ShareLink.find({ relatedPage: { $in: relatedPage } }).populate({ path: 'relatedPage', select: 'path' });
+      const shareLinksResult = await ShareLink.find({ relatedPage }).populate({ path: 'relatedPage', select: 'path' });
       return res.apiv3({ shareLinksResult });
     }
     catch (err) {
@@ -63,8 +77,8 @@ module.exports = (crowi) => {
   });
 
   validator.shareLinkStatus = [
-    // validate the page id is null
-    body('relatedPage').not().isEmpty().withMessage('Page Id is null'),
+    // validate the page id is MongoId
+    body('relatedPage').isMongoId().withMessage('Page Id is required'),
     // validate expireation date is not empty, is not before today and is date.
     body('expiredAt').if(value => value != null).isAfter(today.toString()).withMessage('Your Selected date is past'),
     // validate the length of description is max 100.
@@ -103,6 +117,15 @@ module.exports = (crowi) => {
 
   router.post('/', loginRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
+
+    const page = await Page.findByIdAndViewer(relatedPage, req.user);
+
+    if (page == null) {
+      const msg = 'Page is not found or forbidden';
+      logger.error('Error', msg);
+      return res.apiv3Err(new ErrorV3(msg, 'post-shareLink-failed'));
+    }
+
     const ShareLink = crowi.model('ShareLink');
 
     try {
@@ -116,6 +139,12 @@ module.exports = (crowi) => {
     }
   });
 
+
+  validator.deleteShareLinks = [
+    // validate the page id is MongoId
+    query('relatedPage').isMongoId().withMessage('Page Id is required'),
+  ];
+
   /**
   * @swagger
   *
@@ -135,9 +164,17 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete o all share links related one page
   */
-  router.delete('/', loginRequired, csrf, async(req, res) => {
+  router.delete('/', loginRequired, csrf, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
 
+    const page = await Page.findByIdAndViewer(relatedPage, req.user);
+
+    if (page == null) {
+      const msg = 'Page is not found or forbidden';
+      logger.error('Error', msg);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-shareLinks-for-page-failed'));
+    }
+
     try {
       const deletedShareLink = await ShareLink.remove({ relatedPage });
       return res.apiv3(deletedShareLink);
@@ -174,6 +211,10 @@ module.exports = (crowi) => {
     }
   });
 
+  validator.deleteShareLink = [
+    param('id').isMongoId().withMessage('ShareLink Id is required'),
+  ];
+
   /**
   * @swagger
   *
@@ -192,11 +233,22 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete one share link
   */
-  router.delete('/:id', loginRequired, csrf, async(req, res) => {
+  router.delete('/:id', loginRequired, csrf, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
 
     try {
-      const deletedShareLink = await ShareLink.findOneAndRemove({ _id: id });
+      const deletedShareLink = await ShareLink.findOne({ _id: id });
+
+      // check permission
+      const page = await Page.findByIdAndViewer(deletedShareLink.relatedPage, req.user);
+      if (page == null) {
+        const msg = 'Page is not found or forbidden';
+        logger.error('Error', msg);
+        return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+      }
+
+      // remove
+      await deletedShareLink.remove();
       return res.apiv3({ deletedShareLink });
     }
     catch (err) {

+ 56 - 23
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, testToSlack, sendSuccessMessage } = require('@growi/slack');
+const { getConnectionStatus, getConnectionStatuses, sendSuccessMessage } = require('@growi/slack');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -149,7 +149,7 @@ module.exports = (crowi) => {
     }
 
     // retrieve connection statuses
-    let connectionStatuses;
+    let connectionStatuses = {};
     if (currentBotType == null) {
       // TODO imple null action
     }
@@ -183,8 +183,15 @@ module.exports = (crowi) => {
       if (proxyServerUri != null) {
         try {
           if (settings.slackAppIntegrations.length > 0) {
-            const tokenGtoPs = settings.slackAppIntegrations.map(slackAppIntegration => slackAppIntegration.tokenGtoP);
-            connectionStatuses = (await getConnectionStatusesFromProxy(tokenGtoPs)).connectionStatuses;
+            // key: slackAppIntegration.tokenGtoP, value: slackAppIntegration._id
+            const tokenGtoPToSlackAppIntegrationId = {};
+            settings.slackAppIntegrations.forEach((slackAppIntegration) => {
+              tokenGtoPToSlackAppIntegrationId[slackAppIntegration.tokenGtoP] = slackAppIntegration._id;
+            });
+            const result = (await getConnectionStatusesFromProxy(Object.keys(tokenGtoPToSlackAppIntegrationId)));
+            Object.entries(result.connectionStatuses).forEach(([tokenGtoP, connectionStatus]) => {
+              connectionStatuses[tokenGtoPToSlackAppIntegrationId[tokenGtoP]] = connectionStatus;
+            });
           }
         }
         catch (error) {
@@ -376,25 +383,53 @@ module.exports = (crowi) => {
    *            description: Succeeded to create slack app integration
    */
   router.put('/slack-app-integrations', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-    let checkTokens;
-    let tokenGtoP;
-    let tokenPtoG;
-    let generateTokens;
-    do {
-      generateTokens = SlackAppIntegration.generateAccessToken();
-      tokenGtoP = generateTokens[0];
-      tokenPtoG = generateTokens[1];
-      // eslint-disable-next-line no-await-in-loop
-      checkTokens = await SlackAppIntegration.findOne({ $or: [{ tokenGtoP }, { tokenPtoG }] });
-    } while (checkTokens != null);
+    const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
+    if (SlackAppIntegrationRecordsNum >= 10) {
+      const msg = 'Not be able to create more than 10 slack workspace integration settings';
+      logger.error('Error', msg);
+      return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
+    }
+
+    const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
+
     try {
       const slackAppTokens = await SlackAppIntegration.create({ tokenGtoP, tokenPtoG });
       return res.apiv3(slackAppTokens, 200);
     }
     catch (error) {
-      const msg = 'Error occured in updating access token for slack app tokens';
+      const msg = 'Error occurred during creating slack integration settings procedure';
       logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-slackAppTokens-failed'), 500);
+      return res.apiv3Err(new ErrorV3(msg, 'creating-slack-integration-settings-procedure-failed'), 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/regenerate-tokens:
+   *      put:
+   *        tags: [SlackIntegration]
+   *        operationId: putRegenerateTokens
+   *        summary: /slack-integration
+   *        description: Regenerate SlackAppTokens
+   *        responses:
+   *          200:
+   *            description: Succeeded to regenerate slack app tokens
+   */
+  router.put('/regenerate-tokens', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+
+    const { slackAppIntegrationId } = req.body;
+
+    try {
+      const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
+      const slackAppTokens = await SlackAppIntegration.findOneAndUpdate({ _id: slackAppIntegrationId }, { tokenGtoP, tokenPtoG });
+
+      return res.apiv3(slackAppTokens, 200);
+    }
+    catch (error) {
+      const msg = 'Error occurred during regenerating slack app tokens';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'regenerating-slackAppTokens-failed'), 500);
     }
   });
 
@@ -498,6 +533,7 @@ module.exports = (crowi) => {
     catch (error) {
       return res.apiv3Err(new ErrorV3(`Error occured while sending message. Cause: ${error.message}`, 'send-message-failed', error.stack));
     }
+    return res.apiv3();
 
   });
 
@@ -529,12 +565,9 @@ module.exports = (crowi) => {
     }
 
     const slackBotToken = crowi.configManager.getConfig('crowi', 'slackbot:token');
-    try {
-      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 status = await getConnectionStatus(slackBotToken);
+    if (status.error != null) {
+      return res.apiv3Err(new ErrorV3(`Error occured while getting connection. ${status.error}`, 'send-message-failed'));
     }
 
     const { channel } = req.body;

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

@@ -108,6 +108,9 @@ module.exports = (crowi) => {
         case 'create':
           await crowi.slackBotService.createModal(client, body);
           break;
+        case 'help':
+          await crowi.slackBotService.helpCommand(client, body);
+          break;
         default:
           await crowi.slackBotService.notCommand(client, body);
           break;
@@ -157,12 +160,12 @@ module.exports = (crowi) => {
     }
   };
 
-  const handleViewSubmission = async(payload) => {
+  const handleViewSubmission = async(client, payload) => {
     const { callback_id: callbackId } = payload.view;
 
     switch (callbackId) {
       case 'createPage':
-        await crowi.slackBotService.createPageInGrowi(payload);
+        await crowi.slackBotService.createPageInGrowi(client, payload);
         break;
       default:
         break;

+ 2 - 0
src/server/routes/index.js

@@ -117,6 +117,8 @@ module.exports = function(crowi, app) {
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
+  app.get('/admin/*'                       , loginRequiredStrictly ,adminRequired, admin.notFound.index);
+
   app.get('/me'                       , loginRequiredStrictly , me.index);
   // external-accounts
   app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);

+ 2 - 2
src/server/routes/installer.js

@@ -92,12 +92,12 @@ module.exports = function(crowi) {
     req.logIn(adminUser, (err) => {
       if (err) {
         req.flash('successMessage', req.t('message.complete_to_install1'));
-        req.session.redirectTo = '/admin/app';
+        req.session.redirectTo = '/';
         return res.redirect('/login');
       }
 
       req.flash('successMessage', req.t('message.complete_to_install2'));
-      return res.redirect('/admin/app');
+      return res.redirect('/');
     });
   };
 

+ 2 - 121
src/server/routes/page.js

@@ -1,3 +1,4 @@
+const { isCreatablePage } = require('@commons/util/path-utils');
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');
@@ -478,12 +479,10 @@ module.exports = function(crowi, app) {
   actions.notFound = async function(req, res) {
     const path = getPathFromRequest(req);
 
-    const isCreatable = Page.isCreatableName(path);
-
     let view;
     const renderVars = { path };
 
-    if (!isCreatable) {
+    if (!isCreatablePage(path)) {
       view = 'layout-growi/not_creatable';
     }
     else if (req.isForbidden) {
@@ -1231,124 +1230,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.rename:
-   *      post:
-   *        tags: [Pages, CrowiCompatibles]
-   *        operationId: renamePage
-   *        summary: /pages.rename
-   *        description: Rename page
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    $ref: '#/components/schemas/Page/properties/_id'
-   *                  path:
-   *                    $ref: '#/components/schemas/Page/properties/path'
-   *                  revision_id:
-   *                    $ref: '#/components/schemas/Revision/properties/_id'
-   *                  new_path:
-   *                    type: string
-   *                    description: new path
-   *                    example: /user/alice/new_test
-   *                  create_redirect:
-   *                    type: boolean
-   *                    description: whether redirect page
-   *                required:
-   *                  - page_id
-   *        responses:
-   *          200:
-   *            description: Succeeded to rename page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /pages.rename Rename page
-   * @apiName RenamePage
-   * @apiGroup Page
-   *
-   * @apiParam {String} page_id Page Id.
-   * @apiParam {String} path
-   * @apiParam {String} revision_id
-   * @apiParam {String} new_path New path name.
-   * @apiParam {Bool} create_redirect
-   */
-  api.rename = async function(req, res) {
-    const pageId = req.body.page_id;
-    const previousRevision = req.body.revision_id || null;
-    let newPagePath = pathUtils.normalizePath(req.body.new_path);
-    const options = {
-      createRedirectPage: (req.body.create_redirect != null),
-      updateMetadata: (req.body.remain_metadata == null),
-      socketClientId: +req.body.socketClientId || undefined,
-    };
-    const isRecursively = (req.body.recursively != null);
-
-    if (!Page.isCreatableName(newPagePath)) {
-      return res.json(ApiResponse.error(`Could not use the path '${newPagePath})'`, 'invalid_path'));
-    }
-
-    // check whether path starts slash
-    newPagePath = pathUtils.addHeadingSlash(newPagePath);
-
-    const isExist = await Page.count({ path: newPagePath }) > 0;
-    if (isExist) {
-      // if page found, cannot cannot rename to that path
-      return res.json(ApiResponse.error(`'new_path=${newPagePath}' already exists`, 'already_exists'));
-    }
-
-    let page;
-
-    try {
-      page = await Page.findByIdAndViewer(pageId, req.user);
-
-      if (page == null) {
-        return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
-      }
-
-      if (!page.isUpdatable(previousRevision)) {
-        return res.json(ApiResponse.error('Someone could update this page, so couldn\'t delete.', 'outdated'));
-      }
-
-      page = await crowi.pageService.renamePage(page, newPagePath, req.user, options, isRecursively);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.json(ApiResponse.error('Failed to update page.', 'unknown'));
-    }
-
-    const result = {};
-    result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
-
-    res.json(ApiResponse.success(result));
-
-    try {
-      // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
-        oldPath: req.body.path,
-      });
-    }
-    catch (err) {
-      logger.error('Move notification failed', err);
-    }
-
-    return page;
-  };
-
   /**
    * @swagger
    *

+ 10 - 0
src/server/service/config-loader.js

@@ -242,6 +242,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: undefined,
   },
+  SESSION_MAX_AGE: {
+    ns:      'crowi',
+    key:     'security:sessionMaxAge',
+    type:    TYPES.NUMBER,
+    default: undefined,
+  },
   USER_UPPER_LIMIT: {
     ns:      'crowi',
     key:     'security:userUpperLimit',
@@ -419,6 +425,10 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
   SLACK_BOT_TYPE: {
     ns:      'crowi',
     key:     'slackbot:currentBotType', // 'officialBot' || 'customBotWithoutProxy' || 'customBotWithProxy'
+  },
+  GROWI_APP_ID_FOR_GROWI_CLOUD: {
+    ns:      'crowi',
+    key:     'app:growiAppIdForCloud',
     type:    TYPES.STRING,
     default: null,
   },

+ 34 - 3
src/server/service/slackbot.js

@@ -67,6 +67,7 @@ class SlackBotService extends S2sMessageHandlable {
     client.chat.postEphemeral({
       channel: body.channel_id,
       user: body.user_id,
+      text: 'No command',
       blocks: [
         this.generateMarkdownSectionBlock('*No command.*\n Hint\n `/growi [command] [keyword]`'),
       ],
@@ -74,6 +75,19 @@ class SlackBotService extends S2sMessageHandlable {
     return;
   }
 
+  async helpCommand(client, body) {
+    const message = '*Help*\n growi-bot usage\n `/growi [command] [args]`\n\n Create new page\n `create`\n\n Search pages\n `search [keyword]`';
+    client.chat.postEphemeral({
+      channel: body.channel_id,
+      user: body.user_id,
+      text: 'Help',
+      blocks: [
+        this.generateMarkdownSectionBlock(message),
+      ],
+    });
+    return;
+  }
+
   getKeywords(args) {
     const keywordsArr = args.slice(1);
     const keywords = keywordsArr.join(' ');
@@ -86,6 +100,7 @@ class SlackBotService extends S2sMessageHandlable {
       client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
+        text: 'Input keywords',
         blocks: [
           this.generateMarkdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'),
         ],
@@ -106,6 +121,7 @@ class SlackBotService extends S2sMessageHandlable {
       client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
+        text: `No page found with "${keywords}"`,
         blocks: [
           this.generateMarkdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
           this.generateMarkdownSectionBlock(':mag: *Help: Searching*'),
@@ -140,7 +156,7 @@ class SlackBotService extends S2sMessageHandlable {
   async shareSearchResults(client, payload) {
     client.chat.postMessage({
       channel: payload.channel.id,
-      text: payload.actions[0].value,
+      text: JSON.parse(payload.actions[0].value).pageList,
     });
   }
 
@@ -194,7 +210,9 @@ class SlackBotService extends S2sMessageHandlable {
             },
             style: 'primary',
             action_id: 'shareSearchResults',
-            value: `${keywordsAndDesc} \n\n ${urls.join('\n')}`,
+            value: JSON.stringify({
+              offset, body, args, pageList: `${keywordsAndDesc} \n\n ${urls.join('\n')}`,
+            }),
           },
         ],
       };
@@ -215,6 +233,7 @@ class SlackBotService extends S2sMessageHandlable {
       await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
+        text: 'Successed To Search',
         blocks: [
           this.generateMarkdownSectionBlock(keywordsAndDesc),
           this.generateMarkdownSectionBlock(`${urls.join('\n')}`),
@@ -227,6 +246,7 @@ class SlackBotService extends S2sMessageHandlable {
       await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
+        text: 'Failed To Search',
         blocks: [
           this.generateMarkdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
         ],
@@ -260,6 +280,7 @@ class SlackBotService extends S2sMessageHandlable {
             this.generateInputSectionBlock('path', 'Path', 'path_input', false, '/path'),
             this.generateInputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
           ],
+          private_metadata: JSON.stringify({ channelId: body.channel_id }),
         },
       });
     }
@@ -268,6 +289,7 @@ class SlackBotService extends S2sMessageHandlable {
       await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
+        text: 'Failed To Create',
         blocks: [
           this.generateMarkdownSectionBlock(`*Failed to create new page.*\n ${err}`),
         ],
@@ -291,7 +313,16 @@ class SlackBotService extends S2sMessageHandlable {
 
       // generate a dummy id because Operation to create a page needs ObjectId
       const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
-      await Page.create(path, contentsBody, dummyObjectIdOfUser, {});
+      const page = await Page.create(path, contentsBody, dummyObjectIdOfUser, {});
+
+      // Send a message when page creation is complete
+      const growiUri = this.crowi.appService.getSiteUrl();
+      const channelId = JSON.parse(payload.view.private_metadata).channelId;
+      await client.chat.postEphemeral({
+        channel: channelId,
+        user: payload.user.id,
+        text: `The page <${decodeURI(growiUri + path)} | ${decodeURI(`${growiUri}/${page._id}`)}> has been created.`,
+      });
     }
     catch (err) {
       client.chat.postMessage({

+ 3 - 1
src/server/util/createGrowiPagesFromImports.js

@@ -1,3 +1,5 @@
+const { isCreatablePage } = require('@commons/util/path-utils');
+
 module.exports = (crowi) => {
   const Page = crowi.model('Page');
 
@@ -18,7 +20,7 @@ module.exports = (crowi) => {
       const path = page.path;
       const user = page.user;
       const body = page.body;
-      const isCreatableName = await Page.isCreatableName(path);
+      const isCreatableName = isCreatablePage(path);
       const isPageNameTaken = await Page.findByPathAndViewer(path, user);
 
       if (isCreatableName && !isPageNameTaken) {

+ 7 - 0
src/server/views/admin/not_found.html

@@ -0,0 +1,7 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('not_found_page.page_not_exist')) }}{% endblock %}
+
+{% block content_main %}
+<h1 class="title">{{ t('not_found_page.page_not_exist') }}</h1>
+{% endblock content_main %}

+ 1 - 1
src/server/views/widget/alert_siteurl_undefined.html

@@ -1,6 +1,6 @@
 {% if !getConfig('crowi', 'app:siteUrl') %}
 <div class="alert alert-danger d-edit-none mb-0 px-4 py-2">
   <i class="icon-exclamation"></i>
-  {{ t("security_setting.alert_siteUrl_is_not_set", { link:t('App Settings')}) }}
+  {{ t("security_setting.alert_siteUrl_is_not_set", { link: t('App Settings')}) }} &gt;&gt; <a href="/admin/app">{{t('App Settings')}}<i class="icon-login"></i></a>
 </div>
 {% endif %}

+ 1 - 1
src/server/views/widget/page_content.html

@@ -24,7 +24,7 @@
   data-page-last-update-username="{% if page && page.lastUpdateUser %}{{ page.lastUpdateUser.name }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-delete-username="{% if page && page.deleteUser %}{{ page.deleteUser.name }}{% endif %}"
-  data-page-deleted-at="{% if page %}{{ page.deletedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
+  data-page-deleted-at="{% if page && page.deletedAt %}{{ page.deletedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-page-ids-of-seen-users="{{ page.seenUsers|slice(-15)|default([])|reverse|join(',') }}"

+ 0 - 50
src/test/models/page.test.js

@@ -168,56 +168,6 @@ describe('Page', () => {
     });
   });
 
-  describe('.isCreatableName', () => {
-    test('should decide creatable or not', () => {
-      expect(Page.isCreatableName('/hoge')).toBeTruthy();
-
-      // edge cases
-      expect(Page.isCreatableName('/me')).toBeFalsy();
-      expect(Page.isCreatableName('/me/')).toBeFalsy();
-      expect(Page.isCreatableName('/me/x')).toBeFalsy();
-      expect(Page.isCreatableName('/meeting')).toBeTruthy();
-      expect(Page.isCreatableName('/meeting/x')).toBeTruthy();
-
-      // end with "edit"
-      expect(Page.isCreatableName('/meeting/edit')).toBeFalsy();
-
-      // under score
-      expect(Page.isCreatableName('/_')).toBeTruthy();
-      expect(Page.isCreatableName('/_template')).toBeTruthy();
-      expect(Page.isCreatableName('/__template')).toBeTruthy();
-      expect(Page.isCreatableName('/_r/x')).toBeFalsy();
-      expect(Page.isCreatableName('/_api')).toBeFalsy();
-      expect(Page.isCreatableName('/_apix')).toBeFalsy();
-      expect(Page.isCreatableName('/_api/x')).toBeFalsy();
-
-      expect(Page.isCreatableName('/hoge/xx.md')).toBeFalsy();
-
-      // relative path
-      expect(Page.isCreatableName('/..')).toBeFalsy();
-      expect(Page.isCreatableName('/../page')).toBeFalsy();
-      expect(Page.isCreatableName('/page/..')).toBeFalsy();
-      expect(Page.isCreatableName('/page/../page')).toBeFalsy();
-
-      // start with https?
-      expect(Page.isCreatableName('/http://demo.growi.org/hoge')).toBeFalsy();
-      expect(Page.isCreatableName('/https://demo.growi.org/hoge')).toBeFalsy();
-      expect(Page.isCreatableName('http://demo.growi.org/hoge')).toBeFalsy();
-      expect(Page.isCreatableName('https://demo.growi.org/hoge')).toBeFalsy();
-
-      expect(Page.isCreatableName('/ the / path / with / space')).toBeFalsy();
-
-      const forbidden = ['installer', 'register', 'login', 'logout',
-                         'admin', 'files', 'trash', 'paste', 'comments'];
-      for (let i = 0; i < forbidden.length; i++) {
-        const pn = forbidden[i];
-        expect(Page.isCreatableName(`/${pn}`)).toBeFalsy();
-        expect(Page.isCreatableName(`/${pn}/`)).toBeFalsy();
-        expect(Page.isCreatableName(`/${pn}/abc`)).toBeFalsy();
-      }
-    });
-  });
-
   describe('.isAccessiblePageByViewer', () => {
     describe('with a granted page', () => {
       test('should return true with granted user', async() => {

+ 51 - 1
src/test/util/path-utils.test.js

@@ -1,4 +1,4 @@
-const { isTopPage, convertToNewAffiliationPath } = require('../../lib/util/path-utils');
+const { isTopPage, convertToNewAffiliationPath, isCreatablePage } = require('../../lib/util/path-utils');
 
 
 describe('TopPage Path test', () => {
@@ -55,3 +55,53 @@ describe('convertToNewAffiliationPath test', () => {
     }).toThrow();
   });
 });
+
+describe('isCreatablePage test', () => {
+  test('should decide creatable or not', () => {
+    expect(isCreatablePage('/hoge')).toBeTruthy();
+
+    // edge cases
+    expect(isCreatablePage('/me')).toBeFalsy();
+    expect(isCreatablePage('/me/')).toBeFalsy();
+    expect(isCreatablePage('/me/x')).toBeFalsy();
+    expect(isCreatablePage('/meeting')).toBeTruthy();
+    expect(isCreatablePage('/meeting/x')).toBeTruthy();
+
+    // end with "edit"
+    expect(isCreatablePage('/meeting/edit')).toBeFalsy();
+
+    // under score
+    expect(isCreatablePage('/_')).toBeTruthy();
+    expect(isCreatablePage('/_template')).toBeTruthy();
+    expect(isCreatablePage('/__template')).toBeTruthy();
+    expect(isCreatablePage('/_r/x')).toBeFalsy();
+    expect(isCreatablePage('/_api')).toBeFalsy();
+    expect(isCreatablePage('/_apix')).toBeFalsy();
+    expect(isCreatablePage('/_api/x')).toBeFalsy();
+
+    expect(isCreatablePage('/hoge/xx.md')).toBeFalsy();
+
+    // relative path
+    expect(isCreatablePage('/..')).toBeFalsy();
+    expect(isCreatablePage('/../page')).toBeFalsy();
+    expect(isCreatablePage('/page/..')).toBeFalsy();
+    expect(isCreatablePage('/page/../page')).toBeFalsy();
+
+    // start with https?
+    expect(isCreatablePage('/http://demo.growi.org/hoge')).toBeFalsy();
+    expect(isCreatablePage('/https://demo.growi.org/hoge')).toBeFalsy();
+    expect(isCreatablePage('http://demo.growi.org/hoge')).toBeFalsy();
+    expect(isCreatablePage('https://demo.growi.org/hoge')).toBeFalsy();
+
+    expect(isCreatablePage('/ the / path / with / space')).toBeFalsy();
+
+    const forbidden = ['installer', 'register', 'login', 'logout',
+                       'admin', 'files', 'trash', 'paste', 'comments'];
+    for (let i = 0; i < forbidden.length; i++) {
+      const pn = forbidden[i];
+      expect(isCreatablePage(`/${pn}`)).toBeFalsy();
+      expect(isCreatablePage(`/${pn}/`)).toBeFalsy();
+      expect(isCreatablePage(`/${pn}/abc`)).toBeFalsy();
+    }
+  });
+});

+ 224 - 146
yarn.lock

@@ -2955,6 +2955,13 @@
     "@types/express" "*"
     "@types/node" "*"
 
+"@types/bson@*":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/bson/-/bson-4.0.3.tgz#30889d2ffde6262abbe38659364c631454999fbf"
+  integrity sha512-mVRvYnTOZJz3ccpxhr3wgxVmSeiYinW+zlzQz3SXWaJmD1DuL05Jeq7nKw3SnbKmbleW5qrLG5vdyWe/A9sXhw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/cache-manager@^3.4.0":
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/@types/cache-manager/-/cache-manager-3.4.0.tgz#414136ea3807a8cd071b8f20370c5df5dbffd382"
@@ -3098,6 +3105,14 @@
   resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
   integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
 
+"@types/mongodb@^3.5.27":
+  version "3.6.17"
+  resolved "https://registry.yarnpkg.com/@types/mongodb/-/mongodb-3.6.17.tgz#a8893654989cb11e9a241858bc530060b6fd126d"
+  integrity sha512-9hhgvYPdC5iHyyksPcKCu45gfaAIPQHKHGdvNXu4582DmOZX3wrUJIJPT40o4G1oTKPgpMMFqZglOTjhnYoF+A==
+  dependencies:
+    "@types/bson" "*"
+    "@types/node" "*"
+
 "@types/multer@^1.4.5":
   version "1.4.5"
   resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.5.tgz#db0557562307e9adb6661a9500c334cd7ddd0cd9"
@@ -3856,18 +3871,18 @@ archiver-utils@^2.1.0:
     normalize-path "^3.0.0"
     readable-stream "^2.0.0"
 
-archiver@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
-  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
+archiver@^5.3.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/archiver/-/archiver-5.3.0.tgz#dd3e097624481741df626267564f7dd8640a45ba"
+  integrity sha512-iUw+oDwK0fgNpvveEsdQ0Ase6IIKztBJU2U0E9MzszMfmVVUyv1QJhS2ITW9ZCqx8dktAxVAjWWkKehuZE8OPg==
   dependencies:
     archiver-utils "^2.1.0"
-    async "^2.6.3"
+    async "^3.2.0"
     buffer-crc32 "^0.2.1"
-    glob "^7.1.4"
-    readable-stream "^3.4.0"
-    tar-stream "^2.1.0"
-    zip-stream "^2.1.2"
+    readable-stream "^3.6.0"
+    readdir-glob "^1.0.0"
+    tar-stream "^2.2.0"
+    zip-stream "^4.1.0"
 
 are-we-there-yet@~1.1.2:
   version "1.1.5"
@@ -4008,6 +4023,16 @@ asn1.js@^4.0.0:
     inherits "^2.0.1"
     minimalistic-assert "^1.0.0"
 
+asn1.js@^5.4.1:
+  version "5.4.1"
+  resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
+  integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
+  dependencies:
+    bn.js "^4.0.0"
+    inherits "^2.0.1"
+    minimalistic-assert "^1.0.0"
+    safer-buffer "^2.1.0"
+
 asn1@0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86"
@@ -4079,12 +4104,12 @@ async@1.5.2, async@^1.4.0:
   version "1.5.2"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
 
-async@3.2.0, async@>=3.2.0:
+async@3.2.0, async@>=3.2.0, async@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
   integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
 
-async@^2.6.2, async@^2.6.3:
+async@^2.6.2:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
   integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
@@ -4504,12 +4529,14 @@ bl@^2.2.1:
     readable-stream "^2.3.5"
     safe-buffer "^5.1.1"
 
-bl@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
-  integrity sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==
+bl@^4.0.3:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+  integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
   dependencies:
-    readable-stream "^3.0.1"
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
 
 blob@0.0.4:
   version "0.0.4"
@@ -4849,13 +4876,13 @@ buffer@4.9.1, buffer@^4.3.0:
     ieee754 "^1.1.4"
     isarray "^1.0.0"
 
-buffer@^5.1.0:
-  version "5.4.2"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.2.tgz#2012872776206182480eccb2c0fba5f672a2efef"
-  integrity sha512-iy9koArjAFCzGnx3ZvNA6Z0clIbbFgbdWQ0mKD3hO0krOrZh8UgA6qMKcZvwLJxS+D6iVR76+5/pV56yMNYTag==
+buffer@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
   dependencies:
-    base64-js "^1.0.2"
-    ieee754 "^1.1.4"
+    base64-js "^1.3.1"
+    ieee754 "^1.1.13"
 
 buffer@^6.0.3:
   version "6.0.3"
@@ -5694,16 +5721,16 @@ commander@^5.1.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
   integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
 
-commander@^6.1.0:
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75"
-  integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==
-
 commander@^6.2.1:
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
   integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
 
+commander@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+
 comment-json@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.1.0.tgz#09d08f0fbc4ad5eeccbac20f469adbb967dcbd2c"
@@ -5748,15 +5775,15 @@ component-inherit@0.0.3:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
 
-compress-commons@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
-  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
+compress-commons@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-4.1.1.tgz#df2a09a7ed17447642bad10a85cc9a19e5c42a7d"
+  integrity sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ==
   dependencies:
     buffer-crc32 "^0.2.13"
-    crc32-stream "^3.0.1"
+    crc32-stream "^4.0.2"
     normalize-path "^3.0.0"
-    readable-stream "^2.3.6"
+    readable-stream "^3.6.0"
 
 compressible@^2.0.12:
   version "2.0.17"
@@ -5860,12 +5887,14 @@ connect-injector@^0.4.2:
     stream-buffers "^0.2.3"
     uberproto "^1.1.0"
 
-connect-mongo@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/connect-mongo/-/connect-mongo-3.2.0.tgz#20f776c7f2a9d8144fc76cfdcbf33edb05eb4d52"
-  integrity sha512-0Mx88079Z20CG909wCFlR3UxhMYGg6Ibn1hkIje1hwsqOLWtL9HJV+XD0DAjUvQScK6WqY/FA8tSVQM9rR64Rw==
+connect-mongo@^4.4.1:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/connect-mongo/-/connect-mongo-4.4.1.tgz#b817f97940539b46c9116e92cf2f344c120fae7d"
+  integrity sha512-I1QUE2tSGPtIBDAL2sFqUEPspDeJOR0u4g+N41ARJZk958pncu2PBG48Ev++fnldljobpIfdafak7hSlPYarvA==
   dependencies:
-    mongodb "^3.1.0"
+    debug "^4.3.1"
+    kruptein "^3.0.0"
+    mongodb "3.6.5"
 
 connect-redis@^4.0.4:
   version "4.0.4"
@@ -6164,20 +6193,21 @@ cosmiconfig@^7.0.0:
     path-type "^4.0.0"
     yaml "^1.10.0"
 
-crc32-stream@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
-  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
+crc-32@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208"
+  integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==
   dependencies:
-    crc "^3.4.4"
-    readable-stream "^3.4.0"
+    exit-on-epipe "~1.0.1"
+    printj "~1.1.0"
 
-crc@^3.4.4:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
-  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
+crc32-stream@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-4.0.2.tgz#c922ad22b38395abe9d3870f02fa8134ed709007"
+  integrity sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w==
   dependencies:
-    buffer "^5.1.0"
+    crc-32 "^1.2.0"
+    readable-stream "^3.4.0"
 
 create-ecdh@^4.0.0:
   version "4.0.0"
@@ -6554,10 +6584,10 @@ date-fns@^2.0.0:
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0"
   integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w==
 
-date-fns@^2.16.1:
-  version "2.16.1"
-  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.16.1.tgz#05775792c3f3331da812af253e1a935851d3834b"
-  integrity sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==
+date-fns@^2.19.0:
+  version "2.22.1"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.22.1.tgz#1e5af959831ebb1d82992bf67b765052d8f0efc4"
+  integrity sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==
 
 date-format@^2.0.0, date-format@^2.1.0:
   version "2.1.0"
@@ -6835,6 +6865,11 @@ diff@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff"
 
+diff@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
+  integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
+
 diffie-hellman@^5.0.0:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
@@ -7160,11 +7195,12 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
   dependencies:
     once "^1.4.0"
 
-end-of-stream@~1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.1.0.tgz#e9353258baa9108965efc41cb0ef8ade2f3cfb07"
+end-of-stream@~1.4.1:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
-    once "~1.3.0"
+    once "^1.4.0"
 
 engine.io-client@~3.2.0:
   version "3.2.1"
@@ -7810,6 +7846,11 @@ exenv@^1.2.2:
   resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
   integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
 
+exit-on-epipe@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692"
+  integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==
+
 exit@0.1.2, exit@^0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@@ -7885,6 +7926,11 @@ express-graceful-exit@=0.5.0:
   dependencies:
     underscore "^1.4.4"
 
+express-mongo-sanitize@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/express-mongo-sanitize/-/express-mongo-sanitize-2.1.0.tgz#a8c647787c25ded6e97b5e864d113e7687c5d471"
+  integrity sha512-ELGeH/Tx+kJGn3klCzSmOewfN1ezJQrkqzq83dl/K3xhd5PUbvLtiD5CiuYRmQfoZPL4rUEVjANf/YjE2BpTWQ==
+
 express-session@^1.16.1:
   version "1.16.1"
   resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.16.1.tgz#251ff9776c59382301de6c8c33411af357ed439c"
@@ -8041,6 +8087,11 @@ extend@^3.0.2, extend@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
 
+extensible-custom-error@^0.0.7:
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/extensible-custom-error/-/extensible-custom-error-0.0.7.tgz#4d6cc86c71d60a0e11fa8d24972104720cd30305"
+  integrity sha512-1tgubPkgC+Qi2nUpulI7hGddHh0fA8hXu3P0LBUq2pamZL52KSJZqMu8Q3CiA6kf7Irn/CU1fJe6y4igHCwu4Q==
+
 external-editor@^3.0.3:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27"
@@ -8558,16 +8609,6 @@ fs-extra@^8.1.0:
     jsonfile "^4.0.0"
     universalify "^0.1.0"
 
-fs-extra@^9.0.1:
-  version "9.0.1"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc"
-  integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==
-  dependencies:
-    at-least-node "^1.0.0"
-    graceful-fs "^4.2.0"
-    jsonfile "^6.0.1"
-    universalify "^1.0.0"
-
 fs-extra@^9.1.0:
   version "9.1.0"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d"
@@ -9665,15 +9706,15 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
   dependencies:
     postcss "^7.0.14"
 
-ieee754@^1.1.4:
-  version "1.1.8"
-  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
-
-ieee754@^1.2.1:
+ieee754@^1.1.13, ieee754@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
+ieee754@^1.1.4:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4"
+
 ienoopen@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/ienoopen/-/ienoopen-1.0.0.tgz#346a428f474aac8f50cf3784ea2d0f16f62bda6b"
@@ -9815,7 +9856,7 @@ inflight@^1.0.4:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@~2.0.0, inherits@~2.0.3:
+inherits@2, inherits@2.0.4, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -11194,10 +11235,10 @@ jws@^4.0.0:
     jwa "^2.0.0"
     safe-buffer "^5.0.1"
 
-kareem@2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.1.tgz#def12d9c941017fabfb00f873af95e9c99e1be87"
-  integrity sha512-l3hLhffs9zqoDe8zjmb/mAN4B8VT3L56EUvKNqLFVs9YlFA+zx7ke1DO8STAdDyYNkeSo1nKmjuvQeI12So8Xw==
+kareem@2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.2.tgz#78c4508894985b8d38a0dc15e1a8e11078f2ca93"
+  integrity sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ==
 
 keygrip@~1.0.2:
   version "1.0.2"
@@ -11245,6 +11286,13 @@ known-css-properties@^0.18.0:
   resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.18.0.tgz#d6e00b56ee1d5b0d171fd86df1583cfb012c521f"
   integrity sha512-69AgJ1rQa7VvUsd2kpvVq+VeObDuo3zrj0CzM5Slmf6yduQFAI2kXPDQJR2IE/u6MSAUOJrwSzjg5vlz8qcMiw==
 
+kruptein@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/kruptein/-/kruptein-3.0.0.tgz#9a7960248c9758f1e75522d2b87b8388bd40874f"
+  integrity sha512-Fh5sIb+3XI9L12GsgeBQqXVRPLB1HVViKSUkqPPOcqTEX4NwoF8Z3pEfMSl3Psd1j+QlloV8Uxxwp4gk3aFBGA==
+  dependencies:
+    asn1.js "^5.4.1"
+
 last-call-webpack-plugin@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555"
@@ -11637,11 +11685,6 @@ lodash@^4.17.2, lodash@^4.17.4:
   version "4.17.4"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
 
-lodash@^4.17.20:
-  version "4.17.20"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
-  integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
-
 lodash@^4.17.5:
   version "4.17.10"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7"
@@ -12147,19 +12190,19 @@ micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
-migrate-mongo@^8.1.4:
-  version "8.1.4"
-  resolved "https://registry.yarnpkg.com/migrate-mongo/-/migrate-mongo-8.1.4.tgz#700fb921fae0aecc571c6b251486e0fd1fac93e7"
-  integrity sha512-iuJiG86Qzr1w9B5j6jB6akZO0w040DTEWtKdclISaQg7KdovtXGYDmW8hehDsULY5oFGjNtJv/ABsOPlz0c5mw==
+migrate-mongo@^8.2.2:
+  version "8.2.2"
+  resolved "https://registry.yarnpkg.com/migrate-mongo/-/migrate-mongo-8.2.2.tgz#6c4aaf9bbc6c001276320e5e406b0e21a7046d9a"
+  integrity sha512-RK8zE9QGzaDZ8xN+Cyb/mUhSIA1pkj1Q/aNYeH4QB9U2UNfKej1lmxh20Ot1xFl1C62ro3hqiaZ9QErzCN3qPw==
   dependencies:
     cli-table3 "^0.6.0"
-    commander "^6.1.0"
-    date-fns "^2.16.1"
+    commander "^7.1.0"
+    date-fns "^2.19.0"
     fn-args "^5.0.0"
-    fs-extra "^9.0.1"
-    lodash "^4.17.20"
-    mongodb "^3.6.2"
-    p-each-series "^2.1.0"
+    fs-extra "^9.1.0"
+    lodash "^4.17.21"
+    mongodb "^3.6.4"
+    p-each-series "^2.2.0"
 
 miller-rabin@^4.0.0:
   version "4.0.1"
@@ -12512,10 +12555,10 @@ moment@^2.19.3:
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
   integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
 
-mongodb@3.6.2, mongodb@^3.1.0, mongodb@^3.6.2:
-  version "3.6.2"
-  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.2.tgz#1154a4ac107bf1375112d83a29c5cf97704e96b6"
-  integrity sha512-sSZOb04w3HcnrrXC82NEh/YGCmBuRgR+C1hZgmmv4L6dBz4BkRse6Y8/q/neXer9i95fKUBbFi4KgeceXmbsOA==
+mongodb@3.6.5:
+  version "3.6.5"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.5.tgz#c27d786fd4d3c83dc19302483707d12a9d2aee5f"
+  integrity sha512-mQlYKw1iGbvJJejcPuyTaytq0xxlYbIoVDm2FODR+OHxyEiMR021vc32bTvamgBjCswsD54XIRwhg3yBaWqJjg==
   dependencies:
     bl "^2.2.1"
     bson "^1.1.4"
@@ -12525,6 +12568,19 @@ mongodb@3.6.2, mongodb@^3.1.0, mongodb@^3.6.2:
   optionalDependencies:
     saslprep "^1.0.0"
 
+mongodb@3.6.8, mongodb@^3.6.4:
+  version "3.6.8"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.8.tgz#3e2632af81915b3ff99b7681121ca0895e8ed407"
+  integrity sha512-sDjJvI73WjON1vapcbyBD3Ao9/VN3TKYY8/QX9EPbs22KaCSrQ5rXo5ZZd44tWJ3wl3FlnrFZ+KyUtNH6+1ZPQ==
+  dependencies:
+    bl "^2.2.1"
+    bson "^1.1.4"
+    denque "^1.4.1"
+    optional-require "^1.0.3"
+    safe-buffer "^5.1.2"
+  optionalDependencies:
+    saslprep "^1.0.0"
+
 mongoose-gridfs@^1.2.42:
   version "1.2.42"
   resolved "https://registry.yarnpkg.com/mongoose-gridfs/-/mongoose-gridfs-1.2.42.tgz#15f4ff25b9b4d7563d544cedd716fc326ad34961"
@@ -12568,21 +12624,22 @@ mongoose-valid8@>=1.6.18:
     lodash ">=4.17.15"
     validator ">=13.0.0"
 
-mongoose@5.10.11:
-  version "5.10.11"
-  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.10.11.tgz#4bef4a85d35f38eb45a0af27b276d2bfd2196b5c"
-  integrity sha512-R5BFitKW94/S/Z48w+X+qi/eto66jWBcVEVA8nYVkBoBAPFGq7JSYP/0uso+ZHs+7XjSzTuui+SUllzxIrf9yA==
+mongoose@5.12.13:
+  version "5.12.13"
+  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.12.13.tgz#6707fb2f6284536bb6367c94ccc3ce85475a387d"
+  integrity sha512-QGn1FCzZ8Z+mMGVg8oR2kQw4NmhLloCHsw1NqKWg3Yr7WfPzkE4pe7s9P6o5pkYGsku17n9mqMHowne7EFK/zQ==
   dependencies:
+    "@types/mongodb" "^3.5.27"
     bson "^1.1.4"
-    kareem "2.3.1"
-    mongodb "3.6.2"
+    kareem "2.3.2"
+    mongodb "3.6.8"
     mongoose-legacy-pluralize "1.0.2"
-    mpath "0.7.0"
-    mquery "3.2.2"
+    mpath "0.8.3"
+    mquery "3.2.5"
     ms "2.1.2"
     regexp-clone "1.0.0"
     safe-buffer "5.2.1"
-    sift "7.0.1"
+    sift "13.5.2"
     sliced "1.0.1"
 
 morgan@^1.10.0:
@@ -12617,15 +12674,15 @@ move-concurrently@^1.0.1:
     rimraf "^2.5.4"
     run-queue "^1.0.3"
 
-mpath@0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.7.0.tgz#20e8102e276b71709d6e07e9f8d4d0f641afbfb8"
-  integrity sha512-Aiq04hILxhz1L+f7sjGyn7IxYzWm1zLNNXcfhDtx04kZ2Gk7uvFdgZ8ts1cWa/6d0TQmag2yR8zSGZUmp0tFNg==
+mpath@0.8.3:
+  version "0.8.3"
+  resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.8.3.tgz#828ac0d187f7f42674839d74921970979abbdd8f"
+  integrity sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA==
 
-mquery@3.2.2:
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.2.2.tgz#e1383a3951852ce23e37f619a9b350f1fb3664e7"
-  integrity sha512-XB52992COp0KP230I3qloVUbkLUxJIu328HBP2t2EsxSFtf4W1HPSOBWOXf1bqxK4Xbb66lfMJ+Bpfd9/yZE1Q==
+mquery@3.2.5:
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.2.5.tgz#8f2305632e4bb197f68f60c0cffa21aaf4060c51"
+  integrity sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==
   dependencies:
     bluebird "3.5.1"
     debug "3.1.0"
@@ -13559,12 +13616,6 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0:
   dependencies:
     wrappy "1"
 
-once@~1.3.0:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20"
-  dependencies:
-    wrappy "1"
-
 onetime@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
@@ -13643,6 +13694,11 @@ optimize-css-assets-webpack-plugin@^5.0.3:
     cssnano "^4.1.10"
     last-call-webpack-plugin "^3.0.0"
 
+optional-require@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.0.3.tgz#275b8e9df1dc6a17ad155369c2422a440f89cb07"
+  integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==
+
 optional@0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/optional/-/optional-0.1.4.tgz#cdb1a9bedc737d2025f690ceeb50e049444fd5b3"
@@ -13716,6 +13772,11 @@ p-each-series@^2.1.0:
   resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48"
   integrity sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ==
 
+p-each-series@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a"
+  integrity sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==
+
 p-finally@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
@@ -14913,6 +14974,11 @@ pretty-format@^26.0.0, pretty-format@^26.6.2:
     ansi-styles "^4.0.0"
     react-is "^17.0.1"
 
+printj@~1.1.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222"
+  integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==
+
 private@^0.1.6:
   version "0.1.8"
   resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
@@ -15700,7 +15766,7 @@ read@1, read@~1.0.1:
   dependencies:
     mute-stream "~0.0.4"
 
-"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@~2.3.6:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
   dependencies:
@@ -15730,7 +15796,7 @@ readable-stream@1.1.x:
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-readable-stream@3, readable-stream@^3.0.0:
+readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.6.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
   integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@@ -15764,7 +15830,7 @@ readable-stream@^2.0.2, readable-stream@^2.2.2, readable-stream@^2.2.6, readable
     string_decoder "~1.0.3"
     util-deprecate "~1.0.1"
 
-readable-stream@^3.0.1, readable-stream@^3.0.2, readable-stream@^3.4.0:
+readable-stream@^3.0.2, readable-stream@^3.4.0:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc"
   integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==
@@ -15781,6 +15847,13 @@ readable-stream@^3.1.1:
     string_decoder "^1.1.1"
     util-deprecate "^1.0.1"
 
+readdir-glob@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.1.tgz#f0e10bb7bf7bfa7e0add8baffdc54c3f7dbee6c4"
+  integrity sha512-91/k1EzZwDx6HbERR+zucygRFfiPl2zkIYZtv3Jjr6Mn7SkKcVct8aVO+sSRiGMc6fLf72du3d92/uY63YPdEA==
+  dependencies:
+    minimatch "^3.0.4"
+
 readdir-scoped-modules@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
@@ -16945,10 +17018,10 @@ side-channel@^1.0.4:
     get-intrinsic "^1.0.2"
     object-inspect "^1.9.0"
 
-sift@7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/sift/-/sift-7.0.1.tgz#47d62c50b159d316f1372f8b53f9c10cd21a4b08"
-  integrity sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g==
+sift@13.5.2:
+  version "13.5.2"
+  resolved "https://registry.yarnpkg.com/sift/-/sift-13.5.2.tgz#24a715e13c617b086166cd04917d204a591c9da6"
+  integrity sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA==
 
 signal-exit@^3.0.0, signal-exit@^3.0.3:
   version "3.0.3"
@@ -17488,12 +17561,13 @@ stream-to-array@~2.3.0:
   dependencies:
     any-promise "^1.1.0"
 
-stream-to-promise@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/stream-to-promise/-/stream-to-promise-2.2.0.tgz#b1edb2e1c8cb11289d1b503c08d3f2aef51e650f"
+stream-to-promise@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/stream-to-promise/-/stream-to-promise-3.0.0.tgz#8934d66dcbc9189394e8b33200da3bb9611db774"
+  integrity sha512-h+7wLeFiYegOdgTfTxjRsrT7/Op7grnKEIHWgaO1RTHwcwk7xRreMr3S8XpDfDMesSxzgM2V4CxNCFAGo6ssnA==
   dependencies:
     any-promise "~1.3.0"
-    end-of-stream "~1.1.0"
+    end-of-stream "~1.4.1"
     stream-to-array "~2.3.0"
 
 streamroller@^1.0.3:
@@ -17542,6 +17616,15 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
+string-width@=4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
+  integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.0"
+
 string-width@^1.0.1, string-width@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
@@ -18056,12 +18139,12 @@ tapable@^1.0.0-beta.5, tapable@^1.1.3:
   resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
   integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
 
-tar-stream@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3"
-  integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==
+tar-stream@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
   dependencies:
-    bl "^3.0.0"
+    bl "^4.0.3"
     end-of-stream "^1.4.1"
     fs-constants "^1.0.0"
     inherits "^2.0.3"
@@ -18906,11 +18989,6 @@ universalify@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.1.tgz#fa71badd4437af4c148841e3b3b165f9e9e590b7"
 
-universalify@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d"
-  integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==
-
 universalify@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
@@ -20104,11 +20182,11 @@ zen-observable@^0.8.15:
   resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
   integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==
 
-zip-stream@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.2.tgz#841efd23214b602ff49c497cba1a85d8b5fbc39c"
-  integrity sha512-ykebHGa2+uzth/R4HZLkZh3XFJzivhVsjJt8bN3GvBzLaqqrUdRacu+c4QtnUgjkkQfsOuNE1JgLKMCPNmkKgg==
+zip-stream@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.0.tgz#51dd326571544e36aa3f756430b313576dc8fc79"
+  integrity sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A==
   dependencies:
     archiver-utils "^2.1.0"
-    compress-commons "^2.1.1"
-    readable-stream "^3.4.0"
+    compress-commons "^4.1.0"
+    readable-stream "^3.6.0"