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

Merge pull request #4277 from weseek/master

Release v4.4.3
Yuki Takei 4 лет назад
Родитель
Сommit
0218031485
92 измененных файлов с 1743 добавлено и 1029 удалено
  1. 7 3
      .github/release-drafter.yml
  2. 66 535
      CHANGELOG.md
  3. 1 0
      bin/github-actions/list-branches.js
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 6 6
      packages/app/package.json
  7. BIN
      packages/app/public/images/slack-integration/activate-public-dist.png
  8. BIN
      packages/app/public/images/slack-integration/basicinfo-all-checked.png
  9. BIN
      packages/app/public/images/slack-integration/click-add-to-slack.png
  10. BIN
      packages/app/public/images/slack-integration/growi-bot-kun-icon.png
  11. BIN
      packages/app/public/images/slack-integration/growi-register-modal.png
  12. BIN
      packages/app/public/images/slack-integration/growi-register-sentence.png
  13. BIN
      packages/app/public/images/slack-integration/growi-set-proxy-url.png
  14. BIN
      packages/app/public/images/slack-integration/impossible.png
  15. BIN
      packages/app/public/images/slack-integration/possible.png
  16. BIN
      packages/app/public/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png
  17. BIN
      packages/app/public/images/slack-integration/slack-bot-install-to-workspace.png
  18. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-complete.png
  19. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png
  20. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-introduction.png
  21. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-transition-destination.png
  22. BIN
      packages/app/public/images/slack-integration/triangle-basic-gray.png
  23. 7 0
      packages/app/resource/locales/en_US/admin/admin.json
  24. 7 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  25. 7 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  26. 11 25
      packages/app/src/client/services/PageContainer.js
  27. 0 5
      packages/app/src/client/services/SocketIoContainer.js
  28. 15 8
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  29. 3 3
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  30. 2 0
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  31. 14 2
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  32. 240 91
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  33. 242 0
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  34. 3 3
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  35. 4 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  36. 50 36
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  37. 1 1
      packages/app/src/components/EmptyTrashModal.jsx
  38. 1 1
      packages/app/src/components/PagePathHierarchicalLink.jsx
  39. 1 1
      packages/app/src/components/Sidebar/CustomSidebar.jsx
  40. 138 30
      packages/app/src/components/Sidebar/RecentChanges.jsx
  41. 123 0
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  42. 3 5
      packages/app/src/server/models/page.js
  43. 3 4
      packages/app/src/server/models/slack-app-integration.js
  44. 1 0
      packages/app/src/server/routes/apiv3/page.js
  45. 76 15
      packages/app/src/server/routes/apiv3/pages.js
  46. 91 21
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  47. 64 44
      packages/app/src/server/routes/apiv3/slack-integration.js
  48. 2 2
      packages/app/src/server/routes/hackmd.js
  49. 4 8
      packages/app/src/server/routes/page.js
  50. 7 0
      packages/app/src/server/service/config-loader.ts
  51. 6 10
      packages/app/src/server/service/page.js
  52. 1 1
      packages/app/src/server/service/slack-command-handler/create.js
  53. 7 3
      packages/app/src/server/service/slack-command-handler/search.js
  54. 24 0
      packages/app/src/server/service/socket-io.js
  55. 31 8
      packages/app/src/server/service/system-events/sync-page-status.ts
  56. 28 0
      packages/app/src/server/util/slack-integration.ts
  57. 8 0
      packages/app/src/server/util/socket-io-helpers.ts
  58. 49 0
      packages/app/src/styles/_recent-changes.scss
  59. 4 0
      packages/app/src/styles/_sidebar.scss
  60. 8 2
      packages/app/src/styles/_tag.scss
  61. 7 0
      packages/app/src/styles/_variables.scss
  62. 1 0
      packages/app/src/styles/style-app.scss
  63. 1 1
      packages/app/src/styles/theme/_apply-colors-light.scss
  64. 56 1
      packages/app/src/styles/theme/_apply-colors.scss
  65. 1 1
      packages/app/src/styles/theme/antarctic.scss
  66. 1 1
      packages/app/src/styles/theme/christmas.scss
  67. 2 2
      packages/app/src/styles/theme/default.scss
  68. 1 1
      packages/app/src/styles/theme/future.scss
  69. 1 1
      packages/app/src/styles/theme/halloween.scss
  70. 2 2
      packages/app/src/styles/theme/hufflepuff.scss
  71. 1 1
      packages/app/src/styles/theme/island.scss
  72. 1 1
      packages/app/src/styles/theme/kibela.scss
  73. 2 2
      packages/app/src/styles/theme/mono-blue.scss
  74. 1 1
      packages/app/src/styles/theme/nature.scss
  75. 1 1
      packages/app/src/styles/theme/spring.scss
  76. 1 1
      packages/app/src/styles/theme/wood.scss
  77. 14 17
      packages/app/src/test/service/page.test.js
  78. 1 1
      packages/core/package.json
  79. 1 1
      packages/plugin-attachment-refs/package.json
  80. 1 1
      packages/plugin-lsx/package.json
  81. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  82. 1 1
      packages/slack/package.json
  83. 1 1
      packages/slack/src/middlewares/verify-growi-to-slack-request.ts
  84. 4 0
      packages/slack/src/utils/get-supported-growi-actions-regexps.ts
  85. 2 2
      packages/slackbot-proxy/package.json
  86. 11 8
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  87. 97 57
      packages/slackbot-proxy/src/controllers/slack.ts
  88. 10 11
      packages/slackbot-proxy/src/entities/relation.ts
  89. 21 20
      packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts
  90. 127 13
      packages/slackbot-proxy/src/services/RelationsService.ts
  91. 2 1
      packages/slackbot-proxy/src/services/SelectGrowiService.ts
  92. 1 1
      packages/ui/package.json

+ 7 - 3
.github/release-drafter.yml

@@ -41,10 +41,14 @@ autolabeler:
       - '/^chore\/.+/'
     title:
       - '/^chore/i'
-
+include-labels:
+  - breaking
+  - feature
+  - improvement
+  - bug
+  - support
+  - dependencies
 exclude-labels:
   - 'exclude from changelog'
 template: |
-  ### Changes
-
   $CHANGES

Разница между файлами не показана из-за своего большого размера
+ 66 - 535
CHANGELOG.md


+ 1 - 0
bin/github-actions/list-branches.js

@@ -18,6 +18,7 @@ const EXCLUDE_PATTERNS = [
   // https://regex101.com/r/Lnx7Pz/3
   /^dev\/[\d.x]*$/,
   /^release\/.+$/,
+  /^dependabot\/.+$/,
 ];
 const LEGAL_PATTERNS = [
   /^master$/,

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 6 - 6
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -54,10 +54,10 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/plugin-attachment-refs": "^4.4.2",
-    "@growi/plugin-pukiwiki-like-linker": "^4.4.2",
-    "@growi/plugin-lsx": "^4.4.2",
-    "@growi/slack": "^4.4.2",
+    "@growi/plugin-attachment-refs": "^4.4.3-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.3-RC.0",
+    "@growi/plugin-lsx": "^4.4.3-RC.0",
+    "@growi/slack": "^4.4.3-RC.0",
     "@promster/express": "^5.0.1",
     "@promster/server": "^6.0.0",
     "@slack/events-api": "^3.0.0",
@@ -153,7 +153,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.4.2",
+    "@growi/ui": "^4.4.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

BIN
packages/app/public/images/slack-integration/activate-public-dist.png


BIN
packages/app/public/images/slack-integration/basicinfo-all-checked.png


BIN
packages/app/public/images/slack-integration/click-add-to-slack.png


BIN
packages/app/public/images/slack-integration/growi-bot-kun-icon.png


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


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


BIN
packages/app/public/images/slack-integration/growi-set-proxy-url.png


BIN
packages/app/public/images/slack-integration/impossible.png


BIN
packages/app/public/images/slack-integration/possible.png


BIN
packages/app/public/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png


BIN
packages/app/public/images/slack-integration/slack-bot-install-to-workspace.png


BIN
packages/app/public/images/slack-integration/slack-bot-install-your-app-complete.png


BIN
packages/app/public/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png


BIN
packages/app/public/images/slack-integration/slack-bot-install-your-app-introduction.png


BIN
packages/app/public/images/slack-integration/slack-bot-install-your-app-transition-destination.png


BIN
packages/app/public/images/slack-integration/triangle-basic-gray.png


+ 7 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -340,6 +340,13 @@
       "manage_commands": "Manage GROWI commands",
       "multiple_growi_command": "Commands that could be sent to multiple GROWI instances at once",
       "single_growi_command": "Commands that could be sent to single GROWI instance at a time",
+      "allowed_channels_description": "Input allowed channels for \"{{commandName}}\" command. Separate each channel with \",\" . Users can will be able to use \"{{commandName}}\" command from channels written here.",
+      "allow_all": "Allow all",
+      "deny_all": "Deny all",
+      "allow_specified": "Allow specified",
+      "allow_all_long": "Allow all (The command is allowed from any channel)",
+      "deny_all_long": "Deny all (The command is denied from any channel)",
+      "allow_specified_long": "Allow specified (The command is allowed from only specified channels)",
       "test_connection": "Test Connection",
       "test_connection_by_pressing_button": "Press the button to test the connection",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",

+ 7 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -333,6 +333,13 @@
       "manage_commands": "使用可能なGROWIコマンドを設定する",
       "multiple_growi_command": "複数のGROWIに対して送信できるコマンド",
       "single_growi_command": "一つのGROWIに対して送信できるコマンド",
+      "allowed_channels_description": "\"{{commandName}}\" コマンドの使用を許可するチャンネルを \",\" 区切りで入力してください。ユーザーはここに記入されているチャンネルから \"{{commandName}}\" コマンドを使用することができます。",
+      "allow_all": "全てのチャンネルを許可",
+      "deny_all": "全てのチャンネルを拒否",
+      "allow_specified": "特定のチャンネルを許可",
+      "allow_all-long": "全て許可 (このコマンドは全てのチャンネルから使用することができます)",
+      "deny_all-long": "全て拒否 (このコマンドはどのチャンネルからも使用することはできません)",
+      "allow_specified-long": "特定のチャンネルを許可 (テキストボックスに入力されたチャンネルのみ許可されます)",
       "test_connection": "連携状況のテストをする",
       "test_connection_by_pressing_button": "以下のテストボタンを押して、Slack連携が完了しているかの確認をしましょう",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",

+ 7 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -343,6 +343,13 @@
       "manage_commands": "管理 GROWI 命令",
       "multiple_growi_command": "可以一次发送到多个 GROWI 实例的命令",
       "single_growi_command": "可以一次发送到一个 GROWI 实例的命令",
+      "allowed_channels_description": "为 \"{{commandName}}\" 命令输入允许的通道。每个通道之间用 \",\" 隔开。用户可以从这里写入的通道中使用 \"{{commandName}}\"。",
+      "allow_all": "允许所有",
+      "deny_all": "拒绝所有",
+      "allow_specified": "允许指定",
+      "allow_all_long": "允许所有(允许从任何通道发出命令)",
+      "deny_all_long": "拒绝所有(该命令被拒绝于任何通道)",
+      "allow_specified_long": "允许指定(该命令只允许来自指定的通道)",
       "test_connection": "测试连接",
       "test_connection_by_pressing_button": "按下按钮以测试连接",
       "error_check_logs_below": "发生了错误。请检查以下日志。",

+ 11 - 25
packages/app/src/client/services/PageContainer.js

@@ -125,6 +125,10 @@ export default class PageContainer extends Container {
     this.setTocHtml = this.setTocHtml.bind(this);
     this.save = this.save.bind(this);
     this.checkAndUpdateImageUrlCached = this.checkAndUpdateImageUrlCached.bind(this);
+
+    this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this);
+    this.emitJoinPageRoomRequest();
+
     this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
     this.addWebSocketEventHandlers();
 
@@ -467,7 +471,6 @@ export default class PageContainer extends Container {
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: socketIoContainer.getSocketClientId(),
       path: pagePath,
       body: markdown,
     });
@@ -483,7 +486,6 @@ export default class PageContainer extends Container {
 
     // clone
     const params = Object.assign(tmpParams, {
-      socketClientId: socketIoContainer.getSocketClientId(),
       page_id: pageId,
       revision_id: revisionId,
       body: markdown,
@@ -508,7 +510,6 @@ export default class PageContainer extends Container {
       completely,
       page_id: this.state.pageId,
       revision_id: this.state.revisionId,
-      socketClientId: socketIoContainer.getSocketClientId(),
     });
 
   }
@@ -522,7 +523,6 @@ export default class PageContainer extends Container {
     return this.appContainer.apiPost('/pages.revertRemove', {
       recursively,
       page_id: this.state.pageId,
-      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
@@ -538,7 +538,6 @@ export default class PageContainer extends Container {
       isRemainMetadata,
       newPagePath,
       path,
-      socketClientId: socketIoContainer.getSocketClientId(),
     });
   }
 
@@ -565,6 +564,13 @@ export default class PageContainer extends Container {
     });
   }
 
+  // request to server so the client to join a room for each page
+  emitJoinPageRoomRequest() {
+    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
+    const socket = socketIoContainer.getSocket();
+    socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
+  }
+
   addWebSocketEventHandlers() {
     // eslint-disable-next-line @typescript-eslint/no-this-alias
     const pageContainer = this;
@@ -572,11 +578,6 @@ export default class PageContainer extends Container {
     const socket = socketIoContainer.getSocket();
 
     socket.on('page:create', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
 
       // update remote page data
@@ -587,11 +588,6 @@ export default class PageContainer extends Container {
     });
 
     socket.on('page:update', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
       // update remote page data
@@ -602,11 +598,6 @@ export default class PageContainer extends Container {
     });
 
     socket.on('page:delete', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
 
       // update remote page data
@@ -617,11 +608,6 @@ export default class PageContainer extends Container {
     });
 
     socket.on('page:editingWithHackmd', (data) => {
-      // skip if triggered myself
-      if (data.socketClientId != null && data.socketClientId === socketIoContainer.getSocketClientId()) {
-        return;
-      }
-
       logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
 
       // update isHackmdDraftUpdatingInRealtime

+ 0 - 5
packages/app/src/client/services/SocketIoContainer.js

@@ -23,7 +23,6 @@ export default class SocketIoContainer extends Container {
     this.socket = io(ns, {
       transports: ['websocket'],
     });
-    this.socketClientId = Math.floor(Math.random() * 100000);
 
     this.socket.on('connect_error', (error) => {
       logger.error(error);
@@ -48,8 +47,4 @@ export default class SocketIoContainer extends Container {
     return this.socket;
   }
 
-  getSocketClientId() {
-    return this.socketClientId;
-  }
-
 }

+ 15 - 8
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -2,6 +2,7 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '~/client/util/apiNotification';
@@ -68,20 +69,26 @@ class AdminHome extends React.Component {
         <div className="row mb-5">
           <div className="col-md-12">
             <h2 className="admin-setting-header">{t('admin:admin_top.bug_report')}</h2>
-            <p>
+            <div className="d-flex align-items-center">
               <CopyToClipboard
                 text={adminHomeContainer.generatePrefilledHostInformationMarkdown()}
                 onCopy={() => adminHomeContainer.onCopyPrefilledHostInformation()}
               >
-                <button type="button" className="btn btn-primary">
-                  {adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DEFAULT
-                    ? t('admin:admin_top:copy_prefilled_host_information:default')
-                    : t('admin:admin_top:copy_prefilled_host_information:done')}
+                <button id="prefilledHostInformationButton" type="button" className="btn btn-primary">
+                  {t('admin:admin_top:copy_prefilled_host_information:default')}
                 </button>
-              </CopyToClipboard>{' '}
+              </CopyToClipboard>
+              <Tooltip
+                placement="bottom"
+                isOpen={adminHomeContainer.state.copyState === adminHomeContainer.copyStateValues.DONE}
+                target="prefilledHostInformationButton"
+                fade={false}
+              >
+                {t('admin:admin_top:copy_prefilled_host_information:done')}
+              </Tooltip>
               {/* eslint-disable-next-line react/no-danger */}
-              <span dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
-            </p>
+              <span className="ml-2" dangerouslySetInnerHTML={{ __html: t('admin:admin_top:submit_bug_report') }} />
+            </div>
           </div>
         </div>
       </Fragment>

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

@@ -127,7 +127,7 @@ const CustomBotWithProxySettings = (props) => {
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
-            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
@@ -148,8 +148,8 @@ const CustomBotWithProxySettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
-                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
-                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
+                permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
+                permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

+ 2 - 0
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -50,6 +50,7 @@ const CustomBotWithoutProxySettings = (props) => {
           slackSigningSecret={props.slackSigningSecret}
           onTestConnectionInvoked={props.onTestConnectionInvoked}
           onUpdatedSecretToken={props.onUpdatedSecretToken}
+          commandPermission={props.commandPermission}
         />
       </div>
     </>
@@ -69,6 +70,7 @@ CustomBotWithoutProxySettings.propTypes = {
   onUpdatedSecretToken: PropTypes.func.isRequired,
   onTestConnectionInvoked: PropTypes.func.isRequired,
   connectionStatuses: PropTypes.object.isRequired,
+  commandPermission: PropTypes.object,
 };
 
 export default CustomBotWithoutProxySettingsWrapper;

+ 14 - 2
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -7,6 +7,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 import MessageBasedOnConnection from './MessageBasedOnConnection';
 import CustomBotWithoutProxySecretTokenSection from './CustomBotWithoutProxySecretTokenSection';
 import { addLogs } from './slak-integration-util';
+import ManageCommandsProcessWithoutProxy from './ManageCommandsProcessWithoutProxy';
 
 
 export const botInstallationStep = {
@@ -20,7 +21,7 @@ export const botInstallationStep = {
 const CustomBotWithoutProxySettingsAccordion = (props) => {
   const {
     appContainer, activeStep, onTestConnectionInvoked,
-    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv,
+    slackSigningSecret, slackBotToken, slackSigningSecretEnv, slackBotTokenEnv, commandPermission,
   } = props;
   const successMessage = 'Successfully sent to Slack workspace.';
 
@@ -124,7 +125,17 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
-        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <i className="ml-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="mr-2">④</span>{t('admin:slack_integration.accordion.manage_commands')}</>}
+      >
+        <ManageCommandsProcessWithoutProxy
+          commandPermission={props.commandPermission}
+          apiv3Put={props.appContainer.apiv3.put}
+        />
+      </Accordion>
+      <Accordion
+        defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
+        // eslint-disable-next-line max-len
+        title={<><span className="mr-2">⑤</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <i className="ml-3 text-success fa fa-check"></i>}</>}
       >
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <div className="d-flex justify-content-center">
@@ -185,6 +196,7 @@ CustomBotWithoutProxySettingsAccordion.propTypes = {
   slackSigningSecretEnv: PropTypes.string,
   slackBotToken: PropTypes.string,
   slackBotTokenEnv: PropTypes.string,
+  commandPermission: PropTypes.object,
 
 };
 

+ 240 - 91
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
@@ -8,52 +8,131 @@ import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 
 const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
 
+const PermissionTypes = {
+  ALLOW_ALL: 'allowAll',
+  DENY_ALL: 'denyAll',
+  ALLOW_SPECIFIED: 'allowSpecified',
+};
+
+const CommandUsageTypes = {
+  BROADCAST_USE: 'broadcastUse',
+  SINGLE_USE: 'singleUse',
+};
+
+// A utility function that returns the new state but identical to the previous state
+const getUpdatedChannelsList = (prevState, commandName, value) => {
+  // string to array
+  const allowedChannelsArray = value.split(',');
+  // trim whitespace from all elements
+  const trimedAllowedChannelsArray = allowedChannelsArray.map(channelName => channelName.trim());
+
+  prevState[commandName] = trimedAllowedChannelsArray;
+  return prevState;
+};
+
+// A utility function that returns the new state
+const getUpdatedPermissionSettings = (prevState, commandName, value) => {
+  const newState = { ...prevState };
+  switch (value) {
+    case PermissionTypes.ALLOW_ALL:
+      newState[commandName] = true;
+      break;
+    case PermissionTypes.DENY_ALL:
+      newState[commandName] = false;
+      break;
+    case PermissionTypes.ALLOW_SPECIFIED:
+      newState[commandName] = [];
+      break;
+    default:
+      logger.error('Not implemented');
+      break;
+  }
+
+  return newState;
+};
+
+// A utility function that returns the permission type from the permission value
+const getPermissionTypeFromValue = (value) => {
+  if (Array.isArray(value)) {
+    return PermissionTypes.ALLOW_SPECIFIED;
+  }
+  if (typeof value === 'boolean') {
+    return value ? PermissionTypes.ALLOW_ALL : PermissionTypes.DENY_ALL;
+  }
+  logger.error('The value type must be boolean or string[]');
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const ManageCommandsProcess = ({
-  apiv3Put, slackAppIntegrationId, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+  apiv3Put, slackAppIntegrationId, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
 }) => {
   const { t } = useTranslation();
-  const [selectedCommandsForBroadcastUse, setSelectedCommandsForBroadcastUse] = useState(new Set(supportedCommandsForBroadcastUse));
-  const [selectedCommandsForSingleUse, setSelectedCommandsForSingleUse] = useState(new Set(supportedCommandsForSingleUse));
 
-  const toggleCheckboxForBroadcast = (e) => {
+  const [permissionsForBroadcastUseCommandsState, setPermissionsForBroadcastUseCommandsState] = useState({
+    search: permissionsForBroadcastUseCommands.search,
+  });
+  const [permissionsForSingleUseCommandsState, setPermissionsForSingleUseCommandsState] = useState({
+    create: permissionsForSingleUseCommands.create,
+    togetter: permissionsForSingleUseCommands.togetter,
+  });
+  const [currentPermissionTypes, setCurrentPermissionTypes] = useState(() => {
+    const initialState = {};
+    Object.entries(permissionsForBroadcastUseCommandsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
+    Object.entries(permissionsForSingleUseCommandsState).forEach((entry) => {
+      const [commandName, value] = entry;
+      initialState[commandName] = getPermissionTypeFromValue(value);
+    });
+    return initialState;
+  });
+
+  const updatePermissionsForBroadcastUseCommandsState = useCallback((e) => {
     const { target } = e;
-    const { name, checked } = target;
-
-    setSelectedCommandsForBroadcastUse((prevState) => {
-      const selectedCommands = new Set(prevState);
-      if (checked) {
-        selectedCommands.add(name);
-      }
-      else {
-        selectedCommands.delete(name);
-      }
-
-      return selectedCommands;
+    const { name: commandName, value } = target;
+
+    // update state
+    setPermissionsForBroadcastUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setCurrentPermissionTypes((prevState) => {
+      const newState = { ...prevState };
+      newState[commandName] = value;
+      return newState;
     });
-  };
+  }, []);
 
-  const toggleCheckboxForSingleUse = (e) => {
+  const updatePermissionsForSingleUseCommandsState = useCallback((e) => {
     const { target } = e;
-    const { name, checked } = target;
-
-    setSelectedCommandsForSingleUse((prevState) => {
-      const selectedCommands = new Set(prevState);
-      if (checked) {
-        selectedCommands.add(name);
-      }
-      else {
-        selectedCommands.delete(name);
-      }
-
-      return selectedCommands;
+    const { name: commandName, value } = target;
+
+    // update state
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedPermissionSettings(prev, commandName, value));
+    setCurrentPermissionTypes((prevState) => {
+      const newState = { ...prevState };
+      newState[commandName] = value;
+      return newState;
     });
-  };
+  }, []);
+
+  const updateChannelsListForBroadcastUseCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    // update state
+    setPermissionsForBroadcastUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+  }, []);
 
-  const updateCommandsHandler = async() => {
+  const updateChannelsListForSingleUseCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    // update state
+    setPermissionsForSingleUseCommandsState(prev => getUpdatedChannelsList(prev, commandName, value));
+  }, []);
+
+  const updateCommandsHandler = async(e) => {
     try {
       await apiv3Put(`/slack-integration-settings/slack-app-integrations/${slackAppIntegrationId}/supported-commands`, {
-        supportedCommandsForBroadcastUse: Array.from(selectedCommandsForBroadcastUse),
-        supportedCommandsForSingleUse: Array.from(selectedCommandsForSingleUse),
+        permissionsForBroadcastUseCommands: permissionsForBroadcastUseCommandsState,
+        permissionsForSingleUseCommands: permissionsForSingleUseCommandsState,
       });
       toastSuccess(t('toaster.update_successed', { target: 'Token' }));
     }
@@ -63,69 +142,139 @@ const ManageCommandsProcess = ({
     }
   };
 
+  const PermissionSettingForEachCommandComponent = ({ commandName, commandUsageType }) => {
+    const hiddenClass = currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED ? '' : 'd-none';
+    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
 
-  return (
-    <div className="py-4 px-5">
-      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
-      <div className="d-flex flex-column align-items-center">
-
-        <div>
-          <p className="font-weight-bold mb-0">Multiple GROWI</p>
-          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.multiple_growi_command')}</p>
-          <div className="custom-control custom-checkbox">
-            <div className="row mb-5">
-              {defaultSupportedCommandsNameForBroadcastUse.map((commandName) => {
-                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
-                return (
-                  <div className="col-sm-6 my-1" key={commandName}>
-                    <input
-                      type="checkbox"
-                      className="custom-control-input"
-                      id={checkboxId}
-                      name={commandName}
-                      value={commandName}
-                      checked={selectedCommandsForBroadcastUse.has(commandName)}
-                      onChange={toggleCheckboxForBroadcast}
-                    />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
-                      {commandName}
-                    </label>
-                  </div>
-                );
-              })}
+    const permissionSettings = isCommandBroadcastUse ? permissionsForBroadcastUseCommandsState : permissionsForSingleUseCommandsState;
+    const permission = permissionSettings[commandName];
+    if (permission === undefined) logger.error('Must be implemented');
+
+    const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+
+    return (
+      <div className="my-1 mb-2">
+        <div className="row align-items-center mb-3">
+          <p className="col-md-5 text-md-right text-capitalize mb-2"><strong>{commandName}</strong></p>
+          <div className="col dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+              type="button"
+              id="dropdownMenuButton"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              <span className="float-left">
+                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_ALL
+                && t('admin:slack_integration.accordion.allow_all')}
+                {currentPermissionTypes[commandName] === PermissionTypes.DENY_ALL
+                && t('admin:slack_integration.accordion.deny_all')}
+                {currentPermissionTypes[commandName] === PermissionTypes.ALLOW_SPECIFIED
+                && t('admin:slack_integration.accordion.allow_specified')}
+              </span>
+            </button>
+            <div className="dropdown-menu">
+              <button
+                className="dropdown-item"
+                type="button"
+                name={commandName}
+                value={PermissionTypes.ALLOW_ALL}
+                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
+              >
+                {t('admin:slack_integration.accordion.allow_all_long')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                name={commandName}
+                value={PermissionTypes.DENY_ALL}
+                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
+              >
+                {t('admin:slack_integration.accordion.deny_all_long')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                name={commandName}
+                value={PermissionTypes.ALLOW_SPECIFIED}
+                onClick={isCommandBroadcastUse ? updatePermissionsForBroadcastUseCommandsState : updatePermissionsForSingleUseCommandsState}
+              >
+                {t('admin:slack_integration.accordion.allow_specified_long')}
+              </button>
             </div>
           </div>
+        </div>
+        <div className={`row ${hiddenClass}`}>
+          <div className="col-md-7 offset-md-5">
+            <textarea
+              className="form-control"
+              type="textarea"
+              name={commandName}
+              defaultValue={textareaDefaultValue}
+              onChange={isCommandBroadcastUse ? updateChannelsListForBroadcastUseCommandsState : updateChannelsListForSingleUseCommandsState}
+            />
+            <p className="form-text text-muted small">
+              {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
+              <br />
+            </p>
+          </div>
+        </div>
+      </div>
+    );
+  };
 
-          <p className="font-weight-bold mb-0">Single GROWI</p>
-          <p className="text-muted mb-2">{t('admin:slack_integration.accordion.single_growi_command')}</p>
-          <div className="custom-control custom-checkbox">
-            <div className="row mb-5">
-              {defaultSupportedCommandsNameForSingleUse.map((commandName) => {
-                const checkboxId = `${commandName}-${slackAppIntegrationId}`;
-                return (
-                  <div className="col-sm-6 my-1" key={commandName}>
-                    <input
-                      type="checkbox"
-                      className="custom-control-input"
-                      id={checkboxId}
-                      name={commandName}
-                      value={commandName}
-                      checked={selectedCommandsForSingleUse.has(commandName)}
-                      onChange={toggleCheckboxForSingleUse}
-                    />
-                    <label className="text-capitalize custom-control-label ml-3" htmlFor={checkboxId}>
-                      {commandName}
-                    </label>
-                  </div>
-                );
-              })}
-            </div>
+  PermissionSettingForEachCommandComponent.propTypes = {
+    commandName: PropTypes.string,
+    commandUsageType: PropTypes.string,
+  };
+
+  const PermissionSettingsForEachCommandTypeComponent = ({ commandUsageType }) => {
+    const isCommandBroadcastUse = commandUsageType === CommandUsageTypes.BROADCAST_USE;
+    const defaultCommandsName = isCommandBroadcastUse ? defaultSupportedCommandsNameForBroadcastUse : defaultSupportedCommandsNameForSingleUse;
+    return (
+      <>
+        <div className="row">
+          <div className="col-md-7 offset-md-2">
+            <p className="font-weight-bold mb-1">{isCommandBroadcastUse ? 'Multiple GROWI' : 'Single GROWI'}</p>
+            <p className="text-muted">
+              {isCommandBroadcastUse
+                ? t('admin:slack_integration.accordion.multiple_growi_command')
+                : t('admin:slack_integration.accordion.single_growi_command')}
+            </p>
           </div>
         </div>
+        <div className="custom-control custom-checkbox">
+          <div className="row mb-5 d-block">
+            {defaultCommandsName.map((commandName) => {
+              // eslint-disable-next-line max-len
+              return <PermissionSettingForEachCommandComponent key={`${commandName}-component`} commandName={commandName} commandUsageType={commandUsageType} />;
+            })}
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  PermissionSettingsForEachCommandTypeComponent.propTypes = {
+    commandUsageType: PropTypes.string,
+  };
+
+
+  return (
+    <div className="py-4 px-5">
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <div className="row d-flex flex-column align-items-center">
+
+        <div className="col-8">
+          {Object.values(CommandUsageTypes).map((commandUsageType) => {
+            return <PermissionSettingsForEachCommandTypeComponent key={commandUsageType} commandUsageType={commandUsageType} />;
+          })}
+        </div>
       </div>
       <div className="row">
         <button
-          type="button"
+          type="submit"
           className="btn btn-primary mx-auto"
           onClick={updateCommandsHandler}
         >
@@ -139,8 +288,8 @@ const ManageCommandsProcess = ({
 ManageCommandsProcess.propTypes = {
   apiv3Put: PropTypes.func,
   slackAppIntegrationId: PropTypes.string.isRequired,
-  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
-  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
+  permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
+  permissionsForSingleUseCommands: PropTypes.object.isRequired,
 };
 
 export default ManageCommandsProcess;

+ 242 - 0
packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx

@@ -0,0 +1,242 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+import loggerFactory from '~/utils/logger';
+
+import { toastSuccess, toastError } from '../../../client/util/apiNotification';
+
+const logger = loggerFactory('growi:SlackIntegration:ManageCommandsProcess');
+
+const PermissionTypes = {
+  ALLOW_ALL: 'allowAll',
+  DENY_ALL: 'denyAll',
+  ALLOW_SPECIFIED: 'allowSpecified',
+};
+
+const defaultCommandsName = [...defaultSupportedCommandsNameForBroadcastUse, ...defaultSupportedCommandsNameForSingleUse];
+
+
+// A utility function that returns the new state but identical to the previous state
+const getUpdatedChannelsList = (commandPermissionObj, commandName, value) => {
+  // string to array
+  const allowedChannelsArray = value.split(',');
+  // trim whitespace from all elements
+  const trimedAllowedChannelsArray = allowedChannelsArray.map(channelName => channelName.trim());
+
+  commandPermissionObj[commandName] = trimedAllowedChannelsArray;
+  return commandPermissionObj;
+};
+
+// A utility function that returns the new state
+const getUpdatedPermissionSettings = (commandPermissionObj, commandName, value) => {
+  const editedCommandPermissionObj = { ...commandPermissionObj };
+  switch (value) {
+    case PermissionTypes.ALLOW_ALL:
+      editedCommandPermissionObj[commandName] = true;
+      break;
+    case PermissionTypes.DENY_ALL:
+      editedCommandPermissionObj[commandName] = false;
+      break;
+    case PermissionTypes.ALLOW_SPECIFIED:
+      editedCommandPermissionObj[commandName] = [];
+      break;
+    default:
+      logger.error('Not implemented');
+      break;
+  }
+  return editedCommandPermissionObj;
+};
+
+
+const PermissionSettingForEachCommandComponent = ({
+  commandName, editingCommandPermission, onPermissionTypeClicked, onPermissionListChanged,
+}) => {
+  const { t } = useTranslation();
+
+  if (editingCommandPermission == null) {
+    return null;
+  }
+
+  function permissionTypeClickHandler(e) {
+    if (onPermissionTypeClicked == null) {
+      return;
+    }
+    onPermissionTypeClicked(e);
+  }
+
+  function onPermissionListChangeHandler(e) {
+    if (onPermissionListChanged == null) {
+      return;
+    }
+    onPermissionListChanged(e);
+  }
+
+  const permission = editingCommandPermission[commandName];
+  const hiddenClass = Array.isArray(permission) ? '' : 'd-none';
+  const textareaDefaultValue = Array.isArray(permission) ? permission.join(',') : '';
+
+
+  return (
+    <div className="my-1 mb-2">
+      <div className="row align-items-center mb-3">
+        <p className="col my-auto text-capitalize align-middle">{commandName}</p>
+        <div className="col dropdown">
+          <button
+            className="btn btn-outline-secondary dropdown-toggle text-right col-12 col-md-auto"
+            type="button"
+            id="dropdownMenuButton"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="true"
+          >
+            <span className="float-left">
+              {permission === true && t('admin:slack_integration.accordion.allow_all')}
+              {permission === false && t('admin:slack_integration.accordion.deny_all')}
+              {Array.isArray(permission) && t('admin:slack_integration.accordion.allow_specified')}
+            </span>
+          </button>
+          <div className="dropdown-menu">
+            <button
+              className="dropdown-item"
+              type="button"
+              name={commandName}
+              value={PermissionTypes.ALLOW_ALL}
+              onClick={e => permissionTypeClickHandler(e)}
+            >
+              {t('admin:slack_integration.accordion.allow_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={commandName}
+              value={PermissionTypes.DENY_ALL}
+              onClick={e => permissionTypeClickHandler(e)}
+            >
+              {t('admin:slack_integration.accordion.deny_all_long')}
+            </button>
+            <button
+              className="dropdown-item"
+              type="button"
+              name={commandName}
+              value={PermissionTypes.ALLOW_SPECIFIED}
+              onClick={e => permissionTypeClickHandler(e)}
+            >
+              {t('admin:slack_integration.accordion.allow_specified_long')}
+            </button>
+          </div>
+        </div>
+      </div>
+      <div className={`row-12 row-md-6 ${hiddenClass}`}>
+        <textarea
+          className="form-control"
+          type="textarea"
+          name={commandName}
+          value={textareaDefaultValue}
+          onChange={e => onPermissionListChangeHandler(e)}
+        />
+        <p className="form-text text-muted small">
+          {t('admin:slack_integration.accordion.allowed_channels_description', { commandName })}
+          <br />
+        </p>
+      </div>
+    </div>
+  );
+};
+
+PermissionSettingForEachCommandComponent.propTypes = {
+  commandName: PropTypes.string,
+  editingCommandPermission: PropTypes.object,
+  onPermissionTypeClicked: PropTypes.func,
+  onPermissionListChanged: PropTypes.func,
+};
+
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+const ManageCommandsProcessWithoutProxy = ({ apiv3Put, commandPermission }) => {
+  const { t } = useTranslation();
+  const [editingCommandPermission, setEditingCommandPermission] = useState({});
+
+  const updatePermissionsCommandsState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+
+    // update state
+    setEditingCommandPermission(commandPermissionObj => getUpdatedPermissionSettings(commandPermissionObj, commandName, value));
+  }, []);
+
+
+  useEffect(() => {
+    if (commandPermission == null) {
+      return;
+    }
+    const updatedState = { ...commandPermission };
+    setEditingCommandPermission(updatedState);
+  }, [commandPermission]);
+
+  const updateChannelsListState = useCallback((e) => {
+    const { target } = e;
+    const { name: commandName, value } = target;
+    // update state
+    setEditingCommandPermission((commandPermissionObj) => {
+      return {
+        ...getUpdatedChannelsList(commandPermissionObj, commandName, value),
+      };
+    });
+  }, []);
+
+  const updateCommandsHandler = async(e) => {
+    try {
+      await apiv3Put('/slack-integration-settings/without-proxy/update-permissions', {
+        commandPermission: editingCommandPermission,
+      });
+      toastSuccess(t('toaster.update_successed', { target: 'Token' }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+  return (
+    <div className="py-4 px-5">
+      <p className="mb-4 font-weight-bold">{t('admin:slack_integration.accordion.manage_commands')}</p>
+      <div className="row d-flex flex-column align-items-center">
+        <div className="col-8">
+          <div className="custom-control custom-checkbox">
+            <div className="row mb-5 d-block">
+              { defaultCommandsName.map((commandName) => {
+                // eslint-disable-next-line max-len
+                return (
+                  <PermissionSettingForEachCommandComponent
+                    key={`${commandName}-component`}
+                    commandName={commandName}
+                    editingCommandPermission={editingCommandPermission}
+                    onPermissionTypeClicked={updatePermissionsCommandsState}
+                    onPermissionListChanged={updateChannelsListState}
+                  />
+                );
+              })}
+            </div>
+          </div>
+        </div>
+      </div>
+      <div className="row">
+        <button
+          type="submit"
+          className="btn btn-primary mx-auto"
+          onClick={updateCommandsHandler}
+        >
+          { t('Update') }
+        </button>
+      </div>
+    </div>
+  );
+};
+
+ManageCommandsProcessWithoutProxy.propTypes = {
+  apiv3Put: PropTypes.func,
+  commandPermission: PropTypes.object,
+};
+
+export default ManageCommandsProcessWithoutProxy;

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

@@ -95,7 +95,7 @@ const OfficialBotSettings = (props) => {
       <div className="mx-3">
         {slackAppIntegrations.map((slackAppIntegration, i) => {
           const {
-            tokenGtoP, tokenPtoG, _id, supportedCommandsForBroadcastUse, supportedCommandsForSingleUse,
+            tokenGtoP, tokenPtoG, _id, permissionsForBroadcastUseCommands, permissionsForSingleUseCommands,
           } = slackAppIntegration;
           const workspaceName = connectionStatuses[_id]?.workspaceName;
           return (
@@ -116,8 +116,8 @@ const OfficialBotSettings = (props) => {
                 slackAppIntegrationId={slackAppIntegration._id}
                 tokenGtoP={tokenGtoP}
                 tokenPtoG={tokenPtoG}
-                supportedCommandsForBroadcastUse={supportedCommandsForBroadcastUse}
-                supportedCommandsForSingleUse={supportedCommandsForSingleUse}
+                permissionsForBroadcastUseCommands={permissionsForBroadcastUseCommands}
+                permissionsForSingleUseCommands={permissionsForSingleUseCommands}
                 onUpdateTokens={onUpdateTokens}
                 onSubmitForm={onSubmitForm}
               />

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

@@ -27,6 +27,7 @@ const SlackIntegration = (props) => {
   const [slackBotToken, setSlackBotToken] = useState(null);
   const [slackSigningSecretEnv, setSlackSigningSecretEnv] = useState('');
   const [slackBotTokenEnv, setSlackBotTokenEnv] = useState('');
+  const [commandPermission, setCommandPermission] = useState(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState(false);
   const [slackAppIntegrations, setSlackAppIntegrations] = useState();
   const [proxyServerUri, setProxyServerUri] = useState();
@@ -40,7 +41,7 @@ const SlackIntegration = (props) => {
     try {
       const { data } = await appContainer.apiv3.get('/slack-integration-settings');
       const {
-        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri,
+        slackSigningSecret, slackBotToken, slackSigningSecretEnvVars, slackBotTokenEnvVars, slackAppIntegrations, proxyServerUri, commandPermission,
       } = data.settings;
 
       setErrorMsg(data.errorMsg);
@@ -53,6 +54,7 @@ const SlackIntegration = (props) => {
       setSlackBotTokenEnv(slackBotTokenEnvVars);
       setSlackAppIntegrations(slackAppIntegrations);
       setProxyServerUri(proxyServerUri);
+      setCommandPermission(commandPermission);
     }
     catch (err) {
       toastError(err);
@@ -151,6 +153,7 @@ const SlackIntegration = (props) => {
           onTestConnectionInvoked={fetchSlackIntegrationData}
           onUpdatedSecretToken={changeSecretAndToken}
           connectionStatuses={connectionStatuses}
+          commandPermission={commandPermission}
         />
       );
       break;

+ 50 - 36
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -1,11 +1,12 @@
 /* eslint-disable react/prop-types */
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 
 import { SlackbotType } from '@growi/slack';
 
+import { Tooltip } from 'reactstrap';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -115,6 +116,31 @@ const RegisteringProxyUrlProcess = () => {
   );
 };
 
+// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
+const CustomCopyToClipBoard = (props) => {
+  const { t } = useTranslation();
+  const [tooltipOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, []);
+  return (
+    <>
+      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
+        <div className="btn input-group-text" id="tooltipTarget">
+          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
+        </div>
+      </CopyToClipboard>
+      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
+        {t(props.message)}
+      </Tooltip>
+    </>
+  );
+};
+
 const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers((props) => {
   const { t } = useTranslation();
   const { appContainer, slackAppIntegrationId } = props;
@@ -141,11 +167,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
         <div className="col-md-6">
           <div className="input-group-prepend mx-1">
             <input className="form-control" type="text" value={props.tokenPtoG || ''} readOnly />
-            <CopyToClipboard text={props.tokenPtoG || ''} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
-              <div className="btn input-group-text">
-                <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-              </div>
-            </CopyToClipboard>
+            <CustomCopyToClipBoard textToBeCopied={props.tokenPtoG || ''} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
           </div>
         </div>
       </div>
@@ -154,11 +176,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
         <div className="col-md-6">
           <div className="input-group-prepend mx-1">
             <input className="form-control" type="text" value={props.tokenGtoP || ''} readOnly />
-            <CopyToClipboard text={props.tokenGtoP || ''} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
-              <div className="btn input-group-text">
-                <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-              </div>
-            </CopyToClipboard>
+            <CustomCopyToClipBoard textToBeCopied={props.tokenGtoP || ''} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
           </div>
         </div>
       </div>
@@ -193,11 +211,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
             <div className="input-group align-items-center pl-2 mb-3">
               <div className="input-group-prepend w-75">
                 <input className="form-control" type="text" value={props.growiUrl} readOnly />
-                <CopyToClipboard text={props.growiUrl} onCopy={() => toastSuccess(t('admin:slack_integration.copied_to_clipboard'))}>
-                  <div className="btn input-group-text">
-                    <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-                  </div>
-                </CopyToClipboard>
+                <CustomCopyToClipBoard textToBeCopied={props.growiUrl} message="admin:slack_integration.copied_to_clipboard"></CustomCopyToClipBoard>
               </div>
             </div>
 
@@ -323,6 +337,15 @@ const WithProxyAccordions = (props) => {
       />,
     },
     '③': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
+        permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+      />,
+    },
+    '④': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -332,15 +355,6 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
     },
-    '④': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
   };
 
   const CustomBotIntegrationProcedure = {
@@ -367,6 +381,15 @@ const WithProxyAccordions = (props) => {
       content: <RegisteringProxyUrlProcess />,
     },
     '⑤': {
+      title: 'manage_commands',
+      content: <ManageCommandsProcess
+        apiv3Put={props.appContainer.apiv3.put}
+        slackAppIntegrationId={props.slackAppIntegrationId}
+        permissionsForBroadcastUseCommands={props.permissionsForBroadcastUseCommands}
+        permissionsForSingleUseCommands={props.permissionsForSingleUseCommands}
+      />,
+    },
+    '⑥': {
       title: 'test_connection',
       content: <TestProcess
         apiv3Post={props.appContainer.apiv3.post}
@@ -376,15 +399,6 @@ const WithProxyAccordions = (props) => {
         isLatestConnectionSuccess={isLatestConnectionSuccess}
       />,
     },
-    '⑥': {
-      title: 'manage_commands',
-      content: <ManageCommandsProcess
-        apiv3Put={props.appContainer.apiv3.put}
-        slackAppIntegrationId={props.slackAppIntegrationId}
-        supportedCommandsForBroadcastUse={props.supportedCommandsForBroadcastUse}
-        supportedCommandsForSingleUse={props.supportedCommandsForSingleUse}
-      />,
-    },
   };
 
   const integrationProcedureMapping = props.botType === SlackbotType.OFFICIAL ? officialBotIntegrationProcedure : CustomBotIntegrationProcedure;
@@ -424,8 +438,8 @@ WithProxyAccordions.propTypes = {
   slackAppIntegrationId: PropTypes.string.isRequired,
   tokenPtoG: PropTypes.string,
   tokenGtoP: PropTypes.string,
-  supportedCommandsForBroadcastUse: PropTypes.arrayOf(PropTypes.string),
-  supportedCommandsForSingleUse: PropTypes.arrayOf(PropTypes.string),
+  permissionsForBroadcastUseCommands: PropTypes.object.isRequired,
+  permissionsForSingleUseCommands: PropTypes.object.isRequired,
 };
 
 export default WithProxyAccordionsWrapper;

+ 1 - 1
packages/app/src/components/EmptyTrashModal.jsx

@@ -23,7 +23,7 @@ const EmptyTrashModal = (props) => {
     setErrs(null);
 
     try {
-      await appContainer.apiv3Delete('/pages/empty-trash', { socketClientId: socketIoContainer.getSocketClientId() });
+      await appContainer.apiv3Delete('/pages/empty-trash');
       window.location.reload();
     }
     catch (err) {

+ 1 - 1
packages/app/src/components/PagePathHierarchicalLink.jsx

@@ -46,7 +46,7 @@ const PagePathHierarchicalLink = (props) => {
   const RootElm = ({ children }) => {
     return props.isInnerElem
       ? <>{children}</>
-      : <span className="grw-page-path-hierarchical-link d-inline-block text-break">{children}</span>;
+      : <span className="grw-page-path-hierarchical-link text-break">{children}</span>;
   };
 
   return (

+ 1 - 1
packages/app/src/components/Sidebar/CustomSidebar.jsx

@@ -61,7 +61,7 @@ const CustomSidebar = (props) => {
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
-        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={fetchDataAndRenderHtml}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload-cs" onClick={fetchDataAndRenderHtml}>
           <i className="icon icon-reload"></i>
         </button>
       </div>

+ 138 - 30
packages/app/src/components/Sidebar/RecentChanges.jsx

@@ -10,6 +10,8 @@ import loggerFactory from '~/utils/logger';
 
 import LinkedPagePath from '~/models/linked-page-path';
 
+import FootstampIcon from '../FootstampIcon';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
@@ -17,6 +19,106 @@ import { toastError } from '~/client/util/apiNotification';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
 const logger = loggerFactory('growi:History');
+
+function PageItemLower({ page }) {
+  return (
+    <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
+      <div className="d-flex">
+        <div className="footstamp-icon mr-1 d-inline-block"><FootstampIcon /></div>
+        <div className="mr-2 grw-list-counts d-inline-block">{page.seenUsers.length}</div>
+        <div className="icon-bubble mr-1 d-inline-block"></div>
+        <div className="mr-2 grw-list-counts d-inline-block">{page.commentCount}</div>
+      </div>
+      <div className="grw-formatted-distance-date small mt-auto">
+        <FormattedDistanceDate id={page._id} date={page.updatedAt} />
+      </div>
+    </div>
+  );
+}
+PageItemLower.propTypes = {
+  page: PropTypes.any,
+};
+function LargePageItem({ page }) {
+  const dPagePath = new DevidedPagePath(page.path, false, true);
+  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+  const FormerLink = () => (
+    <div className="grw-page-path-text-muted-container small">
+      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+    </div>
+  );
+
+  let locked;
+  if (page.grant !== 1) {
+    locked = <span><i className="icon-lock ml-2" /></span>;
+  }
+
+  const tags = page.tags;
+  const tagElements = tags.map((tag) => {
+    return (
+      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+        {tag.name}
+      </a>
+    );
+  });
+
+  return (
+    <li className="list-group-item py-3 px-0">
+      <div className="d-flex w-100">
+        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+        <div className="flex-grow-1 ml-2">
+          { !dPagePath.isRoot && <FormerLink /> }
+          <h5 className="my-2">
+            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+            {locked}
+          </h5>
+          <div className="grw-tag-labels mt-1 mb-2">
+            { tagElements }
+          </div>
+          <PageItemLower page={page} />
+        </div>
+      </div>
+    </li>
+  );
+}
+LargePageItem.propTypes = {
+  page: PropTypes.any,
+};
+
+function SmallPageItem({ page }) {
+  const dPagePath = new DevidedPagePath(page.path, false, true);
+  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+  const FormerLink = () => (
+    <div className="grw-page-path-text-muted-container small">
+      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+    </div>
+  );
+
+  let locked;
+  if (page.grant !== 1) {
+    locked = <span><i className="icon-lock ml-2" /></span>;
+  }
+
+  return (
+    <li className="list-group-item py-2 px-0">
+      <div className="d-flex w-100">
+        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+        <div className="flex-grow-1 ml-2">
+          { !dPagePath.isRoot && <FormerLink /> }
+          <h5 className="my-0">
+            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+            {locked}
+          </h5>
+          <PageItemLower page={page} />
+        </div>
+      </div>
+    </li>
+  );
+}
+SmallPageItem.propTypes = {
+  page: PropTypes.any,
+};
 class RecentChanges extends React.Component {
 
   static propTypes = {
@@ -26,10 +128,16 @@ class RecentChanges extends React.Component {
 
   constructor(props) {
     super(props);
-
+    this.state = {
+      isRecentChangesSidebarSmall: false,
+    };
     this.reloadData = this.reloadData.bind(this);
   }
 
+  componentWillMount() {
+    this.retrieveSizePreferenceFromLocalStorage();
+  }
+
   async componentDidMount() {
     this.reloadData();
   }
@@ -46,36 +154,22 @@ class RecentChanges extends React.Component {
     }
   }
 
-  PageItem = ({ page }) => {
-    const dPagePath = new DevidedPagePath(page.path, false, true);
-    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    const FormerLink = () => (
-      <div className="grw-page-path-text-muted-container small">
-        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-      </div>
-    );
+  retrieveSizePreferenceFromLocalStorage() {
+    if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
+      this.setState({
+        isRecentChangesSidebarSmall: true,
+      });
+    }
+  }
 
-    return (
-      <li className="list-group-item p-2">
-        <div className="d-flex w-100">
-          <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
-          <div className="flex-grow-1 ml-2">
-            { !dPagePath.isRoot && <FormerLink /> }
-            <h5 className="mb-1">
-              <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            </h5>
-            <div className="text-right small">
-              <FormattedDistanceDate id={page.id} date={page.updatedAt} />
-            </div>
-          </div>
-        </div>
-      </li>
-    );
+  changeSizeHandler = (e) => {
+    this.setState({
+      isRecentChangesSidebarSmall: e.target.checked,
+    });
+    window.localStorage.setItem('isRecentChangesSidebarSmall', e.target.checked);
   }
 
   render() {
-    const { PageItem } = this;
     const { t } = this.props;
     const { recentlyUpdatedPages } = this.props.appContainer.state;
 
@@ -84,13 +178,26 @@ class RecentChanges extends React.Component {
         <div className="grw-sidebar-content-header p-3 d-flex">
           <h3 className="mb-0">{t('Recent Changes')}</h3>
           {/* <h3 className="mb-0">{t('Recent Created')}</h3> */} {/* TODO: impl switching */}
-          <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={this.reloadData}>
+          <button type="button" className="btn btn-sm ml-auto grw-btn-reload-rc" onClick={this.reloadData}>
             <i className="icon icon-reload"></i>
           </button>
+          <div className="grw-recent-changes-resize-button custom-control custom-switch ml-2">
+            <input
+              id="recentChangesResize"
+              className="custom-control-input"
+              type="checkbox"
+              checked={this.state.isRecentChangesSidebarSmall}
+              onChange={e => this.setState({ isRecentChangesSidebarSmall: e.target.checked })}
+            />
+            <label className="custom-control-label" htmlFor="recentChangesResize">
+            </label>
+          </div>
         </div>
-        <div className="grw-sidebar-content-body p-3">
+        <div className="grw-sidebar-content-body grw-recent-changes p-3">
           <ul className="list-group list-group-flush">
-            { recentlyUpdatedPages.map(page => <PageItem key={page.id} page={page} />) }
+            {recentlyUpdatedPages.map(page => (this.state.isRecentChangesSidebarSmall
+              ? <SmallPageItem key={page._id} page={page} />
+              : <LargePageItem key={page._id} page={page} />))}
           </ul>
         </div>
       </>
@@ -104,4 +211,5 @@ class RecentChanges extends React.Component {
  */
 const RecentChangesWrapper = withUnstatedContainers(RecentChanges, [AppContainer]);
 
+
 export default withTranslation()(RecentChangesWrapper);

+ 123 - 0
packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js

@@ -0,0 +1,123 @@
+import mongoose from 'mongoose';
+import { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } from '@growi/slack';
+
+import config from '^/config/migrate';
+import loggerFactory from '~/utils/logger';
+import { getModelSafely } from '~/server/util/mongoose-utils';
+
+
+const logger = loggerFactory('growi:migrate:update-configs-for-slackbot');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    // create default data
+    const defaultDataForBroadcastUse = {};
+    defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
+      defaultDataForBroadcastUse[commandName] = false;
+    });
+    const defaultDataForSingleUse = {};
+    defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
+      defaultDataForSingleUse[commandName] = false;
+    });
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const copyForBroadcastUse = { ...defaultDataForBroadcastUse };
+      const copyForSingleUse = { ...defaultDataForSingleUse };
+      // when the document does NOT have supportedCommandsFor... columns
+      if (doc._doc.supportedCommandsForBroadcastUse == null) {
+        defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
+          copyForBroadcastUse[commandName] = true;
+        });
+        defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
+          copyForSingleUse[commandName] = true;
+        });
+      }
+      // // when the document has supportedCommandsFor... columns
+      else {
+        doc._doc.supportedCommandsForBroadcastUse.forEach((commandName) => {
+          copyForBroadcastUse[commandName] = true;
+        });
+        doc._doc.supportedCommandsForSingleUse.forEach((commandName) => {
+          copyForSingleUse[commandName] = true;
+        });
+      }
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: [
+            {
+              $set: {
+                permissionsForBroadcastUseCommands: copyForBroadcastUse,
+                permissionsForSingleUseCommands: copyForSingleUse,
+              },
+            },
+            {
+              $unset: ['supportedCommandsForBroadcastUse', 'supportedCommandsForSingleUse'],
+            },
+          ],
+        },
+      };
+    });
+
+    await SlackAppIntegration.bulkWrite(operations);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, next) {
+    logger.info('Rollback migration');
+    // return next();
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const SlackAppIntegration = getModelSafely('SlackAppIntegration') || require('~/server/models/slack-app-integration')();
+
+    const slackAppIntegrations = await SlackAppIntegration.find();
+
+    // create operations
+    const operations = slackAppIntegrations.map((doc) => {
+      const dataForBroadcastUse = [];
+      const dataForSingleUse = [];
+      doc.permissionsForBroadcastUseCommands.forEach((value, commandName) => {
+        if (value === true) {
+          dataForBroadcastUse.push(commandName);
+        }
+      });
+      doc.permissionsForSingleUseCommands.forEach((value, commandName) => {
+        if (value === true) {
+          dataForSingleUse.push(commandName);
+        }
+      });
+
+      return {
+        updateOne: {
+          filter: { _id: doc._id },
+          update: [
+            {
+              $set: {
+                supportedCommandsForBroadcastUse: dataForBroadcastUse,
+                supportedCommandsForSingleUse: dataForSingleUse,
+              },
+            },
+            {
+              $unset: ['permissionsForBroadcastUseCommands', 'permissionsForSingleUseCommands'],
+            },
+          ],
+        },
+      };
+    });
+
+    await SlackAppIntegration.bulkWrite(operations);
+
+    next();
+    logger.info('Migration has successfully applied');
+  },
+};

+ 3 - 5
packages/app/src/server/models/page.js

@@ -772,7 +772,7 @@ module.exports = function(crowi) {
     // find
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-    const pages = await builder.query.exec('find');
+    const pages = await builder.query.lean().exec('find');
 
     const result = {
       pages, totalCount, offset: opt.offset, limit: opt.limit,
@@ -962,7 +962,6 @@ module.exports = function(crowi) {
     const format = options.format || 'markdown';
     const redirectTo = options.redirectTo || null;
     const grantUserGroupId = options.grantUserGroupId || null;
-    const socketClientId = options.socketClientId || null;
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -995,7 +994,7 @@ module.exports = function(crowi) {
     savedPage = await this.findByPath(revision.path);
     await savedPage.populateDataToShowRevision();
 
-    pageEvent.emit('create', savedPage, user, socketClientId);
+    pageEvent.emit('create', savedPage, user);
 
     return savedPage;
   };
@@ -1007,7 +1006,6 @@ module.exports = function(crowi) {
     const grant = options.grant || pageData.grant; //                                  use the previous data if absence
     const grantUserGroupId = options.grantUserGroupId || pageData.grantUserGroupId; // use the previous data if absence
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
-    const socketClientId = options.socketClientId || null;
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     pageData.applyScope(user, grant, grantUserGroupId);
@@ -1023,7 +1021,7 @@ module.exports = function(crowi) {
       savedPage = await this.syncRevisionToHackmd(savedPage);
     }
 
-    pageEvent.emit('update', savedPage, user, socketClientId);
+    pageEvent.emit('update', savedPage, user);
 
     return savedPage;
   };

+ 3 - 4
packages/app/src/server/models/slack-app-integration.js

@@ -2,12 +2,13 @@ const crypto = require('crypto');
 const mongoose = require('mongoose');
 const { defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse } = require('@growi/slack');
 
+
 const schema = new mongoose.Schema({
   tokenGtoP: { type: String, required: true, unique: true },
   tokenPtoG: { type: String, required: true, unique: true },
   isPrimary: { type: Boolean, unique: true, sparse: true },
-  supportedCommandsForBroadcastUse: { type: [String], default: defaultSupportedCommandsNameForBroadcastUse },
-  supportedCommandsForSingleUse: { type: [String], default: defaultSupportedCommandsNameForSingleUse },
+  permissionsForBroadcastUseCommands: Map,
+  permissionsForSingleUseCommands: Map,
 });
 
 class SlackAppIntegration {
@@ -48,9 +49,7 @@ class SlackAppIntegration {
 }
 
 module.exports = function(crowi) {
-
   SlackAppIntegration.crowi = crowi;
-
   schema.loadClass(SlackAppIntegration);
   return mongoose.model('SlackAppIntegration', schema);
 };

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

@@ -132,6 +132,7 @@ module.exports = (crowi) => {
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
 
   const globalNotificationService = crowi.getGlobalNotificationService();
+  const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting } = crowi.models;
   const { exportService } = crowi;
 

+ 76 - 15
packages/app/src/server/routes/apiv3/pages.js

@@ -4,6 +4,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const express = require('express');
 const pathUtils = require('growi-commons').pathUtils;
+const mongoose = require('mongoose');
 
 const { body } = require('express-validator');
 const { query } = require('express-validator');
@@ -22,6 +23,36 @@ const LIMIT_FOR_LIST = 10;
  *    name: Pages
  */
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Tags:
+ *        description: Tags
+ *        type: array
+ *        items:
+ *          $ref: '#/components/schemas/Tag/properties/name'
+ *        example: ['daily', 'report', 'tips']
+ *
+ *      Tag:
+ *        description: Tag
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: tag ID
+ *            example: 5e2d6aede35da4004ef7e0b7
+ *          name:
+ *            type: string
+ *            description: tag name
+ *            example: daily
+ *          count:
+ *            type: number
+ *            description: Count of tagged pages
+ *            example: 3
+ */
+
 /**
  * @swagger
  *
@@ -76,7 +107,7 @@ const LIMIT_FOR_LIST = 10;
  *          path:
  *            type: string
  *            description: page path
- *            example: /
+ *            example: /Sandbox/Math
  *          redirectTo:
  *            type: string
  *            description: redirect path
@@ -137,7 +168,6 @@ module.exports = (crowi) => {
       body('overwriteScopesOfDescendants').if(value => value != null).isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
       body('isSlackEnabled').if(value => value != null).isBoolean().withMessage('isSlackEnabled must be boolean'),
       body('slackChannels').if(value => value != null).isString().withMessage('slackChannels must be string'),
-      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
       body('pageTags').if(value => value != null).isArray().withMessage('pageTags must be array'),
     ],
     renamePage: [
@@ -147,7 +177,6 @@ module.exports = (crowi) => {
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRemainMetadata').if(value => value != null).isBoolean().withMessage('isRemainMetadata must be boolean'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
-      body('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
     ],
 
     duplicatePage: [
@@ -176,7 +205,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /pages/create:
+   *    /pages:
    *      post:
    *        tags: [Pages]
    *        operationId: createPage
@@ -193,6 +222,14 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/Page/properties/path'
    *                  grant:
    *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                  grantUserGroupId:
+   *                    type: string
+   *                    description: UserGroup ID
+   *                    example: 5ae5fccfc5577b0004dbd8ab
+   *                  pageTags:
+   *                    type: array
+   *                    items:
+   *                      $ref: '#/components/schemas/Tag'
    *                required:
    *                  - body
    *                  - path
@@ -203,14 +240,23 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
+   *                    data:
+   *                      type: object
+   *                      properties:
+   *                        page:
+   *                          $ref: '#/components/schemas/Page'
+   *                        tags:
+   *                          type: array
+   *                          items:
+   *                            $ref: '#/components/schemas/Tags'
+   *                        revision:
+   *                          $ref: '#/components/schemas/Revision'
    *          409:
    *            description: page path is already existed
    */
   router.post('/', accessTokenParser, loginRequiredStrictly, csrf, validator.createPage, apiV3FormValidator, async(req, res) => {
     const {
-      body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, socketClientId, pageTags,
+      body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
     } = req.body;
 
     let { path } = req.body;
@@ -224,7 +270,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Failed to post page', 'page_exists'), 500);
     }
 
-    const options = { socketClientId };
+    const options = {};
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -311,6 +357,26 @@ module.exports = (crowi) => {
         }
       });
 
+      const PageTagRelation = mongoose.model('PageTagRelation');
+      const ids = result.pages.map((page) => { return page._id });
+      const relations = await PageTagRelation.find({ relatedPage: { $in: ids } }).populate('relatedTag');
+
+      // { pageId: [{ tag }, ...] }
+      const relationsMap = new Map();
+      // increment relationsMap
+      relations.forEach((relation) => {
+        const pageId = relation.relatedPage.toString();
+        if (!relationsMap.has(pageId)) {
+          relationsMap.set(pageId, []);
+        }
+        relationsMap.get(pageId).push(relation.relatedTag);
+      });
+      // add tags to each page
+      result.pages.forEach((page) => {
+        const pageId = page._id.toString();
+        page.tags = relationsMap.has(pageId) ? relationsMap.get(pageId) : [];
+      });
+
       return res.apiv3(result);
     }
     catch (err) {
@@ -379,7 +445,6 @@ module.exports = (crowi) => {
     const options = {
       createRedirectPage: req.body.isRenameRedirect,
       updateMetadata: !req.body.isRemainMetadata,
-      socketClientId: +req.body.socketClientId || undefined,
     };
 
     if (!isCreatablePage(newPagePath)) {
@@ -429,9 +494,6 @@ module.exports = (crowi) => {
     return res.apiv3(result);
   });
 
-  validator.emptyTrash = [
-    query('socketClientId').if(value => value != null).isInt().withMessage('socketClientId must be int'),
-  ];
   /**
    * @swagger
    *
@@ -443,9 +505,8 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, validator.emptyTrash, apiV3FormValidator, async(req, res) => {
-    const socketClientId = parseInt(req.query.socketClientId);
-    const options = { socketClientId };
+  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
+    const options = {};
 
     try {
       const pages = await crowi.pageService.deleteCompletelyDescendantsWithStream({ path: '/trash' }, req.user, options);

+ 91 - 21
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -2,7 +2,6 @@ import { SlackbotType } from '@growi/slack';
 
 import loggerFactory from '~/utils/logger';
 
-
 const mongoose = require('mongoose');
 const express = require('express');
 const { body, query, param } = require('express-validator');
@@ -54,8 +53,7 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
   const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
-
-  const SlackAppIntegration = mongoose.model('SlackAppIntegration');
+  const SlackAppIntegration = crowi.model('SlackAppIntegration');
 
   const validator = {
     botType: [
@@ -107,6 +105,7 @@ module.exports = (crowi) => {
       'slackbot:withoutProxy:signingSecret': null,
       'slackbot:withoutProxy:botToken': null,
       'slackbot:proxyUri': null,
+      'slackbot:withoutProxy:commandPermission': null,
     };
 
     return updateSlackBotSettings(params);
@@ -175,6 +174,7 @@ module.exports = (crowi) => {
       settings.slackBotTokenEnvVars = configManager.getConfigFromEnvVars('crowi', 'slackbot:withoutProxy:botToken');
       settings.slackSigningSecret = configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret');
       settings.slackBotToken = configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken');
+      settings.commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
     }
     else {
       settings.proxyServerUri = crowi.configManager.getConfig('crowi', 'slackbot:proxyUri');
@@ -245,6 +245,25 @@ module.exports = (crowi) => {
     await resetAllBotSettings(initializedBotType);
     crowi.slackIntegrationService.publishUpdatedMessage();
 
+    if (initializedBotType === 'customBotWithoutProxy') {
+      // set without-proxy command permissions at bot type changing
+      const commandPermission = {};
+      [...defaultSupportedCommandsNameForBroadcastUse, ...defaultSupportedCommandsNameForSingleUse].forEach((commandName) => {
+        commandPermission[commandName] = true;
+      });
+
+      const requestParams = { 'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission) };
+      try {
+        await updateSlackBotSettings(requestParams);
+        crowi.slackIntegrationService.publishUpdatedMessage();
+      }
+      catch (error) {
+        const msg = 'Error occured in updating command permission settigns';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+      }
+    }
+
     // TODO Impl to delete AccessToken both of Proxy and GROWI when botType changes.
     const slackBotTypeParam = { slackBotType: crowi.configManager.getConfig('crowi', 'slackbot:currentBotType') };
     return res.apiv3({ slackBotTypeParam });
@@ -348,7 +367,7 @@ module.exports = (crowi) => {
         slackSigningSecret: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:signingSecret'),
         slackBotToken: crowi.configManager.getConfig('crowi', 'slackbot:withoutProxy:botToken'),
       };
-      return res.apiv3({ customBotWithoutProxySettingParams });
+      return res.apiv3();
     }
     catch (error) {
       const msg = 'Error occured in updating Custom bot setting';
@@ -357,6 +376,43 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    /slack-integration-settings/without-proxy/update-permissions/:
+   *      put:
+   *        tags: [UpdateWithoutProxyPermissions]
+   *        operationId: putWithoutProxyPermissions
+   *        summary: update customBotWithoutProxy permissions
+   *        description: Update customBotWithoutProxy permissions.
+   *        responses:
+   *           200:
+   *             description: Succeeded to put CustomBotWithoutProxy permissions.
+   */
+
+  router.put('/without-proxy/update-permissions', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const currentBotType = crowi.configManager.getConfig('crowi', 'slackbot:currentBotType');
+    if (currentBotType !== SlackbotType.CUSTOM_WITHOUT_PROXY) {
+      const msg = 'Not CustomBotWithoutProxy';
+      return res.apiv3Err(new ErrorV3(msg, 'not-customBotWithoutProxy'), 400);
+    }
+
+    const { commandPermission } = req.body;
+    const requestParams = {
+      'slackbot:withoutProxy:commandPermission': JSON.stringify(commandPermission),
+    };
+    try {
+      await updateSlackBotSettings(requestParams);
+      crowi.slackIntegrationService.publishUpdatedMessage();
+      return res.apiv3();
+    }
+    catch (error) {
+      const msg = 'Error occured in updating command permission settigns';
+      logger.error('Error', error);
+      return res.apiv3Err(new ErrorV3(msg, 'update-CustomBotSetting-failed'), 500);
+    }
+  });
+
 
   /**
    * @swagger
@@ -372,21 +428,30 @@ module.exports = (crowi) => {
    *            description: Succeeded to create slack app integration
    */
   router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
+    if (SlackAppIntegrationRecordsNum >= 10) {
+      const msg = 'Not be able to create more than 10 slack workspace integration settings';
+      logger.error('Error', msg);
+      return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
+    }
+
     const { tokenGtoP, tokenPtoG } = await SlackAppIntegration.generateUniqueAccessTokens();
     try {
-      const count = await SlackAppIntegration.countDocuments();
-      if (count >= 10) {
-        const msg = 'Not be able to create more than 10 slack workspace integration settings';
-        logger.error('Error', msg);
-        return res.apiv3Err(new ErrorV3(msg, 'create-slackAppIntegeration-failed'), 500);
-      }
+      const initialSupportedCommandsForBroadcastUse = new Map();
+      const initialSupportedCommandsForSingleUse = new Map();
+
+      defaultSupportedCommandsNameForBroadcastUse.forEach((commandName) => {
+        initialSupportedCommandsForBroadcastUse.set(commandName, true);
+      });
+      defaultSupportedCommandsNameForSingleUse.forEach((commandName) => {
+        initialSupportedCommandsForSingleUse.set(commandName, true);
+      });
 
       const slackAppTokens = await SlackAppIntegration.create({
         tokenGtoP,
         tokenPtoG,
-        isPrimary: count === 0 ? true : undefined,
-        supportedCommandsForBroadcastUse: defaultSupportedCommandsNameForBroadcastUse,
-        supportedCommandsForSingleUse: defaultSupportedCommandsNameForSingleUse,
+        permissionsForBroadcastUseCommands: initialSupportedCommandsForBroadcastUse,
+        permissionsForSingleUseCommands: initialSupportedCommandsForSingleUse,
       });
       return res.apiv3(slackAppTokens, 200);
     }
@@ -411,7 +476,6 @@ module.exports = (crowi) => {
    *            description: Succeeded to delete access tokens for slack
    */
   router.delete('/slack-app-integrations/:id', validator.deleteIntegration, apiV3FormValidator, async(req, res) => {
-    const SlackAppIntegration = mongoose.model('SlackAppIntegration');
     const { id } = req.params;
 
     try {
@@ -541,13 +605,19 @@ module.exports = (crowi) => {
    */
   // eslint-disable-next-line max-len
   router.put('/slack-app-integrations/:id/supported-commands', loginRequiredStrictly, adminRequired, csrf, validator.updateSupportedCommands, apiV3FormValidator, async(req, res) => {
-    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = req.body;
     const { id } = req.params;
 
+    const updatePermissionsForBroadcastUseCommands = new Map(Object.entries(permissionsForBroadcastUseCommands));
+    const updatePermissionsForSingleUseCommands = new Map(Object.entries(permissionsForSingleUseCommands));
+
     try {
       const slackAppIntegration = await SlackAppIntegration.findByIdAndUpdate(
         id,
-        { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse },
+        {
+          permissionsForBroadcastUseCommands: updatePermissionsForBroadcastUseCommands,
+          permissionsForSingleUseCommands: updatePermissionsForSingleUseCommands,
+        },
         { new: true },
       );
 
@@ -558,13 +628,13 @@ module.exports = (crowi) => {
           'put',
           '/g2s/supported-commands',
           {
-            supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
-            supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+            permissionsForBroadcastUseCommands: slackAppIntegration.permissionsForBroadcastUseCommands,
+            permissionsForSingleUseCommands: slackAppIntegration.permissionsForSingleUseCommands,
           },
         );
       }
 
-      return res.apiv3({ slackAppIntegration });
+      return res.apiv3({});
     }
     catch (error) {
       const msg = `Error occured in updating settings. Cause: ${error.message}`;
@@ -613,8 +683,8 @@ module.exports = (crowi) => {
         'post',
         '/g2s/relation-test',
         {
-          supportedCommandsForBroadcastUse: slackAppIntegration.supportedCommandsForBroadcastUse,
-          supportedCommandsForSingleUse: slackAppIntegration.supportedCommandsForSingleUse,
+          permissionsForBroadcastUseCommands: slackAppIntegration.permissionsForBroadcastUseCommands,
+          permissionsForSingleUseCommands: slackAppIntegration.permissionsForSingleUseCommands,
         },
       );
 

+ 64 - 44
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -4,12 +4,13 @@ const express = require('express');
 const mongoose = require('mongoose');
 const urljoin = require('url-join');
 
-const { verifySlackRequest, generateWebClient, getSupportedGrowiActionsRegExps } = require('@growi/slack');
+const { verifySlackRequest, parseSlashCommand } = require('@growi/slack');
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
 const { respondIfSlackbotError } = require('../../service/slack-command-handler/respond-if-slackbot-error');
+const { checkPermission } = require('../../util/slack-integration');
 
 module.exports = (crowi) => {
   this.app = crowi.express;
@@ -26,14 +27,14 @@ module.exports = (crowi) => {
       return res.status(400).send({ message });
     }
 
-    const slackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
+    const SlackAppIntegrationCount = await SlackAppIntegration.countDocuments({ tokenPtoG });
 
     logger.debug('verifyAccessTokenFromProxy', {
       tokenPtoG,
-      slackAppIntegrationCount,
+      SlackAppIntegrationCount,
     });
 
-    if (slackAppIntegrationCount === 0) {
+    if (SlackAppIntegrationCount === 0) {
       return res.status(403).send({
         message: 'The access token that identifies the request source is slackbot-proxy is invalid. Did you setup with `/growi register`.\n'
         + 'Or did you delete registration for GROWI ? if so, the link with GROWI has been disconnected. '
@@ -44,53 +45,74 @@ module.exports = (crowi) => {
     next();
   }
 
-  async function checkCommandPermission(req, res, next) {
+  async function extractPermissionsCommands(tokenPtoG) {
+    const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    if (slackAppIntegration == null) return null;
+    const permissionsForBroadcastUseCommands = slackAppIntegration.permissionsForBroadcastUseCommands;
+    const permissionsForSingleUseCommands = slackAppIntegration.permissionsForSingleUseCommands;
+
+    return { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands };
+  }
+
+  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  async function checkCommandsPermission(req, res, next) {
+    if (req.body.text == null) return next(); // when /relation-test
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const extractPermissions = await extractPermissionsCommands(tokenPtoG);
+
+    let commandPermission;
+    if (extractPermissions != null) { // with proxy
+      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
+      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
+    }
+    else { // without proxy
+      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
+    }
+
+    const growiCommand = parseSlashCommand(req.body);
+    const fromChannel = req.body.channel_name;
+    const isPermitted = checkPermission(commandPermission, growiCommand.growiCommandType, fromChannel);
+    if (isPermitted) return next();
+
+    // IT IS NOT WORKING. FIX THIS GW-7441
+    return res.status(403).send('It is not allowed to run the command to this GROWI.');
+  }
 
-    const relation = await SlackAppIntegration.findOne({ tokenPtoG });
-    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = relation;
-    const supportedCommands = supportedCommandsForBroadcastUse.concat(supportedCommandsForSingleUse);
-    const supportedGrowiActionsRegExps = getSupportedGrowiActionsRegExps(supportedCommands);
+  // REFACTORIMG THIS MIDDLEWARE GW-7441
+  async function checkInteractionsPermission(req, res, next) {
+    const payload = JSON.parse(req.body.payload);
+    if (payload == null) return next(); // when /relation-test
 
-    // get command name from req.body
-    let command = '';
     let actionId = '';
     let callbackId = '';
-    let payload;
-    if (req.body.payload) {
-      payload = JSON.parse(req.body.payload);
-    }
+    let fromChannel = '';
 
-    if (req.body.text == null && !payload) { // when /relation-test
-      return next();
-    }
-
-    if (!payload) { // when request is to /commands
-      command = req.body.text.split(' ')[0];
-    }
-    else if (payload.actions) { // when request is to /interactions && block_actions
+    if (payload.actions) { // when request is to /interactions && block_actions
       actionId = payload.actions[0].action_id;
+      fromChannel = payload.channel.name;
     }
     else { // when request is to /interactions && view_submission
       callbackId = payload.view.callback_id;
+      fromChannel = JSON.parse(payload.view.private_metadata).channelName;
     }
 
-    let isActionSupported = false;
-    supportedGrowiActionsRegExps.forEach((regexp) => {
-      if (regexp.test(actionId) || regexp.test(callbackId)) {
-        isActionSupported = true;
-      }
-    });
-
-    // validate
-    if (command && !supportedCommands.includes(command)) {
-      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    const tokenPtoG = req.headers['x-growi-ptog-tokens'];
+    const extractPermissions = await extractPermissionsCommands(tokenPtoG);
+    let commandPermission;
+    if (extractPermissions != null) { // with proxy
+      const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = extractPermissions;
+      commandPermission = Object.fromEntries([...permissionsForBroadcastUseCommands, ...permissionsForSingleUseCommands]);
     }
-    if ((actionId || callbackId) && !isActionSupported) {
-      return res.status(403).send(`It is not allowed to run '${command}' command to this GROWI.`);
+    else { // without proxy
+      commandPermission = JSON.parse(configManager.getConfig('crowi', 'slackbot:withoutProxy:commandPermission'));
     }
 
-    next();
+    const callbacIdkOrActionId = callbackId || actionId;
+    const isPermitted = checkPermission(commandPermission, callbacIdkOrActionId, fromChannel);
+    if (isPermitted) return next();
+
+    // IT IS NOT WORKING FIX. THIS GW-7441
+    return res.status(403).send('It is not allowed to run the command to this GROWI.');
   }
 
   const addSigningSecretToReq = (req, res, next) => {
@@ -116,7 +138,6 @@ module.exports = (crowi) => {
       text: 'Processing your request ...',
     });
 
-
     const args = body.text.split(' ');
     const command = args[0];
 
@@ -129,14 +150,13 @@ module.exports = (crowi) => {
 
   }
 
-  router.post('/commands', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
+  router.post('/commands', addSigningSecretToReq, verifySlackRequest, checkCommandsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleCommands(req, res, client);
   });
 
-  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
+  router.post('/proxied/commands', verifyAccessTokenFromProxy, checkCommandsPermission, async(req, res) => {
     const { body } = req;
-
     // 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') {
@@ -145,7 +165,6 @@ module.exports = (crowi) => {
 
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
-
     return handleCommands(req, res, client);
   });
 
@@ -186,12 +205,12 @@ module.exports = (crowi) => {
 
   }
 
-  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, async(req, res) => {
+  router.post('/interactions', addSigningSecretToReq, verifySlackRequest, checkInteractionsPermission, async(req, res) => {
     const client = await slackIntegrationService.generateClientForCustomBotWithoutProxy();
     return handleInteractions(req, res, client);
   });
 
-  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkCommandPermission, async(req, res) => {
+  router.post('/proxied/interactions', verifyAccessTokenFromProxy, checkInteractionsPermission, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
 
@@ -201,8 +220,9 @@ module.exports = (crowi) => {
   router.get('/supported-commands', verifyAccessTokenFromProxy, async(req, res) => {
     const tokenPtoG = req.headers['x-growi-ptog-tokens'];
     const slackAppIntegration = await SlackAppIntegration.findOne({ tokenPtoG });
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = slackAppIntegration;
 
-    return res.send(slackAppIntegration);
+    return res.apiv3({ permissionsForBroadcastUseCommands, permissionsForSingleUseCommands });
   });
 
   return router;

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

@@ -341,11 +341,11 @@ module.exports = function(crowi, app) {
    * @param {object} res
    */
   const saveOnHackmd = async function(req, res) {
-    const page = req.page;
+    const { page, user } = req;
 
     try {
       await Page.updateHasDraftOnHackmd(page, true);
-      pageEvent.emit('saveOnHackmd', page);
+      pageEvent.emit('saveOnHackmd', page, user);
       return res.json(ApiResponse.success());
     }
     catch (err) {

+ 4 - 8
packages/app/src/server/routes/page.js

@@ -682,7 +682,6 @@ module.exports = function(crowi, app) {
     const overwriteScopesOfDescendants = req.body.overwriteScopesOfDescendants || null;
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
-    const socketClientId = req.body.socketClientId || undefined;
     const pageTags = req.body.pageTags || undefined;
 
     if (body === null || pagePath === null) {
@@ -698,7 +697,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Page exists', 'already_exists'));
     }
 
-    const options = { socketClientId };
+    const options = {};
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -816,7 +815,6 @@ module.exports = function(crowi, app) {
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
     const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd; // cast to boolean
-    const socketClientId = req.body.socketClientId || undefined;
     const pageTags = req.body.pageTags || undefined;
 
     if (pageId === null || pageBody === null || revisionId === null) {
@@ -835,7 +833,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
     }
 
-    const options = { isSyncRevisionToHackmd, socketClientId };
+    const options = { isSyncRevisionToHackmd };
     if (grant != null) {
       options.grant = grant;
       options.grantUserGroupId = grantUserGroupId;
@@ -1152,14 +1150,13 @@ module.exports = function(crowi, app) {
   api.remove = async function(req, res) {
     const pageId = req.body.page_id;
     const previousRevision = req.body.revision_id || null;
-    const socketClientId = req.body.socketClientId || undefined;
 
     // get completely flag
     const isCompletely = (req.body.completely != null);
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
 
-    const options = { socketClientId };
+    const options = {};
 
     const page = await Page.findByIdAndViewer(pageId, req.user);
 
@@ -1213,7 +1210,6 @@ module.exports = function(crowi, app) {
    */
   api.revertRemove = async function(req, res, options) {
     const pageId = req.body.page_id;
-    const socketClientId = req.body.socketClientId || undefined;
 
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
@@ -1224,7 +1220,7 @@ module.exports = function(crowi, app) {
       if (page == null) {
         throw new Error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden');
       }
-      page = await crowi.pageService.revertDeletedPage(page, req.user, { socketClientId }, isRecursively);
+      page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively);
     }
     catch (err) {
       logger.error('Error occured while get setting', err);

+ 7 - 0
packages/app/src/server/service/config-loader.ts

@@ -6,6 +6,7 @@ import ConfigModel, {
   Config, defaultCrowiConfigs, defaultMarkdownConfigs, defaultNotificationConfigs,
 } from '../models/config';
 
+
 const logger = loggerFactory('growi:service:ConfigLoader');
 
 enum ValueType { NUMBER, STRING, BOOLEAN }
@@ -486,6 +487,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  SLACKBOT_WITHOUT_PROXY_COMMAND_PERMISSION: {
+    ns:      'crowi',
+    key:     'slackbot:withoutProxy:commandPermission',
+    type:    ValueType.STRING,
+    default: null,
+  },
   SLACKBOT_WITH_PROXY_SALT_FOR_GTOP: {
     ns:      'crowi',
     key:     'slackbot:withProxy:saltForGtoP',

+ 6 - 10
packages/app/src/server/service/page.js

@@ -78,7 +78,6 @@ class PageService {
     const path = page.path;
     const createRedirectPage = options.createRedirectPage || false;
     const updateMetadata = options.updateMetadata || false;
-    const socketClientId = options.socketClientId || null;
 
     // sanitize path
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
@@ -105,8 +104,8 @@ class PageService {
       await Page.create(path, body, user, { redirectTo: newPagePath });
     }
 
-    this.pageEvent.emit('delete', page, user, socketClientId);
-    this.pageEvent.emit('create', renamedPage, user, socketClientId);
+    this.pageEvent.emit('delete', page, user);
+    this.pageEvent.emit('create', renamedPage, user);
 
     return renamedPage;
   }
@@ -415,7 +414,6 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    const socketClientId = options.socketClientId || null;
     if (!Page.isDeletableName(page.path)) {
       throw new Error('Page is not deletable.');
     }
@@ -434,8 +432,8 @@ class PageService {
     const body = `redirect ${newPath}`;
     await Page.create(page.path, body, user, { redirectTo: newPath });
 
-    this.pageEvent.emit('delete', page, user, socketClientId);
-    this.pageEvent.emit('create', deletedPage, user, socketClientId);
+    this.pageEvent.emit('delete', page, user);
+    this.pageEvent.emit('create', deletedPage, user);
 
     return deletedPage;
   }
@@ -530,13 +528,12 @@ class PageService {
   async deleteMultipleCompletely(pages, user, options = {}) {
     const ids = pages.map(page => (page._id));
     const paths = pages.map(page => (page.path));
-    const socketClientId = options.socketClientId || null;
 
     logger.debug('Deleting completely', paths);
 
     await this.deleteCompletelyOperation(ids, paths);
 
-    this.pageEvent.emit('deleteCompletely', pages, user, socketClientId); // update as renamed page
+    this.pageEvent.emit('deleteCompletely', pages, user); // update as renamed page
 
     return;
   }
@@ -544,7 +541,6 @@ class PageService {
   async deleteCompletely(page, user, options = {}, isRecursively = false) {
     const ids = [page._id];
     const paths = [page.path];
-    const socketClientId = options.socketClientId || null;
 
     logger.debug('Deleting completely', paths);
 
@@ -554,7 +550,7 @@ class PageService {
       this.deleteCompletelyDescendantsWithStream(page, user, options);
     }
 
-    this.pageEvent.emit('delete', page, user, socketClientId); // update as renamed page
+    this.pageEvent.emit('delete', page, user); // update as renamed page
 
     return;
   }

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

@@ -34,7 +34,7 @@ module.exports = (crowi) => {
           inputSectionBlock('path', 'Path', 'path_input', false, '/path'),
           inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'),
         ],
-        private_metadata: JSON.stringify({ channelId: body.channel_id }),
+        private_metadata: JSON.stringify({ channelId: body.channel_id, channelName: body.channel_name }),
       },
     });
   };

+ 7 - 3
packages/app/src/server/service/slack-command-handler/search.js

@@ -35,6 +35,10 @@ module.exports = (crowi) => {
       pages, offset, resultsTotal,
     } = searchResult;
 
+    if (pages.length === 0) {
+      return;
+    }
+
     const keywords = this.getKeywords(args);
 
 
@@ -188,7 +192,7 @@ module.exports = (crowi) => {
   handler.showNextResults = async function(client, payload) {
     const parsedValue = JSON.parse(payload.actions[0].value);
 
-    const { body, args, offsetNum } = parsedValue;
+    const { body, args, offset: offsetNum } = parsedValue;
     const newOffsetNum = offsetNum + 10;
     let searchResult;
     try {
@@ -258,7 +262,7 @@ module.exports = (crowi) => {
           },
           accessory: {
             type: 'button',
-            action_id: 'shareSingleSearchResult',
+            action_id: 'search:shareSinglePageResult',
             text: {
               type: 'plain_text',
               text: 'Share',
@@ -361,7 +365,7 @@ module.exports = (crowi) => {
         user: body.user_id,
         text: `No page found with "${keywords}"`,
         blocks: [
-          markdownSectionBlock(`*No page that matches your keyword(s) "${keywords}".*`),
+          markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`),
           markdownSectionBlock(':mag: *Help: Searching*'),
           divider(),
           markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'),

+ 24 - 0
packages/app/src/server/service/socket-io.js

@@ -1,4 +1,5 @@
 import loggerFactory from '~/utils/logger';
+import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 const socketIo = require('socket.io');
 const expressSession = require('express-session');
@@ -40,6 +41,9 @@ class SocketIoService {
     await this.setupCheckConnectionLimitsMiddleware();
 
     await this.setupStoreGuestIdEventHandler();
+
+    await this.setupLoginedUserRoomsJoinOnConnection();
+    await this.setupDefaultSocketJoinRoomsEventHandler();
   }
 
   getDefaultSocket() {
@@ -124,6 +128,26 @@ class SocketIoService {
     });
   }
 
+  setupLoginedUserRoomsJoinOnConnection() {
+    this.io.on('connection', (socket) => {
+      const user = socket.request.user;
+      if (user == null) {
+        logger.debug('Socket io: An anonymous user has connected');
+        return;
+      }
+      socket.join(getRoomNameWithId(RoomPrefix.USER, user._id));
+    });
+  }
+
+  setupDefaultSocketJoinRoomsEventHandler() {
+    this.io.on('connection', (socket) => {
+      // set event handlers for joining rooms
+      socket.on('join:page', ({ pageId }) => {
+        socket.join(getRoomNameWithId(RoomPrefix.PAGE, pageId));
+      });
+    });
+  }
+
   async checkConnectionLimitsForAdmin(socket, next) {
     const namespaceName = socket.nsp.name;
 

+ 31 - 8
packages/app/src/server/service/system-events/sync-page-status.ts

@@ -5,6 +5,8 @@ import { S2cMessagePageUpdated } from '../../models/vo/s2c-message';
 import { S2sMessageHandlable } from '../s2s-messaging/handlable';
 import { S2sMessagingService } from '../s2s-messaging/base';
 
+import { RoomPrefix, getRoomNameWithId } from '../../util/socket-io-helpers';
+
 const logger = loggerFactory('growi:service:system-events:SyncPageStatusService');
 
 /**
@@ -84,33 +86,54 @@ class SyncPageStatusService implements S2sMessageHandlable {
     const { socketIoService } = this;
 
     // register events
-    this.emitter.on('create', (page, user, socketClientId) => {
+    this.emitter.on('create', (page, user) => {
       logger.debug('\'create\' event emitted.');
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
-      socketIoService.getDefaultSocket().emit('page:create', { s2cMessagePageUpdated, socketClientId });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:create', { s2cMessagePageUpdated });
 
       this.publishToOtherServers('page:create', { s2cMessagePageUpdated });
     });
-    this.emitter.on('update', (page, user, socketClientId) => {
+    this.emitter.on('update', (page, user) => {
       logger.debug('\'update\' event emitted.');
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
-      socketIoService.getDefaultSocket().emit('page:update', { s2cMessagePageUpdated, socketClientId });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:update', { s2cMessagePageUpdated });
 
       this.publishToOtherServers('page:update', { s2cMessagePageUpdated });
     });
-    this.emitter.on('delete', (page, user, socketClientId) => {
+    this.emitter.on('delete', (page, user) => {
       logger.debug('\'delete\' event emitted.');
 
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user);
-      socketIoService.getDefaultSocket().emit('page:delete', { s2cMessagePageUpdated, socketClientId });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:delete', { s2cMessagePageUpdated });
 
       this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
     });
-    this.emitter.on('saveOnHackmd', (page) => {
+    this.emitter.on('saveOnHackmd', (page, user) => {
       const s2cMessagePageUpdated = new S2cMessagePageUpdated(page);
-      socketIoService.getDefaultSocket().emit('page:editingWithHackmd', { s2cMessagePageUpdated });
+
+      // emit to the room for each page
+      socketIoService.getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .except(getRoomNameWithId(RoomPrefix.USER, user._id))
+        .emit('page:editingWithHackmd', { s2cMessagePageUpdated });
+
       this.publishToOtherServers('page:editingWithHackmd', { s2cMessagePageUpdated });
     });
   }

+ 28 - 0
packages/app/src/server/util/slack-integration.ts

@@ -0,0 +1,28 @@
+import { getSupportedGrowiActionsRegExp } from '@growi/slack';
+
+type CommandPermission = { [key:string]: string[] | boolean }
+
+export const checkPermission = (
+    commandPermission:CommandPermission, commandOrActionIdOrCallbackId:string, fromChannel:string,
+):boolean => {
+  let isPermitted = false;
+
+  Object.entries(commandPermission).forEach((entry) => {
+    const [command, value] = entry;
+    const permission = value;
+    const commandRegExp = getSupportedGrowiActionsRegExp(command);
+    if (!commandRegExp.test(commandOrActionIdOrCallbackId)) return;
+
+    // permission check
+    if (permission === true) {
+      isPermitted = true;
+      return;
+    }
+    if (Array.isArray(permission) && permission.includes(fromChannel)) {
+      isPermitted = true;
+      return;
+    }
+  });
+
+  return isPermitted;
+};

+ 8 - 0
packages/app/src/server/util/socket-io-helpers.ts

@@ -0,0 +1,8 @@
+export const RoomPrefix = {
+  USER: 'user',
+  PAGE: 'page',
+};
+
+export const getRoomNameWithId = (roomPrefix: string, roomId: string): string => {
+  return `${roomPrefix}:${roomId}`;
+};

+ 49 - 0
packages/app/src/styles/_recent-changes.scss

@@ -0,0 +1,49 @@
+.grw-sidebar-content-header {
+  .grw-btn-reload-rc {
+    font-size: 18px;
+  }
+
+  .grw-recent-changes-resize-button {
+    font-size: 12px;
+    line-height: normal;
+    transform: translateY(6px);
+
+    .custom-control-label::before {
+      padding-left: 16px;
+      content: 'L';
+    }
+
+    .custom-control-input:checked + .custom-control-label::before {
+      padding-left: 5px;
+      content: 'S';
+    }
+  }
+}
+
+.list-group {
+  .list-group-item {
+    .grw-recent-changes-item-lower {
+      height: 17.5px;
+    }
+    .footstamp-icon {
+      svg {
+        width: 14px;
+        height: 14px;
+        transform: translateY(-3.5px);
+      }
+    }
+
+    .grw-list-counts {
+      height: 14px;
+      font-size: 12px;
+    }
+
+    .grw-formatted-distance-date {
+      font-size: 10px;
+    }
+
+    .icon-lock {
+      font-size: 14px;
+    }
+  }
+}

+ 4 - 0
packages/app/src/styles/_sidebar.scss

@@ -133,6 +133,10 @@
   .grw-drawer-toggler {
     display: none; // invisible in default
   }
+
+  .grw-sidebar-content-header {
+    min-width: $grw-sidebar-content-min-width + 20px;
+  }
 }
 
 // Dock Mode

+ 8 - 2
packages/app/src/styles/_tag.scss

@@ -6,9 +6,9 @@
 
 .grw-tag-labels {
   .grw-tag-label {
-    margin-left: 1px;
     font-size: 12px;
-    border-radius: $border-radius-xl;
+    font-weight: normal;
+    border-radius: $border-radius-sm;
   }
 }
 
@@ -17,3 +17,9 @@
     height: auto;
   }
 }
+
+.grw-recent-changes {
+  .grw-tag-label {
+    font-size: 10px;
+  }
+}

+ 7 - 0
packages/app/src/styles/_variables.scss

@@ -2,6 +2,13 @@
 $growi-green: #74bc46;
 $growi-blue: #175fa5;
 
+//== Marker Color
+$grw-marker-yellow: #ff6;
+$grw-marker-red: #f6c;
+$grw-marker-blue: #6cf;
+$grw-marker-cyan: cyan;
+$grw-marker-green: #6f6;
+
 $font-family-for-staff-credit: Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif !default;
 $font-family-monospace-not-strictly: Monaco, Menlo, Consolas, 'Courier New', MeiryoKe_Gothic, monospace;
 

+ 1 - 0
packages/app/src/styles/style-app.scss

@@ -59,6 +59,7 @@
 @import 'page';
 @import 'page-presentation';
 @import 'page-history';
+@import 'recent-changes';
 @import 'search';
 @import 'shortcuts';
 @import 'sidebar';

+ 1 - 1
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -12,7 +12,7 @@ $border-color-table: $gray-200 !default;
 $color-table-hover: $color-table !default;
 $bgcolor-table-hover: rgba(black, 0.075) !default;
 $bgcolor-sidebar-list-group: $bgcolor-list !default;
-$color-tags: $gray-500 !default;
+$color-tags: $secondary !default;
 $bgcolor-tags: $gray-200 !default;
 $border-color-global: $gray-300 !default;
 $border-color-toc: $border-color-global !default;

+ 56 - 1
packages/app/src/styles/theme/_apply-colors.scss

@@ -15,6 +15,9 @@ $bordercolor-nav-tabs-hover: $gray-200 $gray-200 $bordercolor-nav-tabs !default;
 $color-nav-tabs-link-active: $gray-600 !default;
 $bordercolor-nav-tabs-active: $bordercolor-nav-tabs $bordercolor-nav-tabs $bgcolor-global !default;
 $color-seen-user: #549c79 !default;
+$reload-btn-rc-color: $gray-500;
+$reload-btn-cs-color: $gray-500;
+$bgcolor-highlighted: $grw-marker-yellow !default;
 
 // override bootstrap variables
 $body-bg: $bgcolor-global;
@@ -263,6 +266,58 @@ ul.pagination {
       }
     }
   }
+
+  .grw-sidebar-content-header {
+    .grw-btn-reload-rc {
+      color: $reload-btn-rc-color;
+    }
+    .grw-btn-reload-cs {
+      color: $reload-btn-cs-color;
+    }
+
+    .grw-recent-changes-resize-button {
+      .custom-control-label::before {
+        background-color: $primary;
+      }
+
+      .custom-control-label::after {
+        background-color: $bgcolor-global;
+      }
+
+      .custom-control-input:not(:checked) + .custom-control-label::before {
+        color: $bgcolor-global;
+      }
+
+      .custom-control-input:checked + .custom-control-label::before {
+        color: $bgcolor-global;
+        background-color: $primary;
+        // border-color: $primary !important;
+      }
+      .custom-control-input:checked + .custom-control-label::after {
+        color: $bgcolor-global;
+      }
+    }
+  }
+
+  .grw-recent-changes {
+    .list-group {
+      .list-group-item {
+        background-color: transparent;
+
+        .icon-lock {
+          color: $color-link;
+        }
+
+        .grw-recent-changes-item-lower {
+          color: $gray-500;
+
+          svg {
+            fill: $gray-500;
+          }
+        }
+      }
+    }
+  }
 }
 
 /*
@@ -396,7 +451,7 @@ ul.pagination {
  */
 .wiki {
   .highlighted {
-    @include highlighted($bgcolor-highlighted);
+    background: linear-gradient(transparent 60%, $bgcolor-highlighted 60%);
   }
 
   a {

+ 1 - 1
packages/app/src/styles/theme/antarctic.scss

@@ -51,7 +51,7 @@ html[dark] {
   $bgcolor-global: $themelight;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: $gray-50;
-  $bgcolor-highlighted: rgba($primary, 0.15);
+  //$bgcolor-highlighted: $grw-marker-yellow;
 
   // Font colors
   $color-global: black;

+ 1 - 1
packages/app/src/styles/theme/christmas.scss

@@ -43,7 +43,7 @@ html[dark] {
   // Background colors
   $bgcolor-card: $gray-50;
   $bgcolor-inline-code: $gray-100; //optional
-  $bgcolor-highlighted: rgba($primary, 0.5);
+  // $bgcolor-highlighted: $grw-marker-yellow;
 
   // Font colors
   $color-global: #112744;

+ 2 - 2
packages/app/src/styles/theme/default.scss

@@ -22,7 +22,7 @@ html[light] {
   $bgcolor-global: white;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: $gray-50;
-  $bgcolor-highlighted: rgba($primary, 0.1);
+  // $bgcolor-highlighted: $grw-marker-yellow;
 
   // Font colors
   $color-global: #112744;
@@ -123,7 +123,7 @@ html[dark] {
   $bgcolor-global: #131418;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: darken($bgcolor-global, 5%);
-  $bgcolor-highlighted: rgba($primary, 0.4);
+  $bgcolor-highlighted: $grw-marker-red;
 
   // Font colors
   $color-global: $gray-400;

+ 1 - 1
packages/app/src/styles/theme/future.scss

@@ -11,7 +11,7 @@ html[dark] {
   $bgcolor-global: $themecolor;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: darken($themecolor, 5%);
-  $bgcolor-highlighted: rgba($primary, 0.4);
+  $bgcolor-highlighted: $grw-marker-red;
 
   // Font colors
   $color-global: #95abba;

+ 1 - 1
packages/app/src/styles/theme/halloween.scss

@@ -39,7 +39,7 @@ html[dark] {
   $bgcolor-global: #050000;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: $bgcolor-global;
-  $bgcolor-highlighted: rgba($primary, 0.4);
+  $bgcolor-highlighted: $grw-marker-cyan;
 
   // Font colors
   $color-global: #e9af2b;

+ 2 - 2
packages/app/src/styles/theme/hufflepuff.scss

@@ -35,7 +35,7 @@ html[light] {
   $bgcolor-global: lighten($themelight, 10%);
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: $gray-100;
-  $bgcolor-highlighted: rgba($primary, 0.5);
+  $bgcolor-highlighted: $grw-marker-green;
 
   // Font colors
   $color-global: $subthemecolor;
@@ -174,7 +174,7 @@ html[dark] {
   // $bgcolor-navbar: #27343b;
   $bgcolor-inline-code: $subthemecolor;
   $bgcolor-card: darken($themedark, 5%);
-  $bgcolor-highlighted: rgba($primary, 0.5);
+  $bgcolor-highlighted: $grw-marker-red;
 
   // Font colors
   $color-global: #efe2cf;

+ 1 - 1
packages/app/src/styles/theme/island.scss

@@ -11,7 +11,7 @@ html[dark] {
   $bgcolor-card: $gray-50;
   $bgcolor-global: lighten($color-themelight, 10%);
   $bgcolor-inline-code: $gray-100; //optional
-  $bgcolor-highlighted: rgba($primary, 0.3);
+  // $bgcolor-highlighted: $grw-marker-yellow;
 
   // Font colors
   $color-global: #112744;

+ 1 - 1
packages/app/src/styles/theme/kibela.scss

@@ -45,7 +45,7 @@ html[dark] {
   $primary: $bgcolor-theme;
   $info: lighten($bgcolor-theme, 20%);
 
-  $bgcolor-highlighted: rgba($primary, 0.2);
+  // $bgcolor-highlighted: $grw-marker-yellow;
 
   // List Group colors
   $color-list: $color-global;

+ 2 - 2
packages/app/src/styles/theme/mono-blue.scss

@@ -14,7 +14,7 @@ html[light] {
   $bgcolor-global: $themelight;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: darken($themelight, 5%);
-  $bgcolor-highlighted: rgba($primary, 0.1);
+  // $bgcolor-highlighted: $grw-marker-yellow;
 
   // Font colors
   $color-global: $themecolor;
@@ -112,7 +112,7 @@ html[dark] {
   $bgcolor-navbar: #27343b;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: darken($themedark, 5%);
-  $bgcolor-highlighted: rgba($primary, 0.5);
+  $bgcolor-highlighted: $grw-marker-green;
 
   // Font colors
   $color-global: #d3d4d4;

+ 1 - 1
packages/app/src/styles/theme/nature.scss

@@ -45,7 +45,7 @@ html[dark] {
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: #f1ffe4;
   $bgcolor-subnav: #fafafa;
-  $bgcolor-highlighted: rgba($primary, 0.1);
+  // $bgcolor-highlighted: $grw-marker-yellow;
 
   // Font colors
   $color-global: #460039;

+ 1 - 1
packages/app/src/styles/theme/spring.scss

@@ -34,7 +34,7 @@ html[dark] {
   $bgcolor-global: white;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: $gray-50;
-  $bgcolor-highlighted: rgba($primary, 0.5);
+  $bgcolor-highlighted: $grw-marker-cyan;
 
   // Font colors
   $color-global: black;

+ 1 - 1
packages/app/src/styles/theme/wood.scss

@@ -43,7 +43,7 @@ html[dark] {
   // Background colors
   $bgcolor-global: white;
   $bgcolor-card: #ece8de;
-  $bgcolor-highlighted: rgba($primary, 0.3);
+  $bgcolor-highlighted: $grw-marker-blue;
 
   // Font colors
   // $color-global: black;

+ 14 - 17
packages/app/src/test/service/page.test.js

@@ -335,7 +335,6 @@ describe('PageService', () => {
     // mock new Date() and Date.now()
     advanceTo(new Date(2000, 1, 1, 0, 0, 0));
     const dateToUse = new Date();
-    const socketClientId = null;
 
     beforeEach(async() => {
       pageEventSpy = jest.spyOn(crowi.pageService.pageEvent, 'emit').mockImplementation();
@@ -352,8 +351,8 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename1, testUser2, socketClientId);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename1, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
 
         expect(resultPage.path).toBe('/renamed1');
         expect(resultPage.updatedAt).toEqual(parentForRename1.updatedAt);
@@ -371,8 +370,8 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename2, testUser2, socketClientId);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename2, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
 
         expect(resultPage.path).toBe('/renamed2');
         expect(resultPage.updatedAt).toEqual(dateToUse);
@@ -390,8 +389,8 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).not.toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename3, testUser2, socketClientId);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename3, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
 
         expect(resultPage.path).toBe('/renamed3');
         expect(resultPage.updatedAt).toEqual(parentForRename3.updatedAt);
@@ -414,8 +413,8 @@ describe('PageService', () => {
 
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename4, testUser2, socketClientId);
-        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+        expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForRename4, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
 
         expect(resultPage.path).toBe('/renamed4');
         expect(resultPage.updatedAt).toEqual(parentForRename4.updatedAt);
@@ -590,7 +589,6 @@ describe('PageService', () => {
     let pageEventSpy;
     let deleteDescendantsWithStreamSpy;
     const dateToUse = new Date('2000-01-01');
-    const socketClientId = null;
 
     beforeEach(async() => {
       jest.spyOn(global.Date, 'now').mockImplementation(() => dateToUse);
@@ -622,8 +620,8 @@ describe('PageService', () => {
       expect(redirectedFromPageRevision.path).toBe('/parentForDelete1');
       expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete1');
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2, socketClientId);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
 
     });
 
@@ -650,8 +648,8 @@ describe('PageService', () => {
       expect(redirectedFromPageRevision.path).toBe('/parentForDelete2');
       expect(redirectedFromPageRevision.body).toBe('redirect /trash/parentForDelete2');
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2, socketClientId);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
 
     });
 
@@ -683,7 +681,6 @@ describe('PageService', () => {
     let pageEventSpy;
     let deleteCompletelyOperationSpy;
     let deleteCompletelyDescendantsWithStreamSpy;
-    const socketClientId = null;
 
     let deleteManyBookmarkSpy;
     let deleteManyCommentSpy;
@@ -728,7 +725,7 @@ describe('PageService', () => {
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).not.toHaveBeenCalled();
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2);
     });
 
 
@@ -738,7 +735,7 @@ describe('PageService', () => {
       expect(deleteCompletelyOperationSpy).toHaveBeenCalled();
       expect(deleteCompletelyDescendantsWithStreamSpy).toHaveBeenCalled();
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2, socketClientId);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDeleteCompletely, testUser2);
     });
   });
 

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 1 - 1
packages/slack/src/middlewares/verify-growi-to-slack-request.ts

@@ -4,7 +4,7 @@ import createError from 'http-errors';
 import loggerFactory from '../utils/logger';
 import { RequestFromGrowi } from '../interfaces/request-between-growi-and-proxy';
 
-const logger = loggerFactory('@growi/slack:middlewares:verify-slack-request');
+const logger = loggerFactory('@growi/slack:middlewares:verify-growi-to-slack-request');
 
 /**
  * Verify if the request came from slack

+ 4 - 0
packages/slack/src/utils/get-supported-growi-actions-regexps.ts

@@ -1,3 +1,7 @@
 export const getSupportedGrowiActionsRegExps = (supportedGrowiCommands: string[]): RegExp[] => {
   return supportedGrowiCommands.map(command => new RegExp(`^${command}:\\w+`));
 };
+
+export const getSupportedGrowiActionsRegExp = (supportedGrowiCommand: string): RegExp => {
+  return new RegExp(`(^${supportedGrowiCommand}$)|(^${supportedGrowiCommand}:\\w+)`);
+};

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -23,7 +23,7 @@
   "// comments for dependencies": {},
   "dependencies": {
     "@godaddy/terminus": "^4.8.0",
-    "@growi/slack": "^4.4.2",
+    "@growi/slack": "^4.4.3-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 11 - 8
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -98,14 +98,18 @@ export class GrowiToSlackCtrl {
   async putSupportedCommands(@Req() req: GrowiReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
     // asserted (tokenGtoPs.length > 0) by verifyGrowiToSlackRequest
     const { tokenGtoPs } = req;
-    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = req.body;
+
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = req.body;
 
     if (tokenGtoPs.length !== 1) {
       throw createError(400, 'installation is invalid');
     }
 
     const tokenGtoP = tokenGtoPs[0];
-    const relation = await this.relationRepository.update({ tokenGtoP }, { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse });
+
+    const relation = await this.relationRepository.update(
+      { tokenGtoP }, { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands },
+    );
 
     return res.send({ relation });
   }
@@ -145,6 +149,7 @@ export class GrowiToSlackCtrl {
       }
 
       const status = await getConnectionStatus(token);
+
       if (status.error != null) {
         throw createError(400, `failed to get connection. err: ${status.error}`);
       }
@@ -189,7 +194,6 @@ export class GrowiToSlackCtrl {
     // temporary cache for 48 hours
     const expiredAtCommands = addHours(new Date(), 48);
 
-    // Transaction is not considered because it is used infrequently,
     const response = await this.relationRepository.createQueryBuilder('relation')
       .insert()
       .values({
@@ -197,18 +201,17 @@ export class GrowiToSlackCtrl {
         tokenGtoP: order.tokenGtoP,
         tokenPtoG: order.tokenPtoG,
         growiUri: order.growiUrl,
-        supportedCommandsForBroadcastUse: req.body.supportedCommandsForBroadcastUse,
-        supportedCommandsForSingleUse: req.body.supportedCommandsForSingleUse,
+        permissionsForBroadcastUseCommands: req.body.permissionsForBroadcastUseCommands,
+        permissionsForSingleUseCommands: req.body.permissionsForSingleUseCommands,
         expiredAtCommands,
       })
       // https://github.com/typeorm/typeorm/issues/1090#issuecomment-634391487
       .orUpdate({
         conflict_target: ['installation', 'growiUri'],
-        overwrite: ['tokenGtoP', 'tokenPtoG', 'supportedCommandsForBroadcastUse', 'supportedCommandsForSingleUse'],
+        overwrite: ['tokenGtoP', 'tokenPtoG', 'permissionsForBroadcastUseCommands', 'permissionsForSingleUseCommands'],
       })
       .execute();
 
-    // Find the generated relation
     const generatedRelation = await this.relationRepository.findOne({ id: response.identifiers[0].id });
 
     return res.send({ relation: generatedRelation, slackBotToken: token });
@@ -293,7 +296,7 @@ export class GrowiToSlackCtrl {
       logger.error(err);
 
       if (err.code === ErrorCode.PlatformError) {
-        return res.simulateWebAPIPlatformError(err.message, err.code);
+        return res.simulateWebAPIPlatformError(err.message, err.data.error);
       }
 
       return res.simulateWebAPIRequestError(err.message, err.response?.status);

+ 97 - 57
packages/slackbot-proxy/src/controllers/slack.ts

@@ -4,7 +4,7 @@ import {
 
 import axios from 'axios';
 
-import { WebAPICallResult } from '@slack/web-api';
+import { WebAPICallResult, WebClient } from '@slack/web-api';
 import { Installation } from '@slack/oauth';
 
 
@@ -36,7 +36,34 @@ import { JoinToConversationMiddleware } from '~/middlewares/slack-to-growi/join-
 
 const logger = loggerFactory('slackbot-proxy:controllers:slack');
 
-
+const postNotAllowedMessage = async(client:WebClient, channelId:string, userId:string, disallowedGrowiUrls:Set<string>, commandName:string):Promise<void> => {
+
+  const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
+    return '\n'
+      + `• ${new URL('/admin/slack-integration', growiUrl).toString()}`;
+  });
+
+  const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
+
+
+  await client.chat.postEphemeral({
+    text: 'Error occured.',
+    channel: channelId,
+    user: userId,
+    blocks: [
+      markdownSectionBlock('*None of GROWI permitted the command.*'),
+      markdownSectionBlock(`*'${commandName}'* command was not allowed.`),
+      markdownSectionBlock(
+        `To use this command, modify settings from following pages: ${linkUrlList}`,
+      ),
+      markdownSectionBlock(
+        `Or, if your GROWI version is 4.3.0 or below, upgrade GROWI to use commands and permission settings: ${growiDocsLink}`,
+      ),
+    ],
+  });
+
+  return;
+};
 @Controller('/slack')
 export class SlackCtrl {
 
@@ -75,8 +102,8 @@ export class SlackCtrl {
     if (relations.length === 0) {
       throw new Error('relations must be set');
     }
-    const botToken = relations[0].installation?.data.bot?.token; // relations[0] should be exist
 
+    const botToken = relations[0].installation?.data.bot?.token; // relations[0] should be exist
     const promises = relations.map((relation: Relation) => {
       // generate API URL
       const url = new URL('/_api/v3/slack-integration/proxied/commands', relation.growiUri);
@@ -104,6 +131,7 @@ export class SlackCtrl {
     }
   }
 
+
   @Post('/commands')
   @UseBefore(AddSigningSecretToReq, verifySlackRequest, AuthorizeCommandMiddleware, JoinToConversationMiddleware)
   async handleCommand(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
@@ -186,14 +214,14 @@ export class SlackCtrl {
 
     // check permission
     await Promise.all(relations.map(async(relation) => {
-      const isSupportedForSingleUse = await this.relationsService.isSupportedGrowiCommandForSingleUse(
-        relation, growiCommand.growiCommandType, baseDate,
+      const isSupportedForSingleUse = await this.relationsService.isPermissionsForSingleUseCommands(
+        relation, growiCommand.growiCommandType, body.channel_name, baseDate,
       );
 
       let isSupportedForBroadcastUse = false;
       if (!isSupportedForSingleUse) {
-        isSupportedForBroadcastUse = await this.relationsService.isSupportedGrowiCommandForBroadcastUse(
-          relation, growiCommand.growiCommandType, baseDate,
+        isSupportedForBroadcastUse = await this.relationsService.isPermissionsUseBroadcastCommands(
+          relation, growiCommand.growiCommandType, body.channel_name, baseDate,
         );
       }
 
@@ -212,29 +240,7 @@ export class SlackCtrl {
     if (relations.length === disallowedGrowiUrls.size) {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const client = generateWebClient(authorizeResult.botToken!);
-
-      const linkUrlList = Array.from(disallowedGrowiUrls).map((growiUrl) => {
-        return '\n'
-          + `• ${new URL('/admin/slack-integration', growiUrl).toString()}`;
-      });
-
-      const growiDocsLink = 'https://docs.growi.org/en/admin-guide/upgrading/43x.html';
-
-      return client.chat.postEphemeral({
-        text: 'Error occured.',
-        channel: body.channel_id,
-        user: body.user_id,
-        blocks: [
-          markdownSectionBlock('*None of GROWI permitted the command.*'),
-          markdownSectionBlock(`*'${growiCommand.growiCommandType}'* command was not allowed.`),
-          markdownSectionBlock(
-            `To use this command, modify settings from following pages: ${linkUrlList}`,
-          ),
-          markdownSectionBlock(
-            `Or, if your GROWI version is 4.3.0 or below, upgrade GROWI to use commands and permission settings: ${growiDocsLink}`,
-          ),
-        ],
-      });
+      return postNotAllowedMessage(client, body.channel_id, body.user_id, disallowedGrowiUrls, growiCommand.growiCommandType);
     }
 
     // select GROWI
@@ -249,6 +255,7 @@ export class SlackCtrl {
     }
   }
 
+
   @Post('/interactions')
   @UseBefore(AuthorizeInteractionMiddleware, ExtractGrowiUriFromReq)
   async handleInteraction(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|string|Res|WebAPICallResult> {
@@ -262,15 +269,14 @@ export class SlackCtrl {
       return;
     }
 
+    const payload:any = JSON.parse(body.payload);
+    const callbackId:string = payload?.view?.callback_id;
     const installationId = authorizeResult.enterpriseId || authorizeResult.teamId;
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const installation = await this.installationRepository.findByTeamIdOrEnterpriseId(installationId!);
 
-    const payload = JSON.parse(body.payload);
-    const callBackId = payload?.view?.callback_id;
-
     // register
-    if (callBackId === 'register') {
+    if (callbackId === 'register') {
       try {
         await this.registerService.insertOrderRecord(installation, authorizeResult.botToken, payload);
       }
@@ -287,13 +293,21 @@ export class SlackCtrl {
     }
 
     // unregister
-    if (callBackId === 'unregister') {
+    if (callbackId === 'unregister') {
       await this.unregisterService.unregister(installation, authorizeResult, payload);
       return;
     }
 
+    let privateMeta:any;
+
+    if (payload.view != null) {
+      privateMeta = JSON.parse(payload?.view?.private_metadata);
+    }
+
+    const channelName = payload.channel?.name || privateMeta?.body?.channel_name || privateMeta?.channelName;
+
     // forward to GROWI server
-    if (callBackId === 'select_growi') {
+    if (callbackId === 'select_growi') {
       // Send response immediately to avoid opelation_timeout error
       // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
       res.send();
@@ -302,35 +316,61 @@ export class SlackCtrl {
       return this.sendCommand(selectedGrowiInformation.growiCommand, [selectedGrowiInformation.relation], selectedGrowiInformation.sendCommandBody);
     }
 
-    // Send response immediately to avoid opelation_timeout error
-    // See https://api.slack.com/apis/connections/events-api#the-events-api__responding-to-events
-    res.send();
-
-    /*
-    * forward to GROWI server
-    */
-    const relation = await this.relationRepository.findOne({ installation, growiUri: req.growiUri });
+    // check permission
+    const relations = await this.relationRepository.createQueryBuilder('relation')
+      .where('relation.installationId = :id', { id: installation?.id })
+      .leftJoinAndSelect('relation.installation', 'installation')
+      .getMany();
 
-    if (relation == null) {
-      logger.error('*No relation found.*');
-      return;
+    if (relations.length === 0) {
+      return res.json({
+        blocks: [
+          markdownSectionBlock('*No relation found.*'),
+          markdownSectionBlock('Run `/growi register` first.'),
+        ],
+      });
     }
 
+    const actionId:string = payload?.actions?.[0].action_id;
+    const permission = await this.relationsService.checkPermissionForInteractions(relations, actionId, callbackId, channelName);
+    const {
+      allowedRelations, disallowedGrowiUrls, commandName, rejectedResults,
+    } = permission;
+
     try {
-      // generate API URL
-      const url = new URL('/_api/v3/slack-integration/proxied/interactions', req.growiUri);
-      await axios.post(url.toString(), {
-        ...body,
-      }, {
-        headers: {
-          'x-growi-ptog-tokens': relation.tokenPtoG,
-        },
-        timeout: REQUEST_TIMEOUT_FOR_PTOG,
-      });
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      await postEphemeralErrors(rejectedResults, payload.channel.id, payload.user.id, authorizeResult.botToken!);
     }
     catch (err) {
       logger.error(err);
     }
+
+    if (relations.length === disallowedGrowiUrls.size) {
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      const client = generateWebClient(authorizeResult.botToken!);
+      return postNotAllowedMessage(client, payload.channel.id, payload.user.id, disallowedGrowiUrls, commandName);
+    }
+
+    /*
+     * forward to GROWI server
+     */
+    allowedRelations.map(async(relation) => {
+      try {
+        // generate API URL
+        const url = new URL('/_api/v3/slack-integration/proxied/interactions', relation.growiUri);
+        await axios.post(url.toString(), {
+          ...body,
+        }, {
+          headers: {
+            'x-growi-ptog-tokens': relation.tokenPtoG,
+          },
+        });
+      }
+      catch (err) {
+        logger.error(err);
+      }
+
+    });
   }
 
   @Post('/events')

+ 10 - 11
packages/slackbot-proxy/src/entities/relation.ts

@@ -1,9 +1,13 @@
+import { differenceInMilliseconds } from 'date-fns';
 import {
   Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, ManyToOne, Index,
 } from 'typeorm';
-import { differenceInMilliseconds } from 'date-fns';
 import { Installation } from './installation';
 
+interface PermissionSettingsInterface {
+  [commandName: string]: boolean | string[],
+}
+
 @Entity()
 @Index(['installation', 'growiUri'], { unique: true })
 export class Relation {
@@ -31,20 +35,15 @@ export class Relation {
   @Column()
   growiUri: string;
 
-  @Column('simple-array')
-  supportedCommandsForBroadcastUse: string[];
+  @Column({ type: 'json' })
+  permissionsForBroadcastUseCommands: PermissionSettingsInterface;
 
-  @Column('simple-array')
-  supportedCommandsForSingleUse: string[];
+  @Column({ type: 'json' })
+  permissionsForSingleUseCommands: PermissionSettingsInterface;
 
-  @CreateDateColumn()
+  @Column({ type: 'timestamp' })
   expiredAtCommands: Date;
 
-  isExpiredCommands():boolean {
-    const now = Date.now();
-    return this.expiredAtCommands.getTime() < now;
-  }
-
   getDistanceInMillisecondsToExpiredAt(baseDate:Date):number {
     return differenceInMilliseconds(this.expiredAtCommands, baseDate);
   }

+ 21 - 20
packages/slackbot-proxy/src/middlewares/slack-to-growi/authorizer.ts

@@ -1,21 +1,25 @@
 import { AuthorizeResult, InstallationQuery } from '@slack/oauth';
 import {
-  IMiddleware, Inject, Middleware, Req, Res,
+  IMiddleware, Inject, Middleware, Next, Req, Res,
 } from '@tsed/common';
 
 import Logger from 'bunyan';
 
+import createError from 'http-errors';
+
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 import { InstallerService } from '~/services/InstallerService';
 import loggerFactory from '~/utils/logger';
 
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+const logger = loggerFactory('@growi/slackbot-proxy:middlewares:authorizer');
+
 
 const getCommonMiddleware = (query:InstallationQuery<boolean>, installerService:InstallerService, logger:Logger) => {
-  return async(req: SlackOauthReq, res: Res): Promise<void|Res> => {
+  return async(req: SlackOauthReq, res: Res, next: Next): Promise<void|Res> => {
 
     if (query.teamId == null && query.enterpriseId == null) {
-      res.writeHead(400, 'No installation found');
-      return res.end();
+      return next(createError(400, 'No installation found'));
     }
 
     let result: AuthorizeResult;
@@ -23,19 +27,18 @@ const getCommonMiddleware = (query:InstallationQuery<boolean>, installerService:
       result = await installerService.installer.authorize(query);
 
       if (result.botToken == null) {
-        res.writeHead(403, `The installation for the team(${query.teamId || query.enterpriseId}) has no botToken`);
-        return res.end();
+        return next(createError(403, `The installation for the team(${query.teamId || query.enterpriseId}) has no botToken`));
       }
     }
     catch (e) {
       logger.error(e.message);
 
-      res.writeHead(500, e.message);
-      return res.end();
+      return next(createError(500, e.message));
     }
 
     // set authorized data
     req.authorizeResult = result;
+    next();
   };
 };
 @Middleware()
@@ -50,7 +53,7 @@ export class AuthorizeCommandMiddleware implements IMiddleware {
   @Inject()
   installerService: InstallerService;
 
-  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|Res> {
+  async use(@Req() req: SlackOauthReq, @Res() res: Res, @Next() next: Next): Promise<void|Res> {
     const { body } = req;
     const teamId = body.team_id;
     const enterpriseId = body.enterprise_id;
@@ -62,7 +65,7 @@ export class AuthorizeCommandMiddleware implements IMiddleware {
     };
 
     const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
-    await commonMiddleware(req, res);
+    await commonMiddleware(req, res, next);
   }
 
 }
@@ -79,9 +82,13 @@ export class AuthorizeInteractionMiddleware implements IMiddleware {
     @Inject()
     installerService: InstallerService;
 
-    async use(@Req() req: SlackOauthReq, @Res() res:Res): Promise<void|Res> {
+    async use(@Req() req: SlackOauthReq, @Res() res:Res, @Next() next: Next): Promise<void|Res> {
       const { body } = req;
 
+      if (body.payload == null) {
+        return next(createError(400, 'The request has no payload.'));
+      }
+
       const payload = JSON.parse(body.payload);
 
       // extract id from body.payload
@@ -89,12 +96,6 @@ export class AuthorizeInteractionMiddleware implements IMiddleware {
       const enterpriseId = payload.enterprise?.id;
       const isEnterpriseInstall = payload.is_enterprise_install === 'true';
 
-      if (body.payload == null) {
-      // do nothing
-        this.logger.info('body does not have payload');
-        return;
-      }
-
       const query: InstallationQuery<boolean> = {
         teamId,
         enterpriseId,
@@ -102,7 +103,7 @@ export class AuthorizeInteractionMiddleware implements IMiddleware {
       };
 
       const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
-      await commonMiddleware(req, res);
+      await commonMiddleware(req, res, next);
     }
 
 }
@@ -118,7 +119,7 @@ export class AuthorizeEventsMiddleware implements IMiddleware {
   @Inject()
   installerService: InstallerService;
 
-  async use(@Req() req: SlackOauthReq, @Res() res: Res): Promise<void|Res> {
+  async use(@Req() req: SlackOauthReq, @Res() res: Res, @Next() next: Next): Promise<void|Res> {
     const { body } = req;
     const teamId = body.team_id;
     const enterpriseId = body.enterprise_id;
@@ -130,7 +131,7 @@ export class AuthorizeEventsMiddleware implements IMiddleware {
     };
 
     const commonMiddleware = getCommonMiddleware(query, this.installerService, this.logger);
-    await commonMiddleware(req, res);
+    await commonMiddleware(req, res, next);
   }
 
 }

+ 127 - 13
packages/slackbot-proxy/src/services/RelationsService.ts

@@ -3,8 +3,7 @@ import { Inject, Service } from '@tsed/di';
 import axios from 'axios';
 import { addHours } from 'date-fns';
 
-import { REQUEST_TIMEOUT_FOR_PTOG } from '@growi/slack';
-
+import { REQUEST_TIMEOUT_FOR_PTOG, getSupportedGrowiActionsRegExp } from '@growi/slack';
 import { Relation } from '~/entities/relation';
 import { RelationRepository } from '~/repositories/relation';
 
@@ -12,10 +11,24 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('slackbot-proxy:services:RelationsService');
 
+type CheckPermissionForInteractionsResults = {
+  allowedRelations:Relation[],
+  disallowedGrowiUrls:Set<string>,
+  commandName:string,
+  rejectedResults:PromiseRejectedResult[]
+}
+
+type CheckEachRelationResult = {
+  allowedRelation:Relation|null,
+  disallowedGrowiUrl:string|null,
+  eachRelationCommandName:string,
+}
+
 @Service()
 export class RelationsService {
 
   @Inject()
+
   relationRepository: RelationRepository;
 
   async getSupportedGrowiCommands(relation:Relation):Promise<any> {
@@ -31,15 +44,19 @@ export class RelationsService {
 
   async syncSupportedGrowiCommands(relation:Relation): Promise<Relation> {
     const res = await this.getSupportedGrowiCommands(relation);
-    const { supportedCommandsForBroadcastUse, supportedCommandsForSingleUse } = res.data;
-    relation.supportedCommandsForBroadcastUse = supportedCommandsForBroadcastUse;
-    relation.supportedCommandsForSingleUse = supportedCommandsForSingleUse;
-    relation.expiredAtCommands = addHours(new Date(), 48);
-
-    return this.relationRepository.save(relation);
+    const { permissionsForBroadcastUseCommands, permissionsForSingleUseCommands } = res.data.data;
+    if (relation !== null) {
+      relation.permissionsForBroadcastUseCommands = permissionsForBroadcastUseCommands;
+      relation.permissionsForSingleUseCommands = permissionsForSingleUseCommands;
+      relation.expiredAtCommands = addHours(new Date(), 48);
+      return this.relationRepository.save(relation);
+    }
+    throw Error('No relation exists.');
   }
 
   async syncRelation(relation:Relation, baseDate:Date):Promise<Relation|null> {
+    if (relation == null) return null;
+
     const distanceMillisecondsToExpiredAt = relation.getDistanceInMillisecondsToExpiredAt(baseDate);
 
     if (distanceMillisecondsToExpiredAt < 0) {
@@ -53,7 +70,7 @@ export class RelationsService {
     }
 
     // 24 hours
-    if (distanceMillisecondsToExpiredAt < 1000 * 60 * 60 * 24) {
+    if (distanceMillisecondsToExpiredAt < 24 * 60 * 60 * 1000) {
       try {
         this.syncSupportedGrowiCommands(relation);
       }
@@ -65,20 +82,117 @@ export class RelationsService {
     return relation;
   }
 
-  async isSupportedGrowiCommandForSingleUse(relation:Relation, growiCommandType:string, baseDate:Date):Promise<boolean> {
+  async isPermissionsForSingleUseCommands(relation:Relation, growiCommandType:string, channelName:string, baseDate:Date):Promise<boolean> {
     const syncedRelation = await this.syncRelation(relation, baseDate);
     if (syncedRelation == null) {
       return false;
     }
-    return relation.supportedCommandsForSingleUse.includes(growiCommandType);
+
+    const permission = relation.permissionsForSingleUseCommands[growiCommandType];
+
+    if (permission == null) {
+      return false;
+    }
+
+    if (Array.isArray(permission)) {
+      return permission.includes(channelName);
+    }
+
+    return permission;
   }
 
-  async isSupportedGrowiCommandForBroadcastUse(relation:Relation, growiCommandType:string, baseDate:Date):Promise<boolean> {
+  async isPermissionsUseBroadcastCommands(relation:Relation, growiCommandType:string, channelName:string, baseDate:Date):Promise<boolean> {
     const syncedRelation = await this.syncRelation(relation, baseDate);
     if (syncedRelation == null) {
       return false;
     }
-    return relation.supportedCommandsForBroadcastUse.includes(growiCommandType);
+
+    const permission = relation.permissionsForBroadcastUseCommands[growiCommandType];
+
+    if (permission == null) {
+      return false;
+    }
+
+    if (Array.isArray(permission)) {
+      return permission.includes(channelName);
+    }
+
+    return permission;
+  }
+
+  async checkPermissionForInteractions(
+      relations:Relation[], actionId:string, callbackId:string, channelName:string,
+  ):Promise<CheckPermissionForInteractionsResults> {
+
+    const allowedRelations:Relation[] = [];
+    const disallowedGrowiUrls:Set<string> = new Set();
+    let commandName = '';
+
+    const results = await Promise.allSettled(relations.map((relation) => {
+      const relationResult = this.checkEachRelation(relation, actionId, callbackId, channelName);
+      const { allowedRelation, disallowedGrowiUrl, eachRelationCommandName } = relationResult;
+
+      if (allowedRelation != null) {
+        allowedRelations.push(allowedRelation);
+      }
+      if (disallowedGrowiUrl != null) {
+        disallowedGrowiUrls.add(disallowedGrowiUrl);
+      }
+      commandName = eachRelationCommandName;
+      return relationResult;
+    }));
+
+    // Pick up only a relation which status is "rejected" in results. Like bellow
+    const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
+
+    return {
+      allowedRelations, disallowedGrowiUrls, commandName, rejectedResults,
+    };
+  }
+
+  checkEachRelation(relation:Relation, actionId:string, callbackId:string, channelName:string):CheckEachRelationResult {
+
+    let allowedRelation:Relation|null = null;
+    let disallowedGrowiUrl:string|null = null;
+    let eachRelationCommandName = '';
+
+    let permissionForInteractions:boolean|string[];
+    const singleUse = Object.keys(relation.permissionsForSingleUseCommands);
+    const broadCastUse = Object.keys(relation.permissionsForBroadcastUseCommands);
+
+    [...singleUse, ...broadCastUse].forEach(async(tempCommandName) => {
+
+      // ex. search OR search:handlerName
+      const commandRegExp = getSupportedGrowiActionsRegExp(tempCommandName);
+      // skip this forEach loop if the requested command is not in permissionsForBroadcastUseCommands and permissionsForSingleUseCommands
+      if (!commandRegExp.test(actionId) && !commandRegExp.test(callbackId)) {
+        return;
+      }
+
+      eachRelationCommandName = tempCommandName;
+
+      // case: singleUse
+      permissionForInteractions = relation.permissionsForSingleUseCommands[tempCommandName];
+      // case: broadcastUse
+      if (permissionForInteractions == null) {
+        permissionForInteractions = relation.permissionsForBroadcastUseCommands[tempCommandName];
+      }
+
+      if (permissionForInteractions === true) {
+        allowedRelation = relation;
+        return;
+      }
+
+      // check permission at channel level
+      if (Array.isArray(permissionForInteractions) && permissionForInteractions.includes(channelName)) {
+        allowedRelation = relation;
+        return;
+      }
+
+      disallowedGrowiUrl = relation.growiUri;
+    });
+
+    return { allowedRelation, disallowedGrowiUrl, eachRelationCommandName };
   }
 
 }

+ 2 - 1
packages/slackbot-proxy/src/services/SelectGrowiService.ts

@@ -21,7 +21,8 @@ export class SelectGrowiService implements GrowiCommandProcessor {
   @Inject()
   relationRepository: RelationRepository;
 
-  async process(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, body: {[key:string]:string } & {growiUrisForSingleUse:string[]}): Promise<void> {
+  // eslint-disable-next-line max-len
+  async process(growiCommand: GrowiCommand | string, authorizeResult: AuthorizeResult, body: {[key:string]:string } & {growiUrisForSingleUse:string[]}): Promise<void> {
     const { botToken } = authorizeResult;
 
     if (botToken == null) {

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "4.4.2",
+  "version": "4.4.3-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

Некоторые файлы не были показаны из-за большого количества измененных файлов