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

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

Yuki Takei 4 лет назад
Родитель
Сommit
df1206eae5
60 измененных файлов с 1525 добавлено и 435 удалено
  1. 22 4
      CHANGES.md
  2. 1 0
      config/webpack.common.js
  3. 2 1
      package.json
  4. 0 1
      packages/slack/.eslintrc.js
  5. 6 0
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  6. 114 0
      packages/slack/src/utils/reshape-contents-body.test.ts
  7. 2 2
      packages/slack/src/utils/reshape-contents-body.ts
  8. 0 1
      packages/slackbot-proxy/.eslintrc.js
  9. 60 46
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  10. 1 1
      packages/slackbot-proxy/src/controllers/slack.ts
  11. 28 0
      packages/slackbot-proxy/src/interfaces/growi-uri-injector.ts
  12. 0 5
      packages/slackbot-proxy/src/middlewares/slack-to-growi/add-webclient-response-to-res.ts
  13. 20 23
      packages/slackbot-proxy/src/middlewares/slack-to-growi/extract-growi-uri-from-req.ts
  14. 84 0
      packages/slackbot-proxy/src/services/growi-uri-injector/ActionsBlockPayloadDelegator.ts
  15. 0 19
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectionButtonDelegator.ts
  16. 0 7
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjector.ts
  17. 0 18
      packages/slackbot-proxy/src/services/growi-uri-injector/GrowiUriInjectorFactory.ts
  18. 67 0
      packages/slackbot-proxy/src/services/growi-uri-injector/SectionBlockPayloadDelegator.ts
  19. 62 0
      packages/slackbot-proxy/src/services/growi-uri-injector/ViewInteractionPayloadDelegator.ts
  20. 43 0
      packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/ButtonActionPayloadDelegator.ts
  21. 57 0
      packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/CheckboxesActionPayloadDelegator.ts
  22. 0 10
      packages/slackbot-proxy/src/utils/extractGrowiUriFromView.ts
  23. 0 7
      packages/slackbot-proxy/src/utils/injectGrowiUriToView.ts
  24. 98 92
      packages/slackbot-proxy/src/views/privacy.ejs
  25. 70 64
      packages/slackbot-proxy/src/views/term.ejs
  26. BIN
      public/images/themes/hufflepuff/badger-dark.jpg
  27. BIN
      public/images/themes/hufflepuff/badger-light.png
  28. BIN
      public/images/themes/hufflepuff/badger-light3.png
  29. 10 1
      resource/locales/en_US/admin/admin.json
  30. 7 1
      resource/locales/en_US/translation.json
  31. 10 1
      resource/locales/ja_JP/admin/admin.json
  32. 7 1
      resource/locales/ja_JP/translation.json
  33. 10 1
      resource/locales/zh_CN/admin/admin.json
  34. 7 1
      resource/locales/zh_CN/translation.json
  35. 2 0
      src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx
  36. 1 1
      src/client/js/components/Admin/Security/LdapAuthTest.jsx
  37. 2 2
      src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx
  38. 34 1
      src/client/js/components/Admin/Security/ShareLinkSetting.jsx
  39. 6 2
      src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  40. 6 2
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  41. 6 3
      src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  42. 7 2
      src/client/js/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  43. 12 3
      src/client/js/components/Admin/SlackIntegration/SlackIntegration.jsx
  44. 12 6
      src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  45. 2 2
      src/client/js/components/Me/AssociateModal.jsx
  46. 3 2
      src/client/js/components/PageAccessoriesModal.jsx
  47. 6 2
      src/client/js/components/PageAccessoriesModalControl.jsx
  48. 8 0
      src/client/js/components/PageList.jsx
  49. 22 1
      src/client/js/services/AdminGeneralSecurityContainer.js
  50. 6 0
      src/client/styles/scss/_wiki.scss
  51. 310 0
      src/client/styles/scss/theme/hufflepuff.scss
  52. 2 0
      src/server/models/config.js
  53. 53 0
      src/server/routes/apiv3/security-setting.js
  54. 15 2
      src/server/routes/apiv3/share-links.js
  55. 28 26
      src/server/routes/apiv3/slack-integration-settings.js
  56. 6 4
      src/server/routes/apiv3/slack-integration.js
  57. 3 0
      src/server/routes/page.js
  58. 6 0
      src/server/service/config-loader.js
  59. 2 2
      src/server/service/search-delegator/elasticsearch.js
  60. 177 65
      src/server/service/slackbot.js

+ 22 - 4
CHANGES.md

@@ -1,13 +1,24 @@
 # CHANGES
 
-## v4.3.0-RC
+## v4.3.2-RC
+
+* 
+
+## v4.3.1
+
+* Fix: Build script for production
+
 
-* Support: Upgrade libs
-    * striptags
+
+## v4.3.0-RC
 
 ### BREAKING CHANGES
 
-None.
+* GROWI manages dependencies with `lerna`
+    * Use `npx lerna bootstrap` instead of `yarn install`
+* GROWI includes some official plugins in default
+    * Users no longer need to install [growi-plugin-lsx](https://github.com/weseek/growi-plugin-lsx), [growi-plugin-pukiwiki-like-linker](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) and [growi-plugin-attachment-refs](https://github.com/weseek/growi-plugin-attachment-refs) before build client.
+
 
 Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/43x.html>
 
@@ -17,6 +28,13 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/43x.html>
     * Searching GROWI pages from Slack
     * Creating GROWI pages from Slack
         * Easy record conversations
+* Feature: Enable/Disable option for share link
+* Feature: Re-send invitation mail from user management page
+* Improvement: Mark users who failed to send invitation emails
+* Fix: lsx plugin in the custom sidebar does not work when showing search result page
+* Support: Switch the official docker base image from Alpine based to Ubuntu based
+* Support: Upgrade libs
+    * striptags
 
 ## v4.2.21
 

+ 1 - 0
config/webpack.common.js

@@ -45,6 +45,7 @@ module.exports = (options) => {
       'styles/theme-island':      './src/client/styles/scss/theme/island.scss',
       'styles/theme-antarctic':      './src/client/styles/scss/theme/antarctic.scss',
       'styles/theme-spring':         './src/client/styles/scss/theme/spring.scss',
+      'styles/theme-hufflepuff':         './src/client/styles/scss/theme/hufflepuff.scss',
       // styles for external services
       'styles/style-hackmd':          './src/client/styles/hackmd/style.scss',
     }, options.entry || {}), // Merge with env dependent settings

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.3.0-RC",
+  "version": "4.3.2-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -34,6 +34,7 @@
     "build:dev:watch": "npm run build:dev:app:watch",
     "build:dev:watch:poll": "npm run build:dev:app:watch:poll",
     "build:dev": "yarn build:dev:app",
+    "build:prod": "yarn lerna run build --scope @growi/app --scope @growi/slack",
     "build:slack": "lerna run build --scope @growi/slack",
     "build": "npm run build:dev:watch",
     "build:poll": "npm run build:dev:watch:poll",

+ 0 - 1
packages/slack/.eslintrc.js

@@ -6,7 +6,6 @@ module.exports = {
     'plugin:jest/recommended',
   ],
   env: {
-    jquery: true,
     'jest/globals': true,
   },
   globals: {

+ 6 - 0
packages/slack/src/interfaces/request-between-growi-and-proxy.ts

@@ -6,6 +6,12 @@ export type RequestFromGrowi = Request & {
 
   // will be extracted from header
   tokenGtoPs: string[],
+
+  // Block Kit properties
+  body: {
+    view?: string,
+    blocks?: string
+  },
 };
 
 export type RequestFromProxy = Request & {

+ 114 - 0
packages/slack/src/utils/reshape-contents-body.test.ts

@@ -0,0 +1,114 @@
+import { reshapeContentsBody } from './reshape-contents-body';
+
+describe('reshapeContentsBody', () => {
+
+  describe('Markdown only', () => {
+    test('Return the same input', () => {
+      const input = `
+      # Title\u0020\u0020
+      ## Section\u0020\u0020
+      I tested this code at 12:00 AM.\u0020\u0020
+      **bold** text
+      some texts`;
+
+      expect(reshapeContentsBody(input)).toBe(input);
+    });
+  });
+
+  describe('Contains time but no headers', () => {
+    test('Return the same input', () => {
+      const input = `
+12:23
+some messages...
+12:23
+some messages...
+12:23`;
+
+      expect(reshapeContentsBody(input)).toBe(input);
+    });
+  });
+
+  describe('Copied from Slack only', () => {
+    test('Reshape', () => {
+      const input = `
+taichi-m  12:23 PM
+some messages...
+some messages...
+some messages...
+12:23
+some messages...
+12:23
+some messages...`;
+
+      const output = `
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+  describe('Copied from Slack only (24 hours format)', () => {
+    test('Reshape', () => {
+      const input = `
+taichi-m  12:23
+some messages...
+some messages...
+some messages...
+12:23
+some messages...
+12:23
+some messages...`;
+
+      const output = `
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23</span>
+\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+--12:23--\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+  describe('Markdown and copied from Slack', () => {
+    test('Reshape only after the first header', () => {
+      const input = `
+some messages...
+
+taichi-m  12:23 PM
+some messages...`;
+
+      const output = `some messages...
+<div class="grw-togetter">
+
+## **taichi-m**<span class="grw-togetter-time">  12:23 PM</span>
+\u0020\u0020
+some messages...\u0020\u0020
+</div>\u0020\u0020
+`;
+
+      expect(reshapeContentsBody(input)).toBe(output);
+    });
+  });
+
+});

+ 2 - 2
packages/slack/src/utils/reshape-contents-body.ts

@@ -42,7 +42,7 @@ export const reshapeContentsBody = (str: string): string => {
   const splitted = str.split('\n');
   const { linesBeforeFirstHeader, linesAfterFirstHeader } = devideLinesBeforeAfterFirstHeader(splitted);
   if (linesAfterFirstHeader.length === 0) {
-    return linesBeforeFirstHeader.join('');
+    return linesBeforeFirstHeader.join('\n');
   }
 
   let didReactionRemoved = false;
@@ -64,7 +64,7 @@ export const reshapeContentsBody = (str: string): string => {
       }
       // ##*username*  HH:mm AM
       copyline = '\n## **'.concat(copyline);
-      copyline = copyline.replace(regexpTime, '**'.concat(time));
+      copyline = copyline.replace(regexpTime, '**<span class="grw-togetter-time">'.concat(time, '</span>\n'));
     }
     // Check 3: Is this line a short time(HH:mm)?
     else if (regexpShortTime.test(copyline)) {

+ 0 - 1
packages/slackbot-proxy/.eslintrc.js

@@ -6,7 +6,6 @@ module.exports = {
     'plugin:jest/recommended',
   ],
   env: {
-    jquery: true,
     'jest/globals': true,
   },
   globals: {

+ 60 - 46
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -4,7 +4,7 @@ import {
 import axios from 'axios';
 import createError from 'http-errors';
 
-import { WebAPICallOptions, WebAPICallResult } from '@slack/web-api';
+import { WebAPICallResult } from '@slack/web-api';
 
 import {
   verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, generateWebClient,
@@ -19,8 +19,9 @@ 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';
+import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
+import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
+import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/SectionBlockPayloadDelegator';
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
@@ -43,6 +44,15 @@ export class GrowiToSlackCtrl {
   @Inject()
   orderRepository: OrderRepository;
 
+  @Inject()
+  viewInteractionPayloadDelegator: ViewInteractionPayloadDelegator;
+
+  @Inject()
+  actionsBlockPayloadDelegator: ActionsBlockPayloadDelegator;
+
+  @Inject()
+  sectionBlockPayloadDelegator: SectionBlockPayloadDelegator;
+
   async requestToGrowi(growiUrl:string, tokenPtoG:string):Promise<void> {
     const url = new URL('/_api/v3/slack-integration/proxied/commands', growiUrl);
     await axios.post(url.toString(), {
@@ -161,56 +171,62 @@ export class GrowiToSlackCtrl {
     logger.debug('relation test is success', order);
 
     // Transaction is not considered because it is used infrequently,
-    const createdRelation = await this.relationRepository.save({
-      installation: order.installation,
-      tokenGtoP: order.tokenGtoP,
-      tokenPtoG: order.tokenPtoG,
-      growiUri: order.growiUrl,
-      siglePostCommands: temporarySinglePostCommands,
-    });
-
-    return res.send({ relation: createdRelation, slackBotToken: token });
+    const response = await this.relationRepository.createQueryBuilder('relation')
+      .insert()
+      .values({
+        installation: order.installation,
+        tokenGtoP: order.tokenGtoP,
+        tokenPtoG: order.tokenPtoG,
+        growiUri: order.growiUrl,
+        siglePostCommands: temporarySinglePostCommands,
+      })
+      // https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487
+      .orUpdate({ conflict_target: ['installation', 'growiUri'], overwrite: ['tokenGtoP', 'tokenPtoG', 'siglePostCommands'] })
+      .execute();
+
+    // Find the generated relation
+    const generatedRelation = await this.relationRepository.findOne({ id: response.identifiers[0].id });
+
+    return res.send({ relation: generatedRelation, slackBotToken: token });
   }
 
-  injectGrowiUri(req:GrowiReq, growiUri:string):WebAPICallOptions {
+  injectGrowiUri(req: GrowiReq, growiUri: string): void {
+    if (req.body.view == null && req.body.blocks == null) {
+      return;
+    }
 
     if (req.body.view != null) {
-      injectGrowiUriToView(req.body, growiUri);
+      const parsedElement = JSON.parse(req.body.view);
+      // delegate to ViewInteractionPayloadDelegator
+      if (this.viewInteractionPayloadDelegator.shouldHandleToInject(parsedElement)) {
+        this.viewInteractionPayloadDelegator.inject(parsedElement, growiUri);
+        req.body.view = JSON.stringify(parsedElement);
+      }
     }
-
-    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);
+    else if (req.body.blocks != null) {
+      const parsedElement = JSON.parse(req.body.blocks);
+      // delegate to ActionsBlockPayloadDelegator
+      if (this.actionsBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
+        this.actionsBlockPayloadDelegator.inject(parsedElement, growiUri);
+        req.body.blocks = JSON.stringify(parsedElement);
+      }
+      // delegate to SectionBlockPayloadDelegator
+      if (this.sectionBlockPayloadDelegator.shouldHandleToInject(parsedElement)) {
+        this.sectionBlockPayloadDelegator.inject(parsedElement, growiUri);
+        req.body.blocks = JSON.stringify(parsedElement);
+      }
     }
-
-    const opt = req.body;
-    opt.headers = req.headers;
-
-    return opt;
   }
 
   @Post('/:method')
   @UseBefore(AddWebclientResponseToRes, verifyGrowiToSlackRequest)
-  async postResult(
+  async callSlackApi(
     @PathParams('method') method: string, @Req() req: GrowiReq, @Res() res: WebclientRes,
   ): Promise<void|string|Res|WebAPICallResult> {
     const { tokenGtoPs } = req;
 
+    logger.debug('Slack API called: ', { method });
+
     if (tokenGtoPs.length !== 1) {
       return res.webClientErr('tokenGtoPs is invalid', 'invalid_tokenGtoP');
     }
@@ -235,19 +251,17 @@ export class GrowiToSlackCtrl {
     const client = generateWebClient(token);
 
     try {
-      const opt = this.injectGrowiUri(req, relation.growiUri);
+      this.injectGrowiUri(req, relation.growiUri);
+
+      const opt = req.body;
+      opt.headers = req.headers;
 
-      await client.apiCall(method, opt);
+      return client.apiCall(method, opt);
     }
     catch (err) {
       logger.error(err);
       return res.webClientErr(`failed to send to slack. err: ${err.message}`, 'fail_api_call');
     }
-
-    logger.debug('send to slack is success');
-
-    // required to return ok for apiCall
-    return res.webClient();
   }
 
 }

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

@@ -256,7 +256,7 @@ export class SlackCtrl {
   }
 
   @Post('/events')
-  async handleEvent(@BodyParams() body:{[key:string]:string}, @Res() res: Res): Promise<void|string> {
+  async handleEvent(@BodyParams() body:{[key:string]:string} /* , @Res() res: Res */): Promise<void|string> {
     // eslint-disable-next-line max-len
     // see: https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification
     if (body.type === 'url_verification') {

+ 28 - 0
packages/slackbot-proxy/src/interfaces/growi-uri-injector.ts

@@ -0,0 +1,28 @@
+export type GrowiUriWithOriginalData = {
+  growiUri: string,
+  originalData: string,
+}
+
+export type TypedBlock = {
+  type: string,
+}
+
+/**
+ * Type guard for GrowiUriWithOriginalData
+ * @param data
+ * @returns
+ */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const isGrowiUriWithOriginalData = (data: any): data is GrowiUriWithOriginalData => {
+  return data.growiUri != null && data.originalData != null;
+};
+
+export interface GrowiUriInjector<ISDATA, IDATA, ESDATA, EDATA> {
+
+  shouldHandleToInject(data: ISDATA & any): data is IDATA;
+  inject(data: IDATA, growiUri:string): void;
+
+  shouldHandleToExtract(data: ESDATA & any): data is EDATA;
+  extract(data: EDATA): GrowiUriWithOriginalData;
+
+}

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

@@ -4,7 +4,6 @@ import {
 
 
 export type WebclientRes = Res & {
-  webClient: () => void,
   webClientErr: (message?:string, errorCode?:string) => void
 };
 
@@ -14,10 +13,6 @@ 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 });
     };

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

@@ -1,43 +1,40 @@
 import {
-  IMiddleware, Middleware, Next, Req, Res,
+  IMiddleware, Inject, 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';
+import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
+import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
+
 
 @Middleware()
 export class ExtractGrowiUriFromReq implements IMiddleware {
 
-  use(@Req() req: Req & SlackOauthReq, @Res() res: Res, @Next() next: Next): void {
+  @Inject()
+  viewInteractionPayloadDelegator: ViewInteractionPayloadDelegator;
+
+  @Inject()
+  actionsBlockPayloadDelegator: ActionsBlockPayloadDelegator;
+
+  use(@Req() req: SlackOauthReq, @Res() res: Res, @Next() next: Next): void {
 
     // There is no payload in the request from slack
     if (req.body.payload == null) {
       return next();
     }
 
-    const payload = JSON.parse(req.body.payload);
+    const parsedPayload = 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;
+    if (this.viewInteractionPayloadDelegator.shouldHandleToExtract(parsedPayload)) {
+      const data = this.viewInteractionPayloadDelegator.extract(parsedPayload);
+      req.growiUri = data.growiUri;
     }
-    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;
-        }
-      }
+    else if (this.actionsBlockPayloadDelegator.shouldHandleToExtract(parsedPayload)) {
+      const data = this.actionsBlockPayloadDelegator.extract(parsedPayload);
+      req.growiUri = data.growiUri;
     }
 
-    req.body.payload = JSON.stringify(payload);
+    req.body.payload = JSON.stringify(parsedPayload);
 
     return next();
   }

+ 84 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/ActionsBlockPayloadDelegator.ts

@@ -0,0 +1,84 @@
+import { Inject, OnInit, Service } from '@tsed/di';
+import {
+  GrowiUriInjector, GrowiUriWithOriginalData, TypedBlock,
+} from '~/interfaces/growi-uri-injector';
+import { ButtonActionPayloadDelegator } from './block-elements/ButtonActionPayloadDelegator';
+import { CheckboxesActionPayloadDelegator } from './block-elements/CheckboxesActionPayloadDelegator';
+
+
+// see: https://api.slack.com/reference/block-kit/blocks
+type BlockElement = TypedBlock & {
+  elements: (TypedBlock & any)[],
+}
+
+// see: https://api.slack.com/reference/interaction-payloads/block-actions
+type BlockActionsPayload = TypedBlock & {
+  actions: TypedBlock[],
+}
+
+@Service()
+export class ActionsBlockPayloadDelegator implements GrowiUriInjector<any, BlockElement[], any, BlockActionsPayload>, OnInit {
+
+  @Inject()
+  buttonActionPayloadDelegator: ButtonActionPayloadDelegator;
+
+  @Inject()
+  checkboxesActionPayloadDelegator: CheckboxesActionPayloadDelegator;
+
+  private childDelegators: GrowiUriInjector<TypedBlock[], any, TypedBlock, any>[] = [];
+
+  $onInit(): void | Promise<any> {
+    this.childDelegators.push(
+      this.buttonActionPayloadDelegator,
+      this.checkboxesActionPayloadDelegator,
+    );
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToInject(data: any): data is BlockElement[] {
+    const actionsBlocks = data.filter(blockElement => blockElement.type === 'actions');
+    return actionsBlocks.length > 0;
+  }
+
+  inject(data: BlockElement[], growiUri: string): void {
+    const actionsBlocks = data.filter(blockElement => blockElement.type === 'actions');
+
+    // collect elements
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const elements = actionsBlocks.flatMap(actionBlock => actionBlock.elements!);
+
+    this.childDelegators.forEach((delegator) => {
+      if (delegator.shouldHandleToInject(elements)) {
+        delegator.inject(elements, growiUri);
+      }
+    });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToExtract(data: any): data is BlockActionsPayload {
+    if (data.actions == null || data.actions.length === 0) {
+      return false;
+    }
+
+    const action = data.actions[0];
+    return this.childDelegators
+      .map(delegator => delegator.shouldHandleToExtract(action))
+      .includes(true);
+  }
+
+  extract(data: BlockActionsPayload): GrowiUriWithOriginalData {
+    let growiUriWithOriginalData: GrowiUriWithOriginalData;
+
+    const action = data.actions[0];
+    for (const delegator of this.childDelegators) {
+      if (delegator.shouldHandleToExtract(action)) {
+        growiUriWithOriginalData = delegator.extract(action);
+        break;
+      }
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return growiUriWithOriginalData!;
+  }
+
+}

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

@@ -1,19 +0,0 @@
-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;
-  }
-
-}

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

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

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

@@ -1,18 +0,0 @@
-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]();
-};

+ 67 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/SectionBlockPayloadDelegator.ts

@@ -0,0 +1,67 @@
+import { Inject, OnInit, Service } from '@tsed/di';
+import {
+  GrowiUriInjector, GrowiUriWithOriginalData, TypedBlock,
+} from '~/interfaces/growi-uri-injector';
+import { ButtonActionPayloadDelegator } from './block-elements/ButtonActionPayloadDelegator';
+import { CheckboxesActionPayloadDelegator } from './block-elements/CheckboxesActionPayloadDelegator';
+
+
+// see: https://api.slack.com/reference/block-kit/blocks#section
+type SectionWithAccessoryElement = TypedBlock & {
+  accessory: TypedBlock & any,
+}
+
+// see: https://api.slack.com/reference/interaction-payloads/block-actions
+type BlockActionsPayload = TypedBlock & {
+  actions: TypedBlock[],
+}
+
+@Service()
+export class SectionBlockPayloadDelegator implements GrowiUriInjector<any, SectionWithAccessoryElement[], any, BlockActionsPayload>, OnInit {
+
+  @Inject()
+  buttonActionPayloadDelegator: ButtonActionPayloadDelegator;
+
+  @Inject()
+  checkboxesActionPayloadDelegator: CheckboxesActionPayloadDelegator;
+
+  private childDelegators: GrowiUriInjector<TypedBlock[], any, TypedBlock, any>[] = [];
+
+  $onInit(): void | Promise<any> {
+    this.childDelegators.push(
+      this.buttonActionPayloadDelegator,
+      this.checkboxesActionPayloadDelegator,
+    );
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToInject(data: any): data is SectionWithAccessoryElement[] {
+    const sectionBlocks = data.filter(blockElement => blockElement.type === 'section' && blockElement.accessory != null);
+    return sectionBlocks.length > 0;
+  }
+
+  inject(data: SectionWithAccessoryElement[], growiUri: string): void {
+    const sectionBlocks = data.filter(blockElement => blockElement.type === 'section' && blockElement.accessory != null);
+
+    // collect elements
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const accessories = sectionBlocks.flatMap(sectionBlock => sectionBlock.accessory);
+
+    this.childDelegators.forEach((delegator) => {
+      if (delegator.shouldHandleToInject(accessories)) {
+        delegator.inject(accessories, growiUri);
+      }
+    });
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToExtract(data: any): data is BlockActionsPayload {
+    return false;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  extract(data: BlockActionsPayload): GrowiUriWithOriginalData {
+    throw new Error('No need to implement. Use ActionsBlockPayloadDelegator');
+  }
+
+}

+ 62 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/ViewInteractionPayloadDelegator.ts

@@ -0,0 +1,62 @@
+import { Service } from '@tsed/di';
+import {
+  GrowiUriInjector, GrowiUriWithOriginalData, isGrowiUriWithOriginalData, TypedBlock,
+} from '~/interfaces/growi-uri-injector';
+
+// see: https://api.slack.com/reference/interaction-payloads/views
+type ViewElement = TypedBlock & {
+  'private_metadata'?: any,
+}
+
+// see: https://api.slack.com/reference/interaction-payloads/views
+type ViewInteractionPayload = TypedBlock & {
+  view: {
+    'private_metadata'?: any,
+  },
+}
+
+@Service()
+export class ViewInteractionPayloadDelegator implements GrowiUriInjector<any, ViewElement, any, ViewInteractionPayload> {
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToInject(data: any): data is ViewElement {
+    return data.type != null && data.private_metadata != null;
+  }
+
+  inject(data: ViewElement, growiUri :string): void {
+    const originalData = data.private_metadata;
+
+    const urlWithOrgData: GrowiUriWithOriginalData = { growiUri, originalData };
+
+    data.private_metadata = JSON.stringify(urlWithOrgData);
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  shouldHandleToExtract(data: any): data is ViewInteractionPayload {
+    const { type, view } = data;
+    if (type !== 'view_submission') {
+      return false;
+    }
+    if (view.private_metadata == null) {
+      return false;
+    }
+
+    try {
+      const restoredData: any = JSON.parse(view.private_metadata);
+      return isGrowiUriWithOriginalData(restoredData);
+    }
+    // when parsing failed
+    catch (err) {
+      return false;
+    }
+  }
+
+  extract(data: ViewInteractionPayload): GrowiUriWithOriginalData {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const restoredData: GrowiUriWithOriginalData = JSON.parse(data.view.private_metadata!); // private_metadata must not be null at this moment
+    data.view.private_metadata = restoredData.originalData;
+
+    return restoredData;
+  }
+
+}

+ 43 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/ButtonActionPayloadDelegator.ts

@@ -0,0 +1,43 @@
+import { Service } from '@tsed/di';
+import { GrowiUriWithOriginalData, GrowiUriInjector, TypedBlock } from '~/interfaces/growi-uri-injector';
+
+
+type ButtonElement = TypedBlock & {
+  value: string,
+}
+
+type ButtonActionPayload = TypedBlock & {
+  value: string,
+}
+
+@Service()
+export class ButtonActionPayloadDelegator implements GrowiUriInjector<TypedBlock[], ButtonElement[], TypedBlock, ButtonActionPayload> {
+
+  shouldHandleToInject(elements: TypedBlock[]): elements is ButtonElement[] {
+    const buttonElements = elements.filter(element => element.type === 'button');
+    return buttonElements.length > 0;
+  }
+
+  inject(elements: ButtonElement[], growiUri: string): void {
+    const buttonElements = elements.filter(blockElement => blockElement.type === 'button');
+
+    buttonElements
+      .forEach((element) => {
+        const urlWithOrgData: GrowiUriWithOriginalData = { growiUri, originalData: element.value };
+        element.value = JSON.stringify(urlWithOrgData);
+      });
+  }
+
+  shouldHandleToExtract(action: TypedBlock): action is ButtonActionPayload {
+    return action.type === 'button';
+  }
+
+  extract(action: ButtonActionPayload): GrowiUriWithOriginalData {
+    const restoredData: GrowiUriWithOriginalData = JSON.parse(action.value);
+    action.value = restoredData.originalData;
+
+    return restoredData;
+  }
+
+
+}

+ 57 - 0
packages/slackbot-proxy/src/services/growi-uri-injector/block-elements/CheckboxesActionPayloadDelegator.ts

@@ -0,0 +1,57 @@
+import { Service } from '@tsed/di';
+import { GrowiUriWithOriginalData, GrowiUriInjector, TypedBlock } from '~/interfaces/growi-uri-injector';
+
+
+type CheckboxesElement = TypedBlock & {
+  options: { value: string }[],
+}
+
+type CheckboxesActionPayload = TypedBlock & {
+  'selected_options': { value: string }[],
+}
+
+@Service()
+export class CheckboxesActionPayloadDelegator implements GrowiUriInjector<TypedBlock[], CheckboxesElement[], TypedBlock, CheckboxesActionPayload> {
+
+  shouldHandleToInject(elements: TypedBlock[]): elements is CheckboxesElement[] {
+    const buttonElements = elements.filter(element => element.type === 'checkboxes');
+    return buttonElements.length > 0;
+  }
+
+  inject(elements: CheckboxesElement[], growiUri: string): void {
+    const cbElements = elements.filter(blockElement => blockElement.type === 'checkboxes');
+
+    cbElements.forEach((element) => {
+      element.options.forEach((option) => {
+        const urlWithOrgData: GrowiUriWithOriginalData = { growiUri, originalData: option.value };
+        option.value = JSON.stringify(urlWithOrgData);
+      });
+    });
+  }
+
+  shouldHandleToExtract(action: TypedBlock): action is CheckboxesActionPayload {
+    return (
+      action.type === 'checkboxes'
+      && (action as CheckboxesActionPayload).selected_options != null
+      // ...Unsolved problem...
+      // slackbot-proxy can't determine growiUri when selected_options is empty -- 2021.07.12 Yuki Takei
+      && (action as CheckboxesActionPayload).selected_options.length > 0
+    );
+  }
+
+  extract(action: CheckboxesActionPayload): GrowiUriWithOriginalData {
+    let oneOfRestoredData: GrowiUriWithOriginalData;
+
+    action.selected_options.forEach((selectedOption) => {
+      const restoredData = JSON.parse(selectedOption.value);
+      selectedOption.value = restoredData.originalData;
+
+      // update oneOfRestoredData
+      oneOfRestoredData = restoredData;
+    });
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return oneOfRestoredData!;
+  }
+
+}

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

@@ -1,10 +0,0 @@
-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;
-};

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

@@ -1,7 +0,0 @@
-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);
-};

+ 98 - 92
packages/slackbot-proxy/src/views/privacy.ejs

@@ -1,95 +1,101 @@
 <%- include('commons/head'); %>
 
-<body style="max-width: 600px; padding-top:100px; margin: 0 auto;">
-  <h1 style="text-align:center;">Privacy Policy</h1>
-  <h2 style="text-align:center;">At First</h2>
-  <p>
-    Your privacy is critically important to us. At GROWI Official Bot we have a few fundamental principles:
-  </p>
-  <ul>
-    <li>We don’t ask you for personal information unless we truly need it.</li>
-    <li>We don’t share your personal information with anyone except to comply with the law, develop our products, or protect our rights.</li>
-    <li>We don’t store personal information on our servers unless required for the on-going operation of the service.</li>
-  </ul>
-  <p>
-    If you have questions about deleting or correcting your personal data please contact support.
-  </p>
-  <p>
-    WESEEK, Inc. operates slack bot about GROWI. – henceforth referred to as "GROWI Official Bot". It is slack bot’s policy to respect your privacy regarding any information we may collect while operating our service.
-  </p>
-  <h2 style="text-align:center;">What Personal Data Do We Receive?</h2>
-  <p>
-    Personal information is information about an identified or identifiable individual, or about an identifiable individual, including information that WESEEK, Inc. can associate with an individual.
-  </p>
-  <p>
-    When using or operating the GROWI Official Bot, we may collect or process the following categories of personal information on your behalf.
-  </p>
-  <h2 style="text-align:center;">Protection of specific personal information</h2>
-  <p>
-    WESEEK, Inc. provides potentially personally identifiable information and personally identifiable information.
-  </p>
-  <ul>
-    <li>
-      We will only disclose it to the information of employees, contractors, and related organizations who need to know that information to process on behalf of WESEEK, Inc. or to provide the services available on the GROWI Official Bot.
-    </li>
-    <li>
-      Those who have agreed not to disclose it to others. Some of these employees, contractors, and related organizations may be located outside of their home country.
-    </li>
-  </ul>
-  <p>
-    By using GROWI Official Bot, you agree to transfer such information to them. As mentioned above, other than employees, contractors, and related organizations, WESEEK, Inc. does not lend or sell personally identifiable or personally identifiable information to third parties.
-  </p>
-  <p>
-    WESEEK, Inc. will take all reasonable steps to protect personally identifiable information and personally identifiable information from unauthorized access, use, modification or destruction.
-  </p>
-  <h2 style="text-align:center;">Other information to collect</h2>
-  <p>
-    order to enable mutual communication between your GROWI and Slack, we may collect, retain and process the following information that does not fall within the definition of personal information.
-  </p>
-  <ul>
-    <li>
-      Slack workspace information
-      <ul>
-        <li>
-          Includes workspace name, team ID, bot token associated with the workspace, and more.
-        </li>
-      </ul>
-    </li>
-    <li>
-      GROWI information
-      <ul>
-        <li>
-          Includes GROWI URIs for communicating with Slack, access tokens, and more.
-        </li>
-      </ul>
-    </li>
-    <li>
-      Information about communication
-      <ul>
-        <li>
-          Contains information about communication between Slack and GROWI.
-        </li>
-      </ul>
-    </li>
-  </ul>
-  <h2 style="text-align:center;">
-    Business Transfers
-  </h2>
-  <p>
-    If WESEEK, Inc. or substantially all of its assets, were acquired, or in the unlikely event that WESEEK, Inc.
-    goes out of business or enters bankruptcy, user information would be one of the assets that is transferred or acquired by a third party.
-  </p>
-  <p>
-    You acknowledge that such transfers may occur, and that any acquirer of WESEEK, Inc. may continue to use your personal information as set forth in this policy.
-  </p>
-  <h2 style="text-align:center;">
-    Privacy Policy Changes
-  </h2>
-  <p>
-    Although most changes are likely to be minor, WESEEK, Inc. may change its Privacy Policy from time to time, and in WESEEK, Inc.’s sole discretion.
-    WESEEK, Inc. encourages visitors to frequently check this page for any changes to its Privacy Policy.
-  </p>
-  <p>
-    Your continued use of this site after any change in this Privacy Policy will constitute your acceptance of such change.
-  </p>
+<body>
+  <div class="row">
+    <div class="col-12 col-md-6 offset-md-3">
+      <div class="p-3">
+        <h1 class="text-center">Privacy Policy</h1>
+        <h2 class="text-center"">At First</h2>
+        <p>
+          Your privacy is critically important to us. At GROWI Official Bot we have a few fundamental principles:
+        </p>
+        <ul>
+          <li>We don’t ask you for personal information unless we truly need it.</li>
+          <li>We don’t share your personal information with anyone except to comply with the law, develop our products, or protect our rights.</li>
+          <li>We don’t store personal information on our servers unless required for the on-going operation of the service.</li>
+        </ul>
+        <p>
+          If you have questions about deleting or correcting your personal data please contact support.
+        </p>
+        <p>
+          WESEEK, Inc. operates slack bot about GROWI. – henceforth referred to as "GROWI Official Bot". It is slack bot’s policy to respect your privacy regarding any information we may collect while operating our service.
+        </p>
+        <h2 class="text-center"">What Personal Data Do We Receive?</h2>
+        <p>
+          Personal information is information about an identified or identifiable individual, or about an identifiable individual, including information that WESEEK, Inc. can associate with an individual.
+        </p>
+        <p>
+          When using or operating the GROWI Official Bot, we may collect or process the following categories of personal information on your behalf.
+        </p>
+        <h2 class="text-center"">Protection of specific personal information</h2>
+        <p>
+          WESEEK, Inc. provides potentially personally identifiable information and personally identifiable information.
+        </p>
+        <ul>
+          <li>
+            We will only disclose it to the information of employees, contractors, and related organizations who need to know that information to process on behalf of WESEEK, Inc. or to provide the services available on the GROWI Official Bot.
+          </li>
+          <li>
+            Those who have agreed not to disclose it to others. Some of these employees, contractors, and related organizations may be located outside of their home country.
+          </li>
+        </ul>
+        <p>
+          By using GROWI Official Bot, you agree to transfer such information to them. As mentioned above, other than employees, contractors, and related organizations, WESEEK, Inc. does not lend or sell personally identifiable or personally identifiable information to third parties.
+        </p>
+        <p>
+          WESEEK, Inc. will take all reasonable steps to protect personally identifiable information and personally identifiable information from unauthorized access, use, modification or destruction.
+        </p>
+        <h2 class="text-center"">Other information to collect</h2>
+        <p>
+          order to enable mutual communication between your GROWI and Slack, we may collect, retain and process the following information that does not fall within the definition of personal information.
+        </p>
+        <ul>
+          <li>
+            Slack workspace information
+            <ul>
+              <li>
+                Includes workspace name, team ID, bot token associated with the workspace, and more.
+              </li>
+            </ul>
+          </li>
+          <li>
+            GROWI information
+            <ul>
+              <li>
+                Includes GROWI URIs for communicating with Slack, access tokens, and more.
+              </li>
+            </ul>
+          </li>
+          <li>
+            Information about communication
+            <ul>
+              <li>
+                Contains information about communication between Slack and GROWI.
+              </li>
+            </ul>
+          </li>
+        </ul>
+        <h2 class="text-center"">
+          Business Transfers
+        </h2>
+        <p>
+          If WESEEK, Inc. or substantially all of its assets, were acquired, or in the unlikely event that WESEEK, Inc.
+          goes out of business or enters bankruptcy, user information would be one of the assets that is transferred or acquired by a third party.
+        </p>
+        <p>
+          You acknowledge that such transfers may occur, and that any acquirer of WESEEK, Inc. may continue to use your personal information as set forth in this policy.
+        </p>
+        <h2 class="text-center"">
+          Privacy Policy Changes
+        </h2>
+        <p>
+          Although most changes are likely to be minor, WESEEK, Inc. may change its Privacy Policy from time to time, and in WESEEK, Inc.’s sole discretion.
+          WESEEK, Inc. encourages visitors to frequently check this page for any changes to its Privacy Policy.
+        </p>
+        <p>
+          Your continued use of this site after any change in this Privacy Policy will constitute your acceptance of such change.
+        </p>
+      </div>
+    </div>
+  </div>
 </body>

+ 70 - 64
packages/slackbot-proxy/src/views/term.ejs

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

BIN
public/images/themes/hufflepuff/badger-dark.jpg


BIN
public/images/themes/hufflepuff/badger-light.png


BIN
public/images/themes/hufflepuff/badger-light3.png


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

@@ -339,7 +339,16 @@
       "integration_some_ws_is_not_complete": "Some work spaces is not linked"
     },
     "custom_bot_with_proxy_integration": "Custom Bot With Proxy Integration",
-    "official_bot_integration": "Official bot integration"
+    "official_bot_integration": "Official bot integration",
+    "docs_url": {
+      "slack_integration": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/",
+      "official_bot": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#official-bot-%E3%80%90recommended%E3%80%91",
+      "custom_bot_without_proxy": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#custom-bot-without-proxy",
+      "custom_bot_with_proxy": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#custom-bot-with-proxy",
+      "official_bot_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html",
+      "custom_bot_without_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-without-proxy-settings.html",
+      "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
+    }
   },
   "user_management": {
     "invite_users": "Temporarily issue a new user",

+ 7 - 1
resource/locales/en_US/translation.json

@@ -151,6 +151,7 @@
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
+  "Link sharing is disabled": "Link sharing is disabled",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -170,7 +171,8 @@
     "page_not_exist_alert": "This page does not exist. Please create a new page."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_page_list": "There are no pages under this page.",
+    "link_sharing_is_disabled": "Link sharing is disabled."
   },
   "installer": {
     "setup": "Setup",
@@ -444,6 +446,7 @@
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",
+    "switch_disable_link_sharing_success": "Succeeded to update share link setting",
     "failed_to_reset_password":"Failed to reset password"
   },
   "template": {
@@ -587,6 +590,9 @@
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
     },
+    "share_link_rights": "Share link rights",
+    "enable_link_sharing": "Enable link sharing",
+    "all_share_links": "All share links",
     "configuration": " Configuration",
     "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",

+ 10 - 1
resource/locales/ja_JP/admin/admin.json

@@ -337,7 +337,16 @@
 
     },
     "custom_bot_with_proxy_integration": "Custom bot with proxy 連携",
-    "official_bot_integration": "Official bot 連携"
+    "official_bot_integration": "Official bot 連携",
+    "docs_url": {
+      "slack_integration": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/",
+      "official_bot": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/#official-bot-%E6%8E%A8%E5%A5%A8",
+      "custom_bot_without_proxy": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/#custom-bot-without-proxy",
+      "custom_bot_with_proxy": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/#custom-bot-with-proxy",
+      "official_bot_setting": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/official-bot-settings.html",
+      "custom_bot_without_proxy_setting": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/custom-bot-without-proxy-settings.html",
+      "custom_bot_with_proxy_setting": "https://docs.growi.org/ja/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
+    }
   },
   "user_management": {
     "invite_users": "新規ユーザーの仮発行",

+ 7 - 1
resource/locales/ja_JP/translation.json

@@ -154,6 +154,7 @@
   "original_path":"元のパス",
   "new_path":"新しいパス",
   "duplicated_path":"重複したパス",
+  "Link sharing is disabled": "リンクのシェアは無効化されています",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -173,7 +174,8 @@
     "page_not_exist_alert": "このページは存在しません。新たに作成する必要があります。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_page_list": "このページの配下にはページが存在しません。",
+    "link_sharing_is_disabled": "リンクのシェアは無効化されています"
   },
   "installer": {
     "setup": "セットアップ",
@@ -446,6 +448,7 @@
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",
+    "switch_disable_link_sharing_success": "共有リンクの設定を変更しました",
     "failed_to_reset_password":"パスワードのリセットに失敗しました"
   },
   "template": {
@@ -586,6 +589,9 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
+    "share_link_rights": "シェアリンクの権限",
+    "enable_link_sharing": "リンクのシェアを許可",
+    "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
     "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",

+ 10 - 1
resource/locales/zh_CN/admin/admin.json

@@ -346,7 +346,16 @@
       "integration_some_ws_is_not_complete": "有的工作空间未能连接"
     },
     "custom_bot_with_proxy_integration": "Custom bot with proxy 一体化",
-    "official_bot_integration": "Official bot 一体化"
+    "official_bot_integration": "Official bot 一体化",
+    "docs_url": {
+      "slack_integration": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/",
+      "official_bot": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#official-bot-%E3%80%90recommended%E3%80%91",
+      "custom_bot_without_proxy": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#custom-bot-without-proxy",
+      "custom_bot_with_proxy": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#custom-bot-with-proxy",
+      "official_bot_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html",
+      "custom_bot_without_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-without-proxy-settings.html",
+      "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
+    }
   },
 	"user_management": {
 		"invite_users": "临时发布新用户",

+ 7 - 1
resource/locales/zh_CN/translation.json

@@ -160,6 +160,7 @@
   "original_path":"Original path",
   "new_path":"New path",
   "duplicated_path":"duplicated_path",
+  "Link sharing is disabled": "你不允许分享该链接",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -171,7 +172,8 @@
     "page_not_exist_alert": "该页面不存在,请创建一个新页面"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_page_list": "There are no pages under this page.",
+    "link_sharing_is_disabled": "链接共享已被禁用"
   },
 	"installer": {
 		"setup": "安装",
@@ -421,6 +423,7 @@
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
 		"remove_user_success": "Succeeded to removing {{username}} ",
     "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password"
   },
 	"template": {
@@ -575,6 +578,9 @@
 			"restricted": "受限(需要管理员批准)",
 			"closed": "已关闭(仅限邀请)"
 		},
+    "share_link_rights": "分享链接权",
+    "enable_link_sharing": "启用链接共享",
+    "all_share_links": "所有共享链接",
 		"configuration": " 配置",
 		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",

+ 2 - 0
src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -20,6 +20,8 @@ class CustomizeThemeOptions extends React.Component {
       name: 'default',    bg: '#ffffff', topbar: '#2a2929', sidebar: '#122c55', theme: '#209fd8',
     }, {
       name: 'mono-blue',  bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
+    }, {
+      name: 'hufflepuff',  bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
     }];
 
     const uniqueTheme = [{

+ 1 - 1
src/client/js/components/Admin/Security/LdapAuthTest.jsx

@@ -114,7 +114,7 @@ class LdapAuthTest extends React.Component {
 
         <div className="form-group">
           <label><h5>Logs</h5></label>
-          <textarea id="taLogs" className="col" rows="4" value={this.state.logs} readOnly />
+          <textarea id="taLogs" className="col form-control" rows="4" value={this.state.logs} readOnly />
         </div>
 
         <div>

+ 2 - 2
src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -225,9 +225,9 @@ class LdapSecuritySettingContents extends React.Component {
                 <p className="form-text text-muted">
                   <small>
                     {t('security_setting.example')}1 - {t('security_setting.ldap.search_filter_example1')}:
-                    <code>(|(uid={'{{ username }}'})(mail={'{{ username }}'}))</code><br />
+                    <code>(|(uid={'{{username}}'})(mail={'{{username}}'}))</code><br />
                     {t('security_setting.example')}2 - {t('security_setting.ldap.search_filter_example2')}:
-                    <code>(sAMAccountName={'{{ username }}'})</code>
+                    <code>(sAMAccountName={'{{username}}'})</code>
                   </small>
                 </p>
               </div>

+ 34 - 1
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -51,6 +51,7 @@ class ShareLinkSetting extends React.Component {
     this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
     this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
     this.deleteLinkById = this.deleteLinkById.bind(this);
+    this.switchDisableLinkSharing = this.switchDisableLinkSharing.bind(this);
   }
 
   componentWillMount() {
@@ -105,11 +106,22 @@ class ShareLinkSetting extends React.Component {
     this.getShareLinkList(shareLinksActivePage);
   }
 
+  async switchDisableLinkSharing() {
+    const { t, adminGeneralSecurityContainer } = this.props;
+    try {
+      await adminGeneralSecurityContainer.switchDisableLinkSharing();
+      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
 
   render() {
     const { t, adminGeneralSecurityContainer } = this.props;
     const {
-      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit, disableLinkSharing,
     } = adminGeneralSecurityContainer.state;
 
     return (
@@ -125,6 +137,27 @@ class ShareLinkSetting extends React.Component {
           </button>
           <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
         </div>
+        <h4>{t('security_setting.share_link_rights')}</h4>
+        <div className="row mb-5">
+          <div className="col-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="disableLinkSharing"
+                checked={!disableLinkSharing}
+                onChange={() => this.switchDisableLinkSharing()}
+              />
+              <label className="custom-control-label" htmlFor="disableLinkSharing">
+                {t('security_setting.enable_link_sharing')}
+              </label>
+            </div>
+            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && disableLinkSharing && (
+              <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>
+            )}
+          </div>
+        </div>
+        <h4>{t('security_setting.all_share_links')}</h4>
         <Pager
           links={shareLinks}
           activePage={shareLinksActivePage}

+ 6 - 2
src/client/js/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -66,8 +66,12 @@ const CustomBotWithProxySettings = (props) => {
   return (
     <>
       <h2 className="admin-setting-header mb-2">{t('admin:slack_integration.custom_bot_with_proxy_integration')}
-        {/* TODO: If Bot-manual section of docs is merged into master, show links and add an appropriate links by GW-5614. */}
-        {/* <i className="fa fa-external-link btn-link ml-2" aria-hidden="true"></i> */}
+        <a href={t('admin:slack_integration.docs_url.custom_bot_with_proxy')} target="_blank" rel="noopener noreferrer">
+          <i
+            className="fa fa-external-link btn-link ml-2"
+            aria-hidden="true"
+          />
+        </a>
       </h2>
 
       {slackAppIntegrations.length !== 0 && (

+ 6 - 2
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -21,8 +21,12 @@ const CustomBotWithoutProxySettings = (props) => {
   return (
     <>
       <h2 className="admin-setting-header">{t('admin:slack_integration.custom_bot_without_proxy_integration')}
-        {/* TODO: If Bot-manual section of docs is merged into master, show links and add an appropriate links by GW-5614. */}
-        {/* <i className="fa fa-external-link btn-link ml-2" aria-hidden="true"></i> */}
+        <a href={t('admin:slack_integration.docs_url.custom_bot_without_proxy')} target="_blank" rel="noopener noreferrer">
+          <i
+            className="fa fa-external-link btn-link ml-2"
+            aria-hidden="true"
+          />
+        </a>
       </h2>
 
       <CustomBotWithoutProxyConnectionStatus

+ 6 - 3
src/client/js/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -76,15 +76,18 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
             {t('admin:slack_integration.accordion.create_bot')}
             <i className="fa fa-external-link ml-2" aria-hidden="true" />
           </button>
-          {/* TODO: If Bot-manual section of docs is merged into master, show links and add an appropriate links by GW-5614. */}
-          {/* <a href="#">
+          <a
+            href={t('admin:slack_integration.docs_url.custom_bot_without_proxy_setting')}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
             <p className="text-center mt-1">
               <small>
                 {t('admin:slack_integration.accordion.how_to_create_a_bot')}
                 <i className="fa fa-external-link ml-2" aria-hidden="true" />
               </small>
             </p>
-          </a> */}
+          </a>
         </div>
       </Accordion>
       <Accordion

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

@@ -47,8 +47,13 @@ const OfficialBotSettings = (props) => {
   return (
     <>
       <h2 className="admin-setting-header">{t('admin:slack_integration.official_bot_integration')}
-        {/* TODO: If Bot-manual section of docs is merged into master, show links and add an appropriate links by GW-5614. */}
-        {/* <i className="fa fa-external-link btn-link ml-2" aria-hidden="true"></i> */}
+        <a href={t('admin:slack_integration.docs_url.official_bot')} target="_blank" rel="noopener noreferrer">
+          <i
+            className="fa fa-external-link btn-link ml-2"
+            aria-hidden="true"
+            onClick={() => window.open(`${t('admin:slack_integration.docs_url.official_bot')}`, '_blank')}
+          />
+        </a>
       </h2>
 
       {slackAppIntegrations.length !== 0 && (

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

@@ -28,6 +28,8 @@ const SlackIntegration = (props) => {
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
   const [connectionStatuses, setConnectionStatuses] = useState({});
+  const [errorMsg, setErrorMsg] = useState(null);
+  const [errorCode, setErrorCode] = useState(null);
   const [isLoading, setIsLoading] = useState(true);
 
 
@@ -38,6 +40,8 @@ const SlackIntegration = (props) => {
         slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri,
       } = data.settings;
 
+      setErrorMsg(data.errorMsg);
+      setErrorCode(data.errorCode);
       setConnectionStatuses(data.connectionStatuses);
       setCurrentBotType(data.currentBotType);
       setSlackSigningSecret(slackSigningSecret);
@@ -187,13 +191,18 @@ const SlackIntegration = (props) => {
       <div className="selecting-bot-type mb-5">
         <h2 className="admin-setting-header mb-4">
           {t('admin:slack_integration.selecting_bot_types.slack_bot')}
-          {/* TODO: If Bot-manual section of docs is merged into master, show links and add an appropriate links by GW-5614. */}
-          {/* <a className="ml-2 btn-link" href="#">
+          <a className="ml-2 btn-link" href={t('admin:slack_integration.docs_url.slack_integration')} target="_blank" rel="noopener noreferrer">
             {t('admin:slack_integration.selecting_bot_types.detailed_explanation')}
             <i className="fa fa-external-link ml-1" aria-hidden="true"></i>
-          </a> */}
+          </a>
         </h2>
 
+        { errorCode && (
+          <div className="alert alert-warning">
+            <strong>ERROR: </strong>{errorMsg} ({errorCode})
+          </div>
+        ) }
+
         <div className="d-flex justify-content-end">
           <button
             className="btn btn-outline-danger"

+ 12 - 6
src/client/js/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -22,15 +22,18 @@ const BotCreateProcess = () => {
         {t('admin:slack_integration.accordion.create_bot')}
         <i className="fa fa-external-link ml-2" aria-hidden="true" />
       </button>
-      {/* TODO: If Bot-manual section of docs is merged into master, show links and add an appropriate links by GW-5614. */}
-      {/* <a href="#">
+      <a
+        href={t('admin:slack_integration.docs_url.custom_bot_with_proxy_setting')}
+        target="_blank"
+        rel="noopener noreferrer"
+      >
         <p className="text-center mt-1">
           <small>
             {t('admin:slack_integration.accordion.how_to_create_a_bot')}
             <i className="fa fa-external-link ml-2" aria-hidden="true" />
           </small>
         </p>
-      </a> */}
+      </a>
     </div>
   );
 };
@@ -43,15 +46,18 @@ const BotInstallProcessForOfficialBot = () => {
         {t('admin:slack_integration.accordion.install_now')}
         <i className="fa fa-external-link ml-2" aria-hidden="true" />
       </button>
-      {/* TODO: If Bot-manual section of docs is merged into master, show links and add an appropriate links by GW-5614. */}
-      {/* <a href="#">
+      <a
+        href={t('admin:slack_integration.docs_url.official_bot_setting')}
+        target="_blank"
+        rel="noopener noreferrer"
+      >
         <p className="text-center mt-1">
           <small>
             {t('admin:slack_integration.accordion.how_to_install')}
             <i className="fa fa-external-link ml-2" aria-hidden="true" />
           </small>
         </p>
-      </a> */}
+      </a>
     </div>
   );
 };

+ 2 - 2
src/client/js/components/Me/AssociateModal.jsx

@@ -71,7 +71,7 @@ class AssociateModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg">
-        <ModalHeader className="bg-info text-light" toggle={this.props.onClose}>
+        <ModalHeader className="bg-primary text-light" toggle={this.props.onClose}>
           { t('admin:user_management.create_external_account') }
         </ModalHeader>
         <ModalBody>
@@ -118,7 +118,7 @@ class AssociateModal extends React.Component {
           </div>
         </ModalBody>
         <ModalFooter className="border-top-0">
-          <button type="button" className="btn btn-info mt-3" onClick={this.onClickAddBtn}>
+          <button type="button" className="btn btn-primary mt-3" onClick={this.onClickAddBtn}>
             <i className="fa fa-plus-circle" aria-hidden="true"></i>
             {t('add')}
           </button>

+ 3 - 2
src/client/js/components/PageAccessoriesModal.jsx

@@ -26,6 +26,7 @@ const PageAccessoriesModal = (props) => {
   const {
     t, pageAccessoriesContainer, onClose, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
+  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
   const { switchActiveTab } = pageAccessoriesContainer;
   const { activeTab, activeComponents } = pageAccessoriesContainer.state;
   const [isWindowExpanded, setIsWindowExpanded] = useState(false);
@@ -60,10 +61,10 @@ const PageAccessoriesModal = (props) => {
         Icon: ShareLinkIcon,
         i18n: t('share_links.share_link_management'),
         index: 4,
-        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage,
+        isLinkEnabled: v => !isGuestUser && !isSharedUser && !isNotFoundPage && !isLinkSharingDisabled,
       },
     };
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
 
   const closeModalHandler = useCallback(() => {
     if (onClose == null) {

+ 6 - 2
src/client/js/components/PageAccessoriesModalControl.jsx

@@ -19,6 +19,7 @@ const PageAccessoriesModalControl = (props) => {
   const {
     t, pageAccessoriesContainer, isGuestUser, isSharedUser, isNotFoundPage,
   } = props;
+  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
 
   const accessoriesBtnList = useMemo(() => {
     return [
@@ -49,11 +50,11 @@ const PageAccessoriesModalControl = (props) => {
       {
         name: 'shareLink',
         Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isNotFoundPage,
+        disabled: isGuestUser || isSharedUser || isNotFoundPage || isLinkSharingDisabled,
         i18n: t('share_links.share_link_management'),
       },
     ];
-  }, [t, isGuestUser, isSharedUser, isNotFoundPage]);
+  }, [t, isGuestUser, isSharedUser, isNotFoundPage, isLinkSharingDisabled]);
 
   return (
     <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
@@ -62,6 +63,9 @@ const PageAccessoriesModalControl = (props) => {
         let tooltipMessage;
         if (accessory.disabled) {
           tooltipMessage = isNotFoundPage ? t('not_found_page.page_not_exist') : t('Not available for guest');
+          if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
+            tooltipMessage = t('Link sharing is disabled');
+          }
         }
         else {
           tooltipMessage = accessory.i18n;

+ 8 - 0
src/client/js/components/PageList.jsx

@@ -64,6 +64,14 @@ const PageList = (props) => {
       </div>
     );
   }
+  if (appContainer.config.disableLinkSharing) {
+    return (
+      <div className="mt-2">
+        {/* eslint-disable-next-line react/no-danger */}
+        <p>{t('custom_navigation.link_sharing_is_disabled')}</p>
+      </div>
+    );
+  }
 
   return (
     <div className="page-list">

+ 22 - 1
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -35,6 +35,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isGitHubEnabled: false,
       isTwitterEnabled: false,
       setupStrategies: [],
+      disableLinkSharing: false,
       shareLinks: [],
       totalshareLinks: 0,
       shareLinksPagingLimit: Infinity,
@@ -46,7 +47,7 @@ export default class AdminGeneralSecurityContainer extends Container {
   async retrieveSecurityData() {
     await this.retrieveSetupStratedies();
     const response = await this.appContainer.apiv3.get('/security-setting/');
-    const { generalSetting, generalAuth } = response.data.securityParams;
+    const { generalSetting, shareLinkSetting, generalAuth } = response.data.securityParams;
     this.setState({
       currentRestrictGuestMode: generalSetting.restrictGuestMode,
       currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
@@ -54,6 +55,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
+      disableLinkSharing: shareLinkSetting.disableLinkSharing,
       isLocalEnabled: generalAuth.isLocalEnabled,
       isLdapEnabled: generalAuth.isLdapEnabled,
       isSamlEnabled: generalAuth.isSamlEnabled,
@@ -88,6 +90,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ sessionMaxAge });
   }
 
+  /**
+   * setter for disableLinkSharing
+   */
+  setDisableLinkSharing(disableLinkSharing) {
+    this.setState({ disableLinkSharing });
+  }
+
   /**
    * Change restrictGuestMode
    */
@@ -137,6 +146,18 @@ export default class AdminGeneralSecurityContainer extends Container {
     return securitySettingParams;
   }
 
+  /**
+   * Switch disableLinkSharing
+   */
+  async switchDisableLinkSharing() {
+    const requestParams = {
+      disableLinkSharing: !this.state.disableLinkSharing,
+    };
+    const response = await this.appContainer.apiv3.put('/security-setting/share-link-setting', requestParams);
+    this.setDisableLinkSharing(!this.state.disableLinkSharing);
+    return response;
+  }
+
   /**
    * Switch authentication
    */

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

@@ -225,6 +225,12 @@ div.body {
     margin: 0 7%;
     background-color: rgba(200, 200, 200, 0.2);
     border-radius: 10px;
+
+    .grw-togetter-time {
+      float: right;
+      font-size: 0.8em;
+      font-weight: normal;
+    }
   }
 }
 

+ 310 - 0
src/client/styles/scss/theme/hufflepuff.scss

@@ -0,0 +1,310 @@
+@import '../variables';
+@import '../override-bootstrap-variables';
+
+// == Define Bootstrap theme colors
+//
+
+// colors for overriding bootstrap $theme-colors
+// $secondary: #;
+// $info: #;
+// $success: #;
+// $warning: #;
+// $danger: #;
+// $light: #;
+// $dark: #;
+
+// .grw-navbar {
+//   border-bottom: $accentcolor 4px solid;
+// }
+
+//== Light Mode
+//
+html[light] {
+  // Theme colors
+  $themecolor: #eaab20;
+  $themelight: #efe2cf;
+  $subthemecolor: #231e1d;
+  $third-main-color: #f0c05a;
+  $accentcolor: #993439;
+
+  $primary: $themecolor;
+  // $secondary: $subthemecolor;
+  $secondary: $third-main-color;
+
+  // Background colors
+  $bgcolor-global: lighten($themelight, 10%);
+  $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-card: $gray-100;
+  $bgcolor-highlighted: rgba($primary, 0.5);
+
+  // Font colors
+  $color-global: $subthemecolor;
+  $color-reversal: white;
+  $color-link: $accentcolor;
+  $color-link-hover: lighten($accentcolor, 10%);
+  $color-link-wiki: $accentcolor;
+  $color-link-wiki-hover: lighten($color-link-wiki, 10%);
+  $color-link-nabvar: $color-reversal;
+  $color-inline-code: #c7254e; // optional
+
+  // List Group colors
+  // $color-list: $color-global;
+  $bgcolor-list: transparent;
+  $color-list-hover: lighten($themecolor, 10%);
+  // $bgcolor-list-hover: darken($bgcolor-list, 2%);
+  // $color-list-active: $bgcolor-global;
+  // $bgcolor-list-active: $accentcolor;
+
+  // Navbar
+  $bgcolor-navbar: $third-main-color;
+  $bgcolor-search-top-dropdown: $themecolor;
+  $border-image-navbar: linear-gradient(to right, #90a555 0%, #a84be6 50%, #eaab20 100%);
+
+  // Logo colors
+  $bgcolor-logo: $bgcolor-navbar;
+  $fillcolor-logo-mark: lighten(desaturate($bgcolor-inline-code, 10%), 15%);
+
+  // Sidebar
+  $bgcolor-sidebar: $themecolor;
+  // Sidebar resize button
+  $color-resize-button: $color-reversal;
+  $bgcolor-resize-button: $subthemecolor;
+  $color-resize-button-hover: $color-reversal;
+  $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 10%);
+  // Sidebar contents
+  $color-sidebar-context: $accentcolor;
+  $bgcolor-sidebar-context: lighten($themelight, 8%);
+  // Sidebar list group
+  $bgcolor-sidebar-list-group: lighten($themelight, 10%);
+
+  // Icon colors
+  $color-editor-icons: $accentcolor;
+
+  // Border colors
+  $border-color-theme: lighten($subthemecolor, 40%);
+  $bordercolor-inline-code: #ccc8c8; // optional
+
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $growi-blue;
+  $color-dropdown-link-active: $color-reversal;
+  $color-dropdown-link-hover: $color-global;
+
+  // admin theme box
+  $color-theme-color-box: darken($primary, 5%);
+
+  @import 'apply-colors';
+  @import 'apply-colors-light';
+
+  //Button
+  .btn.btn-outline-primary {
+    @include btn-page-editor-mode-manager(darken($primary, 50%), darken($primary, 50%), lighten($primary, 20%));
+  }
+  .btn-group.grw-page-editor-mode-manager {
+    .btn.btn-outline-primary {
+      @include btn-page-editor-mode-manager(darken($primary, 70%), lighten($primary, 5%), lighten($primary, 20%));
+    }
+  }
+
+  .growi:not(.login-page) {
+    // add background-image
+    #page-wrapper,
+    .page-editor-preview-container {
+      background-image: url('/images/themes/hufflepuff/badger-light3.png');
+      background-attachment: fixed;
+      background-position: bottom;
+      background-size: cover;
+    }
+  }
+
+  // login and register
+  .nologin {
+    #page-wrapper {
+      background-color: $themelight;
+      background-image: url('/images/themes/hufflepuff/badger-light.png');
+      background-attachment: fixed;
+      background-position: bottom;
+      background-size: cover;
+    }
+
+    .login-header,
+    .login-dialog {
+      background-color: rgba(black, 0.1);
+    }
+
+    .link-switch {
+      color: $color-global;
+    }
+
+    .grw-external-auth-form {
+      border-color: $accentcolor !important;
+    }
+  }
+
+  .table {
+    background-color: $bgcolor-global;
+  }
+
+  .card-timeline > .card-header {
+    background-color: $third-main-color;
+  }
+
+  .nav.nav-tabs {
+    > .nav-item {
+      > .nav-link.active {
+        color: $subthemecolor;
+      }
+    }
+  }
+}
+
+html[dark] {
+  // Theme colors
+  $themecolor: #eaab20;
+  $themedark: #3d3f38;
+  $subthemecolor: #231e1d;
+  $third-main-color: #967224;
+  $accentcolor: #993439;
+
+  $primary: darken($themecolor, 10%);
+  $secondary: $third-main-color;
+  $dark: #031018;
+
+  // Background colors
+  $bgcolor-global: $themedark;
+  // $bgcolor-navbar: #27343b;
+  $bgcolor-inline-code: $subthemecolor;
+  $bgcolor-card: darken($themedark, 5%);
+  $bgcolor-highlighted: rgba($primary, 0.5);
+
+  // Font colors
+  $color-global: #efe2cf;
+  $color-reversal: $gray-100;
+  $color-link: lighten($themecolor, 20%);
+  $color-link-hover: lighten($color-link, 10%);
+  $color-link-wiki: lighten($primary, 20%);
+  $color-link-wiki-hover: lighten($color-link-wiki, 20%);
+  $color-link-nabvar: $color-reversal;
+  $color-inline-code: $themecolor;
+  // $color-inline-code: #c7254e; // optional
+  // $color-search: #000102;
+
+  // List Group colors
+  // $color-list: $color-global;
+  $bgcolor-list: transparent;
+  $color-list-hover: $accentcolor;
+  // $bgcolor-list-hover: lighten($bgcolor-global, 3%);
+  // $color-list-active: $color-reversal;
+  // $bgcolor-list-active: $primary;
+
+  // Navbar
+  $bgcolor-navbar: $third-main-color;
+  $bgcolor-search-top-dropdown: $themecolor;
+  $border-image-navbar: linear-gradient(to right, #90a555 0%, #3d98a3 50%, #eaab20 100%);
+
+  // Logo colors
+  $bgcolor-logo: #13191c;
+  $fillcolor-logo-mark: white;
+
+  // Sidebar
+  $bgcolor-sidebar: $themecolor;
+  // $bgcolor-sidebar-nav-item-active: rgba(#, 0.3); // optional
+  $text-shadow-sidebar-nav-item-active: 0px 0px 10px #cc951e; // optional
+  // Sidebar resize button
+  $color-resize-button: $color-global;
+  $bgcolor-resize-button: $accentcolor;
+  $color-resize-button-hover: $color-global;
+  $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 7%);
+  // Sidebar contents
+  $color-sidebar-context: $color-global;
+  $bgcolor-sidebar-context: $subthemecolor;
+  // Sidebar list group
+  $bgcolor-sidebar-list-group: lighten($subthemecolor, 5%);
+
+  // Icon colors
+  $color-editor-icons: $themecolor;
+
+  // Border colors
+  $border-color-theme: darken($themecolor, 25%);
+  $bordercolor-inline-code: #4d4d4d; // optional
+
+  // Dropdown colors
+  $bgcolor-dropdown-link-active: $primary;
+  $color-dropdown-link-active: $color-global;
+  $color-dropdown-link-hover: $color-reversal;
+
+  // admin theme box
+  $color-theme-color-box: $primary;
+
+  @import 'apply-colors';
+  @import 'apply-colors-dark';
+
+  // Navs
+  .nav-tabs {
+    border-bottom: $border-color-theme 1px solid;
+    .nav-link {
+      &:hover {
+        border-color: lighten($border-color-theme, 10%);
+        border-bottom: none;
+      }
+      &.active {
+        color: $color-link;
+        background-color: transparent;
+        border-color: $border-color-theme;
+      }
+    }
+  }
+
+  // Table
+  .table {
+    color: white;
+  }
+
+  // Button
+  .btn.btn-outline-primary {
+    @include btn-page-editor-mode-manager(lighten($primary, 40%), lighten($primary, 15%), darken($primary, 10%), darken($primary, 30%));
+  }
+  .btn-group.grw-page-editor-mode-manager {
+    .btn.btn-outline-primary {
+      @include btn-page-editor-mode-manager(lighten($primary, 40%), lighten($primary, 15%), darken($primary, 0%), darken($primary, 30%));
+    }
+  }
+
+  .card-timeline > .card-header {
+    background-color: $accentcolor;
+  }
+
+  .growi:not(.login-page) {
+    // add background-image
+    #page-wrapper,
+    .page-editor-preview-container {
+      background-image: url('/images/themes/hufflepuff/badger-dark.jpg');
+      background-attachment: fixed;
+      background-position: bottom;
+      background-size: cover;
+    }
+  }
+
+  // login and register
+  .nologin {
+    #page-wrapper {
+      background-color: $themedark;
+      background-image: url('/images/themes/hufflepuff/badger-light.png');
+      background-attachment: fixed;
+      background-position: bottom;
+      background-size: cover;
+    }
+
+    .login-header,
+    .login-dialog {
+      background-color: rgba(black, 0.1);
+    }
+
+    .link-switch {
+      color: $color-global;
+    }
+
+    .grw-external-auth-form {
+      border-color: $accentcolor !important;
+    }
+  }
+}

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

@@ -47,6 +47,7 @@ module.exports = function(crowi) {
       'security:list-policy:hideRestrictedByOwner' : false,
       'security:list-policy:hideRestrictedByGroup' : false,
       'security:pageCompleteDeletionAuthority' : undefined,
+      'security:disableLinkSharing' : false,
 
       'security:passport-local:isEnabled' : true,
       'security:passport-ldap:isEnabled' : false,
@@ -193,6 +194,7 @@ module.exports = function(crowi) {
         file: crowi.fileUploadService.getFileUploadEnabled(),
       },
       registrationWhiteList: crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+      disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
       themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),

+ 53 - 0
src/server/routes/apiv3/security-setting.js

@@ -22,6 +22,9 @@ const validator = {
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
   ],
+  shareLinkSetting: [
+    body('disableLinkSharing').if(value => value != null).isBoolean(),
+  ],
   authenticationSetting: [
     body('isEnabled').if(value => value != null).isBoolean(),
     body('authId').isString().isIn([
@@ -129,6 +132,12 @@ const validator = {
  *          hideRestrictedByGroup:
  *            type: boolean
  *            description: enable hide by group
+ *      ShareLinkSetting:
+ *        type: object
+ *        properties:
+ *          disableLinkSharing:
+ *            type: boolean
+ *            description: disable link sharing
  *      LocalSetting:
  *        type: object
  *        properties:
@@ -364,6 +373,9 @@ module.exports = (crowi) => {
         wikiMode: await crowi.configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
+      shareLinkSetting: {
+        disableLinkSharing: await crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+      },
       localSetting: {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
@@ -589,6 +601,47 @@ module.exports = (crowi) => {
         hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
       };
+
+      return res.apiv3({ securitySettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating security setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-secuirty-setting failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /_api/v3/security-setting/share-link-setting:
+   *      put:
+   *        tags: [SecuritySetting, apiv3]
+   *        description: Update ShareLink Setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/ShareLinkSetting'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update ShareLink Setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/ShareLinkSetting'
+   */
+  router.put('/share-link-setting', loginRequiredStrictly, adminRequired, csrf, validator.generalSetting, apiV3FormValidator, async(req, res) => {
+    const updateData = {
+      'security:disableLinkSharing': req.body.disableLinkSharing,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
+      const securitySettingParams = {
+        disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+      };
+
       return res.apiv3({ securitySettingParams });
     }
     catch (err) {

+ 15 - 2
src/server/routes/apiv3/share-links.js

@@ -30,6 +30,19 @@ module.exports = (crowi) => {
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
 
+  /**
+   * middleware to limit link sharing
+   */
+  const linkSharingRequired = (req, res, next) => {
+    const isLinkSharingDisabled = crowi.configManager.getConfig('crowi', 'security:disableLinkSharing');
+    logger.debug(`isLinkSharingDisabled: ${isLinkSharingDisabled}`);
+
+    if (isLinkSharingDisabled) {
+      return res.apiv3Err(new ErrorV3('Link sharing is disabled', 'link-sharing-disabled'));
+    }
+    next();
+  };
+
   validator.getShareLinks = [
     // validate the page id is MongoId
     query('relatedPage').isMongoId().withMessage('Page Id is required'),
@@ -54,7 +67,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to get share links
    */
-  router.get('/', loginRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
+  router.get('/', loginRequired, linkSharingRequired, validator.getShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -115,7 +128,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    */
 
-  router.post('/', loginRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, linkSharingRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);

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

@@ -153,8 +153,10 @@ module.exports = (crowi) => {
 
     // retrieve connection statuses
     let connectionStatuses = {};
+    let errorMsg;
+    let errorCode;
     if (currentBotType == null) {
-      // TODO imple null action
+      // no need to do anything
     }
     else if (currentBotType === 'customBotWithoutProxy') {
       const token = settings.slackBotToken;
@@ -163,10 +165,10 @@ module.exports = (crowi) => {
         try {
           connectionStatuses = await getConnectionStatuses([token]);
         }
-        catch (error) {
-          const msg = 'Error occured in getting connection statuses';
-          logger.error('Error', error);
-          return res.apiv3Err(new ErrorV3(msg, 'get-connection-failed'), 500);
+        catch (e) {
+          errorMsg = 'Error occured in getting connection statuses';
+          errorCode = 'get-connection-failed';
+          logger.error(errorMsg, e);
         }
       }
     }
@@ -175,37 +177,37 @@ module.exports = (crowi) => {
         const slackAppIntegrations = await SlackAppIntegration.find();
         settings.slackAppIntegrations = slackAppIntegrations;
       }
-      catch (error) {
-        const msg = 'Error occured in getting connection statuses';
-        logger.error('Error', error);
-        return res.apiv3Err(new ErrorV3(msg, 'get-connection-failed'), 500);
+      catch (e) {
+        errorMsg = 'Error occured in finding SlackAppIntegration entities.';
+        errorCode = 'get-slackappintegration-failed';
+        logger.error(errorMsg, e);
       }
 
       const proxyServerUri = settings.proxyServerUri;
 
-      if (proxyServerUri != null) {
+      if (proxyServerUri != null && settings.slackAppIntegrations != null && settings.slackAppIntegrations.length > 0) {
         try {
-          if (settings.slackAppIntegrations.length > 0) {
-            // 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;
-            });
-          }
+          // 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) {
-          const msg = 'Incorrect Proxy URL';
-          logger.error('Error', error);
-          return res.apiv3Err(new ErrorV3(msg, 'test-connection-failed'), 400);
+        catch (e) {
+          errorMsg = 'Incorrect Proxy URL';
+          errorCode = 'test-connection-failed';
+          logger.error(errorMsg, e);
         }
       }
     }
 
-    return res.apiv3({ currentBotType, settings, connectionStatuses });
+    return res.apiv3({
+      currentBotType, settings, connectionStatuses, errorMsg, errorCode,
+    });
   });
 
   /**

+ 6 - 4
src/server/routes/apiv3/slack-integration.js

@@ -121,7 +121,6 @@ module.exports = (crowi) => {
     }
     catch (error) {
       logger.error(error);
-      return res.send(error.message);
     }
   }
 
@@ -146,8 +145,12 @@ module.exports = (crowi) => {
     const { action_id: actionId } = payload.actions[0];
 
     switch (actionId) {
-      case 'shareSearchResults': {
-        await crowi.slackBotService.shareSearchResults(client, payload);
+      case 'shareSingleSearchResult': {
+        await crowi.slackBotService.shareSinglePage(client, payload);
+        break;
+      }
+      case 'dismissSearchResults': {
+        await crowi.slackBotService.dismissSearchResults(client, payload);
         break;
       }
       case 'showNextResults': {
@@ -210,7 +213,6 @@ module.exports = (crowi) => {
     }
     catch (error) {
       logger.error(error);
-      return res.send(error.message);
     }
 
   }

+ 3 - 0
src/server/routes/page.js

@@ -413,6 +413,9 @@ module.exports = function(crowi, app) {
       // page or sharelink are not found
       return res.render('layout-growi/not_found_shared_page');
     }
+    if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
+      return res.render('layout-growi/forbidden');
+    }
 
     const renderVars = {};
 

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

@@ -254,6 +254,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  DISABLE_LINK_SHARING: {
+    ns:      'crowi',
+    key:     'security:disableSharing',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
   LOCAL_STRATEGY_ENABLED: {
     ns:      'crowi',
     key:     'security:passport-local:isEnabled',

+ 2 - 2
src/server/service/search-delegator/elasticsearch.js

@@ -556,7 +556,7 @@ class ElasticsearchDelegator {
 
   createSearchQuerySortedByUpdatedAt(option) {
     // getting path by default is almost for debug
-    let fields = ['path', 'bookmark_count', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
     if (option) {
       fields = option.fields || fields;
     }
@@ -577,7 +577,7 @@ class ElasticsearchDelegator {
   }
 
   createSearchQuerySortedByScore(option) {
-    let fields = ['path', 'bookmark_count', 'tag_names'];
+    let fields = ['path', 'bookmark_count', 'comment_count', 'updated_at', 'tag_names'];
     if (option) {
       fields = option.fields || fields;
     }

+ 177 - 65
src/server/service/slackbot.js

@@ -1,6 +1,8 @@
 
 const logger = require('@alias/logger')('growi:service:SlackBotService');
 const mongoose = require('mongoose');
+const axios = require('axios');
+const { formatDistanceStrict } = require('date-fns');
 
 const PAGINGLIMIT = 10;
 
@@ -97,7 +99,7 @@ class SlackBotService extends S2sMessageHandlable {
     return keywords;
   }
 
-  async getSearchResultPaths(client, body, args, offset = 0) {
+  async retrieveSearchResults(client, body, args, offset = 0) {
     const firstKeyword = args[1];
     if (firstKeyword == null) {
       client.chat.postEphemeral({
@@ -144,119 +146,229 @@ class SlackBotService extends S2sMessageHandlable {
           this.generateMarkdownSectionBlock('`-tag:wiki` \n Exclude pages with wiki tag'),
         ],
       });
-      return { resultPaths: [] };
+      return { pages: [] };
     }
 
-    const resultPaths = results.data.map((data) => {
-      return data._source.path;
+    const pages = results.data.map((data) => {
+      const { path, updated_at: updatedAt, comment_count: commentCount } = data._source;
+      return { path, updatedAt, commentCount };
     });
 
     return {
-      resultPaths, offset, resultsTotal,
+      pages, offset, resultsTotal,
     };
   }
 
-  async shareSearchResults(client, payload) {
-    client.chat.postMessage({
-      channel: payload.channel.id,
-      text: JSON.parse(payload.actions[0].value).pageList,
+  generatePageLinkMrkdwn(pathname, href) {
+    return `<${decodeURI(href)} | ${decodeURI(pathname)}>`;
+  }
+
+  appendSpeechBaloon(mrkdwn, commentCount) {
+    return (commentCount != null && commentCount > 0)
+      ? `${mrkdwn}   :speech_balloon: ${commentCount}`
+      : mrkdwn;
+  }
+
+  generateLastUpdateMrkdwn(updatedAt, baseDate) {
+    if (updatedAt != null) {
+      // cast to date
+      const date = new Date(updatedAt);
+      return formatDistanceStrict(date, baseDate);
+    }
+    return '';
+  }
+
+  async shareSinglePage(client, payload) {
+    const { channel, user, actions } = payload;
+
+    const appUrl = this.crowi.appService.getSiteUrl();
+    const appTitle = this.crowi.appService.getAppTitle();
+
+    const channelId = channel.id;
+    const action = actions[0]; // shareSinglePage action must have button action
+
+    // restore page data from value
+    const { page, href, pathname } = JSON.parse(action.value);
+    const { updatedAt, commentCount } = page;
+
+    // share
+    const now = new Date();
+    return client.chat.postMessage({
+      channel: channelId,
+      blocks: [
+        { type: 'divider' },
+        this.generateMarkdownSectionBlock(`${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`),
+        {
+          type: 'context',
+          elements: [
+            {
+              type: 'mrkdwn',
+              text: `<${decodeURI(appUrl)}|*${appTitle}*>  |  Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}  |  Shared by *${user.username}*`,
+            },
+          ],
+        },
+      ],
     });
   }
 
-  async showEphemeralSearchResults(client, body, args, offsetNum) {
-    const {
-      resultPaths, offset, resultsTotal,
-    } = await this.getSearchResultPaths(client, body, args, offsetNum);
+  async dismissSearchResults(client, payload) {
+    const { response_url: responseUrl } = payload;
 
-    const keywords = this.getKeywords(args);
+    return axios.post(responseUrl, {
+      delete_original: true,
+    });
+  }
 
-    if (resultPaths.length === 0) {
-      return;
+  async showEphemeralSearchResults(client, body, args, offsetNum) {
+
+    let searchResult;
+    try {
+      searchResult = await this.retrieveSearchResults(client, body, args, offsetNum);
+    }
+    catch (err) {
+      logger.error('Failed to get search results.', err);
+      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]`'),
+        ],
+      });
+      throw new Error('/growi command:search: Failed to search');
     }
 
     const appUrl = this.crowi.appService.getSiteUrl();
     const appTitle = this.crowi.appService.getAppTitle();
 
-    const urls = resultPaths.map((path) => {
-      const url = new URL(path, appUrl);
-      return `<${decodeURI(url.href)} | ${decodeURI(url.pathname)}>`;
-    });
+    const {
+      pages, offset, resultsTotal,
+    } = searchResult;
 
-    const searchResultsNum = resultPaths.length;
-    let searchResultsDesc;
+    const keywords = this.getKeywords(args);
 
-    switch (searchResultsNum) {
-      case 10:
-        searchResultsDesc = 'Maximum number of results that can be displayed is 10';
-        break;
 
+    let searchResultsDesc;
+
+    switch (resultsTotal) {
       case 1:
-        searchResultsDesc = `${searchResultsNum} page is found`;
+        searchResultsDesc = `*${resultsTotal}* page is found.`;
         break;
 
       default:
-        searchResultsDesc = `${searchResultsNum} pages are found`;
+        searchResultsDesc = `*${resultsTotal}* pages are found.`;
         break;
     }
 
-    const keywordsAndDesc = `keyword(s) : "${keywords}" \n ${searchResultsDesc}.`;
 
-    try {
-      // DEFAULT show "Share" button
-      const actionBlocks = {
-        type: 'actions',
-        elements: [
-          {
+    const contextBlock = {
+      type: 'context',
+      elements: [
+        {
+          type: 'mrkdwn',
+          text: `keyword(s) : *"${keywords}"*  |  Current: ${offset + 1} - ${offset + pages.length}  |  Total ${resultsTotal} pages`,
+        },
+      ],
+    };
+
+    const now = new Date();
+    const blocks = [
+      this.generateMarkdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`),
+      contextBlock,
+      { type: 'divider' },
+      // create an array by map and extract
+      ...pages.map((page) => {
+        const { path, updatedAt, commentCount } = page;
+        // generate URL
+        const url = new URL(path, appUrl);
+        const { href, pathname } = url;
+
+        return {
+          type: 'section',
+          text: {
+            type: 'mrkdwn',
+            text: `${this.appendSpeechBaloon(`*${this.generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`
+              + `\n    Last updated: ${this.generateLastUpdateMrkdwn(updatedAt, now)}`,
+          },
+          accessory: {
             type: 'button',
+            action_id: 'shareSingleSearchResult',
             text: {
               type: 'plain_text',
               text: 'Share',
             },
-            style: 'primary',
-            action_id: 'shareSearchResults',
-            value: JSON.stringify({
-              offset, body, args, pageList: `${keywordsAndDesc} \n\n ${urls.join('\n')}`,
-            }),
+            value: JSON.stringify({ page, href, pathname }),
           },
-        ],
-      };
-      // show "Next" button if next page exists
-      if (resultsTotal > offset + PAGINGLIMIT) {
-        actionBlocks.elements.unshift(
-          {
-            type: 'button',
-            text: {
-              type: 'plain_text',
-              text: 'Next',
-            },
-            action_id: 'showNextResults',
-            value: JSON.stringify({ offset, body, args }),
+        };
+      }),
+      { type: 'divider' },
+      contextBlock,
+    ];
+
+    // DEFAULT show "Share" button
+    // const actionBlocks = {
+    //   type: 'actions',
+    //   elements: [
+    //     {
+    //       type: 'button',
+    //       text: {
+    //         type: 'plain_text',
+    //         text: 'Share',
+    //       },
+    //       style: 'primary',
+    //       action_id: 'shareSearchResults',
+    //     },
+    //   ],
+    // };
+    const actionBlocks = {
+      type: 'actions',
+      elements: [
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Dismiss',
           },
-        );
-      }
+          style: 'danger',
+          action_id: 'dismissSearchResults',
+        },
+      ],
+    };
+    // show "Next" button if next page exists
+    if (resultsTotal > offset + PAGINGLIMIT) {
+      actionBlocks.elements.unshift(
+        {
+          type: 'button',
+          text: {
+            type: 'plain_text',
+            text: 'Next',
+          },
+          action_id: 'showNextResults',
+          value: JSON.stringify({ offset, body, args }),
+        },
+      );
+    }
+    blocks.push(actionBlocks);
+
+    try {
       await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
         text: 'Successed To Search',
-        blocks: [
-          this.generateMarkdownSectionBlock(`<${decodeURI(appUrl)}|*${appTitle}*>`),
-          this.generateMarkdownSectionBlock(keywordsAndDesc),
-          this.generateMarkdownSectionBlock(`${urls.join('\n')}`),
-          actionBlocks,
-        ],
+        blocks,
       });
     }
     catch (err) {
-      logger.error('Failed to get search results.', err);
+      logger.error('Failed to post ephemeral message.', err);
       await client.chat.postEphemeral({
         channel: body.channel_id,
         user: body.user_id,
-        text: 'Failed To Search',
+        text: 'Failed to post ephemeral message.',
         blocks: [
-          this.generateMarkdownSectionBlock('*Failed to search.*\n Hint\n `/growi search [keyword]`'),
+          this.generateMarkdownSectionBlock(err.toString()),
         ],
       });
-      throw new Error('/growi command:search: Failed to search');
+      throw new Error(err);
     }
   }