Przeglądaj źródła

Merge branch 'imprv/show-page-control-menu-on-empty-page' into imprv/remove-toc-in-empty-page

yohei0125 4 lat temu
rodzic
commit
ad697c4734
100 zmienionych plików z 744 dodań i 457 usunięć
  1. 7 0
      .devcontainer/Dockerfile
  2. 27 0
      .eslintrc.js
  3. 9 14
      .github/workflows/reusable-app-prod.yml
  4. 11 0
      CHANGELOG.md
  5. 1 1
      package.json
  6. 2 2
      packages/app/cypress.json
  7. 1 1
      packages/app/package.json
  8. 1 0
      packages/app/resource/locales/en_US/admin/admin.json
  9. 3 1
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  10. 0 0
      packages/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt
  11. 3 1
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  12. 1 1
      packages/app/resource/locales/en_US/translation.json
  13. 1 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  14. 3 1
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  15. 3 1
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  16. 1 1
      packages/app/resource/locales/ja_JP/translation.json
  17. 1 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  18. 3 1
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  19. 0 0
      packages/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt
  20. 3 1
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  21. 1 1
      packages/app/resource/locales/zh_CN/translation.json
  22. 11 0
      packages/app/src/client/services/PersonalContainer.js
  23. 4 1
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  24. 25 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  25. 21 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  26. 24 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  27. 7 7
      packages/app/src/components/Fab.jsx
  28. 7 3
      packages/app/src/components/Me/ApiSettings.jsx
  29. 5 4
      packages/app/src/components/Me/AssociateModal.jsx
  30. 23 3
      packages/app/src/components/Me/BasicInfoSettings.jsx
  31. 11 4
      packages/app/src/components/Me/EditorSettings.tsx
  32. 12 4
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  33. 4 2
      packages/app/src/components/Me/InAppNotificationSettings.tsx
  34. 5 3
      packages/app/src/components/Me/PasswordSettings.jsx
  35. 8 4
      packages/app/src/components/Me/PersonalSettings.jsx
  36. 4 3
      packages/app/src/components/Me/UserSettings.jsx
  37. 27 8
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  38. 7 8
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  39. 18 17
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  40. 7 10
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  41. 5 3
      packages/app/src/components/Page/DisplaySwitcher.tsx
  42. 4 4
      packages/app/src/components/PageCreateModal.jsx
  43. 15 23
      packages/app/src/components/PageList/PageListItemL.tsx
  44. 5 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  45. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  46. 5 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  47. 25 0
      packages/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js
  48. 5 4
      packages/app/src/server/models/obsolete-page.js
  49. 11 2
      packages/app/src/server/models/password-reset-order.ts
  50. 12 3
      packages/app/src/server/models/user-registration-order.ts
  51. 17 0
      packages/app/src/server/models/user.js
  52. 7 5
      packages/app/src/server/routes/apiv3/forgot-password.js
  53. 2 0
      packages/app/src/server/routes/apiv3/personal-setting.js
  54. 1 1
      packages/app/src/server/routes/apiv3/user-group.js
  55. 36 36
      packages/app/src/server/routes/page.js
  56. 5 1
      packages/app/src/server/routes/user-activation.ts
  57. 2 2
      packages/app/src/server/service/page.ts
  58. 13 25
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  59. 2 2
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  60. 37 4
      packages/app/src/server/service/slack-command-handler/keep.js
  61. 1 1
      packages/app/src/server/views/layout-growi/user_page.html
  62. 1 1
      packages/app/src/server/views/layout/layout.html
  63. 5 0
      packages/app/src/styles/_page-accessories-control.scss
  64. 2 2
      packages/app/src/styles/_page-tree.scss
  65. 0 4
      packages/app/src/styles/_search.scss
  66. 12 0
      packages/app/src/styles/style-app.scss
  67. 5 3
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  68. 0 14
      packages/app/test/cypress/integration/2-basic-features/use-tools.spec.ts
  69. 5 0
      packages/app/test/cypress/integration/4-admin/access-to-admin-page.spec.ts
  70. 0 32
      packages/app/test/cypress/integration/6-home-settings/access-home.spec.ts
  71. 0 85
      packages/app/test/cypress/integration/6-home-settings/access-user-settings.spec.ts
  72. 101 0
      packages/app/test/cypress/integration/6-home/home.spec.ts
  73. 11 0
      packages/app/test/cypress/plugins/index.ts
  74. 2 2
      packages/codemirror-textlint/src/index.ts
  75. 2 2
      packages/core/src/index.js
  76. 1 1
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  77. 1 0
      packages/core/src/utils/page-path-utils.ts
  78. 1 1
      packages/plugin-attachment-refs/src/client-entry.js
  79. 4 5
      packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx
  80. 1 2
      packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx
  81. 3 3
      packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  82. 1 1
      packages/plugin-lsx/src/client-entry.js
  83. 4 4
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  84. 3 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxListView.jsx
  85. 2 3
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx
  86. 2 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx
  87. 2 2
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  88. 1 1
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js
  89. 1 0
      packages/slack/src/interfaces/growi-interaction-processor.ts
  90. 2 1
      packages/slack/src/middlewares/parse-slack-interaction-request.ts
  91. 2 2
      packages/slack/src/middlewares/verify-growi-to-slack-request.ts
  92. 4 3
      packages/slack/src/middlewares/verify-slack-request.ts
  93. 4 3
      packages/slack/src/utils/check-communicable.ts
  94. 3 1
      packages/slack/src/utils/interaction-payload-accessor.ts
  95. 2 1
      packages/slack/src/utils/post-ephemeral-errors.ts
  96. 2 1
      packages/slack/src/utils/respond-util-factory.ts
  97. 8 7
      packages/slackbot-proxy/src/Server.ts
  98. 9 12
      packages/slackbot-proxy/src/controllers/growi-to-slack.ts
  99. 11 13
      packages/slackbot-proxy/src/controllers/slack.ts
  100. 1 2
      packages/slackbot-proxy/src/controllers/top.ts

+ 7 - 0
.devcontainer/Dockerfile

@@ -30,8 +30,15 @@ RUN chown -R $USER_UID:$USER_GID /home/$USERNAME /workspace;
 # * See https://docs.docker.com/engine/reference/builder/#run *
 # * See https://docs.docker.com/engine/reference/builder/#run *
 # *************************************************************
 # *************************************************************
 ENV DEBIAN_FRONTEND=noninteractive
 ENV DEBIAN_FRONTEND=noninteractive
+
+# Prepare to install Chrome for VRT
+RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
+RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
+
 RUN apt-get update \
 RUN apt-get update \
    && apt-get -y install --no-install-recommends git-lfs \
    && apt-get -y install --no-install-recommends git-lfs \
+      # Chrome
+      google-chrome-stable \
       # for Cypress
       # for Cypress
       libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
       libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb fonts-noto-cjk \
 
 

+ 27 - 0
.eslintrc.js

@@ -16,6 +16,33 @@ module.exports = {
   ],
   ],
   rules: {
   rules: {
     'import/prefer-default-export': 'off',
     'import/prefer-default-export': 'off',
+    'import/order': [
+      'warn',
+      {
+        pathGroups: [
+          {
+            pattern: 'react',
+            group: 'builtin',
+            position: 'before',
+          },
+          {
+            pattern: '^/**',
+            group: 'parent',
+            position: 'before',
+          },
+          {
+            pattern: '~/**',
+            group: 'parent',
+            position: 'before',
+          },
+        ],
+        alphabetize: {
+          order: 'asc',
+        },
+        pathGroupsExcludedImportTypes: ['react'],
+        'newlines-between': 'always',
+      },
+    ],
     '@typescript-eslint/no-explicit-any': 'off',
     '@typescript-eslint/no-explicit-any': 'off',
     indent: [
     indent: [
       'error',
       'error',

+ 9 - 14
.github/workflows/reusable-app-prod.yml

@@ -175,17 +175,12 @@ jobs:
     if: ${{ !inputs.skip-cypress }}
     if: ${{ !inputs.skip-cypress }}
 
 
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    container:
-      image: cypress/base:16.13.0
-      # solve permissions issue
-      # see: https://github.com/cypress-io/github-action/issues/446#issuecomment-987015822
-      options: --user 1001
 
 
     strategy:
     strategy:
       fail-fast: false
       fail-fast: false
       matrix:
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4']
+        spec-group: ['1', '2', '3', '4', '6']
 
 
     services:
     services:
       mongodb:
       mongodb:
@@ -202,10 +197,11 @@ jobs:
     steps:
     steps:
     - uses: actions/checkout@v3
     - uses: actions/checkout@v3
 
 
-    - name: Get yarn cache dir
-      id: yarn-cache-dir
-      run: |
-        echo "::set-output name=value::`yarn cache dir --silent`"
+    - uses: actions/setup-node@v3
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: 'yarn'
+        cache-dependency-path: '**/yarn.lock'
 
 
     - name: Cache/Restore dependencies
     - name: Cache/Restore dependencies
       uses: actions/cache@v3
       uses: actions/cache@v3
@@ -213,7 +209,6 @@ jobs:
         path: |
         path: |
           **/node_modules
           **/node_modules
           ~/.cache/Cypress
           ~/.cache/Cypress
-          ${{ steps.yarn-cache-dir.outputs.value }}
         key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         key: deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('packages/app/package.json') }}
         restore-keys: |
         restore-keys: |
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
@@ -252,14 +247,14 @@ jobs:
     - name: Cypress Run
     - name: Cypress Run
       uses: cypress-io/github-action@v3
       uses: cypress-io/github-action@v3
       with:
       with:
+        browser: chrome
         working-directory: ./packages/app
         working-directory: ./packages/app
-        install: false
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
         start: yarn server
         start: yarn server
         wait-on: 'http://localhost:3000'
         wait-on: 'http://localhost:3000'
       env:
       env:
-        MONGO_URI: mongodb://mongodb:27017/growi-vrt
-        ELASTICSEARCH_URI: http://elasticsearch:9200/growi
+        MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-vrt
+        ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi
 
 
     - name: Upload results
     - name: Upload results
       if: always()
       if: always()

+ 11 - 0
CHANGELOG.md

@@ -54,6 +54,17 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 
 
+## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
+
+### 🐛 Bug Fixes
+
+- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
+
 ## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
 ## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 1
package.json

@@ -59,7 +59,7 @@
     "@typescript-eslint/parser": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "cypress": "^9.2.0",
     "cypress": "^9.2.0",
     "eslint": "^7.31.0",
     "eslint": "^7.31.0",
-    "eslint-config-weseek": "^1.1.0",
+    "eslint-config-weseek": "^2.1.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-import-resolver-typescript": "^2.4.0",
     "eslint-plugin-import": "^2.23.4",
     "eslint-plugin-import": "^2.23.4",
     "eslint-plugin-jest": "^24.3.2",
     "eslint-plugin-jest": "^24.3.2",

+ 2 - 2
packages/app/cypress.json

@@ -10,8 +10,8 @@
   "pluginsFile": "test/cypress/plugins/index.ts",
   "pluginsFile": "test/cypress/plugins/index.ts",
   "testFiles": "**/*.spec.ts",
   "testFiles": "**/*.spec.ts",
 
 
-  "viewportWidth": 1440,
-  "viewportHeight": 1200,
+  "viewportWidth": 1400,
+  "viewportHeight": 1024,
 
 
   "experimentalSessionSupport": true
   "experimentalSessionSupport": true
 }
 }

+ 1 - 1
packages/app/package.json

@@ -28,7 +28,7 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status",
     "dev:migrate:status": "yarn dev:migrate-mongo status",
     "dev:migrate:up": "yarn dev:migrate-mongo up",
     "dev:migrate:up": "yarn dev:migrate-mongo up",
     "dev:migrate:down": "yarn dev:migrate-mongo down",
     "dev:migrate:down": "yarn dev:migrate-mongo down",
-    "cy:run": "cypress run --headless",
+    "cy:run": "cypress run --browser chrome",
     "//// for CI": "",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
     "predev:ci": "run-p resources:*",

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

@@ -472,6 +472,7 @@
   "user_group_management": {
   "user_group_management": {
     "create_group": "Create new group",
     "create_group": "Create new group",
     "add_child_group": "Add child group",
     "add_child_group": "Add child group",
+    "remove_child_group": "Remove",
     "deny_create_group": "You can't create a new group with the current settings.",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",
     "group_example": "e.g. : Group1",

+ 3 - 1
packages/app/resource/locales/en_US/notifications/passwordReset.txt

@@ -2,9 +2,11 @@ Password Reset
 
 
 Hi, {{ email }}
 Hi, {{ email }}
 
 
-A request has been received to change the password your GROWI account {{ appTitle }}.
+A request has been received to change the password your GROWI ({{ appTitle }}) account.
 To reset your password, click on the link below.
 To reset your password, click on the link below.
 
 
 {{ url }}
 {{ url }}
 
 
+This link will expire in 10 minutes at  {{ expiredAt }}.
+
 If you did not request a password reset, you can safely ignore this email.
 If you did not request a password reset, you can safely ignore this email.

+ 0 - 0
packages/app/resource/locales/en_US/notifications/PasswordResetSuccessful.txt → packages/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt


+ 3 - 1
packages/app/resource/locales/en_US/notifications/userActivation.txt

@@ -2,9 +2,11 @@ Account confirmation
 
 
 Hi, {{ email }}
 Hi, {{ email }}
 
 
-An acount has been created in GROWI {{ appTitle }}.
+An acount has been created in GROWI ({{ appTitle }}).
 To activate your account, click on the link below.
 To activate your account, click on the link below.
 
 
 {{ url }}
 {{ url }}
 
 
+This link will expire in 1 hour at  {{ expiredAt }}.
+
 If you did not created the account, you can safely ignore this email.
 If you did not created the account, you can safely ignore this email.

+ 1 - 1
packages/app/resource/locales/en_US/translation.json

@@ -687,7 +687,7 @@
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_caution": "Restarting the server is required after you modify this value.",
     "max_age_caution": "Restarting the server is required after you modify this value.",
     "forced_update_desc": "Settings have been forcibly changed. Previous setting: ",
     "forced_update_desc": "Settings have been forcibly changed. Previous setting: ",
-    "page_delete_rights_caution": "The \"Delete / Delete All\" permission (including descendant pages) is forced to be stronger than the \"Delete / Completely Delete\" permission. <br> <br> Anyone > Admin and autor > Admin only",
+    "page_delete_rights_caution": "The \"Delete / Delete All\" permission (including descendant pages) is forced to be stronger than the \"Delete / Completely Delete\" permission. <br> <br> Admin only > Admin and autor > Anyone",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "setup_is_not_yet_complete": "Setup is not yet complete",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",

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

@@ -471,6 +471,7 @@
   "user_group_management": {
   "user_group_management": {
     "create_group": "新規グループの作成",
     "create_group": "新規グループの作成",
     "add_child_group": "子グループの追加",
     "add_child_group": "子グループの追加",
+    "remove_child_group": "解除",
     "deny_create_group": "新規グループの作成はできません。",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_name": "グループ名",
     "group_example": "例: Group1",
     "group_example": "例: Group1",

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 
 こんにちは, {{ email }}
 こんにちは, {{ email }}
 
 
-あなたのGROWIアカウント {{ appTitle }} から、パスワード再設定のリクエストがありました。
+あなたのGROWI ({{ appTitle }}) アカウントから、パスワード再設定のリクエストがありました。
 パスワードをリセットするには、以下のリンクをクリックしてください。
 パスワードをリセットするには、以下のリンクをクリックしてください。
 
 
 {{ url }}
 {{ url }}
 
 
+このリンクは10分後の {{ expiredAt }} に失効します。
+
 もしこのリクエストに心当たりがない場合は、このメールを無視してください。
 もしこのリクエストに心当たりがない場合は、このメールを無視してください。

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/userActivation.txt

@@ -2,10 +2,12 @@
 
 
 {{ email }} さん
 {{ email }} さん
 
 
-GROWI {{ appTitle }} で仮登録が完了いたしました。
+GROWI ({{ appTitle }}) で仮登録が完了いたしました。
 
 
 ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
 ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
 
 
 {{ url }}
 {{ url }}
 
 
+このリンクは1時間後の {{ expiredAt }} に失効します。
+
 ※当メールの内容に心当たりがない場合は、このメールを無視してください。
 ※当メールの内容に心当たりがない場合は、このメールを無視してください。

+ 1 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -686,7 +686,7 @@
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
     "forced_update_desc": "設定が強制変更されました。前回の設定: ",
     "forced_update_desc": "設定が強制変更されました。前回の設定: ",
-    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 誰でも可能 > 管理者とページ作者が可能 > 管理者のみ可能",
+    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 管理者のみ可能 > 管理者とページ作者が可能 > 誰でも可能",
     "Authentication mechanism settings": "認証機構設定",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",

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

@@ -481,6 +481,7 @@
   "user_group_management": {
   "user_group_management": {
     "create_group": "创建新组",
     "create_group": "创建新组",
     "add_child_group": "添加一个子组",
     "add_child_group": "添加一个子组",
+    "remove_child_group": "移除",
     "deny_create_group": "不能用当前设置创建新组。",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_name": "组名",
     "group_example": "e.g.:第1组",
     "group_example": "e.g.:第1组",

+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 
 嗨,{{ email }}
 嗨,{{ email }}
 
 
-已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+已收到更改您 GROWI ({{appTitle}}) 帐户 密码的请求。
 要重置密码,请单击下面的链接。
 要重置密码,请单击下面的链接。
 
 
 {{ url }}
 {{ url }}
 
 
+这个链接在10分钟后的{ expiredAt }}失效。
+
 如果您没有要求重置密码,则可以放心地忽略此电子邮件。
 如果您没有要求重置密码,则可以放心地忽略此电子邮件。

+ 0 - 0
packages/app/resource/locales/zh_CN/notifications/PasswordResetSuccessful.txt → packages/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt


+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/userActivation.txt

@@ -2,9 +2,11 @@
 
 
 致{{ email }},
 致{{ email }},
 
 
-已使用 GROWI {{ appTitle }} 创建帐户。
+已使用 GROWI ({{ appTitle }}) 创建帐户。
 单击下面的链接以激活您的帐户。
 单击下面的链接以激活您的帐户。
 
 
 {{ url }}
 {{ url }}
 
 
+这个链接将在1小时后即{{ expiredAt }}失效。
+
 如果您尚未创建,请忽略此电子邮件。
 如果您尚未创建,请忽略此电子邮件。

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

@@ -645,7 +645,7 @@
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
     "max_age_caution": "修改该值后需要重启服务器。",
     "forced_update_desc": "设置已被强行更改。以前的设置: ",
     "forced_update_desc": "设置已被强行更改。以前的设置: ",
-    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 任何人 > 管理员|作者 > 仅管理员",
+    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 仅管理员 > 管理员|作者 > 何人",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
 		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",

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

@@ -30,6 +30,7 @@ export default class PersonalContainer extends Container {
       uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       externalAccounts: [],
       apiToken: '',
       apiToken: '',
+      slackMemberId: '',
     };
     };
 
 
   }
   }
@@ -55,6 +56,7 @@ export default class PersonalContainer extends Container {
         lang: currentUser.lang,
         lang: currentUser.lang,
         isGravatarEnabled: currentUser.isGravatarEnabled,
         isGravatarEnabled: currentUser.isGravatarEnabled,
         apiToken: currentUser.apiToken,
         apiToken: currentUser.apiToken,
+        slackMemberId: currentUser.slackMemberId,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -114,6 +116,13 @@ export default class PersonalContainer extends Container {
     this.setState({ email: inputValue });
     this.setState({ email: inputValue });
   }
   }
 
 
+  /**
+   * Change Slack Member ID
+   */
+  changeSlackMemberId(inputValue) {
+    this.setState({ slackMemberId: inputValue });
+  }
+
   /**
   /**
    * Change isEmailPublished
    * Change isEmailPublished
    */
    */
@@ -147,6 +156,7 @@ export default class PersonalContainer extends Container {
         email: this.state.email,
         email: this.state.email,
         isEmailPublished: this.state.isEmailPublished,
         isEmailPublished: this.state.isEmailPublished,
         lang: this.state.lang,
         lang: this.state.lang,
+        slackMemberId: this.state.slackMemberId,
       });
       });
       const { updatedUser } = response.data;
       const { updatedUser } = response.data;
 
 
@@ -155,6 +165,7 @@ export default class PersonalContainer extends Container {
         email: updatedUser.email,
         email: updatedUser.email,
         isEmailPublished: updatedUser.isEmailPublished,
         isEmailPublished: updatedUser.isEmailPublished,
         lang: updatedUser.lang,
         lang: updatedUser.lang,
+        slackMemberId: updatedUser.slackMemberId,
       });
       });
     }
     }
     catch (err) {
     catch (err) {

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

@@ -69,7 +69,10 @@ const BotTypeCard = (props) => {
       <div className="card-body p-4">
       <div className="card-body p-4">
         <div className="card-text">
         <div className="card-text">
           <div className="my-2">
           <div className="my-2">
-            <img className="d-block mx-auto mb-4" src={`/images/slack-integration/slackbot-difficulty-level-${botDetails[props.botType].setUp}.svg`}></img>
+            <img
+              className="bot-difficulty-icon d-block mx-auto mb-4"
+              src={`/images/slack-integration/slackbot-difficulty-level-${botDetails[props.botType].setUp}.svg`}
+            />
             <div className="d-flex justify-content-between mb-3">
             <div className="d-flex justify-content-between mb-3">
               <span>{t('admin:slack_integration.selecting_bot_types.multiple_workspaces_integration')}</span>
               <span>{t('admin:slack_integration.selecting_bot_types.multiple_workspaces_integration')}</span>
               <img className="bot-type-disc" src={`/images/slack-integration/${botDetails[props.botType].multiWSIntegration}.png`} alt="" />
               <img className="bot-type-disc" src={`/images/slack-integration/${botDetails[props.botType].multiWSIntegration}.png`} alt="" />

+ 25 - 1
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   FC, useState, useCallback, useEffect,
   FC, useState, useCallback, useEffect,
 } from 'react';
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { TFunctionResult } from 'i18next';
 import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
@@ -16,6 +17,7 @@ type Props = {
   childUserGroups: IUserGroupHasId[],
   childUserGroups: IUserGroupHasId[],
   isAclEnabled: boolean,
   isAclEnabled: boolean,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
 };
 };
 
 
@@ -73,7 +75,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     });
     });
   };
   };
 
 
-  const onClickEdit = (e) => {
+  const onClickEdit = async(e) => {
     if (props.onEdit == null) {
     if (props.onEdit == null) {
       return;
       return;
     }
     }
@@ -86,6 +88,25 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     props.onEdit(userGroup);
     props.onEdit(userGroup);
   };
   };
 
 
+  const onClickRemove = async(e) => {
+    if (props.onRemove == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    try {
+      await props.onRemove(userGroup);
+      userGroup.parent = null;
+    }
+    catch {
+      //
+    }
+  };
+
   const onClickDelete = (e) => { // no preventDefault
   const onClickDelete = (e) => { // no preventDefault
     if (props.onDelete == null) {
     if (props.onDelete == null) {
       return;
       return;
@@ -179,6 +200,9 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
                           </button>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                          </button>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>
                           </button>

+ 21 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -267,6 +267,26 @@ const UserGroupDetailPage: FC = () => {
     }
     }
   }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
   }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
 
 
+  const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: null,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+      throw err;
+    }
+  }, [t, mutateChildUserGroups, mutateSelectableChildUserGroups]);
+
   /*
   /*
    * Dependencies
    * Dependencies
    */
    */
@@ -337,6 +357,7 @@ const UserGroupDetailPage: FC = () => {
         childUserGroups={grandChildUserGroups}
         childUserGroups={grandChildUserGroups}
         isAclEnabled={isAclEnabled ?? false}
         isAclEnabled={isAclEnabled ?? false}
         onEdit={showUpdateModal}
         onEdit={showUpdateModal}
+        onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
         userGroupRelations={childUserGroupRelations}
       />
       />

+ 24 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -134,32 +134,46 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 
         {/* Bookmark */}
         {/* Bookmark */}
         { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
         { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
-          <DropdownItem onClick={bookmarkItemClickedHandler}>
-            <i className="fa fa-fw fa-bookmark-o"></i>
+          <DropdownItem
+            onClick={bookmarkItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
         {/* Duplicate */}
         {/* Duplicate */}
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
-            <i className="icon-fw icon-docs"></i>
+          <DropdownItem
+            onClick={duplicateItemClickedHandler}
+            data-testid="open-page-duplicate-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
             {t('Duplicate')}
             {t('Duplicate')}
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
         {/* Move/Rename */}
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
-          <DropdownItem onClick={renameItemClickedHandler} data-testid="open-page-move-rename-modal-btn">
-            <i className="icon-fw  icon-action-redo"></i>
+          <DropdownItem
+            onClick={renameItemClickedHandler}
+            data-testid="open-page-move-rename-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
             {t(isInstantRename ? 'Rename' : 'Move/Rename')}
             {t(isInstantRename ? 'Rename' : 'Move/Rename')}
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
         {/* Revert */}
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
-          <DropdownItem onClick={revertItemClickedHandler}>
-            <i className="icon-fw  icon-action-undo"></i>
+          <DropdownItem
+            onClick={revertItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-undo grw-page-control-dropdown-icon"></i>
             {t('modal_putback.label.Put Back Page')}
             {t('modal_putback.label.Put Back Page')}
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
@@ -177,12 +191,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <>
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
             <DropdownItem
-              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
               onClick={deleteItemClickedHandler}
               data-testid="open-page-delete-modal-btn"
               data-testid="open-page-delete-modal-btn"
             >
             >
-              <i className="icon-fw icon-trash"></i>
+              <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
               {t('Delete')}
               {t('Delete')}
             </DropdownItem>
             </DropdownItem>
           </>
           </>

+ 7 - 7
packages/app/src/components/Fab.jsx

@@ -1,18 +1,18 @@
 import React, { useState, useCallback, useEffect } from 'react';
 import React, { useState, useCallback, useEffect } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
-import loggerFactory from '~/utils/logger';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-
-import { usePageCreateModal } from '~/stores/modal';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { useCurrentPagePath } from '~/stores/context';
+import { usePageCreateModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import CreatePageIcon from './Icons/CreatePageIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
 import ReturnTopIcon from './Icons/ReturnTopIcon';
-import { useCurrentPagePath } from '~/stores/context';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 
 const logger = loggerFactory('growi:cli:Fab');
 const logger = loggerFactory('growi:cli:Fab');
 
 
@@ -55,7 +55,7 @@ const Fab = (props) => {
   function renderPageCreateButton() {
   function renderPageCreateButton() {
     return (
     return (
       <>
       <>
-        <div data-testid="grw-fab-create-page" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+        <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
           <button
           <button
             type="button"
             type="button"
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
             className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
@@ -69,7 +69,7 @@ const Fab = (props) => {
   }
   }
 
 
   return (
   return (
-    <div className="grw-fab d-none d-md-block d-edit-none">
+    <div className="grw-fab d-none d-md-block d-edit-none" data-testid="grw-fab">
       {currentUser != null && renderPageCreateButton()}
       {currentUser != null && renderPageCreateButton()}
       <div data-testid="grw-fab-return-to-top" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
       <div data-testid="grw-fab-return-to-top" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button
         <button

+ 7 - 3
packages/app/src/components/Me/ApiSettings.jsx

@@ -1,13 +1,14 @@
 
 
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
 class ApiSettings extends React.Component {
 class ApiSettings extends React.Component {
@@ -46,6 +47,8 @@ class ApiSettings extends React.Component {
             {personalContainer.state.apiToken != null
             {personalContainer.state.apiToken != null
               ? (
               ? (
                 <input
                 <input
+                  data-testid="grw-api-settings-input"
+                  data-hide-in-vrt
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
                   name="apiToken"
                   name="apiToken"
@@ -76,6 +79,7 @@ class ApiSettings extends React.Component {
         <div className="row my-3">
         <div className="row my-3">
           <div className="offset-4 col-5">
           <div className="offset-4 col-5">
             <button
             <button
+              data-testid="grw-api-settings-update-button"
               type="button"
               type="button"
               className="btn btn-primary text-nowrap"
               className="btn btn-primary text-nowrap"
               onClick={this.onClickSubmit}
               onClick={this.onClickSubmit}

+ 5 - 4
packages/app/src/components/Me/AssociateModal.jsx

@@ -1,21 +1,22 @@
 
 
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-
 import {
 import {
   Modal,
   Modal,
   ModalHeader,
   ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
 import LdapAuthTest from '../Admin/Security/LdapAuthTest';
 import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 class AssociateModal extends React.Component {
 class AssociateModal extends React.Component {
 
 
@@ -70,7 +71,7 @@ class AssociateModal extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg">
+      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg" data-testid="grw-associate-modal">
         <ModalHeader className="bg-primary text-light" toggle={this.props.onClose}>
         <ModalHeader className="bg-primary text-light" toggle={this.props.onClose}>
           { t('admin:user_management.create_external_account') }
           { t('admin:user_management.create_external_account') }
         </ModalHeader>
         </ModalHeader>

+ 23 - 3
packages/app/src/components/Me/BasicInfoSettings.jsx

@@ -1,14 +1,15 @@
 
 
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { localeMetadatas } from '~/client/util/i18n';
 import { localeMetadatas } from '~/client/util/i18n';
 
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-import PersonalContainer from '~/client/services/PersonalContainer';
 
 
 class BasicInfoSettings extends React.Component {
 class BasicInfoSettings extends React.Component {
 
 
@@ -128,10 +129,29 @@ class BasicInfoSettings extends React.Component {
             }
             }
           </div>
           </div>
         </div>
         </div>
+        <div className="form-group row">
+          <label htmlFor="userForm[slackMemberId]" className="text-left text-md-right col-md-3 col-form-label">{t('Slack Member ID')}</label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              key={personalContainer.state.slackMemberId}
+              name="userForm[slackMemberId]"
+              defaultValue={personalContainer.state.slackMemberId}
+              onChange={(e) => { personalContainer.changeSlackMemberId(e.target.value) }}
+            />
+          </div>
+        </div>
 
 
         <div className="row my-3">
         <div className="row my-3">
           <div className="offset-4 col-5">
           <div className="offset-4 col-5">
-            <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>
+            <button
+              data-testid="grw-besic-info-settings-update-button"
+              type="button"
+              className="btn btn-primary"
+              onClick={this.onClickSubmit}
+              disabled={personalContainer.state.retrieveError != null}
+            >
               {t('Update')}
               {t('Update')}
             </button>
             </button>
           </div>
           </div>

+ 11 - 4
packages/app/src/components/Me/EditorSettings.tsx

@@ -2,13 +2,15 @@ import React, {
   Dispatch,
   Dispatch,
   FC, SetStateAction, useCallback, useEffect, useState,
   FC, SetStateAction, useCallback, useEffect, useState,
 } from 'react';
 } from 'react';
-import { useTranslation } from 'react-i18next';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
+
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 
 type EditorSettingsBodyProps = {
 type EditorSettingsBodyProps = {
   appContainer: AppContainer
   appContainer: AppContainer
@@ -249,8 +251,12 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
     }
     }
   };
   };
 
 
+  if (textlintRules == null) {
+    return <></>;
+  }
+
   return (
   return (
-    <>
+    <div data-testid="grw-editor-settings">
       <RuleListGroup
       <RuleListGroup
         title="editor_settings.common_settings.common_settings"
         title="editor_settings.common_settings.common_settings"
         ruleList={commonRulesMenuItems}
         ruleList={commonRulesMenuItems}
@@ -267,6 +273,7 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
       <div className="row my-3">
       <div className="row my-3">
         <div className="offset-4 col-5">
         <div className="offset-4 col-5">
           <button
           <button
+            data-testid="grw-editor-settings-update-button"
             type="button"
             type="button"
             className="btn btn-primary"
             className="btn btn-primary"
             onClick={updateRulesHandler}
             onClick={updateRulesHandler}
@@ -275,7 +282,7 @@ const EditorSettingsBody: FC<EditorSettingsBodyProps> = (props) => {
           </button>
           </button>
         </div>
         </div>
       </div>
       </div>
-    </>
+    </div>
   );
   );
 };
 };
 
 

+ 12 - 4
packages/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -1,16 +1,19 @@
 
 
 import React, { Fragment } from 'react';
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import ExternalAccountRow from './ExternalAccountRow';
+import { toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+
 import AssociateModal from './AssociateModal';
 import AssociateModal from './AssociateModal';
 import DisassociateModal from './DisassociateModal';
 import DisassociateModal from './DisassociateModal';
+import ExternalAccountRow from './ExternalAccountRow';
 
 
 class ExternalAccountLinkedMe extends React.Component {
 class ExternalAccountLinkedMe extends React.Component {
 
 
@@ -68,7 +71,12 @@ class ExternalAccountLinkedMe extends React.Component {
     return (
     return (
       <Fragment>
       <Fragment>
         <h2 className="border-bottom my-4">
         <h2 className="border-bottom my-4">
-          <button type="button" className="btn btn-outline-secondary btn-sm pull-right" onClick={this.openAssociateModal}>
+          <button
+            type="button"
+            data-testid="grw-external-account-add-button"
+            className="btn btn-outline-secondary btn-sm pull-right"
+            onClick={this.openAssociateModal}
+          >
             <i className="icon-plus" aria-hidden="true" />
             <i className="icon-plus" aria-hidden="true" />
             Add
             Add
           </button>
           </button>

+ 4 - 2
packages/app/src/components/Me/InAppNotificationSettings.tsx

@@ -2,10 +2,11 @@ import React, {
   FC, useState, useEffect, useCallback,
   FC, useState, useEffect, useCallback,
 } from 'react';
 } from 'react';
 
 
-import { useTranslation } from 'react-i18next';
 import { pullAllBy } from 'lodash';
 import { pullAllBy } from 'lodash';
-import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { useTranslation } from 'react-i18next';
+
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { subscribeRuleNames, SubscribeRuleDescriptions } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames, SubscribeRuleDescriptions } from '~/interfaces/in-app-notification';
 
 
 type SubscribeRule = {
 type SubscribeRule = {
@@ -96,6 +97,7 @@ const InAppNotificationSettings: FC = () => {
       <div className="row my-3">
       <div className="row my-3">
         <div className="offset-4 col-5">
         <div className="offset-4 col-5">
           <button
           <button
+            data-testid="grw-in-app-notification-settings-update-button"
             type="button"
             type="button"
             className="btn btn-primary"
             className="btn btn-primary"
             onClick={updateSettingsHandler}
             onClick={updateSettingsHandler}

+ 5 - 3
packages/app/src/components/Me/PasswordSettings.jsx

@@ -1,13 +1,14 @@
 
 
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
 class PasswordSettings extends React.Component {
 class PasswordSettings extends React.Component {
@@ -138,6 +139,7 @@ class PasswordSettings extends React.Component {
         <div className="row my-3">
         <div className="row my-3">
           <div className="offset-5">
           <div className="offset-5">
             <button
             <button
+              data-testid="grw-password-settings-update-button"
               type="button"
               type="button"
               className="btn btn-primary"
               className="btn btn-primary"
               onClick={this.onClickSubmit}
               onClick={this.onClickSubmit}

+ 8 - 4
packages/app/src/components/Me/PersonalSettings.jsx

@@ -1,15 +1,17 @@
 
 
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
-import UserSettings from './UserSettings';
-import PasswordSettings from './PasswordSettings';
-import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
+
 import ApiSettings from './ApiSettings';
 import ApiSettings from './ApiSettings';
 import { EditorSettings } from './EditorSettings';
 import { EditorSettings } from './EditorSettings';
+import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import InAppNotificationSettings from './InAppNotificationSettings';
 import InAppNotificationSettings from './InAppNotificationSettings';
+import PasswordSettings from './PasswordSettings';
+import UserSettings from './UserSettings';
 
 
 const PersonalSettings = (props) => {
 const PersonalSettings = (props) => {
 
 
@@ -58,7 +60,9 @@ const PersonalSettings = (props) => {
 
 
 
 
   return (
   return (
-    <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+    <div data-testid="grw-personal-settings">
+      <CustomNavAndContents navTabMapping={navTabMapping} navigationMode="both" tabContentClasses={['px-0']} />
+    </div>
   );
   );
 
 
 };
 };

+ 4 - 3
packages/app/src/components/Me/UserSettings.jsx

@@ -1,5 +1,6 @@
 
 
-import React, { Fragment } from 'react';
+import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
@@ -12,7 +13,7 @@ class UserSettings extends React.Component {
     const { t } = this.props;
     const { t } = this.props;
 
 
     return (
     return (
-      <Fragment>
+      <div data-testid="grw-user-settings">
         <div className="mb-5">
         <div className="mb-5">
           <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
           <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
           <BasicInfoSettings />
           <BasicInfoSettings />
@@ -21,7 +22,7 @@ class UserSettings extends React.Component {
           <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
           <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
           <ProfileImageSettings />
           <ProfileImageSettings />
         </div>
         </div>
-      </Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 27 - 8
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -77,14 +77,20 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
       <DropdownItem
         onClick={() => openPresentationModal(hrefForPresentationModal)}
         onClick={() => openPresentationModal(hrefForPresentationModal)}
         data-testid="open-presentation-modal-btn"
         data-testid="open-presentation-modal-btn"
+        className="grw-page-control-dropdown-item"
       >
       >
-        <i className="icon-fw"><PresentationIcon /></i>
+        <i className="icon-fw grw-page-control-dropdown-icon">
+          <PresentationIcon />
+        </i>
         { t('Presentation Mode') }
         { t('Presentation Mode') }
       </DropdownItem>
       </DropdownItem>
 
 
       {/* Export markdown */}
       {/* Export markdown */}
-      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-        <i className="icon-fw icon-cloud-download"></i>
+      <DropdownItem
+        onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
         {t('export_bulk.export_page_markdown')}
         {t('export_bulk.export_page_markdown')}
       </DropdownItem>
       </DropdownItem>
 
 
@@ -97,31 +103,44 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
         disabled={isGuestUser || isSharedUser}
+        className="grw-page-control-dropdown-item"
       >
       >
-        <span className="mr-1"><HistoryIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <HistoryIcon />
+        </span>
         {t('History')}
         {t('History')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        className="grw-page-control-dropdown-item"
       >
       >
-        <span className="mr-1"><AttachmentIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <AttachmentIcon />
+        </span>
         {t('attachment_data')}
         {t('attachment_data')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+        className="grw-page-control-dropdown-item"
       >
       >
-        <span className="mr-1"><ShareLinkIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <ShareLinkIcon />
+        </span>
         {t('share_links.share_link_management')}
         {t('share_links.share_link_management')}
       </DropdownItem>
       </DropdownItem>
 
 
       <DropdownItem divider />
       <DropdownItem divider />
 
 
       {/* Create template */}
       {/* Create template */}
-      <DropdownItem onClick={openPageTemplateModalHandler}>
-        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      <DropdownItem
+        onClick={openPageTemplateModalHandler}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
+        { t('template.option_label.create/edit') }
       </DropdownItem>
       </DropdownItem>
     </>
     </>
   );
   );

+ 7 - 8
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -1,22 +1,21 @@
 import React, { FC, memo } from 'react';
 import React, { FC, memo } from 'react';
-import PropTypes from 'prop-types';
 
 
+import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd } from '~/stores/ui';
-import { usePageCreateModal } from '~/stores/modal';
 import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
 import { useIsSearchPage, useCurrentPagePath } from '~/stores/context';
+import { usePageCreateModal } from '~/stores/modal';
+import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
 import GrowiLogo from '../Icons/GrowiLogo';
 import GrowiLogo from '../Icons/GrowiLogo';
+import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-import PersonalDropdown from './PersonalDropdown';
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
-import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+import PersonalDropdown from './PersonalDropdown';
 
 
 
 
 type NavbarRightProps = {
 type NavbarRightProps = {
@@ -52,7 +51,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         </button>
         </button>
       </li>
       </li>
 
 
-      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+      <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret" data-testid="grw-personal-dropdown">
         <PersonalDropdown />
         <PersonalDropdown />
       </li>
       </li>
     </>
     </>

+ 18 - 17
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -48,16 +48,15 @@ function PageEditorModeManager(props) {
   const isAdmin = appContainer.isAdmin;
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
-    if (isBtnDisabled) {
+    if (isBtnDisabled || !isHackmdEnabled) {
       return;
       return;
     }
     }
     if (onPageEditorModeButtonClicked != null) {
     if (onPageEditorModeButtonClicked != null) {
       onPageEditorModeButtonClicked(viewType);
       onPageEditorModeButtonClicked(viewType);
     }
     }
-  }, [isBtnDisabled, onPageEditorModeButtonClicked]);
+  }, [isBtnDisabled, isHackmdEnabled, onPageEditorModeButtonClicked]);
 
 
   return (
   return (
     <>
     <>
@@ -88,15 +87,22 @@ function PageEditorModeManager(props) {
           />
           />
         )}
         )}
         {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
         {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
-          <PageEditorModeButtonWrapper
-            editorMode={editorMode}
-            isBtnDisabled={isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
-            targetMode={EditorMode.HackMD}
-            icon={<i className="fa fa-file-text-o" />}
-            label={t('hackmd.hack_md')}
-            id="grw-page-editor-mode-manager-hackmd-button"
-          />
+          <>
+            <PageEditorModeButtonWrapper
+              editorMode={editorMode}
+              isBtnDisabled={isBtnDisabled || !isHackmdEnabled}
+              onClick={pageEditorModeButtonClickedHandler}
+              targetMode={EditorMode.HackMD}
+              icon={<i className="fa fa-file-text-o" />}
+              label={t('hackmd.hack_md')}
+              id="grw-page-editor-mode-manager-hackmd-button"
+            />
+            { !isHackmdEnabled && (
+              <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
+                {t('hackmd.not_set_up')}
+              </UncontrolledTooltip>
+            )}
+          </>
         )}
         )}
       </div>
       </div>
       {isBtnDisabled && (
       {isBtnDisabled && (
@@ -104,11 +110,6 @@ function PageEditorModeManager(props) {
           {t('Not available for guest')}
           {t('Not available for guest')}
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       )}
       )}
-      {!isBtnDisabled && showHackmdDisabledTooltip && (
-        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
-          {t('hackmd.not_set_up')}
-        </UncontrolledTooltip>
-      )}
     </>
     </>
   );
   );
 
 

+ 7 - 10
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,18 +1,13 @@
 import React, { useState, useCallback } from 'react';
 import React, { useState, useCallback } from 'react';
-import PropTypes from 'prop-types';
 
 
+import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
-import { UserPicture } from '@growi/ui';
 
 
-import { useUserUISettings } from '~/client/services/user-ui-settings';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
-
+import { useUserUISettings } from '~/client/services/user-ui-settings';
 import {
 import {
   isUserPreferenceExists,
   isUserPreferenceExists,
   isDarkMode as isDarkModeByUtil,
   isDarkMode as isDarkModeByUtil,
@@ -21,12 +16,14 @@ import {
   updateUserPreference,
   updateUserPreference,
   updateUserPreferenceWithOsSettings,
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
 } from '~/client/util/color-scheme';
+import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 
 
 
-import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
-import SidebarDockIcon from '../Icons/SidebarDockIcon';
 import MoonIcon from '../Icons/MoonIcon';
 import MoonIcon from '../Icons/MoonIcon';
+import SidebarDockIcon from '../Icons/SidebarDockIcon';
+import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SunIcon from '../Icons/SunIcon';
 import SunIcon from '../Icons/SunIcon';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
 const PersonalDropdown = (props) => {
 const PersonalDropdown = (props) => {

+ 5 - 3
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -70,7 +70,9 @@ const DisplaySwitcher = (): JSX.Element => {
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         onClick={() => openDescendantPageListModal(currentPath)}
                         onClick={() => openDescendantPageListModal(currentPath)}
                       >
                       >
-                        <PageListIcon />
+                        <div className="grw-page-accessories-control-icon">
+                          <PageListIcon />
+                        </div>
                         {t('page_list')}
                         {t('page_list')}
                         <span></span> {/* for a count badge */}
                         <span></span> {/* for a count badge */}
                       </button>
                       </button>
@@ -79,13 +81,13 @@ const DisplaySwitcher = (): JSX.Element => {
 
 
                   {/* Comments */}
                   {/* Comments */}
                   { getCommentListDom != null && !isTopPagePath && (
                   { getCommentListDom != null && !isTopPagePath && (
-                    <div className="mt-2">
+                    <div className="grw-page-accessories-control mt-2">
                       <button
                       <button
                         type="button"
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
                         onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
                       >
                       >
-                        <i className="mr-2 icon-fw icon-bubbles"></i>
+                        <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
                         <span>Comments</span>
                         <span></span> {/* for a count badge */}
                         <span></span> {/* for a count badge */}
                       </button>
                       </button>

+ 4 - 4
packages/app/src/components/PageCreateModal.jsx

@@ -1,4 +1,3 @@
-
 import React, {
 import React, {
   useEffect, useState, useMemo, useCallback,
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 } from 'react';
@@ -35,7 +34,8 @@ const PageCreateModal = (props) => {
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
   const pathname = path || '';
   const pathname = path || '';
   const userPageRootPath = userPageRoot(appContainer.currentUser);
   const userPageRootPath = userPageRoot(appContainer.currentUser);
-  const pageNameInputInitialValue = isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/';
+  const isCreatable = isCreatablePage(pathname) || isUsersHomePage(pathname);
+  const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
 
 
   const [todayInput1, setTodayInput1] = useState(t('Memo'));
   const [todayInput1, setTodayInput1] = useState(t('Memo'));
@@ -46,8 +46,8 @@ const PageCreateModal = (props) => {
 
 
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   useEffect(() => {
   useEffect(() => {
-    setPageNameInput(isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/');
-  }, [pathname]);
+    setPageNameInput(isCreatable ? pathUtils.addTrailingSlash(pathname) : '/');
+  }, [pathname, isCreatable]);
 
 
   const checkIsUsersHomePageDebounce = useMemo(() => {
   const checkIsUsersHomePageDebounce = useMemo(() => {
     const checkIsUsersHomePage = () => {
     const checkIsUsersHomePage = () => {

+ 15 - 23
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,6 +1,6 @@
 import React, {
 import React, {
-  forwardRef,
-  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
+  forwardRef, useState,
+  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef, useEffect,
 } from 'react';
 } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -49,13 +49,15 @@ type Props = {
 
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
   const {
-    // todo: refactoring variable name to clear what changed
     page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     forceHideMenuItems,
     showPageUpdatedTime,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
   } = props;
 
 
+  const [likerCount, setLikerCount] = useState(pageData.liker.length);
+  const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
+
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
@@ -97,6 +99,14 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
 
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
 
+  useEffect(() => {
+    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+      // likerCount
+      setLikerCount(pageInfo.likerIds?.length ?? 0);
+      // bookmarkCount
+      setBookmarkCount(pageInfo.bookmarkCount ?? 0);
+    }
+  }, [pageInfo]);
 
 
   // click event handler
   // click event handler
   const clickHandler = useCallback(() => {
   const clickHandler = useCallback(() => {
@@ -147,22 +157,6 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
 
   const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
   const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
 
 
-  let likerCount;
-  if (isSelected && isIPageInfoForEntity(pageInfo)) {
-    likerCount = pageInfo.likerIds?.length;
-  }
-  else {
-    likerCount = pageData.liker.length;
-  }
-
-  let bookmarkCount;
-  if (isSelected && isIPageInfoForEntity(pageInfo)) {
-    bookmarkCount = pageInfo.bookmarkCount;
-  }
-  else {
-    bookmarkCount = pageMeta?.bookmarkCount;
-  }
-
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
 
 
@@ -170,11 +164,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     <li
     <li
       key={pageData._id}
       key={pageData._id}
       className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
       className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
+      onClick={clickHandler}
     >
     >
-      <div
-        className="text-break w-100"
-        onClick={clickHandler}
-      >
+      <div className="text-break w-100">
         <div className="d-flex">
         <div className="d-flex">
           {/* checkbox */}
           {/* checkbox */}
           {onCheckboxChanged != null && (
           {onCheckboxChanged != null && (

+ 5 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -42,8 +42,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
 
   return (
   return (
     // Export markdown
     // Export markdown
-    <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-      <i className="icon-fw icon-cloud-download"></i>
+    <DropdownItem
+      onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+      className="grw-page-control-dropdown-item"
+    >
+      <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
       {t('export_bulk.export_page_markdown')}
       {t('export_bulk.export_page_markdown')}
     </DropdownItem>
     </DropdownItem>
   );
   );

+ 1 - 1
packages/app/src/components/SearchPage/SortControl.tsx

@@ -38,7 +38,7 @@ const SortControl: FC <Props> = (props: Props) => {
         <div className="border rounded-right">
         <div className="border rounded-right">
           <button
           <button
             type="button"
             type="button"
-            className="btn dropdown-toggle search-sort-option-btn py-1"
+            className="btn dropdown-toggle py-1"
             data-toggle="dropdown"
             data-toggle="dropdown"
           >
           >
             <span className="mr-2 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>
             <span className="mr-2 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>

+ 5 - 1
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -101,7 +101,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   return (
   return (
     <>
     <>
-      <span className="grw-pagetree-count px-2 badge badge-pill badge-light">
+      <span className="grw-pagetree-count badge badge-pill badge-light">
         {props.descendantCount}
         {props.descendantCount}
       </span>
       </span>
     </>
     </>
@@ -195,6 +195,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
       await mutateChildren();
       await mutateChildren();
 
 
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
       // force open
       // force open
       setIsOpen(true);
       setIsOpen(true);
     }
     }

+ 25 - 0
packages/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js

@@ -0,0 +1,25 @@
+import { getModelSafely, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:set-sparse-option-to-slack-member-id');
+
+/**
+ * set sparse option to slackMemberId
+ */
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    const User = getModelSafely('User') || require('~/server/models/user')();
+    await User.syncIndexes();
+
+    logger.info('Migration has successfully applied');
+  },
+
+  down(db) {
+    // do not rollback
+  },
+};

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

@@ -9,12 +9,13 @@ import loggerFactory from '~/utils/logger';
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 
 
 const debug = require('debug')('growi:models:page');
 const debug = require('debug')('growi:models:page');
+
 const nodePath = require('path');
 const nodePath = require('path');
-const urljoin = require('url-join');
-const mongoose = require('mongoose');
-const differenceInYears = require('date-fns/differenceInYears');
 
 
+const differenceInYears = require('date-fns/differenceInYears');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
+const mongoose = require('mongoose');
+const urljoin = require('url-join');
 
 
 const { isTopPage, isTrashPage } = pagePathUtils;
 const { isTopPage, isTrashPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 const { checkTemplatePath } = templateChecker;
@@ -253,7 +254,7 @@ export const getPageSchema = (crowi) => {
 
 
     this.grant = grant || GRANT_PUBLIC;
     this.grant = grant || GRANT_PUBLIC;
 
 
-    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP) {
+    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP && grant !== GRANT_RESTRICTED) {
       this.grantedUsers.push(user._id);
       this.grantedUsers.push(user._id);
     }
     }
 
 

+ 11 - 2
packages/app/src/server/models/password-reset-order.ts

@@ -2,6 +2,7 @@ import mongoose, {
   Schema, Model, Document,
   Schema, Model, Document,
 } from 'mongoose';
 } from 'mongoose';
 
 
+import { addMinutes } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 import crypto from 'crypto';
 import crypto from 'crypto';
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
@@ -28,13 +29,21 @@ export interface PasswordResetOrderModel extends Model<PasswordResetOrderDocumen
   createPasswordResetOrder(email: string): PasswordResetOrderDocument
   createPasswordResetOrder(email: string): PasswordResetOrderDocument
 }
 }
 
 
+const expiredAt = (): Date => {
+  return addMinutes(new Date(), 10);
+};
+
 const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
 const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
   token: { type: String, required: true, unique: true },
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
   email: { type: String, required: true },
   relatedUser: { type: ObjectId, ref: 'User' },
   relatedUser: { type: ObjectId, ref: 'User' },
   isRevoked: { type: Boolean, default: false, required: true },
   isRevoked: { type: Boolean, default: false, required: true },
-  createdAt: { type: Date, default: new Date(Date.now()), required: true },
-  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+  expiredAt: { type: Date, default: expiredAt, required: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 });
 schema.plugin(uniqueValidator);
 schema.plugin(uniqueValidator);
 
 

+ 12 - 3
packages/app/src/server/models/user-registration-order.ts

@@ -1,7 +1,8 @@
-import mongoose, {
+import {
   Schema, Model, Document,
   Schema, Model, Document,
 } from 'mongoose';
 } from 'mongoose';
 
 
+import { addHours } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 import crypto from 'crypto';
 import crypto from 'crypto';
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
@@ -24,12 +25,20 @@ export interface UserRegistrationOrderModel extends Model<UserRegistrationOrderD
   createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
   createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
 }
 }
 
 
+const expiredAt = (): Date => {
+  return addHours(new Date(), 1);
+};
+
 const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
 const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
   token: { type: String, required: true, unique: true },
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
   email: { type: String, required: true },
   isRevoked: { type: Boolean, default: false, required: true },
   isRevoked: { type: Boolean, default: false, required: true },
-  createdAt: { type: Date, default: new Date(Date.now()), required: true },
-  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+  expiredAt: { type: Date, default: expiredAt, required: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 });
 schema.plugin(uniqueValidator);
 schema.plugin(uniqueValidator);
 
 

+ 17 - 0
packages/app/src/server/models/user.js

@@ -47,6 +47,7 @@ module.exports = function(crowi) {
     name: { type: String },
     name: { type: String },
     username: { type: String, required: true, unique: true },
     username: { type: String, required: true, unique: true },
     email: { type: String, unique: true, sparse: true },
     email: { type: String, unique: true, sparse: true },
+    slackMemberId: { type: String, unique: true, sparse: true },
     // === Crowi settings
     // === Crowi settings
     // username: { type: String, index: true },
     // username: { type: String, index: true },
     // email: { type: String, required: true, index: true },
     // email: { type: String, required: true, index: true },
@@ -689,6 +690,22 @@ module.exports = function(crowi) {
     user.save();
     user.save();
   };
   };
 
 
+  userSchema.statics.findUserBySlackMemberId = async function(slackMemberId) {
+    const user = this.findOne({ slackMemberId });
+    if (user == null) {
+      throw new Error('User not found');
+    }
+    return user;
+  };
+
+  userSchema.statics.findUsersBySlackMemberIds = async function(slackMemberIds) {
+    const users = this.find({ slackMemberId: { $in: slackMemberIds } });
+    if (users.length === 0) {
+      throw new Error('No user found');
+    }
+    return users;
+  };
+
   class UserUpperLimitException {
   class UserUpperLimitException {
 
 
     constructor() {
     constructor() {

+ 7 - 5
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -1,3 +1,4 @@
+import { format } from 'date-fns';
 import rateLimit from 'express-rate-limit';
 import rateLimit from 'express-rate-limit';
 
 
 import PasswordResetOrder from '~/server/models/password-reset-order';
 import PasswordResetOrder from '~/server/models/password-reset-order';
@@ -45,23 +46,23 @@ module.exports = (crowi) => {
 
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
 
-  async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
+  async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
     return mailService.send({
     return mailService.send({
       to: email,
       to: email,
-      subject: txtFileName,
+      subject: '[GROWI] Password Reset',
       template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
       template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
       vars: {
       vars: {
         appTitle: appService.getAppTitle(),
         appTitle: appService.getAppTitle(),
         email,
         email,
         url,
         url,
+        expiredAt,
       },
       },
     });
     });
   }
   }
 
 
   router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
   router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
     const { email } = req.body;
     const { email } = req.body;
-    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
-    const i18n = req.language || grobalLang;
+    const i18n = configManager.getConfig('crowi', 'app:globalLang');
     const appUrl = appService.getSiteUrl();
     const appUrl = appService.getSiteUrl();
 
 
     try {
     try {
@@ -76,7 +77,8 @@ module.exports = (crowi) => {
       const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
       const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
       const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
       const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
       const oneTimeUrl = url.href;
       const oneTimeUrl = url.href;
-      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl);
+      const expiredAt = format(passwordResetOrderData.expiredAt, 'yyyy/MM/dd HH:mm');
+      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, expiredAt);
       return res.apiv3();
       return res.apiv3();
     }
     }
     catch (err) {
     catch (err) {

+ 2 - 0
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -84,6 +84,7 @@ module.exports = (crowi) => {
         }),
         }),
       body('lang').isString().isIn(listLocaleIds()),
       body('lang').isString().isIn(listLocaleIds()),
       body('isEmailPublished').isBoolean(),
       body('isEmailPublished').isBoolean(),
+      body('slackMemberId').optional().isString(),
     ],
     ],
     imageType: [
     imageType: [
       body('isGravatarEnabled').isBoolean(),
       body('isGravatarEnabled').isBoolean(),
@@ -226,6 +227,7 @@ module.exports = (crowi) => {
       user.email = req.body.email;
       user.email = req.body.email;
       user.lang = req.body.lang;
       user.lang = req.body.lang;
       user.isEmailPublished = req.body.isEmailPublished;
       user.isEmailPublished = req.body.isEmailPublished;
+      user.slackMemberId = req.body.slackMemberId;
 
 
       const updatedUser = await user.save();
       const updatedUser = await user.save();
       req.i18n.changeLanguage(req.body.lang);
       req.i18n.changeLanguage(req.body.lang);

+ 1 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -49,7 +49,7 @@ module.exports = (crowi) => {
     update: [
     update: [
       body('name', 'Group name must be a string').optional().trim().isString(),
       body('name', 'Group name must be a string').optional().trim().isString(),
       body('description', 'Group description must be a string').optional().isString(),
       body('description', 'Group description must be a string').optional().isString(),
-      body('parentId', 'parentId must be a string').optional().isString(),
+      body('parentId', 'ParentId must be a string or null').optional({ nullable: true }).isString(),
       body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
       body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
     ],
     ],
     delete: [
     delete: [

+ 36 - 36
packages/app/src/server/routes/page.js

@@ -600,6 +600,31 @@ module.exports = function(crowi, app) {
     res.render('layout-growi/page_list', renderVars);
     res.render('layout-growi/page_list', renderVars);
   };
   };
 
 
+  async function redirectOperationForMultiplePages(builder, res, redirectFrom, path) {
+    // populate to list
+    builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+    const pages = await builder.query.lean().exec('find');
+
+    // remove empty pages if any.
+    const identicalPathPages = pages.filter(p => !p.isEmpty);
+    // render identical-path-page if count of remaining pages are 2 or more after removal
+    if (identicalPathPages.length >= 2) {
+      return res.render('layout-growi/identical-path-page', {
+        identicalPathPages,
+        redirectFrom,
+        path,
+      });
+    }
+  }
+
+  async function redirectOperationForSinglePage(page, req, res) {
+    const url = new URL('https://dummy.origin');
+    url.pathname = `/${page._id}`;
+    Object.entries(req.query).forEach(([key, value], i) => {
+      url.searchParams.append(key, value);
+    });
+    return res.safeRedirect(urljoin(url.pathname, url.search));
+  }
   /**
   /**
    * redirector
    * redirector
    */
    */
@@ -612,49 +637,24 @@ module.exports = function(crowi, app) {
 
 
     const pages = await builder.query.lean().clone().exec('find');
     const pages = await builder.query.lean().clone().exec('find');
 
 
-    if (pages.length >= 2) {
+    const nonEmptyPageCount = pages.reduce((prev, current) => {
+      return prev + (current.isEmpty ? 0 : 1);
+    }, 0);
 
 
-      // populate to list
-      builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
-      const pages = await builder.query.lean().exec('find');
-
-      // remove empty pages if any.
-      const identicalPathPages = pages.filter(p => !p.isEmpty);
-      // do nothing and return if count of remaining pages are less than 2 after removal
-      if (identicalPathPages.length < 2) return;
-
-      return res.render('layout-growi/identical-path-page', {
-        identicalPathPages,
-        redirectFrom,
-        path,
-      });
-    }
-
-
-    let isEmptyPage = false;
-    if (pages.length === 1) {
-      isEmptyPage = pages[0].isEmpty;
-      if (!isEmptyPage) {
-        const url = new URL('https://dummy.origin');
-        url.pathname = `/${pages[0]._id}`;
-        Object.entries(req.query).forEach(([key, value], i) => {
-          url.searchParams.append(key, value);
-        });
-        return res.safeRedirect(urljoin(url.pathname, url.search));
+    if (nonEmptyPageCount >= 1) { // Perform the operation only if nonEmptyPage(s) exist
+      if (nonEmptyPageCount >= 2) {
+        return redirectOperationForMultiplePages(builder, res, redirectFrom, path, next);
       }
       }
+      const nonEmptyPage = pages.find(p => !p.isEmpty); // find the nonEmpty Page
+      return redirectOperationForSinglePage(nonEmptyPage, req, res);
     }
     }
 
 
-    // Exclude isEmpty page to handle _notFound or forbidden
-    const isForbidden = await Page.exists({ path, isEmpty: false });
-    if (isForbidden) {
-      req.isForbidden = true;
-      return _notFound(req, res);
-    }
-    if (isEmptyPage) {
+    // Processing of nonEmptyPage is finished by the time this code is read
+    // If any pages exists then it should be empty pages
+    if (pages.length >= 1) {
       req.pageId = pages[0]._id;
       req.pageId = pages[0]._id;
       return _notFound(req, res);
       return _notFound(req, res);
     }
     }
-
     // redirect by PageRedirect
     // redirect by PageRedirect
     const pageRedirect = await PageRedirect.findOne({ fromPath: path });
     const pageRedirect = await PageRedirect.findOne({ fromPath: path });
     if (pageRedirect != null) {
     if (pageRedirect != null) {

+ 5 - 1
packages/app/src/server/routes/user-activation.ts

@@ -1,5 +1,7 @@
 import path from 'path';
 import path from 'path';
+import { format } from 'date-fns';
 import { body, validationResult } from 'express-validator';
 import { body, validationResult } from 'express-validator';
+
 import UserRegistrationOrder from '../models/user-registration-order';
 import UserRegistrationOrder from '../models/user-registration-order';
 
 
 export const form = (req, res): void => {
 export const form = (req, res): void => {
@@ -20,17 +22,19 @@ async function makeRegistrationEmailToken(email, crowi) {
   const appUrl = appService.getSiteUrl();
   const appUrl = appService.getSiteUrl();
 
 
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const expiredAt = format(userRegistrationOrder.expiredAt, 'yyyy/MM/dd HH:mm');
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const oneTimeUrl = url.href;
   const oneTimeUrl = url.href;
   const txtFileName = 'userActivation';
   const txtFileName = 'userActivation';
 
 
   return mailService.send({
   return mailService.send({
     to: email,
     to: email,
-    subject: txtFileName,
+    subject: '[GROWI] User Activation',
     template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
     template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
     vars: {
     vars: {
       appTitle: appService.getAppTitle(),
       appTitle: appService.getAppTitle(),
       email,
       email,
+      expiredAt,
       url: oneTimeUrl,
       url: oneTimeUrl,
     },
     },
   });
   });

+ 2 - 2
packages/app/src/server/service/page.ts

@@ -2100,8 +2100,8 @@ class PageService {
         isV5Compatible: true,
         isV5Compatible: true,
         isEmpty: true,
         isEmpty: true,
         isMovable,
         isMovable,
-        isDeletable: true, // Todo: Restrict who can delete a page
-        isAbleToDeleteCompletely: true, // Todo: Restrict who can delete a page
+        isDeletable: true,
+        isAbleToDeleteCompletely: true,
         isRevertible: false,
         isRevertible: false,
       };
       };
     }
     }

+ 13 - 25
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,22 +1,24 @@
+import { Writable, Transform } from 'stream';
+import { URL } from 'url';
+
 import elasticsearch6 from '@elastic/elasticsearch6';
 import elasticsearch6 from '@elastic/elasticsearch6';
 import elasticsearch7 from '@elastic/elasticsearch7';
 import elasticsearch7 from '@elastic/elasticsearch7';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
-
-import { URL } from 'url';
-
-import { Writable, Transform } from 'stream';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 
 
-import { createBatchStream } from '../../util/batch-stream';
-import loggerFactory from '~/utils/logger';
-import { PageModel } from '../../models/page';
-import {
-  SearchDelegator, SearchableData, QueryTerms,
-} from '../../interfaces/search';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
 import {
   IFormattedSearchResult, ISearchResult, SORT_AXIS, SORT_ORDER,
   IFormattedSearchResult, ISearchResult, SORT_AXIS, SORT_ORDER,
 } from '~/interfaces/search';
 } from '~/interfaces/search';
+import loggerFactory from '~/utils/logger';
+
+import {
+  SearchDelegator, SearchableData, QueryTerms,
+} from '../../interfaces/search';
+import { PageModel } from '../../models/page';
+import { createBatchStream } from '../../util/batch-stream';
+
+
 import ElasticsearchClient from './elasticsearch-client';
 import ElasticsearchClient from './elasticsearch-client';
 
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
@@ -855,27 +857,13 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
 
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const {
     const {
-      GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
+      GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = Page;
     } = Page;
 
 
     const grantConditions: any[] = [
     const grantConditions: any[] = [
       { term: { grant: GRANT_PUBLIC } },
       { term: { grant: GRANT_PUBLIC } },
     ];
     ];
 
 
-    // ensure to hit to GRANT_RESTRICTED pages that the user specified at own
-    if (user != null) {
-      grantConditions.push(
-        {
-          bool: {
-            must: [
-              { term: { grant: GRANT_RESTRICTED } },
-              { term: { granted_users: user._id.toString() } },
-            ],
-          },
-        },
-      );
-    }
-
     if (showPagesRestrictedByOwner) {
     if (showPagesRestrictedByOwner) {
       grantConditions.push(
       grantConditions.push(
         { term: { grant: GRANT_SPECIFIED } },
         { term: { grant: GRANT_SPECIFIED } },

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

@@ -11,7 +11,7 @@ class CreatePageService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, userId) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
 
@@ -20,7 +20,7 @@ class CreatePageService {
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
 
 
     // generate a dummy id because Operation to create a page needs ObjectId
     // generate a dummy id because Operation to create a page needs ObjectId
-    const dummyObjectIdOfUser = new mongoose.Types.ObjectId();
+    const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
     const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
     const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
 
 
     // Send a message when page creation is complete
     // Send a message when page creation is complete

+ 37 - 4
packages/app/src/server/service/slack-command-handler/keep.js

@@ -12,6 +12,7 @@ module.exports = (crowi) => {
   const createPageService = new CreatePageService(crowi);
   const createPageService = new CreatePageService(crowi);
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
   const handler = new BaseSlackCommandHandler();
+  const { User } = crowi.models;
 
 
   handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
   handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
     await respondUtil.respond({
     await respondUtil.respond({
@@ -32,8 +33,9 @@ module.exports = (crowi) => {
   handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
   handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
     let result = [];
     let result = [];
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
     const channelId = payload.channel.id; // this must exist since the type is always block_actions
-    const userChannelId = payload.user.id;
+    const user = await User.findUserBySlackMemberId(payload.user.id);
 
 
+    const userId = user != null ? user._id : null;
     // validate form
     // validate form
     const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
     const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
     // get messages
     // get messages
@@ -43,7 +45,7 @@ module.exports = (crowi) => {
 
 
     const contentsBody = cleanedContents.join('');
     const contentsBody = cleanedContents.join('');
     // create and send url message
     // create and send url message
-    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil);
+    await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userId, contentsBody, respondUtil);
   };
   };
 
 
   handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
   handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) {
@@ -137,10 +139,41 @@ module.exports = (crowi) => {
     return result;
     return result;
   };
   };
 
 
+  /**
+   * Get all growi users from messages
+   * @param {*} messages (array of messages)
+   * @returns users object with matching Slack Member ID
+   */
+  handler.getGrowiUsersFromMessages = async function(messages) {
+    const users = messages.map((message) => {
+      return message.user;
+    });
+    const growiUsers = await User.findUsersBySlackMemberIds(users);
+    return growiUsers;
+  };
+  /**
+   * Convert slack member ID to growi user if slack member ID is found in messages
+   * @param {*} messages
+   */
+  handler.injectGrowiUsernameToMessages = async function(messages) {
+    const growiUsers = await this.getGrowiUsersFromMessages(messages);
+
+    messages.map(async(message) => {
+      const growiUser = growiUsers.find(user => user.slackMemberId === message.user);
+      if (growiUser != null) {
+        message.user = `${growiUser.name} (@${growiUser.username})`;
+      }
+      else {
+        message.user = `This slack member ID is not registered (${message.user})`;
+      }
+    });
+  };
+
   handler.keepCleanMessages = async function(messages) {
   handler.keepCleanMessages = async function(messages) {
     const cleanedContents = [];
     const cleanedContents = [];
     let lastMessage = {};
     let lastMessage = {};
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    await this.injectGrowiUsernameToMessages(messages);
     messages
     messages
       .sort((a, b) => {
       .sort((a, b) => {
         return a.ts - b.ts;
         return a.ts - b.ts;
@@ -164,8 +197,8 @@ module.exports = (crowi) => {
     return cleanedContents;
     return cleanedContents;
   };
   };
 
 
-  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody, respondUtil) {
-    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil);
+  handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userId, contentsBody, respondUtil) {
+    await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, userId);
 
 
     // TODO: contentsBody text characters must be less than 3001
     // TODO: contentsBody text characters must be less than 3001
     // send preview to dm
     // send preview to dm

+ 1 - 1
packages/app/src/server/views/layout-growi/user_page.html

@@ -1,7 +1,7 @@
 {% extends 'page.html' %}
 {% extends 'page.html' %}
 
 
 {% block content_main %}
 {% block content_main %}
-  <div class="grw-container-convertible user-page">
+  <div class="grw-container-convertible user-page" data-testid="grw-user-page">
 
 
     {% include '../widget/page_content.html' %}
     {% include '../widget/page_content.html' %}
 
 

+ 1 - 1
packages/app/src/server/views/layout/layout.html

@@ -96,7 +96,7 @@
 </div><!-- /#wrapper -->
 </div><!-- /#wrapper -->
 
 
 {% block fixed-controls %}
 {% block fixed-controls %}
-<div id="grw-fab-container"></div>
+<div id="grw-fab-container" data-testid="grw-fab-container"></div>
 {% endblock %}
 {% endblock %}
 
 
 <div id="grw-hotkeys-manager"></div>
 <div id="grw-hotkeys-manager"></div>

+ 5 - 0
packages/app/src/styles/_page-accessories-control.scss

@@ -8,4 +8,9 @@
       height: 16px;
       height: 16px;
     }
     }
   }
   }
+  .grw-page-accessories-control-icon {
+    display: flex;
+    justify-content: center;
+    width: 20px;
+  }
 }
 }

+ 2 - 2
packages/app/src/styles/_page-tree.scss

@@ -50,8 +50,8 @@ $grw-pagetree-item-padding-left: 10px;
       }
       }
 
 
       .grw-pagetree-count {
       .grw-pagetree-count {
-        width: auto;
-        padding: 0.1rem 0;
+        min-width: 28px;
+        padding: 0.1rem 0.5rem;
         font-size: 12px;
         font-size: 12px;
       }
       }
     }
     }

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

@@ -142,10 +142,6 @@
     padding-bottom: unset;
     padding-bottom: unset;
   }
   }
 
 
-  // To fix the sort options position
-  .search-sort-option-btn {
-    min-width: 150px;
-  }
   .search-control-include-options {
   .search-control-include-options {
     .card-body {
     .card-body {
       padding: 5px 10px;
       padding: 5px 10px;

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

@@ -150,3 +150,15 @@
     content: 'Ctrl';
     content: 'Ctrl';
   }
   }
 }
 }
+
+.grw-page-control-dropdown-item {
+  display: flex !important;
+  align-items: center;
+
+  .grw-page-control-dropdown-icon {
+    display: flex;
+    justify-content: center;
+    width: 25px;
+  }
+
+}

+ 5 - 3
packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts

@@ -18,9 +18,11 @@ context('Access to page', () => {
 
 
   it('/Sandbox with anchor hash is successfully loaded', () => {
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#Headers');
     cy.visit('/Sandbox#Headers');
-    cy.screenshot(`${ssPrefix}-sandbox-headers`, {
-      disableTimersAndAnimations: false,
-    });
+
+    // hide fab
+    cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+
+    cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
   });
 
 
   it('/Sandbox/Math is successfully loaded', () => {
   it('/Sandbox/Math is successfully loaded', () => {

+ 0 - 14
packages/app/test/cypress/integration/2-basic-features/use-tools.spec.ts

@@ -11,7 +11,6 @@ context('Switch Sidebar content', () => {
   it('PageTree is successfully shown', () => {
   it('PageTree is successfully shown', () => {
     cy.visit('/page');
     cy.visit('/page');
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
-    cy.screenshot(`${ssPrefix}-pagetree-before-load`, { capture: 'viewport' });
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1500);
     cy.wait(1500);
     cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
     cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
@@ -105,17 +104,4 @@ context('Open presentation modal', () => {
     cy.screenshot(`${ssPrefix}-open-top`);
     cy.screenshot(`${ssPrefix}-open-top`);
   });
   });
 
 
-  it('PresentationModal for "/Sandbox/Bootstrap4" is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4');
-
-    cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('open-page-item-control-btn').click({force: true});
-      cy.getByTestid('open-presentation-modal-btn').click({force: true});
-    });
-
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-open-bootstrap4`);
-  });
-
 });
 });

+ 5 - 0
packages/app/test/cypress/integration/4-admin/access-to-admin-page.spec.ts

@@ -77,6 +77,11 @@ context('Access to Admin page', () => {
   it('/admin/slack-integration is successfully loaded', () => {
   it('/admin/slack-integration is successfully loaded', () => {
     cy.visit('/admin/slack-integration');
     cy.visit('/admin/slack-integration');
     cy.getByTestid('admin-slack-integration').should('be.visible');
     cy.getByTestid('admin-slack-integration').should('be.visible');
+
+    cy.get('img.bot-difficulty-icon')
+      .should('have.length', 3)
+      .should('be.visible');
+
     cy.screenshot(`${ssPrefix}-admin-slack-integration`);
     cy.screenshot(`${ssPrefix}-admin-slack-integration`);
   });
   });
 
 

+ 0 - 32
packages/app/test/cypress/integration/6-home-settings/access-home.spec.ts

@@ -1,32 +0,0 @@
-/* eslint-disable cypress/no-unnecessary-waiting */
-context('Access Home', () => {
-  const ssPrefix = 'access-home-';
-
-  let connectSid: string | undefined;
-
-  before(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    cy.getCookie('connect.sid').then(cookie => {
-      connectSid = cookie?.value;
-    });
-  });
-
-  beforeEach(() => {
-    if (connectSid != null) {
-      cy.setCookie('connect.sid', connectSid);
-    }
-  });
-
-  it('Visit home', () => {
-    cy.visit('/');
-    cy.get('.grw-personal-dropdown').click();
-    cy.get('.grw-personal-dropdown .dropdown-menu .btn-group > .btn-outline-secondary:eq(0)').click();
-
-    cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-visit-home`, { capture: 'viewport' });
-  });
-
-});

+ 0 - 85
packages/app/test/cypress/integration/6-home-settings/access-user-settings.spec.ts

@@ -1,85 +0,0 @@
-/* eslint-disable cypress/no-unnecessary-waiting */
-context('Access User settings', () => {
-  const ssPrefix = 'access-user-settings-';
-
-  let connectSid: string | undefined;
-
-  before(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    cy.getCookie('connect.sid').then(cookie => {
-      connectSid = cookie?.value;
-    });
-
-    cy.visit('/');
-    cy.get('.grw-personal-dropdown').click();
-    cy.get('[href="/me"]').click();
-
-    cy.wait(1500);
-  });
-
-  beforeEach(() => {
-    if (connectSid != null) {
-      cy.setCookie('connect.sid', connectSid);
-    }
-  });
-
-  it('Update settings', () => {
-    // Access User information
-    cy.get('#personal-setting .tab-pane.active > div:first button').click(); // Click basic info update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-user-information`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access External account
-    cy.get('#personal-setting .nav-title.nav li:eq(1) a').click(); // click
-    cy.get('#personal-setting .tab-pane.active h2 button').click(); // click add button
-    cy.get('.modal-footer button').click(); // click add button in modal form
-    cy.get('.close[aria-label="Close"]').click(); // close modal form
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-external-account`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access Password setting
-    cy.get('#personal-setting .nav-title.nav li:eq(2) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-password-setting`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access API setting
-    cy.get('#personal-setting .nav-title.nav li:eq(2) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update API token button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-api-setting`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access Editor setting
-    cy.get('#personal-setting .nav-title.nav li:eq(3) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-editor-setting`, { capture: 'viewport' });
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
-
-    // Access In-app notification setting
-    cy.get('#personal-setting .nav-title.nav li:eq(4) a').click();
-    cy.get('#personal-setting .tab-pane.active button').click(); // click update button
-
-    cy.wait(500);
-
-    cy.screenshot(`${ssPrefix}-in-app-notification-setting`, { capture: 'viewport' });
-  });
-
-});

+ 101 - 0
packages/app/test/cypress/integration/6-home/home.spec.ts

@@ -0,0 +1,101 @@
+context('Access Home', () => {
+  const ssPrefix = 'home-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it('Visit home', () => {
+    cy.visit('/dummy');
+    cy.getByTestid('grw-personal-dropdown').click();
+    cy.getByTestid('grw-personal-dropdown').find('.dropdown-menu .btn-group > .btn-outline-secondary:eq(0)').click();
+
+    cy.getByTestid('grw-user-page').should('be.visible');
+
+    cy.screenshot(`${ssPrefix}-visit-home`);
+  });
+
+});
+
+
+context('Access User settings', () => {
+  const ssPrefix = 'access-user-settings-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it('Update settings', () => {
+    cy.visit('/me');
+
+    // hide fab
+    cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+
+    // User information
+    cy.getByTestid('grw-user-settings').should('be.visible');
+    cy.screenshot(`${ssPrefix}-user-information-1`);
+    cy.getByTestid('grw-besic-info-settings-update-button').click();
+    cy.get('.toast').should('be.visible');
+    cy.screenshot(`${ssPrefix}-user-information-2`);
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access External account
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(1) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-external-account-1`);
+    cy.getByTestid('grw-external-account-add-button').click();
+    cy.getByTestid('grw-associate-modal').should('be.visible');
+    cy.screenshot(`${ssPrefix}-external-account-2`);
+    cy.getByTestid('grw-associate-modal').find('.modal-footer button').click(); // click add button in modal form
+    cy.screenshot(`${ssPrefix}-external-account-3`);
+    cy.getByTestid('grw-associate-modal').find('.close').click();
+    cy.get('.toast').should('be.visible');
+    cy.screenshot(`${ssPrefix}-external-account-4`);
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access Password setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(2) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-password-settings-1`);
+    cy.getByTestid('grw-password-settings-update-button').click();
+    cy.get('.toast').should('be.visible');
+    cy.screenshot(`${ssPrefix}-password-settings-2`);
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access API setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(3) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-api-setting-1`);
+    cy.getByTestid('grw-api-settings-update-button').click();
+    cy.getByTestid('grw-api-settings-input').should('be.visible');
+    cy.get('.toast').should('be.visible');
+    cy.screenshot(`${ssPrefix}-api-setting-2`);
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access Editor setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(4) a').click();
+    cy.scrollTo('top');
+    cy.getByTestid('grw-editor-settings').should('be.visible');
+    cy.screenshot(`${ssPrefix}-editor-setting-1`);
+    cy.getByTestid('grw-editor-settings-update-button').click();
+    cy.get('.toast').should('be.visible');
+    cy.screenshot(`${ssPrefix}-editor-setting-2`);
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+
+    // Access In-app notification setting
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(5) a').click();
+    cy.scrollTo('top');
+    cy.screenshot(`${ssPrefix}-in-app-notification-setting-1`);
+    cy.getByTestid('grw-in-app-notification-settings-update-button').click();
+    cy.get('.toast').should('be.visible');
+    cy.screenshot(`${ssPrefix}-in-app-notification-setting-2`);
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+  });
+
+});

+ 11 - 0
packages/app/test/cypress/plugins/index.ts

@@ -19,4 +19,15 @@
 module.exports = (on, config) => {
 module.exports = (on, config) => {
   // `on` is used to hook into various events Cypress emits
   // `on` is used to hook into various events Cypress emits
   // `config` is the resolved Cypress config
   // `config` is the resolved Cypress config
+
+  // change screen size
+  // see: https://docs.cypress.io/api/plugins/browser-launch-api#Set-screen-size-when-running-headless
+  on('before:browser:launch', (browser, launchOptions) => {
+    if (browser.name === 'chrome' && browser.isHeadless) {
+      launchOptions.args.push('--window-size=1400,1024')
+      launchOptions.args.push('--force-device-scale-factor=1')
+    }
+
+    return launchOptions
+  })
 }
 }

+ 2 - 2
packages/codemirror-textlint/src/index.ts

@@ -1,6 +1,7 @@
+import textlintRuleNoUnmatchedPair from '@textlint-rule/textlint-rule-no-unmatched-pair';
 import { TextlintKernel, TextlintKernelRule, TextlintRuleOptions } from '@textlint/kernel';
 import { TextlintKernel, TextlintKernelRule, TextlintRuleOptions } from '@textlint/kernel';
+import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 import textlintToCodeMirror from 'textlint-message-to-codemirror';
 import textlintToCodeMirror from 'textlint-message-to-codemirror';
-import textlintRuleNoUnmatchedPair from '@textlint-rule/textlint-rule-no-unmatched-pair';
 import textlintRuleCommonMisspellings from 'textlint-rule-common-misspellings';
 import textlintRuleCommonMisspellings from 'textlint-rule-common-misspellings';
 import textlintRuleDateWeekdayMismatch from 'textlint-rule-date-weekday-mismatch';
 import textlintRuleDateWeekdayMismatch from 'textlint-rule-date-weekday-mismatch';
 // import textlintRuleEnCapitalization from 'textlint-rule-en-capitalization';  // omit because en-pos package is too big
 // import textlintRuleEnCapitalization from 'textlint-rule-en-capitalization';  // omit because en-pos package is too big
@@ -28,7 +29,6 @@ import textlintRulePreferTariTari from 'textlint-rule-prefer-tari-tari';
 import textlintRuleSentenceLength from 'textlint-rule-sentence-length';
 import textlintRuleSentenceLength from 'textlint-rule-sentence-length';
 import textlintRuleUseSiUnits from 'textlint-rule-use-si-units';
 import textlintRuleUseSiUnits from 'textlint-rule-use-si-units';
 
 
-import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 import { loggerFactory } from './utils/logger';
 import { loggerFactory } from './utils/logger';
 
 
 type RulesConfigObj = {
 type RulesConfigObj = {

+ 2 - 2
packages/core/src/index.js

@@ -1,9 +1,9 @@
-import * as _pathUtils from './utils/path-utils';
+import * as _customTagUtils from './plugin/util/custom-tag-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
 import * as _pagePathUtils from './utils/page-path-utils';
 import * as _pageUtils from './utils/page-utils';
 import * as _pageUtils from './utils/page-utils';
+import * as _pathUtils from './utils/path-utils';
 import * as _templateChecker from './utils/template-checker';
 import * as _templateChecker from './utils/template-checker';
-import * as _customTagUtils from './plugin/util/custom-tag-utils';
 
 
 // export utils
 // export utils
 export const pathUtils = _pathUtils;
 export const pathUtils = _pathUtils;

+ 1 - 1
packages/core/src/test/plugin/service/tag-cache-manager.test.js

@@ -3,8 +3,8 @@
 // import each from 'jest-each';
 // import each from 'jest-each';
 jest.mock('~/service/localstorage-manager');
 jest.mock('~/service/localstorage-manager');
 
 
-import LocalStorageManager from '~/service/localstorage-manager';
 import TagCacheManager from '~/plugin/service/tag-cache-manager';
 import TagCacheManager from '~/plugin/service/tag-cache-manager';
+import LocalStorageManager from '~/service/localstorage-manager';
 /* eslint-enable import/first */
 /* eslint-enable import/first */
 
 
 describe('TagCacheManager.constructor', () => {
 describe('TagCacheManager.constructor', () => {

+ 1 - 0
packages/core/src/utils/page-path-utils.ts

@@ -1,6 +1,7 @@
 import nodePath from 'path';
 import nodePath from 'path';
 
 
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
+
 import { addTrailingSlash } from './path-utils';
 import { addTrailingSlash } from './path-utils';
 
 
 /**
 /**

+ 1 - 1
packages/plugin-attachment-refs/src/client-entry.js

@@ -1,5 +1,5 @@
-import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
 import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
+import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 
 
 export default (appContainer) => {
 export default (appContainer) => {
   // add interceptors
   // add interceptors

+ 4 - 5
packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx

@@ -1,16 +1,15 @@
-import React from 'react';
+import { Attachment } from '@growi/ui';
+import axios from 'axios'; // import axios from growi dependencies
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import React from 'react';
 
 
 // eslint-disable-next-line import/no-unresolved
 // eslint-disable-next-line import/no-unresolved
-import axios from 'axios'; // import axios from growi dependencies
-
-import { Attachment } from '@growi/ui';
 
 
+import styles from '../../css/index.css';
 import RefsContext from '../util/RefsContext';
 import RefsContext from '../util/RefsContext';
 import TagCacheManagerFactory from '../util/TagCacheManagerFactory';
 import TagCacheManagerFactory from '../util/TagCacheManagerFactory';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
-import styles from '../../css/index.css';
 
 
 import ExtractedAttachments from './ExtractedAttachments';
 import ExtractedAttachments from './ExtractedAttachments';
 
 

+ 1 - 2
packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx

@@ -1,6 +1,5 @@
-import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-
+import React from 'react';
 import Carousel, { Modal, ModalGateway } from 'react-images';
 import Carousel, { Modal, ModalGateway } from 'react-images';
 
 
 import RefsContext from '../util/RefsContext';
 import RefsContext from '../util/RefsContext';

+ 3 - 3
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js

@@ -1,12 +1,12 @@
+import { BasicInterceptor } from '@growi/core';
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 
 
-import { BasicInterceptor } from '@growi/core';
 
 
-import RefsContext from '../RefsContext';
+import AttachmentList from '../../components/AttachmentList';
 import GalleryContext from '../GalleryContext';
 import GalleryContext from '../GalleryContext';
+import RefsContext from '../RefsContext';
 
 
-import AttachmentList from '../../components/AttachmentList';
 
 
 /**
 /**
  * The interceptor for refs
  * The interceptor for refs

+ 1 - 1
packages/plugin-lsx/src/client-entry.js

@@ -1,6 +1,6 @@
 import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInterceptor';
 import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInterceptor';
-import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
 import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
+import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 
 
 export default (appContainer) => {
 export default (appContainer) => {
   // add interceptors
   // add interceptors

+ 4 - 4
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -1,17 +1,17 @@
-import React from 'react';
-import PropTypes from 'prop-types';
 
 
 import * as url from 'url';
 import * as url from 'url';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
+import PropTypes from 'prop-types';
+import React from 'react';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 import styles from '../../css/index.css';
 import styles from '../../css/index.css';
-
 import { LsxContext } from '../util/LsxContext';
 import { LsxContext } from '../util/LsxContext';
 import { TagCacheManagerFactory } from '../util/TagCacheManagerFactory';
 import { TagCacheManagerFactory } from '../util/TagCacheManagerFactory';
-import { PageNode } from './PageNode';
+
 import { LsxListView } from './LsxPageList/LsxListView';
 import { LsxListView } from './LsxPageList/LsxListView';
+import { PageNode } from './PageNode';
 
 
 export class Lsx extends React.Component {
 export class Lsx extends React.Component {
 
 

+ 3 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxListView.jsx

@@ -1,8 +1,9 @@
-import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import React from 'react';
 
 
-import { PageNode } from '../PageNode';
 import { LsxContext } from '../../util/LsxContext';
 import { LsxContext } from '../../util/LsxContext';
+import { PageNode } from '../PageNode';
+
 import { LsxPage } from './LsxPage';
 import { LsxPage } from './LsxPage';
 
 
 export class LsxListView extends React.Component {
 export class LsxListView extends React.Component {

+ 2 - 3
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx

@@ -1,9 +1,8 @@
-import React from 'react';
-import PropTypes from 'prop-types';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
-
 import { PageListMeta } from '@growi/ui';
 import { PageListMeta } from '@growi/ui';
+import PropTypes from 'prop-types';
+import React from 'react';
 
 
 import { LsxContext } from '../../util/LsxContext';
 import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
 import { PageNode } from '../PageNode';

+ 2 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx

@@ -1,7 +1,7 @@
-import React from 'react';
+import { PagePathLabel } from '@growi/ui';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import React from 'react';
 
 
-import { PagePathLabel } from '@growi/ui';
 
 
 export class PagePathWrapper extends React.Component {
 export class PagePathWrapper extends React.Component {
 
 

+ 2 - 2
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -1,10 +1,10 @@
+import { BasicInterceptor } from '@growi/core';
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 
 
-import { BasicInterceptor } from '@growi/core';
 
 
-import { LsxContext } from '../LsxContext';
 import { Lsx } from '../../components/Lsx';
 import { Lsx } from '../../components/Lsx';
+import { LsxContext } from '../LsxContext';
 
 
 /**
 /**
  * The interceptor for lsx
  * The interceptor for lsx

+ 1 - 1
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js

@@ -1,5 +1,5 @@
-import ReactDOM from 'react-dom';
 import { customTagUtils, BasicInterceptor } from '@growi/core';
 import { customTagUtils, BasicInterceptor } from '@growi/core';
+import ReactDOM from 'react-dom';
 
 
 /**
 /**
  * The interceptor for lsx
  * The interceptor for lsx

+ 1 - 0
packages/slack/src/interfaces/growi-interaction-processor.ts

@@ -1,4 +1,5 @@
 import { AuthorizeResult } from '@slack/oauth';
 import { AuthorizeResult } from '@slack/oauth';
+
 import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
 import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
 
 
 
 

+ 2 - 1
packages/slack/src/middlewares/parse-slack-interaction-request.ts

@@ -1,7 +1,8 @@
 import { Response, NextFunction } from 'express';
 import { Response, NextFunction } from 'express';
-import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
 
 
 import { RequestFromSlack } from '../interfaces/request-from-slack';
 import { RequestFromSlack } from '../interfaces/request-from-slack';
+import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
+
 
 
 export const parseSlackInteractionRequest = (req: RequestFromSlack, res: Response, next: NextFunction): Record<string, any> | void => {
 export const parseSlackInteractionRequest = (req: RequestFromSlack, res: Response, next: NextFunction): Record<string, any> | void => {
   // There is no payload in the request from slack
   // There is no payload in the request from slack

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

@@ -1,8 +1,8 @@
 import { Response, NextFunction } from 'express';
 import { Response, NextFunction } from 'express';
-
 import createError from 'http-errors';
 import createError from 'http-errors';
-import loggerFactory from '../utils/logger';
+
 import { RequestFromGrowi } from '../interfaces/request-between-growi-and-proxy';
 import { RequestFromGrowi } from '../interfaces/request-between-growi-and-proxy';
+import loggerFactory from '../utils/logger';
 
 
 const logger = loggerFactory('@growi/slack:middlewares:verify-growi-to-slack-request');
 const logger = loggerFactory('@growi/slack:middlewares:verify-growi-to-slack-request');
 
 

+ 4 - 3
packages/slack/src/middlewares/verify-slack-request.ts

@@ -1,10 +1,11 @@
 import { createHmac, timingSafeEqual } from 'crypto';
 import { createHmac, timingSafeEqual } from 'crypto';
-import { stringify } from 'qs';
-import { Response, NextFunction } from 'express';
 
 
+import { Response, NextFunction } from 'express';
 import createError from 'http-errors';
 import createError from 'http-errors';
-import loggerFactory from '../utils/logger';
+import { stringify } from 'qs';
+
 import { RequestFromSlack } from '../interfaces/request-from-slack';
 import { RequestFromSlack } from '../interfaces/request-from-slack';
+import loggerFactory from '../utils/logger';
 
 
 const logger = loggerFactory('@growi/slack:middlewares:verify-slack-request');
 const logger = loggerFactory('@growi/slack:middlewares:verify-slack-request');
 
 

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

@@ -1,11 +1,12 @@
-import axios, { AxiosError } from 'axios';
 
 
 import { WebClient } from '@slack/web-api';
 import { WebClient } from '@slack/web-api';
+import axios, { AxiosError } from 'axios';
 
 
-import { generateWebClient } from './webclient-factory';
 import { ConnectionStatus } from '../interfaces/connection-status';
 import { ConnectionStatus } from '../interfaces/connection-status';
-import { requiredScopes } from './required-scopes';
+
 import { markdownSectionBlock } from './block-kit-builder';
 import { markdownSectionBlock } from './block-kit-builder';
+import { requiredScopes } from './required-scopes';
+import { generateWebClient } from './webclient-factory';
 
 
 /**
 /**
  * Check whether the HTTP server responds or not.
  * Check whether the HTTP server responds or not.

+ 3 - 1
packages/slack/src/utils/interaction-payload-accessor.ts

@@ -1,6 +1,8 @@
 import assert from 'assert';
 import assert from 'assert';
-import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+
 import { IChannel } from '../interfaces/channel';
 import { IChannel } from '../interfaces/channel';
+import { IInteractionPayloadAccessor } from '../interfaces/request-from-slack';
+
 import loggerFactory from './logger';
 import loggerFactory from './logger';
 
 
 const logger = loggerFactory('@growi/slack:utils:interaction-payload-accessor');
 const logger = loggerFactory('@growi/slack:utils:interaction-payload-accessor');

+ 2 - 1
packages/slack/src/utils/post-ephemeral-errors.ts

@@ -1,7 +1,8 @@
 import { WebAPICallResult } from '@slack/web-api';
 import { WebAPICallResult } from '@slack/web-api';
-import { respond } from './response-url';
 
 
 import { markdownSectionBlock } from './block-kit-builder';
 import { markdownSectionBlock } from './block-kit-builder';
+import { respond } from './response-url';
+
 
 
 export const respondRejectedErrors = async(
 export const respondRejectedErrors = async(
     rejectedResults: PromiseRejectedResult[],
     rejectedResults: PromiseRejectedResult[],

+ 2 - 1
packages/slack/src/utils/respond-util-factory.ts

@@ -1,7 +1,8 @@
 import axios from 'axios';
 import axios from 'axios';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
-import { RespondBodyForResponseUrl } from '../interfaces/response-url';
+
 import { IRespondUtil } from '../interfaces/respond-util';
 import { IRespondUtil } from '../interfaces/respond-util';
+import { RespondBodyForResponseUrl } from '../interfaces/response-url';
 
 
 type AxiosOptions = {
 type AxiosOptions = {
   headers?: {
   headers?: {

+ 8 - 7
packages/slackbot-proxy/src/Server.ts

@@ -1,26 +1,27 @@
-import { Configuration, Inject, InjectorService } from '@tsed/di';
-import { HttpServer, PlatformApplication } from '@tsed/common';
 import '@tsed/platform-express'; // !! DO NOT MODIFY !!
 import '@tsed/platform-express'; // !! DO NOT MODIFY !!
 import '@tsed/typeorm'; // !! DO NOT MODIFY !! -- https://github.com/tsedio/tsed/issues/1332#issuecomment-837840612
 import '@tsed/typeorm'; // !! DO NOT MODIFY !! -- https://github.com/tsedio/tsed/issues/1332#issuecomment-837840612
 import '@tsed/swagger';
 import '@tsed/swagger';
 
 
+import { createTerminus } from '@godaddy/terminus';
+import { HttpServer, PlatformApplication } from '@tsed/common';
+import { Configuration, Inject, InjectorService } from '@tsed/di';
 import bodyParser from 'body-parser';
 import bodyParser from 'body-parser';
 import compress from 'compression';
 import compress from 'compression';
 import cookieParser from 'cookie-parser';
 import cookieParser from 'cookie-parser';
-import methodOverride from 'method-override';
-import helmet from 'helmet';
 import { Express } from 'express';
 import { Express } from 'express';
 import expressBunyanLogger from 'express-bunyan-logger';
 import expressBunyanLogger from 'express-bunyan-logger';
-
+import helmet from 'helmet';
+import methodOverride from 'method-override';
 import { ConnectionOptions, getConnectionManager } from 'typeorm';
 import { ConnectionOptions, getConnectionManager } from 'typeorm';
-import { createTerminus } from '@godaddy/terminus';
 
 
 import swaggerSettingsForDev from '~/config/swagger/config.dev';
 import swaggerSettingsForDev from '~/config/swagger/config.dev';
 import swaggerSettingsForProd from '~/config/swagger/config.prod';
 import swaggerSettingsForProd from '~/config/swagger/config.prod';
+import loggerFactory from '~/utils/logger';
+
 import { GlobalHttpErrorHandlingMiddleware } from './middlewares/GlobalHttpErrorHandlingMiddleware';
 import { GlobalHttpErrorHandlingMiddleware } from './middlewares/GlobalHttpErrorHandlingMiddleware';
+
 import './filters/CustomHttpErrorFilter';
 import './filters/CustomHttpErrorFilter';
 import './filters/ResourceNotFoundFilter';
 import './filters/ResourceNotFoundFilter';
-import loggerFactory from '~/utils/logger';
 
 
 export const rootDir = __dirname;
 export const rootDir = __dirname;
 const isProduction = process.env.NODE_ENV === 'production';
 const isProduction = process.env.NODE_ENV === 'production';

+ 9 - 12
packages/slackbot-proxy/src/controllers/growi-to-slack.ts

@@ -1,28 +1,25 @@
+import {
+  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient, BlockKitRequest,
+} from '@growi/slack';
+import { ErrorCode, WebAPICallResult } from '@slack/web-api';
 import {
 import {
   Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put, QueryParams,
   Controller, Get, Post, Inject, Req, Res, UseBefore, PathParams, Put, QueryParams,
 } from '@tsed/common';
 } from '@tsed/common';
 import axios from 'axios';
 import axios from 'axios';
-import createError from 'http-errors';
 import { addHours } from 'date-fns';
 import { addHours } from 'date-fns';
+import createError from 'http-errors';
 
 
-import { ErrorCode, WebAPICallResult } from '@slack/web-api';
-
-import {
-  verifyGrowiToSlackRequest, getConnectionStatuses, getConnectionStatus, REQUEST_TIMEOUT_FOR_PTOG, generateWebClient, BlockKitRequest,
-} from '@growi/slack';
-
-import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
 
 
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
 import { GrowiReq } from '~/interfaces/growi-to-slack/growi-req';
+import { WebclientRes, AddWebclientResponseToRes } from '~/middlewares/growi-to-slack/add-webclient-response-to-res';
 import { InstallationRepository } from '~/repositories/installation';
 import { InstallationRepository } from '~/repositories/installation';
-import { RelationRepository } from '~/repositories/relation';
 import { OrderRepository } from '~/repositories/order';
 import { OrderRepository } from '~/repositories/order';
-
+import { RelationRepository } from '~/repositories/relation';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
-import loggerFactory from '~/utils/logger';
-import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
 import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
 import { ActionsBlockPayloadDelegator } from '~/services/growi-uri-injector/ActionsBlockPayloadDelegator';
 import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/SectionBlockPayloadDelegator';
 import { SectionBlockPayloadDelegator } from '~/services/growi-uri-injector/SectionBlockPayloadDelegator';
+import { ViewInteractionPayloadDelegator } from '~/services/growi-uri-injector/ViewInteractionPayloadDelegator';
+import loggerFactory from '~/utils/logger';
 
 
 
 
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');
 const logger = loggerFactory('slackbot-proxy:controllers:growi-to-slack');

+ 11 - 13
packages/slackbot-proxy/src/controllers/slack.ts

@@ -1,11 +1,3 @@
-import {
-  Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
-} from '@tsed/common';
-
-import axios from 'axios';
-
-import { WebAPICallResult } from '@slack/web-api';
-import { Installation } from '@slack/oauth';
 
 
 
 
 import {
 import {
@@ -14,23 +6,29 @@ import {
   parseSlackInteractionRequest, verifySlackRequest,
   parseSlackInteractionRequest, verifySlackRequest,
   respond, supportedGrowiCommands, IChannelOptionalId,
   respond, supportedGrowiCommands, IChannelOptionalId,
 } from '@growi/slack';
 } from '@growi/slack';
+import { Installation } from '@slack/oauth';
+import { WebAPICallResult } from '@slack/web-api';
+import {
+  Controller, Get, Inject, PlatformResponse, Post, Req, Res, UseBefore,
+} from '@tsed/common';
+import axios from 'axios';
 
 
 import { Relation } from '~/entities/relation';
 import { Relation } from '~/entities/relation';
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
 import { SlackOauthReq } from '~/interfaces/slack-to-growi/slack-oauth-req';
-import { InstallationRepository } from '~/repositories/installation';
-import { RelationRepository } from '~/repositories/relation';
-import { OrderRepository } from '~/repositories/order';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
 import { AddSigningSecretToReq } from '~/middlewares/slack-to-growi/add-signing-secret-to-req';
 import {
 import {
   AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware, AuthorizeEventsMiddleware,
   AuthorizeCommandMiddleware, AuthorizeInteractionMiddleware, AuthorizeEventsMiddleware,
 } from '~/middlewares/slack-to-growi/authorizer';
 } from '~/middlewares/slack-to-growi/authorizer';
-import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
 import { ExtractGrowiUriFromReq } from '~/middlewares/slack-to-growi/extract-growi-uri-from-req';
+import { UrlVerificationMiddleware } from '~/middlewares/slack-to-growi/url-verification';
+import { InstallationRepository } from '~/repositories/installation';
+import { OrderRepository } from '~/repositories/order';
+import { RelationRepository } from '~/repositories/relation';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
-import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { LinkSharedService } from '~/services/LinkSharedService';
 import { LinkSharedService } from '~/services/LinkSharedService';
 import { RegisterService } from '~/services/RegisterService';
 import { RegisterService } from '~/services/RegisterService';
 import { RelationsService } from '~/services/RelationsService';
 import { RelationsService } from '~/services/RelationsService';
+import { SelectGrowiService } from '~/services/SelectGrowiService';
 import { UnregisterService } from '~/services/UnregisterService';
 import { UnregisterService } from '~/services/UnregisterService';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { postInstallSuccessMessage, postWelcomeMessageOnce } from '~/utils/welcome-message';
 import { postInstallSuccessMessage, postWelcomeMessageOnce } from '~/utils/welcome-message';

+ 1 - 2
packages/slackbot-proxy/src/controllers/top.ts

@@ -1,10 +1,9 @@
+import { requiredScopes } from '@growi/slack';
 import {
 import {
   Controller, Get, Inject, View,
   Controller, Get, Inject, View,
 } from '@tsed/common';
 } from '@tsed/common';
-
 import readPkgUp from 'read-pkg-up';
 import readPkgUp from 'read-pkg-up';
 
 
-import { requiredScopes } from '@growi/slack';
 import { InstallerService } from '~/services/InstallerService';
 import { InstallerService } from '~/services/InstallerService';
 
 
 const isOfficialMode = process.env.OFFICIAL_MODE === 'true';
 const isOfficialMode = process.env.OFFICIAL_MODE === 'true';

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików