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

Merge branch 'master' into imprv/7436-display-version-on-the-slackbot-proxy-top-page

Yuki Takei 4 лет назад
Родитель
Сommit
2e3cbfbc28
100 измененных файлов с 2286 добавлено и 915 удалено
  1. 17 0
      .github/dependabot.yml
  2. 9 8
      .github/release-drafter.yml
  3. 5 1
      .github/workflows/ci-slackbot-proxy.yml
  4. 4 1
      .github/workflows/ci.yml
  5. 1 1
      .github/workflows/draft-release.yml
  6. 1 1
      .github/workflows/pr-to-master.yml
  7. 1 1
      .github/workflows/release-rc.yml
  8. 1 1
      .github/workflows/release-slackbot-proxy.yml
  9. 2 2
      .github/workflows/release.yml
  10. 115 508
      CHANGELOG.md
  11. 22 0
      SECURITY.md
  12. 1 0
      bin/github-actions/list-branches.js
  13. 1 1
      lerna.json
  14. 2 1
      package.json
  15. 1 0
      packages/app/.gitignore
  16. 13 1
      packages/app/bin/cdn/cdn-resources-downloader.ts
  17. 1 0
      packages/app/config/cdn.js
  18. 3 4
      packages/app/docker/Dockerfile
  19. 2 2
      packages/app/docker/README.md
  20. 14 12
      packages/app/package.json
  21. BIN
      packages/app/public/images/slack-integration/activate-public-dist.png
  22. BIN
      packages/app/public/images/slack-integration/basicinfo-all-checked.png
  23. BIN
      packages/app/public/images/slack-integration/click-add-to-slack.png
  24. BIN
      packages/app/public/images/slack-integration/growi-bot-kun-icon.png
  25. BIN
      packages/app/public/images/slack-integration/growi-register-modal.png
  26. BIN
      packages/app/public/images/slack-integration/growi-register-sentence.png
  27. BIN
      packages/app/public/images/slack-integration/growi-set-proxy-url.png
  28. BIN
      packages/app/public/images/slack-integration/impossible.png
  29. BIN
      packages/app/public/images/slack-integration/possible.png
  30. BIN
      packages/app/public/images/slack-integration/slack-bot-install-to-workspace-joined-bot.png
  31. BIN
      packages/app/public/images/slack-integration/slack-bot-install-to-workspace.png
  32. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-complete.png
  33. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-introduction-to-channel.png
  34. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-introduction.png
  35. BIN
      packages/app/public/images/slack-integration/slack-bot-install-your-app-transition-destination.png
  36. BIN
      packages/app/public/images/slack-integration/triangle-basic-gray.png
  37. 99 13
      packages/app/resource/cdn-manifests.js
  38. 11 1
      packages/app/resource/locales/en_US/admin/admin.json
  39. 37 0
      packages/app/resource/locales/en_US/translation.json
  40. 11 1
      packages/app/resource/locales/ja_JP/admin/admin.json
  41. 36 0
      packages/app/resource/locales/ja_JP/translation.json
  42. 11 1
      packages/app/resource/locales/zh_CN/admin/admin.json
  43. 37 1
      packages/app/resource/locales/zh_CN/translation.json
  44. 7 7
      packages/app/src/client/legacy/crowi.js
  45. 11 0
      packages/app/src/client/services/AdminCustomizeContainer.js
  46. 23 0
      packages/app/src/client/services/EditorContainer.js
  47. 11 25
      packages/app/src/client/services/PageContainer.js
  48. 0 5
      packages/app/src/client/services/SocketIoContainer.js
  49. 15 8
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  50. 15 0
      packages/app/src/components/Admin/Customize/CustomizeFunctionSetting.jsx
  51. 3 3
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  52. 2 0
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  53. 17 2
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  54. 240 91
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcess.jsx
  55. 242 0
      packages/app/src/components/Admin/SlackIntegration/ManageCommandsProcessWithoutProxy.jsx
  56. 3 3
      packages/app/src/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  57. 4 1
      packages/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  58. 53 36
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  59. 1 1
      packages/app/src/components/EmptyTrashModal.jsx
  60. 286 0
      packages/app/src/components/Me/EditorSettings.tsx
  61. 7 0
      packages/app/src/components/Me/PersonalSettings.jsx
  62. 3 1
      packages/app/src/components/Navbar/GlobalSearch.jsx
  63. 1 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  64. 41 4
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  65. 8 1
      packages/app/src/components/PageEditor/Editor.jsx
  66. 41 0
      packages/app/src/components/PageEditor/OptionsSelector.jsx
  67. 1 1
      packages/app/src/components/PagePathHierarchicalLink.jsx
  68. 1 1
      packages/app/src/components/Sidebar/CustomSidebar.jsx
  69. 138 30
      packages/app/src/components/Sidebar/RecentChanges.jsx
  70. 123 0
      packages/app/src/migrations/20210913153942-migrate-slack-app-integration-schema.js
  71. 2 0
      packages/app/src/server/models/config.ts
  72. 41 0
      packages/app/src/server/models/editor-settings.ts
  73. 3 5
      packages/app/src/server/models/page.js
  74. 3 4
      packages/app/src/server/models/slack-app-integration.js
  75. 1 0
      packages/app/src/server/models/user.js
  76. 6 0
      packages/app/src/server/routes/apiv3/customize-setting.js
  77. 1 0
      packages/app/src/server/routes/apiv3/page.js
  78. 25 11
      packages/app/src/server/routes/apiv3/pages.js
  79. 92 1
      packages/app/src/server/routes/apiv3/personal-setting.js
  80. 91 21
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  81. 64 44
      packages/app/src/server/routes/apiv3/slack-integration.js
  82. 2 6
      packages/app/src/server/routes/apiv3/users.js
  83. 2 2
      packages/app/src/server/routes/hackmd.js
  84. 4 8
      packages/app/src/server/routes/page.js
  85. 7 0
      packages/app/src/server/service/config-loader.ts
  86. 6 10
      packages/app/src/server/service/page.js
  87. 1 1
      packages/app/src/server/service/slack-command-handler/create.js
  88. 7 3
      packages/app/src/server/service/slack-command-handler/search.js
  89. 24 0
      packages/app/src/server/service/socket-io.js
  90. 31 8
      packages/app/src/server/service/system-events/sync-page-status.ts
  91. 28 0
      packages/app/src/server/util/slack-integration.ts
  92. 8 0
      packages/app/src/server/util/socket-io-helpers.ts
  93. 4 4
      packages/app/src/services/cdn-resources-service.js
  94. 3 2
      packages/app/src/styles/_mixins.scss
  95. 6 0
      packages/app/src/styles/_override-codemirror.scss
  96. 49 0
      packages/app/src/styles/_recent-changes.scss
  97. 4 0
      packages/app/src/styles/_sidebar.scss
  98. 8 2
      packages/app/src/styles/_tag.scss
  99. 7 0
      packages/app/src/styles/_variables.scss
  100. 1 0
      packages/app/src/styles/style-app.scss

+ 17 - 0
.github/dependabot.yml

@@ -0,0 +1,17 @@
+version: 2
+updates:
+  - package-ecosystem: github-actions
+    directory: '/'
+    schedule:
+      interval: daily
+    commit-message:
+      prefix: ci
+      include: scope
+
+  - package-ecosystem: npm
+    directory: '/'
+    schedule:
+      interval: daily
+    commit-message:
+      prefix: ci
+      include: scope

+ 9 - 8
.github/release-drafter.yml

@@ -33,18 +33,19 @@ autolabeler:
     branch:
       - '/^support\/.+/'
     title:
+      - '/^support/i'
+      - '/^chore/i'
       - '/^ci/i'
       - '/^docs/i'
       - '/^test/i'
-  - label: 'exclude from changelog'
-    branch:
-      - '/^chore\/.+/'
-    title:
-      - '/^chore/i'
-
+include-labels:
+  - breaking
+  - feature
+  - improvement
+  - bug
+  - support
+  - dependencies
 exclude-labels:
   - 'exclude from changelog'
 template: |
-  ### Changes
-
   $CHANGES

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

@@ -138,6 +138,10 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Remove unnecessary packages
+      working-directory: ./packages
+      run: |
+        ls | egrep -v '^(slack|slackbot-proxy)$' | xargs rm -r
     - name: lerna bootstrap
       run: |
         npx lerna bootstrap
@@ -148,7 +152,7 @@ jobs:
         yarn list --depth=0
     - name: lerna run build
       run: |
-        yarn lerna run build --scope @growi/slack --scope @growi/slackbot-proxy
+        yarn lerna run build
     - name: lerna bootstrap --production
       run: |
         npx lerna bootstrap -- --production

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

@@ -194,6 +194,9 @@ jobs:
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
+    - name: Remove unnecessary packages
+      run: |
+        rm -rf packages/slackbot-proxy
     - name: lerna bootstrap
       run: |
         npx lerna bootstrap
@@ -204,7 +207,7 @@ jobs:
         yarn list --depth=0
     - name: Build
       run: |
-        yarn lerna run build --scope @growi/core --scope @growi/slack --scope @growi/plugin-* --scope @growi/app
+        yarn lerna run build
     - name: lerna bootstrap --production
       run: |
         npx lerna bootstrap -- --production

+ 1 - 1
.github/workflows/draft-release.yml

@@ -19,7 +19,7 @@ jobs:
       - uses: actions/checkout@v2
 
       - name: Retrieve information from package.json
-        uses: myrotvorets/info-from-package-json-action@0.0.2
+        uses: myrotvorets/info-from-package-json-action@1.1.0
         id: package-json
 
       # Drafts your next Release notes as Pull Requests are merged into "master"

+ 1 - 1
.github/workflows/pr-to-master.yml

@@ -27,7 +27,7 @@ jobs:
 
     if: |
       (!contains( github.event.pull_request.labels.*.name, 'exclude from changelog' ) &&
-        !startsWith( github.ref, 'refs/heads/chore/' ))
+        !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:
       - uses: amannn/action-semantic-pull-request@v3.4.2

+ 1 - 1
.github/workflows/release-rc.yml

@@ -15,7 +15,7 @@ jobs:
     - uses: actions/checkout@v2
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@0.0.2
+      uses: myrotvorets/info-from-package-json-action@1.1.0
       id: package-json
 
     - name: Docker meta

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -17,7 +17,7 @@ jobs:
         ref: ${{ github.event.pull_request.base.ref }}
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@0.0.2
+      uses: myrotvorets/info-from-package-json-action@1.1.0
       id: package-json
 
     - name: Docker meta

+ 2 - 2
.github/workflows/release.yml

@@ -38,7 +38,7 @@ jobs:
         sh ./packages/app/bin/github-actions/update-readme.sh
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@0.0.2
+      uses: myrotvorets/info-from-package-json-action@1.1.0
       id: package-json
 
     - name: Update Changelog
@@ -98,7 +98,7 @@ jobs:
         node ./bin/github-actions/bump-versions -i prerelease
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@0.0.2
+      uses: myrotvorets/info-from-package-json-action@1.1.0
       id: package-json
 
     - name: Commit

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


+ 22 - 0
SECURITY.md

@@ -0,0 +1,22 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported          |
+| ------- | ------------------ |
+| 4.4.x   | :white_check_mark: |
+| < 4.3   | :x:                |
+
+## Reporting Security Issues
+
+**please do not report security vulnerabilities through public GitHub issues.**
+
+If you believe you have found a security vulnerability in any GROWI related repository, please report it to us using one of the methods described below.
+
+  * [Join our Slack team](https://growi-slackin.weseek.co.jp/) and send DM to `@yuki` who is the lead developer
+  * Report to JPCERT/CC ([en](https://www.jpcert.or.jp/english/ir/form.html)/[ja](https://www.jpcert.or.jp/form/))
+
+## Preferred Languages
+
+Communication in English and Japanese is possible.  
+In Japanese, we can reply more quickly. 

+ 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.3-RC.0",
+  "version": "4.4.6-RC.0",
   "packages": [
     "packages/*"
   ]

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.4.3-RC.0",
+  "version": "4.4.6-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -45,6 +45,7 @@
     "npm-run-all": "^4.1.5",
     "ts-node": "^9.1.1",
     "tsconfig-paths": "^3.9.0",
+    "tslib": "^2.3.1",
     "typescript": "^4.2.3"
   },
   "devDependencies": {

+ 1 - 0
packages/app/.gitignore

@@ -6,6 +6,7 @@
 /dist/
 /transpiled/
 /public/static/js
+/public/static/dict
 /public/static/styles
 /public/uploads
 /tmp/

+ 13 - 1
packages/app/bin/cdn/cdn-resources-downloader.ts

@@ -4,7 +4,9 @@ import urljoin from 'url-join';
 import { Transform } from 'stream';
 import replaceStream from 'replacestream';
 
-import { cdnLocalScriptRoot, cdnLocalStyleRoot, cdnLocalStyleWebRoot } from '^/config/cdn';
+import {
+  cdnLocalScriptRoot, cdnLocalDictRoot, cdnLocalStyleRoot, cdnLocalStyleWebRoot,
+} from '^/config/cdn';
 import * as cdnManifests from '^/resource/cdn-manifests';
 
 import { CdnResource, CdnManifest } from '~/interfaces/cdn';
@@ -19,6 +21,15 @@ export default class CdnResourcesDownloader {
     const cdnScriptResources: CdnResource[] = cdnManifests.js.map((manifest: CdnManifest) => {
       return { manifest, outDir: cdnLocalScriptRoot };
     });
+
+    const cdnDictResources: CdnResource[] = cdnManifests.dict.map((manifest: CdnManifest) => {
+      return { manifest, outDir: cdnLocalDictRoot };
+    });
+
+    const dictExtensionOptions = {
+      ext: 'gz',
+    };
+
     const cdnStyleResources: CdnResource[] = cdnManifests.style.map((manifest) => {
       return { manifest, outDir: cdnLocalStyleRoot };
     });
@@ -31,6 +42,7 @@ export default class CdnResourcesDownloader {
 
     return Promise.all([
       this.downloadScripts(cdnScriptResources),
+      this.downloadScripts(cdnDictResources, dictExtensionOptions),
       this.downloadStyles(cdnStyleResources, dlStylesOptions),
     ]);
   }

+ 1 - 0
packages/app/config/cdn.js

@@ -4,5 +4,6 @@ import { projectRoot } from '~/utils/project-dir-utils';
 
 export const cdnLocalScriptRoot = path.join(projectRoot, 'public/static/js/cdn');
 export const cdnLocalScriptWebRoot = '/static/js/cdn';
+export const cdnLocalDictRoot = path.join(projectRoot, 'public/static/dict/cdn');
 export const cdnLocalStyleRoot = path.join(projectRoot, 'public/static/styles/cdn');
 export const cdnLocalStyleWebRoot = '/static/styles/cdn';

+ 3 - 4
packages/app/docker/Dockerfile

@@ -18,6 +18,7 @@ COPY ./yarn.lock .
 COPY ./lerna.json .
 COPY ./packages/app/package.json packages/app/
 COPY ./packages/core/package.json packages/core/
+COPY ./packages/codemirror-textlint/package.json packages/codemirror-textlint/
 COPY ./packages/plugin-attachment-refs/package.json packages/plugin-attachment-refs/
 COPY ./packages/plugin-lsx/package.json packages/plugin-lsx/
 COPY ./packages/plugin-pukiwiki-like-linker/package.json packages/plugin-pukiwiki-like-linker/
@@ -39,10 +40,7 @@ RUN tar cf node_modules.tar \
 ## deps-resolver-prod
 ##
 FROM deps-resolver AS deps-resolver-prod
-
-# shrink dependencies for production
-RUN yarn install --production
-
+RUN npx lerna bootstrap -- --production
 # make artifacts
 RUN tar cf node_modules.tar \
   node_modules \
@@ -96,6 +94,7 @@ COPY ./tsconfig.base.json ./
 # copy all related packages
 COPY packages/app packages/app
 COPY packages/core packages/core
+COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`4.4.2`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.2/docker/Dockerfile)
-* [`4.4.2-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.2/docker/Dockerfile)
+* [`4.4.5`, `4.4`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.5/docker/Dockerfile)
+* [`4.4.5-nocdn`, `4.4-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.5/docker/Dockerfile)
 * [`4.3.3`, `4.3` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 * [`4.3.3-nocdn`, `4.3-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.3.3/docker/Dockerfile)
 

+ 14 - 12
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "4.4.3-RC.0",
+  "version": "4.4.6-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -54,12 +54,13 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
-    "@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",
+    "@growi/codemirror-textlint": "^4.4.6-RC.0",
+    "@growi/plugin-attachment-refs": "^4.4.6-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^4.4.6-RC.0",
+    "@growi/plugin-lsx": "^4.4.6-RC.0",
+    "@growi/slack": "^4.4.6-RC.0",
+    "@promster/express": "^5.1.0",
+    "@promster/server": "^6.0.3",
     "@slack/events-api": "^3.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
@@ -96,8 +97,7 @@
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
     "helmet": "^4.6.0",
-    "nocache": "^3.0.1",
-    "http-errors": "~1.6.2",
+    "http-errors": "~1.8.0",
     "i18next": "^20.3.2",
     "i18next-express-middleware": "^2.0.0",
     "i18next-node-fs-backend": "^2.1.0",
@@ -114,6 +114,7 @@
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
+    "nocache": "^3.0.1",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
     "openid-client": "=2.5.0",
@@ -123,7 +124,7 @@
     "passport-http": "^0.3.0",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
-    "passport-saml": "^1.0.0",
+    "passport-saml": "^2.2.0",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "react-card-flip": "^1.0.10",
@@ -153,7 +154,7 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
-    "@growi/ui": "^4.4.3-RC.0",
+    "@growi/ui": "^4.4.6-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -164,7 +165,7 @@
     "browser-sync": "^2.26.3",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
-    "codemirror": "^5.48.4",
+    "codemirror": "^5.63.0",
     "colors": "^1.2.5",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
@@ -180,6 +181,7 @@
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
+    "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",

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


+ 99 - 13
packages/app/resource/cdn-manifests.js

@@ -55,28 +55,28 @@ module.exports = {
     },
     {
       name: 'codemirror-dialog',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/addon/dialog/dialog.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/addon/dialog/dialog.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-vim',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/keymap/vim.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/keymap/vim.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-emacs',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/keymap/emacs.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/keymap/emacs.min.js',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-keymap-sublime',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/keymap/sublime.min.js',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/keymap/sublime.min.js',
       args: {
         integrity: '',
       },
@@ -89,6 +89,92 @@ module.exports = {
       },
     },
   ],
+  dict: [
+    {
+      name: 'base.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/base.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'cc.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/cc.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'check.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/check.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'tid_map.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid_map.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'tid_pos.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid_pos.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'tid.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/tid.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_char.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_char.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_compat.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_compat.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_invoke.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_invoke.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_map.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_map.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk_pos.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk_pos.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+    {
+      name: 'unk.dat',
+      url: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/unk.dat.gz',
+      args: {
+        integrity: '',
+      },
+    },
+  ],
   style: [
     {
       name: 'lato',
@@ -170,63 +256,63 @@ module.exports = {
     },
     {
       name: 'codemirror-dialog',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/addon/dialog/dialog.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/addon/dialog/dialog.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-eclipse',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/eclipse.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/eclipse.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-elegant',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/elegant.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/elegant.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-neo',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/neo.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/neo.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-mdn-like',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/mdn-like.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/mdn-like.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-material',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/material.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/material.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-dracula',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/dracula.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/dracula.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-monokai',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/monokai.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/monokai.min.css',
       args: {
         integrity: '',
       },
     },
     {
       name: 'codemirror-theme-twilight',
-      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/theme/twilight.min.css',
+      url: 'https://cdn.jsdelivr.net/npm/codemirror@5.63.0/theme/twilight.min.css',
       args: {
         integrity: '',
       },

+ 11 - 1
packages/app/resource/locales/en_US/admin/admin.json

@@ -154,7 +154,9 @@
       "stale_notification": "Display notification on stale pages",
       "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
       "show_all_reply_comments": "Show all reply comments",
-      "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted."
+      "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted.",
+      "select_search_scope_children_as_default": "Select 'Only children of this tree' as default value of search range",
+      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range."
     },
     "code_highlight": "Code highlight",
     "nocdn_desc": "This function is disabled when the environment variable <code>NO_CDN=true</code>.<br>Github style has been forcibly applied.",
@@ -340,8 +342,16 @@
       "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",
+      "test_connection_only_public_channel":"Please test connection in a public channel",
       "error_check_logs_below": "An error has occurred. Please check the logs below.",
       "send_message_to_slack_work_space": "Send message to Slack work space.",
       "add_slack_workspace": "Add a Slack Workspace"

+ 37 - 0
packages/app/resource/locales/en_US/translation.json

@@ -255,6 +255,43 @@
       "This tree": "Only children of this tree"
     }
   },
+  "editor_settings": {
+    "editor_settings": "Editor Settings",
+    "common_settings": {
+      "common_settings": "Common Settings",
+      "common_misspellings": "Textlint rules to find common misspellings from Wikipedia.",
+      "max_comma": "Textlint rule is that limit maximum ten(,) count of sentence. Default: 4",
+      "sentence_length": "Textlint rules that limit Maximum Length of Sentence. Default: 100",
+      "en_capitalization": "Textlint rule that check capitalization in english text.",
+      "no_unmatched_pair": "Textlint rule that check unmatched pairs like ( and ]",
+      "date_weekday_mismatch": "Textlint rule that found mismatch between date and weekday.",
+      "no_kangxi_radicals": "Textlint rule to prevent using kangxi radicals.",
+      "no_surrogate_pair": "Detects surrogate pairs (D800-DBFF and DC00-DFFF) in sentences.",
+      "no_zero_width_spaces": "Textlint rule that disallow zero width spaces.",
+      "period_in_list_item": "Textlint rule that check with or without period in list item.",
+      "use_si_units": "Use of units other than SI unit units is prohibited."
+
+      },
+    "japanese_settings": {
+      "japanese_settings": "Japanese Settings",
+      "ja_no_abusage": "Textlint rules to check for common misuse.",
+      "ja_hiragana_keishikimeishi": "Textlint rules to check easy-to-read Keishikimeishi(pronouns) written in Hiragana than Kanji.",
+      "ja_no_inappropriate_words": "Textlint rules to check for inappropriate expressions",
+      "ja_no_mixed_period": "Textlint rules to check that a paragraph always has a punctuation mark at the end.",
+      "ja_no_redundant_expression": "Textlint rules that prohibits redundant expressions. Redundant expressions are expressions that make sense even if they are omitted from the sentence.",
+      "max_kanji_continuous_len": "Textlint rules that limits the maximum number of consecutive Kanji. Default: 5",
+      "max_ten": "Textlint rule is that limit maximum ten(、) count of sentence.",
+      "no_double_negative_ja": "Textlint rules that detects double negation.",
+      "no_doubled_conjunction": "Textlint rules to check duplicated same conjunctions.",
+      "no_doubled_joshi": "Textlint rules that checks that the same particle appears consecutively in one sentence.",
+      "no_dropping_the_ra": "Textlint rules that detects the word dropping the ra.",
+      "no_hankaku_kana": "Textlint rules that disallow to use Half-width kana.",
+      "prefer_tari_tari": "Textlint rules that checks tari tari.",
+      "ja_unnatural_alphabet": "Detects unnatural alphabets.",
+      "no_mixed_zenkaku_and_hankaku_alphabet": "Check for mixed full-width and half-width alphabets.",
+      "no_nfd": "textlint rule that disallow to use NFD like UTF8-MAC Sonant mark."
+    }
+  },
   "copy_to_clipboard": {
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",

+ 11 - 1
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -148,7 +148,9 @@
       "stale_notification": "古いページに通知を表示する",
       "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
       "show_all_reply_comments": "返信コメントを全て表示する",
-      "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。"
+      "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
+      "select_search_scope_children_as_default": "検索範囲のデフォルト設定を「この階層下の子ページ」にする",
+      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。"
     },
     "code_highlight": "コードハイライト",
     "nocdn_desc": "この機能は、環境変数 <code>NO_CDN=true</code> の時は無効化されます。<br>GitHub スタイルが適用されています。",
@@ -333,8 +335,16 @@
       "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連携が完了しているかの確認をしましょう",
+      "test_connection_only_public_channel":"連携テストは public チャンネルで確認してください",
       "error_check_logs_below": "エラーが発生しました。下記のログを確認してください。",
       "send_message_to_slack_work_space": "Slack ワークスペースに送信しました",
       "add_slack_workspace": "Slackワークスペースを追加"

+ 36 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -258,6 +258,42 @@
       "This tree": "この階層下の子ページのみ"
     }
   },
+  "editor_settings": {
+    "editor_settings": "エディター設定",
+    "common_settings": {
+      "common_settings": "共通設定",
+      "common_misspellings": "ウィキペディアから一般的なスペルミスを見つけます。",
+      "max_comma": "文の読点(,)を最大10個に制限します。初期値: 4。",
+      "sentence_length": "文の最大文字数を制限します。初期値: 100。",
+      "en_capitalization": "英文の大文字化をチェックします",
+      "no_unmatched_pair": "( と ] のような一致しないペアをチェックします",
+      "date_weekday_mismatch": "日付と平日の不一致を検出します。",
+      "no_kangxi_radicals": "康熙帝の部首の使用を防ぎます。",
+      "no_surrogate_pair": "文中のサロゲートペア(D800-DBFFおよびDC00-DFFF)を検出します。",
+      "no_zero_width_spaces": "ゼロ幅スペースを許可しません。",
+      "period_in_list_item": "リストアイテムのピリオドの有無をチェックします。",
+      "use_si_units": "SI単位系以外の使用を禁止します。"
+      },
+    "japanese_settings": {
+      "japanese_settings": "日本語設定",
+      "ja_hiragana_keishikimeishi": "漢字よりひらがなで書かれた読みやすい形式名詞をチェックします。",
+      "ja_no_abusage": "よくある誤用をチェックします。",
+      "ja_no_inappropriate_words": "不適切表現をチェックします。",
+      "ja_no_mixed_period": "パラグラフの末尾に必ず句点記号を付けていることをチェックします。",
+      "ja_no_redundant_expression": "冗長な表現を禁止します。冗長な表現とは、その文から省いても意味が通じるような表現を示しています。",
+      "max_kanji_continuous_len": "漢字が連続する最大文字数を制限します。初期値: 5。",
+      "max_ten": "一文に利用できる、の数を制限します。一文の読点の数が多いと冗長で読みにくい文章となるため、読点の数を一定数以下にするルールです。 読点の数を減らすためには、句点(。)で文を区切る必要があります。",
+      "no_double_negative_ja": "二重否定を検出します。",
+      "no_doubled_conjunction": "同じ接続詞が連続して出現していないかどうかをチェックします。",
+      "no_doubled_joshi": "1つの文中に同じ助詞が連続して出てくるのをチェックします。",
+      "no_dropping_the_ra": "ら抜き言葉を検出します。",
+      "no_hankaku_kana": "半角カナの利用を禁止します。",
+      "prefer_tari_tari": "「〜たり〜たりする」をチェックします。",
+      "ja_unnatural_alphabet": "不自然なアルファベットを検知します。",
+      "no_mixed_zenkaku_and_hankaku_alphabet": "全角と半角アルファベットを混在をチェックします。",
+      "no_nfd": "UTF8-MAC濁点のようなNFDの使用を禁止します。"
+    }
+  },
   "copy_to_clipboard": {
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",

+ 11 - 1
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -158,7 +158,9 @@
       "stale_notification": "在过期页上显示通知",
       "stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
       "show_all_reply_comments": "显示所有回复评论",
-      "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
+      "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
+      "select_search_scope_children_as_default": "选择“当前分支以下内容”, 作为搜索范围的默认值",
+      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。"
     },
     "code_highlight": "代码突出显示",
     "nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
@@ -343,8 +345,16 @@
       "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": "按下按钮以测试连接",
+      "test_connection_only_public_channel":"请在一个公共频道中测试连接",
       "error_check_logs_below": "发生了错误。请检查以下日志。",
       "send_message_to_slack_work_space": "发送到 Slack 工作区。",
       "add_slack_workspace": "添加Slack Workspace"

+ 37 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -236,7 +236,43 @@
 			"All pages": "所有页面",
 			"This tree": "当前分支以下内容"
 		}
-	},
+  },
+  "editor_settings": {
+    "editor_settings": "编辑器设置",
+    "common_settings": {
+      "common_settings": "常用设置",
+      "common_misspellings": "从 Wikipedia 中查找常见拼写错误的 Textlint。",
+      "max_comma": "Textlint 规则是限制句子的最大十(,)个计数。默认:4。",
+      "sentence_length": "限制最大句子长度的 Textlint 默认: 100。",
+      "en_capitalization": "检查英文文本大小写的 Textlint 规则。",
+      "no_unmatched_pair": "检查不匹配对的 Textlint 规则,如 ( 和 ]",
+      "date_weekday_mismatch": "发现日期和工作日之间不匹配的 Textlint 规则。",
+      "no_kangxi_radicals": "防止使用康熙部首的 Textlint 规则。",
+      "no_surrogate_pair": "检测句子中的代理对(D800-DBFF 和 DC00-DFFF)。",
+      "no_zero_width_spaces": "不允许零宽度空格的 Textlint 规则。",
+      "period_in_list_item": "在列表项中检查是否有句点的 Textlint 规则。",
+      "use_si_units": "禁止使用 SI 单位以外的单位。"
+      },
+    "japanese_settings": {
+      "japanese_settings": "日语设置",
+      "ja_no_abusage": "用于检查常见误用的 Textlint 规则。",
+      "ja_hiragana_keishikimeishi": "Textlint 规则检查易于阅读的 Keishikimeishi(代词)用平假名而不是汉字编写。",
+      "ja_no_inappropriate_words": "Textlint 规则来检查不适当的表达",
+      "ja_no_mixed_period": "Textlint 规则用于检查段落末尾是否总是有标点符号。",
+      "ja_no_redundant_expression": "禁止冗余表达式的 Textlint 规则。冗余表达式是即使从句子中省略也有意义的表达式。",
+      "max_kanji_continuous_len": "限制连续汉字的最大数量的 Textlint 规则。默认:5。",
+      "max_ten": "Textlint 规则是限制句子的最大十(、)个计数。",
+      "no_double_negative_ja": "检测双重否定的 Textlint 规则。",
+      "no_doubled_conjunction": "Textlint 规则来检查重复的相同连词。",
+      "no_doubled_joshi": "Textlint 规则,用于检查同一个粒子是否连续出现在一个句子中。",
+      "no_dropping_the_ra": "检测丢弃 ra 的单词的 Textlint 规则。",
+      "no_hankaku_kana": "不允许使用半角假名的 Textlint 规则。",
+      "prefer_tari_tari": "检查 tari tari 的 Textlint 规则。",
+      "ja_unnatural_alphabet": "检测不自然的字母。",
+      "no_mixed_zenkaku_and_hankaku_alphabet": "检查混合的全角和半角字母。",
+      "no_nfd": "禁止使用 UTF8-MAC 浊音等 NFD。"
+    }
+  },
 	"copy_to_clipboard": {
 		"Copy to clipboard": "复制到剪贴板",
 		"Page path": "页面路径",

+ 7 - 7
packages/app/src/client/legacy/crowi.js

@@ -140,17 +140,17 @@ Crowi.findSectionHeader = function(hash) {
   return null;
 };
 
-Crowi.unhighlightSelectedSection = function(hash) {
+Crowi.unblinkSelectedSection = function(hash) {
   const elem = Crowi.findSectionHeader(hash);
   if (elem != null) {
-    elem.classList.remove('highlighted');
+    elem.classList.remove('blink');
   }
 };
 
-Crowi.highlightSelectedSection = function(hash) {
+Crowi.blinkSelectedSection = function(hash) {
   const elem = Crowi.findSectionHeader(hash);
   if (elem != null) {
-    elem.classList.add('highlighted');
+    elem.classList.add('blink');
   }
 };
 
@@ -219,14 +219,14 @@ window.addEventListener('load', () => {
     });
   }
 
-  Crowi.highlightSelectedSection(window.location.hash);
+  Crowi.blinkSelectedSection(window.location.hash);
   Crowi.modifyScrollTop();
   Crowi.initClassesByOS();
 });
 
 window.addEventListener('hashchange', (e) => {
-  Crowi.unhighlightSelectedSection(Crowi.findHashFromUrl(e.oldURL));
-  Crowi.highlightSelectedSection(Crowi.findHashFromUrl(e.newURL));
+  Crowi.unblinkSelectedSection(Crowi.findHashFromUrl(e.oldURL));
+  Crowi.blinkSelectedSection(Crowi.findHashFromUrl(e.newURL));
   Crowi.modifyScrollTop();
   const { appContainer } = window;
   const navigationContainer = appContainer.getContainer('NavigationContainer');

+ 11 - 0
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -35,6 +35,7 @@ export default class AdminCustomizeContainer extends Container {
 
       isEnabledStaleNotification: false,
       isAllReplyShown: false,
+      isSearchScopeChildrenAsDefault: false,
       currentHighlightJsStyleId: '',
       isHighlightJsStyleBorderEnabled: false,
       currentCustomizeTitle: '',
@@ -89,6 +90,7 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: customizeParams.pageLimitationXL,
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
         isAllReplyShown: customizeParams.isAllReplyShown,
+        isSearchScopeChildrenAsDefault: customizeParams.isSearchScopeChildrenAsDefault,
         currentHighlightJsStyleId: customizeParams.styleName,
         isHighlightJsStyleBorderEnabled: customizeParams.styleBorder,
         currentCustomizeTitle: customizeParams.customizeTitle,
@@ -183,6 +185,13 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ isAllReplyShown: !this.state.isAllReplyShown });
   }
 
+  /**
+   * Switch isSearchScopeChildrenAsDefault
+   */
+  switchIsSearchScopeChildrenAsDefault() {
+    this.setState({ isSearchScopeChildrenAsDefault: !this.state.isSearchScopeChildrenAsDefault });
+  }
+
   /**
    * Switch highlightJsStyle
    */
@@ -295,6 +304,7 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: this.state.pageLimitationXL,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isAllReplyShown: this.state.isAllReplyShown,
+        isSearchScopeChildrenAsDefault: this.state.isSearchScopeChildrenAsDefault,
       });
       const { customizedParams } = response.data;
       this.setState({
@@ -307,6 +317,7 @@ export default class AdminCustomizeContainer extends Container {
         pageLimitationXL: customizedParams.pageLimitationXL,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isAllReplyShown: customizedParams.isAllReplyShown,
+        isSearchScopeChildrenAsDefault: customizedParams.isSearchScopeChildrenAsDefault,
       });
     }
     catch (err) {

+ 23 - 0
packages/app/src/client/services/EditorContainer.js

@@ -15,6 +15,7 @@ export default class EditorContainer extends Container {
 
     this.appContainer = appContainer;
     this.appContainer.registerContainer(this);
+    this.retrieveEditorSettings = this.retrieveEditorSettings.bind(this);
 
     const mainContent = document.querySelector('#content-main');
 
@@ -35,6 +36,9 @@ export default class EditorContainer extends Container {
 
       editorOptions: {},
       previewOptions: {},
+      isTextlintEnabled: false,
+      textlintRules: [],
+
       indentSize: this.appContainer.config.adminPreferredIndentSize || 4,
     };
 
@@ -195,4 +199,23 @@ export default class EditorContainer extends Container {
     return null;
   }
 
+
+  /**
+   * Retrieve Editor Settings
+   */
+  async retrieveEditorSettings() {
+    const { data } = await this.appContainer.apiv3Get('/personal-setting/editor-settings');
+
+    if (data?.textlintSettings == null) {
+      return;
+    }
+
+    const { isTextlintEnabled = false, textlintRules = [] } = data.textlintSettings;
+
+    this.setState({
+      isTextlintEnabled,
+      textlintRules,
+    });
+  }
+
 }

+ 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>

+ 15 - 0
packages/app/src/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -139,6 +139,21 @@ class CustomizeFunctionSetting extends React.Component {
               </div>
             </div>
 
+            <div className="form-group row">
+              <div className="offset-md-3 col-md-6 text-left">
+                <CustomizeFunctionOption
+                  optionId="isSearchScopeChildrenAsDefault"
+                  label={t('admin:customize_setting.function_options.select_search_scope_children_as_default')}
+                  isChecked={adminCustomizeContainer.state.isSearchScopeChildrenAsDefault || false}
+                  onChecked={() => { adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault() }}
+                >
+                  <p className="form-text text-muted">
+                    {t('admin:customize_setting.function_options.select_search_scope_children_as_default_desc')}
+                  </p>
+                </CustomizeFunctionOption>
+              </div>
+            </div>
+
             <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
           </div>
         </div>

+ 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;

+ 17 - 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,9 +125,22 @@ 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>
+        <p className="text-center text-warning">
+          <i className="icon-info">{t('admin:slack_integration.accordion.test_connection_only_public_channel')}</i>
+        </p>
         <div className="d-flex justify-content-center">
           <form className="form-row align-items-center" onSubmit={e => submitForm(e)}>
             <div className="input-group col-8">
@@ -185,6 +199,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;

+ 53 - 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>
 
@@ -250,6 +264,9 @@ const TestProcess = ({
   return (
     <>
       <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
+      <p className="text-center text-warning">
+        <i className="icon-info">{t('admin:slack_integration.accordion.test_connection_only_public_channel')}</i>
+      </p>
       <div className="d-flex justify-content-center">
         <form className="form-row justify-content-center" onSubmit={e => submitForm(e)}>
           <div className="input-group col-8">
@@ -323,6 +340,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 +358,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 +384,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 +402,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 +441,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) {

+ 286 - 0
packages/app/src/components/Me/EditorSettings.tsx

@@ -0,0 +1,286 @@
+import React, {
+  Dispatch,
+  FC, SetStateAction, useCallback, useEffect, useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import AppContainer from '~/client/services/AppContainer';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+type EditorSettingsBodyProps = {
+  appContainer: AppContainer
+}
+
+type RuleListGroupProps = {
+  title: string;
+  ruleList: RulesMenuItem[]
+  textlintRules: LintRule[]
+  setTextlintRules: Dispatch<SetStateAction<LintRule[]>>
+}
+
+type LintRule = {
+  name: string
+  options?: unknown
+  isEnabled?: boolean
+}
+
+type RulesMenuItem = {
+  name: string
+  description: string
+}
+
+
+const commonRulesMenuItems = [
+  {
+    name: 'common-misspellings',
+    description: 'editor_settings.common_settings.common_misspellings',
+  },
+  {
+    name: 'max-comma',
+    description: 'editor_settings.common_settings.max_comma',
+  },
+  {
+    name: 'sentence-length',
+    description: 'editor_settings.common_settings.sentence_length',
+  },
+  {
+    name: 'en-capitalization',
+    description: 'editor_settings.common_settings.en_capitalization',
+  },
+  {
+    name: 'no-unmatched-pair',
+    description: 'editor_settings.common_settings.no_unmatched_pair',
+  },
+  {
+    name: 'date-weekday-mismatch',
+    description: 'editor_settings.common_settings.date_weekday_mismatch',
+  },
+  {
+    name: 'no-kangxi-radicals',
+    description: 'editor_settings.common_settings.no_kangxi_radicals',
+  },
+  {
+    name: 'no-surrogate-pair',
+    description: 'editor_settings.common_settings.no_surrogate_pair',
+  },
+  {
+    name: 'no-zero-width-spaces',
+    description: 'editor_settings.common_settings.no_zero_width_spaces',
+  },
+  {
+    name: 'period-in-list-item',
+    description: 'editor_settings.common_settings.period_in_list_item',
+  },
+  {
+    name: 'use-si-units',
+    description: 'editor_settings.common_settings.use_si_units',
+  },
+];
+
+const japaneseRulesMenuItems = [
+  {
+    name: 'ja-hiragana-keishikimeishi',
+    description: 'editor_settings.japanese_settings.ja_hiragana_keishikimeishi',
+  },
+  {
+    name: 'ja-no-abusage',
+    description: 'editor_settings.japanese_settings.ja_no_abusage',
+  },
+  {
+    name: 'ja-no-inappropriate-words',
+    description: 'editor_settings.japanese_settings.ja_no_inappropriate_words',
+  },
+  {
+    name: 'ja-no-mixed-period',
+    description: 'editor_settings.japanese_settings.ja_no_mixed_period',
+  },
+  {
+    name: 'ja-no-redundant-expression',
+    description: 'editor_settings.japanese_settings.ja_no_redundant_expression',
+  },
+  {
+    name: 'max-kanji-continuous-len',
+    description: 'editor_settings.japanese_settings.max_kanji_continuous_len',
+  },
+  {
+    name: 'max-ten',
+    description: 'editor_settings.japanese_settings.max_ten',
+  },
+  {
+    name: 'no-double-negative-ja',
+    description: 'editor_settings.japanese_settings.no_double_negative_ja',
+  },
+  {
+    name: 'no-doubled-conjunction',
+    description: 'editor_settings.japanese_settings.no_doubled_conjunction',
+  },
+  {
+    name: 'no-doubled-joshi',
+    description: 'editor_settings.japanese_settings.no_doubled_joshi',
+  },
+  {
+    name: 'no-dropping-the-ra',
+    description: 'editor_settings.japanese_settings.no_dropping_the_ra',
+  },
+  {
+    name: 'no-hankaku-kana',
+    description: 'editor_settings.japanese_settings.no_hankaku_kana',
+  },
+  {
+    name: 'prefer-tari-tari',
+    description: 'editor_settings.japanese_settings.prefer_tari_tari',
+  },
+  {
+    name: 'ja-unnatural-alphabet',
+    description: 'editor_settings.japanese_settings.ja_unnatural_alphabet',
+  },
+  {
+    name: 'no-mixed-zenkaku-and-hankaku-alphabet',
+    description: 'editor_settings.japanese_settings.no_mixed_zenkaku_and_hankaku_alphabet',
+  },
+  {
+    name: 'no-nfd',
+    description: 'editor_settings.japanese_settings.no_nfd',
+  },
+
+];
+
+
+const RuleListGroup: FC<RuleListGroupProps> = ({
+  title, ruleList, textlintRules, setTextlintRules,
+}) => {
+  const { t } = useTranslation();
+
+  const isCheckedRule = (ruleName: string) => (
+    textlintRules.find(stateRule => (
+      stateRule.name === ruleName
+    ))?.isEnabled || false
+  );
+
+  const ruleCheckboxHandler = (isChecked: boolean, ruleName: string) => {
+    setTextlintRules(prevState => (
+      prevState.filter(rule => rule.name !== ruleName).concat({ name: ruleName, isEnabled: isChecked })
+    ));
+  };
+
+  return (
+    <>
+      <h2 className="border-bottom my-4">{t(title)}</h2>
+      <div className="form-group row">
+        <div className="offset-md-3 col-md-6 text-left">
+          {ruleList.map(rule => (
+            <div
+              key={rule.name}
+              className="custom-control custom-switch custom-checkbox-success"
+            >
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id={rule.name}
+                checked={isCheckedRule(rule.name)}
+                onChange={e => ruleCheckboxHandler(e.target.checked, rule.name)}
+              />
+              <label className="custom-control-label" htmlFor={rule.name}>
+                <strong>{rule.name}</strong>
+              </label>
+              <p className="form-text text-muted small">
+                {t(rule.description)}
+              </p>
+            </div>
+          ))}
+        </div>
+      </div>
+    </>
+  );
+};
+
+
+RuleListGroup.propTypes = {
+  title: PropTypes.string.isRequired,
+  ruleList: PropTypes.array.isRequired,
+  textlintRules: PropTypes.array.isRequired,
+  setTextlintRules: PropTypes.func.isRequired,
+};
+
+
+const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
+  const { t } = useTranslation();
+  const { appContainer } = props;
+  const [textlintRules, setTextlintRules] = useState<LintRule[]>([]);
+
+  const initializeEditorSettings = useCallback(async() => {
+    const { data } = await appContainer.apiv3Get('/personal-setting/editor-settings');
+    const retrievedRules: LintRule[] = data?.textlintSettings?.textlintRules;
+
+    // If database is empty, add default rules to state
+    if (retrievedRules != null && retrievedRules.length > 0) {
+      setTextlintRules(retrievedRules);
+    }
+    else {
+      const createRulesFromDefaultList = (rule: { name: string }) => (
+        {
+          name: rule.name,
+          isEnabled: true,
+        }
+      );
+
+      const defaultCommonRules = commonRulesMenuItems.map(rule => createRulesFromDefaultList(rule));
+      const defaultJapaneseRules = japaneseRulesMenuItems.map(rule => createRulesFromDefaultList(rule));
+      setTextlintRules([...defaultCommonRules, ...defaultJapaneseRules]);
+    }
+
+  }, [appContainer]);
+
+  useEffect(() => {
+    initializeEditorSettings();
+  }, [initializeEditorSettings]);
+
+  const updateRulesHandler = async() => {
+    try {
+      const { data } = await appContainer.apiv3Put('/personal-setting/editor-settings', { textlintSettings: { textlintRules: [...textlintRules] } });
+      setTextlintRules(data.textlintSettings.textlintRules);
+      toastSuccess(t('toaster.update_successed', { target: 'Updated Textlint Settings' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  };
+
+  return (
+    <>
+      <RuleListGroup
+        title="editor_settings.common_settings.common_settings"
+        ruleList={commonRulesMenuItems}
+        textlintRules={textlintRules}
+        setTextlintRules={setTextlintRules}
+      />
+      <RuleListGroup
+        title="editor_settings.japanese_settings.japanese_settings"
+        ruleList={japaneseRulesMenuItems}
+        textlintRules={textlintRules}
+        setTextlintRules={setTextlintRules}
+      />
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button
+            type="button"
+            className="btn btn-primary"
+            onClick={updateRulesHandler}
+          >
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export const EditorSettings = withUnstatedContainers(EditorSettingsBody, [AppContainer]);
+
+EditorSettingsBody.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};

+ 7 - 0
packages/app/src/components/Me/PersonalSettings.jsx

@@ -8,6 +8,7 @@ import UserSettings from './UserSettings';
 import PasswordSettings from './PasswordSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import ApiSettings from './ApiSettings';
+import { EditorSettings } from './EditorSettings';
 
 const PersonalSettings = (props) => {
 
@@ -39,6 +40,12 @@ const PersonalSettings = (props) => {
         i18n: t('API Settings'),
         index: 3,
       },
+      editor_settings: {
+        Icon: () => <i className="icon-fw icon-pencil"></i>,
+        Content: EditorSettings,
+        i18n: t('editor_settings.editor_settings'),
+        index: 4,
+      },
     };
   }, [t]);
 

+ 3 - 1
packages/app/src/components/Navbar/GlobalSearch.jsx

@@ -14,9 +14,11 @@ class GlobalSearch extends React.Component {
   constructor(props) {
     super(props);
 
+    const isSearchScopeChildrenAsDefault = this.props.appContainer.getConfig().isSearchScopeChildrenAsDefault;
+
     this.state = {
       text: '',
-      isScopeChildren: false,
+      isScopeChildren: isSearchScopeChildrenAsDefault,
     };
 
     this.onInputChange = this.onInputChange.bind(this);

+ 1 - 1
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -69,7 +69,7 @@ class RevisionRenderer extends React.PureComponent {
         .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
         .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
       const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
-      returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
+      returnBody = returnBody.replace(keywordExp, '<em class="highlighted-keyword">$&</em>');
     });
 
     return returnBody;

+ 41 - 4
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -7,9 +7,12 @@ import * as codemirror from 'codemirror';
 import { Button } from 'reactstrap';
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
+import { JSHINT } from 'jshint';
+
 import * as loadScript from 'simple-load-script';
 import * as loadCssSync from 'load-css-file';
 
+import { createValidator } from '@growi/codemirror-textlint';
 import InterceptorManager from '~/services/interceptor-manager';
 import loggerFactory from '~/utils/logger';
 
@@ -30,6 +33,9 @@ import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
 import DrawioModal from './DrawioModal';
 
+
+window.JSHINT = JSHINT;
+
 // set save handler
 codemirror.commands.save = (instance) => {
   if (instance.codeMirrorEditor != null) {
@@ -56,6 +62,8 @@ require('codemirror/addon/fold/foldgutter.css');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/addon/display/placeholder');
+require('codemirror/addon/lint/lint');
+require('codemirror/addon/lint/lint.css');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/gfm-growi.mode');
 // import modes to highlight
@@ -144,8 +152,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   init() {
     this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.42.0';
-    this.cmNoCdnScriptRoot = '/js/cdn';
-    this.cmNoCdnStyleRoot = '/styles/cdn';
+    this.cmNoCdnScriptRoot = '/static/js/cdn';
+    this.cmNoCdnStyleRoot = '/static/styles/cdn';
+    window.kuromojin = this.props.noCdn
+      ? { dicPath: '/static/dict/cdn' }
+      : { dicPath: 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict' };
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([
@@ -162,6 +173,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
       this.emojiAutoCompleteHelper = new EmojiAutoCompleteHelper(this.props.emojiStrategy);
       this.setState({ isEnabledEmojiAutoComplete: true });
     }
+
+    this.initializeTextlint();
   }
 
   componentDidMount() {
@@ -187,6 +200,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.setKeymapMode(keymapMode);
   }
 
+  async initializeTextlint() {
+    if (this.props.onInitializeTextlint != null) {
+      await this.props.onInitializeTextlint();
+      // If database has empty array, pass null instead to enable all default rules
+      const rulesForValidator = this.props.textlintRules?.length !== 0 ? this.props.textlintRules : null;
+      this.textlintValidator = createValidator(rulesForValidator);
+      this.codemirrorLintConfig = { getAnnotations: this.textlintValidator, async: true };
+    }
+  }
+
   getCodeMirror() {
     return this.cm.editor;
   }
@@ -860,9 +883,17 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   render() {
     const mode = this.state.isGfmMode ? 'gfm-growi' : undefined;
+    const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
+    const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
 
-    const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
+    const gutters = [];
+    if (this.props.lineNumbers != null) {
+      gutters.push('CodeMirror-linenumbers', 'CodeMirror-foldgutter');
+    }
+    if (this.props.isTextlintEnabled === true) {
+      gutters.push('CodeMirror-lint-markers');
+    }
 
     return (
       <React.Fragment>
@@ -893,7 +924,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
             matchTags: { bothTags: true },
             // folding
             foldGutter: this.props.lineNumbers,
-            gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
+            gutters,
             // match-highlighter, matchesonscrollbar, annotatescrollbar options
             highlightSelectionMatches: { annotateScrollbar: true },
             // continuelist, indentlist
@@ -905,6 +936,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
               'Shift-Tab': 'indentLess',
               'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
             },
+            lint,
           }}
           onCursor={this.cursorHandler}
           onScroll={(editor, data) => {
@@ -953,11 +985,16 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 CodeMirrorEditor.propTypes = Object.assign({
   editorOptions: PropTypes.object.isRequired,
+  isTextlintEnabled: PropTypes.bool,
+  textlintRules: PropTypes.array,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
   onMarkdownHelpButtonClicked: PropTypes.func,
   onAddAttachmentButtonClicked: PropTypes.func,
+  onInitializeTextlint: PropTypes.func,
 }, AbstractEditor.propTypes);
+
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,
+  isTextlintEnabled: false,
 };

+ 8 - 1
packages/app/src/components/PageEditor/Editor.jsx

@@ -10,6 +10,7 @@ import {
 import Dropzone from 'react-dropzone';
 
 import EditorContainer from '~/client/services/EditorContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 import Cheatsheet from './Cheatsheet';
 import AbstractEditor from './AbstractEditor';
@@ -18,7 +19,7 @@ import TextAreaEditor from './TextAreaEditor';
 
 import pasteHelper from './PasteHelper';
 
-export default class Editor extends AbstractEditor {
+class Editor extends AbstractEditor {
 
   constructor(props) {
     super(props);
@@ -316,6 +317,9 @@ export default class Editor extends AbstractEditor {
                         ref={(c) => { this.cmEditor = c }}
                         indentSize={editorContainer.state.indentSize}
                         editorOptions={editorContainer.state.editorOptions}
+                        isTextlintEnabled={editorContainer.state.isTextlintEnabled}
+                        textlintRules={editorContainer.state.textlintRules}
+                        onInitializeTextlint={editorContainer.retrieveEditorSettings}
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}
                         onMarkdownHelpButtonClicked={this.showMarkdownHelp}
@@ -377,4 +381,7 @@ Editor.propTypes = Object.assign({
   emojiStrategy: PropTypes.object,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 }, AbstractEditor.propTypes);
+
+export default withUnstatedContainers(Editor, [EditorContainer]);

+ 41 - 0
packages/app/src/components/PageEditor/OptionsSelector.jsx

@@ -10,6 +10,7 @@ import {
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
+import { toastError } from '~/client/util/apiNotification';
 
 
 export const defaultEditorOptions = {
@@ -51,6 +52,8 @@ class OptionsSelector extends React.Component {
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
+    this.switchTextlintEnabledHandler = this.switchTextlintEnabledHandler.bind(this);
+    this.updateIsTextlintEnabledToDB = this.updateIsTextlintEnabledToDB.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
     this.onChangeIndentSize = this.onChangeIndentSize.bind(this);
   }
@@ -111,6 +114,23 @@ class OptionsSelector extends React.Component {
     editorContainer.saveOptsToLocalStorage();
   }
 
+  async updateIsTextlintEnabledToDB(newVal) {
+    const { appContainer } = this.props;
+    try {
+      await appContainer.apiv3Put('/personal-setting/editor-settings', { textlintSettings: { isTextlintEnabled: newVal } });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  async switchTextlintEnabledHandler() {
+    const { editorContainer } = this.props;
+    const newVal = !editorContainer.state.isTextlintEnabled;
+    editorContainer.setState({ isTextlintEnabled: newVal });
+    this.updateIsTextlintEnabledToDB(newVal);
+  }
+
   onToggleConfigurationDropdown(newValue) {
     this.setState({ isCddMenuOpened: !this.state.isCddMenuOpened });
   }
@@ -207,6 +227,7 @@ class OptionsSelector extends React.Component {
             {this.renderActiveLineMenuItem()}
             {this.renderRealtimeMathJaxMenuItem()}
             {this.renderMarkdownTableAutoFormattingMenuItem()}
+            {this.renderIsTextlintEnabledMenuItem()}
             {/* <DropdownItem divider /> */}
           </DropdownMenu>
 
@@ -286,6 +307,26 @@ class OptionsSelector extends React.Component {
     );
   }
 
+  renderIsTextlintEnabledMenuItem() {
+    const isActive = this.props.editorContainer.state.isTextlintEnabled;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={this.switchTextlintEnabledHandler}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">Textlint</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }
+
   renderIndentSizeSelector() {
     const { appContainer, editorContainer } = this.props;
     const menuItems = this.typicalIndentSizes.map((indent) => {

+ 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');
+  },
+};

+ 2 - 0
packages/app/src/server/models/config.ts

@@ -129,6 +129,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:showPageLimitationXL' : 20,
   'customize:isEnabledStaleNotification': false,
   'customize:isAllReplyShown': false,
+  'customize:isSearchScopeChildrenAsDefault': false,
 
   'notification:owner-page:isEnabled': false,
   'notification:group-page:isEnabled': false,
@@ -205,6 +206,7 @@ schema.statics.getLocalconfig = function(crowi) {
     isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
     isEnabledTimeline: crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
     isAllReplyShown: crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
+    isSearchScopeChildrenAsDefault: crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
     xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
     tagWhiteList: crowi.xssService.getTagWhiteList(),
     attrWhiteList: crowi.xssService.getAttrWhiteList(),

+ 41 - 0
packages/app/src/server/models/editor-settings.ts

@@ -0,0 +1,41 @@
+import {
+  Schema, Model, Document,
+} from 'mongoose';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+
+export interface ILintRule {
+  name: string;
+  options?: unknown;
+  isEnabled?: boolean;
+}
+
+export interface ITextlintSettings {
+  isTexlintEnabled: string;
+  textlintRules: ILintRule[];
+}
+
+export interface IEditorSettings {
+  userId: Schema.Types.ObjectId;
+  textlintSettings: ITextlintSettings;
+}
+
+export interface EditorSettingsDocument extends IEditorSettings, Document {}
+export type EditorSettingsModel = Model<EditorSettingsDocument>
+
+const textlintSettingsSchema = new Schema<ITextlintSettings>({
+  isTextlintEnabled: { type: Boolean, default: false },
+  textlintRules: {
+    type: [
+      { name: { type: String }, options: { type: Object }, isEnabled: { type: Boolean } },
+    ],
+  },
+});
+
+const editorSettingsSchema = new Schema<IEditorSettings>({
+  userId: { type: String },
+  textlintSettings: textlintSettingsSchema,
+});
+
+
+export default getOrCreateModel<EditorSettingsDocument, EditorSettingsModel>('EditorSettings', editorSettingsSchema);

+ 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/models/user.js

@@ -35,6 +35,7 @@ module.exports = function(crowi) {
     userEvent.on('activated', userEvent.onActivated);
   }
 
+
   const userSchema = new mongoose.Schema({
     userId: String,
     image: String,

+ 6 - 0
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -51,6 +51,8 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: boolean
  *          isAllReplyShown:
  *            type: boolean
+ *          isSearchScopeChildrenAsDefault:
+ *            type: boolean
  *      CustomizeHighlight:
  *        description: CustomizeHighlight
  *        type: object
@@ -112,6 +114,7 @@ module.exports = (crowi) => {
       body('pageLimitationXL').isInt().isInt({ min: 1, max: 1000 }),
       body('isEnabledStaleNotification').isBoolean(),
       body('isAllReplyShown').isBoolean(),
+      body('isSearchScopeChildrenAsDefault').isBoolean(),
     ],
     customizeTitle: [
       body('customizeTitle').isString(),
@@ -166,6 +169,7 @@ module.exports = (crowi) => {
       pageLimitationXL: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
       isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
+      isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
@@ -363,6 +367,7 @@ module.exports = (crowi) => {
       'customize:showPageLimitationXL': req.body.pageLimitationXL,
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
       'customize:isAllReplyShown': req.body.isAllReplyShown,
+      'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
     };
 
     try {
@@ -377,6 +382,7 @@ module.exports = (crowi) => {
         pageLimitationXL: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
         isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
         isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
+        isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
       };
       return res.apiv3({ customizedParams });
     }

+ 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;
 

+ 25 - 11
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');
@@ -167,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: [
@@ -177,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: [
@@ -257,7 +256,7 @@ module.exports = (crowi) => {
    */
   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;
@@ -271,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;
@@ -358,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) {
@@ -426,7 +445,6 @@ module.exports = (crowi) => {
     const options = {
       createRedirectPage: req.body.isRenameRedirect,
       updateMetadata: !req.body.isRemainMetadata,
-      socketClientId: +req.body.socketClientId || undefined,
     };
 
     if (!isCreatablePage(newPagePath)) {
@@ -476,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
    *
@@ -490,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);

+ 92 - 1
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -4,6 +4,8 @@ import loggerFactory from '~/utils/logger';
 
 import { listLocaleIds } from '~/utils/locale-utils';
 
+import EditorSettings from '../../models/editor-settings';
+
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 
 const express = require('express');
@@ -15,7 +17,7 @@ const router = express.Router();
 /**
  * @swagger
  *  tags:
- *    name: PsersonalSetting
+ *    name: PersonalSetting
  */
 
 /**
@@ -98,6 +100,13 @@ module.exports = (crowi) => {
       body('providerType').isString().not().isEmpty(),
       body('accountId').isString().not().isEmpty(),
     ],
+    editorSettings: [
+      body('textlintSettings.isTextlintEnabled').optional().isBoolean(),
+      body('textlintSettings.textlintRules.*.name').optional().isString(),
+      body('textlintSettings.textlintRules.*.options').optional(),
+      body('textlintSettings.textlintRules.*.isEnabled').optional().isBoolean(),
+
+    ],
   };
 
   /**
@@ -459,5 +468,87 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+   * @swagger
+   *
+   *    /personal-setting/editor-settings:
+   *      put:
+   *        tags: [EditorSetting]
+   *        operationId: putEditorSettings
+   *        summary: /editor-setting
+   *        description: Put editor preferences
+   *        responses:
+   *          200:
+   *            description: params of editor settings
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: editor settings
+   */
+  router.put('/editor-settings', accessTokenParser, loginRequiredStrictly, csrf, validator.editorSettings, apiV3FormValidator, async(req, res) => {
+    const query = { userId: req.user.id };
+    const textlintSettings = req.body.textlintSettings;
+    const document = {};
+
+    if (textlintSettings == null) {
+      return res.apiv3Err('no-settings-found');
+    }
+
+    if (textlintSettings.isTextlintEnabled != null) {
+      Object.assign(document, { 'textlintSettings.isTextlintEnabled': textlintSettings.isTextlintEnabled });
+    }
+    if (textlintSettings.textlintRules != null) {
+      Object.assign(document, { 'textlintSettings.textlintRules': textlintSettings.textlintRules });
+    }
+
+    // Insert if document does not exist, and return new values
+    // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
+    const options = { upsert: true, new: true };
+    try {
+      const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
+      return res.apiv3(response);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('updating-editor-settings-failed');
+    }
+  });
+
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/editor-settings:
+   *      get:
+   *        tags: [EditorSetting]
+   *        operationId: getEditorSettings
+   *        summary: /editor-setting
+   *        description: Get editor preferences
+   *        responses:
+   *          200:
+   *            description: params of editor settings
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    currentUser:
+   *                      type: object
+   *                      description: editor settings
+   */
+  router.get('/editor-settings', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    try {
+      const query = { userId: req.user.id };
+      const response = await EditorSettings.findOne(query);
+      return res.apiv3(response);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('getting-editor-settings-failed');
+    }
+  });
+
   return router;
 };

+ 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 - 6
packages/app/src/server/routes/apiv3/users.js

@@ -11,6 +11,7 @@ const path = require('path');
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
+const { serializePageSecurely } = require('../../models/serializers/page-serializer');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -330,12 +331,7 @@ module.exports = (crowi) => {
     try {
       const result = await Page.findListByCreator(user, req.user, queryOptions);
 
-      // Delete unnecessary data about users
-      result.pages = result.pages.map((page) => {
-        const user = page.lastUpdateUser.toObject();
-        page.lastUpdateUser = user;
-        return page;
-      });
+      result.pages = result.pages.map(page => serializePageSecurely(page));
 
       return res.apiv3(result);
     }

+ 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}`;
+};

+ 4 - 4
packages/app/src/services/cdn-resources-service.js

@@ -6,10 +6,10 @@ const urljoin = require('url-join');
 
 const { envUtils } = require('growi-commons');
 
-const cdnLocalScriptRoot = 'public/js/cdn';
-const cdnLocalScriptWebRoot = '/js/cdn';
-const cdnLocalStyleRoot = 'public/styles/cdn';
-const cdnLocalStyleWebRoot = '/styles/cdn';
+const cdnLocalScriptRoot = 'public/static/js/cdn';
+const cdnLocalScriptWebRoot = '/static/js/cdn';
+const cdnLocalStyleRoot = 'public/static/styles/cdn';
+const cdnLocalStyleWebRoot = '/static/styles/cdn';
 
 
 class CdnResourcesService {

+ 3 - 2
packages/app/src/styles/_mixins.scss

@@ -253,7 +253,8 @@
     }
   }
 }
-@mixin highlighted($color) {
+
+@mixin blink-bgcolor($bgcolor) {
   @keyframes fadeout {
     100% {
       opacity: 0;
@@ -270,7 +271,7 @@
     width: 100%;
     height: 70%;
     content: '';
-    background-color: $color;
+    background-color: $bgcolor;
     border-radius: 2px;
     animation: fadeout 1s ease-in 1.5s forwards;
   }

+ 6 - 0
packages/app/src/styles/_override-codemirror.scss

@@ -45,3 +45,9 @@
     color: $text-muted;
   }
 }
+
+// patch to fix https://github.com/codemirror/CodeMirror/issues/4089
+// see also https://github.com/codemirror/CodeMirror/commit/51a1e7da60a99e019f026a118dc7c98c2b1f9d62
+.CodeMirror-wrap > div > textarea {
+  font-size: #{$line-height-base}rem;
+}

+ 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: #6ff;
+$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';

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