Browse Source

Merge pull request #5649 from weseek/master

Release v5.0.1
Yuki Takei 4 years ago
parent
commit
749d1f6293
100 changed files with 1181 additions and 565 deletions
  1. 7 0
      .devcontainer/Dockerfile
  2. 27 0
      .eslintrc.js
  3. 2 0
      .github/workflows/ci-app-prod.yml
  4. 1 1
      .github/workflows/ci-app.yml
  5. 3 2
      .github/workflows/pr-to-master.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 14 19
      .github/workflows/reusable-app-prod.yml
  8. 5 1
      .github/workflows/reusable-app-reg-suit.yml
  9. 11 0
      CHANGELOG.md
  10. 1 1
      lerna.json
  11. 2 2
      package.json
  12. 2 2
      packages/app/cypress.json
  13. 8 8
      packages/app/package.json
  14. 1 0
      packages/app/resource/locales/en_US/admin/admin.json
  15. 3 1
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  16. 0 0
      packages/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt
  17. 3 1
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  18. 2 1
      packages/app/resource/locales/en_US/translation.json
  19. 1 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  20. 3 1
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  21. 3 1
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  22. 2 1
      packages/app/resource/locales/ja_JP/translation.json
  23. 1 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  24. 3 1
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  25. 0 0
      packages/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt
  26. 3 1
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  27. 2 1
      packages/app/resource/locales/zh_CN/translation.json
  28. 11 0
      packages/app/src/client/services/PersonalContainer.js
  29. 4 1
      packages/app/src/components/Admin/SlackIntegration/BotTypeCard.jsx
  30. 25 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  31. 21 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  32. 24 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  33. 7 7
      packages/app/src/components/Fab.jsx
  34. 7 3
      packages/app/src/components/Me/ApiSettings.jsx
  35. 5 4
      packages/app/src/components/Me/AssociateModal.jsx
  36. 23 3
      packages/app/src/components/Me/BasicInfoSettings.jsx
  37. 11 4
      packages/app/src/components/Me/EditorSettings.tsx
  38. 12 4
      packages/app/src/components/Me/ExternalAccountLinkedMe.jsx
  39. 4 2
      packages/app/src/components/Me/InAppNotificationSettings.tsx
  40. 8 3
      packages/app/src/components/Me/PasswordSettings.jsx
  41. 8 4
      packages/app/src/components/Me/PersonalSettings.jsx
  42. 4 3
      packages/app/src/components/Me/UserSettings.jsx
  43. 27 8
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  44. 7 8
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  45. 18 17
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  46. 7 10
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  47. 5 3
      packages/app/src/components/Page/DisplaySwitcher.tsx
  48. 4 4
      packages/app/src/components/PageCreateModal.jsx
  49. 25 34
      packages/app/src/components/PageList/PageListItemL.tsx
  50. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  51. 5 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  52. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  53. 5 1
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  54. 2 0
      packages/app/src/components/Sidebar/Tag.tsx
  55. 2 6
      packages/app/src/components/TagCloudBox.tsx
  56. 0 126
      packages/app/src/components/TagsList.jsx
  57. 85 0
      packages/app/src/components/TagsList.tsx
  58. 8 0
      packages/app/src/interfaces/tag.ts
  59. 25 0
      packages/app/src/migrations/20220411114257-set-sparse-option-to-slack-member-id.js
  60. 5 4
      packages/app/src/server/models/obsolete-page.js
  61. 11 2
      packages/app/src/server/models/password-reset-order.ts
  62. 12 3
      packages/app/src/server/models/user-registration-order.ts
  63. 17 0
      packages/app/src/server/models/user.js
  64. 14 9
      packages/app/src/server/routes/apiv3/forgot-password.js
  65. 2 0
      packages/app/src/server/routes/apiv3/personal-setting.js
  66. 1 1
      packages/app/src/server/routes/apiv3/user-group.js
  67. 8 1
      packages/app/src/server/routes/user-activation.ts
  68. 1 1
      packages/app/src/server/service/page-grant.ts
  69. 52 20
      packages/app/src/server/service/page.ts
  70. 13 25
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  71. 2 2
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  72. 37 4
      packages/app/src/server/service/slack-command-handler/keep.js
  73. 1 1
      packages/app/src/server/views/layout-growi/user_page.html
  74. 1 1
      packages/app/src/server/views/layout/layout.html
  75. 14 0
      packages/app/src/stores/tag.tsx
  76. 14 16
      packages/app/src/stores/ui.tsx
  77. 5 0
      packages/app/src/styles/_page-accessories-control.scss
  78. 2 2
      packages/app/src/styles/_page-tree.scss
  79. 0 4
      packages/app/src/styles/_search.scss
  80. 12 0
      packages/app/src/styles/style-app.scss
  81. 3 2
      packages/app/src/utils/project-dir-utils.ts
  82. 5 3
      packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts
  83. 0 14
      packages/app/test/cypress/integration/2-basic-features/use-tools.spec.ts
  84. 2 0
      packages/app/test/cypress/integration/3-search/search.spec.ts
  85. 5 0
      packages/app/test/cypress/integration/4-admin/access-to-admin-page.spec.ts
  86. 0 32
      packages/app/test/cypress/integration/6-home-settings/access-home.spec.ts
  87. 0 85
      packages/app/test/cypress/integration/6-home-settings/access-user-settings.spec.ts
  88. 114 0
      packages/app/test/cypress/integration/6-home/home.spec.ts
  89. 11 0
      packages/app/test/cypress/plugins/index.ts
  90. 283 3
      packages/app/test/integration/service/v5.migration.test.js
  91. 1 1
      packages/codemirror-textlint/package.json
  92. 2 2
      packages/codemirror-textlint/src/index.ts
  93. 1 1
      packages/core/package.json
  94. 3 2
      packages/core/src/index.js
  95. 1 1
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  96. 7 0
      packages/core/src/utils/browser-utils.ts
  97. 1 0
      packages/core/src/utils/page-path-utils.ts
  98. 1 1
      packages/plugin-attachment-refs/package.json
  99. 1 1
      packages/plugin-attachment-refs/src/client-entry.js
  100. 4 5
      packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx

+ 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 *
 # *************************************************************
 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 \
    && apt-get -y install --no-install-recommends git-lfs \
+      # Chrome
+      google-chrome-stable \
       # for Cypress
       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: {
     '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',
     indent: [
       'error',

+ 2 - 0
.github/workflows/ci-app-prod.yml

@@ -24,6 +24,7 @@ jobs:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
     with:
       node-version: 16.x
+      skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -38,6 +39,7 @@ jobs:
 
     with:
       node-version: 16.x
+      skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:
       REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}

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

@@ -102,7 +102,7 @@ jobs:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
       - name: Upload coverage report as artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: Coverage Report
           path: packages/app/coverage

+ 3 - 2
.github/workflows/pr-to-master.yml

@@ -13,7 +13,8 @@ jobs:
   auto-labeling:
     runs-on: ubuntu-latest
 
-    if: ${{ !contains(github.event.pull_request.labels.*.name, 'exclude from changelog') }}
+    if: |
+      !contains(github.event.pull_request.labels.*.name, 'exclude from changelog')
 
     steps:
       - uses: release-drafter/release-drafter@v5
@@ -30,7 +31,7 @@ jobs:
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:
-      - uses: amannn/action-semantic-pull-request@v3.4.5
+      - uses: amannn/action-semantic-pull-request@v4.2.0
         with:
           types: |
             feat

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

@@ -81,7 +81,7 @@ jobs:
         mv /tmp/.buildx-cache-new /tmp/.buildx-cache
 
     - name: Add tag
-      uses: anothrNick/github-tag-action@1.36.0
+      uses: anothrNick/github-tag-action@1.38.0
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         CUSTOM_TAG: v${{ steps.package-json.outputs.packageVersion }}

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

@@ -63,13 +63,13 @@ jobs:
         echo ::set-output name=file::production.tar
 
     - name: Upload production files as artifact
-      uses: actions/upload-artifact@v2
+      uses: actions/upload-artifact@v3
       with:
         name: Production Files
         path: ${{ steps.archive-prod-files.outputs.file }}
 
     - name: Upload report as artifact
-      uses: actions/upload-artifact@v2
+      uses: actions/upload-artifact@v3
       with:
         name: Bundle Analyzing Report
         path: packages/app/report/bundle-analyzer.html
@@ -140,7 +140,7 @@ jobs:
         npx lerna bootstrap -- --production
 
     - name: Download production files artifact
-      uses: actions/download-artifact@v2
+      uses: actions/download-artifact@v3
       with:
         name: Production Files
 
@@ -175,17 +175,12 @@ jobs:
     if: ${{ !inputs.skip-cypress }}
 
     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:
       fail-fast: false
       matrix:
         # 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:
       mongodb:
@@ -202,10 +197,11 @@ jobs:
     steps:
     - 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
       uses: actions/cache@v3
@@ -213,7 +209,6 @@ jobs:
         path: |
           **/node_modules
           ~/.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') }}
         restore-keys: |
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-${{ hashFiles('**/yarn.lock') }}-
@@ -224,7 +219,7 @@ jobs:
         npx lerna bootstrap -- --frozen-lockfile
 
     - name: Download production files artifact
-      uses: actions/download-artifact@v2
+      uses: actions/download-artifact@v3
       with:
         name: Production Files
 
@@ -252,18 +247,18 @@ jobs:
     - name: Cypress Run
       uses: cypress-io/github-action@v3
       with:
+        browser: chrome
         working-directory: ./packages/app
-        install: false
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
         start: yarn server
         wait-on: 'http://localhost:3000'
       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
       if: always()
-      uses: actions/upload-artifact@v2
+      uses: actions/upload-artifact@v3
       with:
         name: ${{ inputs.cypress-report-artifact-name }}
         path: |

+ 5 - 1
.github/workflows/reusable-app-reg-suit.yml

@@ -9,6 +9,8 @@ on:
       checkout-ref:
         type: string
         default: ${{ github.head_ref }}
+      skip-reg-suit:
+        type: boolean
       cypress-report-artifact-name:
         required: true
         type: string
@@ -33,6 +35,8 @@ jobs:
     # https://github.com/weseek/growi/settings/environments/376165508/edit
     environment: VRT
 
+    if: ${{ !inputs.skip-reg-suit }}
+
     env:
       REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
       AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
@@ -71,7 +75,7 @@ jobs:
         npx lerna bootstrap -- --frozen-lockfile
 
     - name: Download screenshots taken by cypress
-      uses: actions/download-artifact@v2
+      uses: actions/download-artifact@v3
       with:
         name: ${{ inputs.cypress-report-artifact-name }}
         path: packages/app/test/cypress

+ 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 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
 
 ### 🚀 Improvement

+ 1 - 1
lerna.json

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.0",
+  "version": "5.0.1-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -59,7 +59,7 @@
     "@typescript-eslint/parser": "^4.28.5",
     "cypress": "^9.2.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-plugin-import": "^2.23.4",
     "eslint-plugin-jest": "^24.3.2",

+ 2 - 2
packages/app/cypress.json

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

+ 8 - 8
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.0",
+  "version": "5.0.1-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -28,7 +28,7 @@
     "dev:migrate:status": "yarn dev:migrate-mongo status",
     "dev:migrate:up": "yarn dev:migrate-mongo up",
     "dev:migrate:down": "yarn dev:migrate-mongo down",
-    "cy:run": "cypress run --headless",
+    "cy:run": "cypress run --browser chrome",
     "//// for CI": "",
     "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
     "predev:ci": "run-p resources:*",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.0",
-    "@growi/plugin-attachment-refs": "^5.0.0",
-    "@growi/plugin-lsx": "^5.0.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.0",
-    "@growi/slack": "^5.0.0",
+    "@growi/codemirror-textlint": "^5.0.1-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.1-RC.0",
+    "@growi/plugin-lsx": "^5.0.1-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.1-RC.0",
+    "@growi/slack": "^5.0.1-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.0",
+    "@growi/ui": "^5.0.1-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

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

@@ -472,6 +472,7 @@
   "user_group_management": {
     "create_group": "Create new 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.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",

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

@@ -2,9 +2,11 @@ Password Reset
 
 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.
 
 {{ url }}
 
+This link will expire in 10 minutes at  {{ expiredAt }}.
+
 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 }}
 
-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.
 
 {{ url }}
 
+This link will expire in 1 hour at  {{ expiredAt }}.
+
 If you did not created the account, you can safely ignore this email.

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

@@ -171,6 +171,7 @@
   "you_can_not_create_page_with_this_name": "You can not create page with this name",
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
+  "Successfully requested": "Successfully requested.",
   "personal_dropdown": {
     "home": "Home",
     "settings": "Settings",
@@ -687,7 +688,7 @@
     "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.",
     "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",
     "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}}",

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

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

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

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

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

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

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

@@ -173,6 +173,7 @@
   "you_can_not_create_page_with_this_name": "この名前でページを作成することはできません",
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
+  "Successfully requested": "正常に処理を受け付けました",
   "personal_dropdown": {
     "home": "ホーム",
     "settings": "設定",
@@ -686,7 +687,7 @@
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_caution": "この値を変更した後は、サーバーを再起動する必要があります。",
     "forced_update_desc": "設定が強制変更されました。前回の設定: ",
-    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 誰でも可能 > 管理者とページ作者が可能 > 管理者のみ可能",
+    "page_delete_rights_caution": "「(子孫ページを含む)ゴミ箱に入れる操作 / 完全に削除する」の権限は、「ゴミ箱に入れる操作 / 完全に削除する」よりも強い権限になるように強制されます。 <br><br> 管理者のみ可能 > 管理者とページ作者が可能 > 誰でも可能",
     "Authentication mechanism settings": "認証機構設定",
     "setup_is_not_yet_complete":"セットアップはまだ完了してません",
     "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": {
     "create_group": "创建新组",
     "add_child_group": "添加一个子组",
+    "remove_child_group": "移除",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",

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

@@ -2,9 +2,11 @@
 
 嗨,{{ email }}
 
-已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+已收到更改您 GROWI ({{appTitle}}) 帐户 密码的请求。
 要重置密码,请单击下面的链接。
 
 {{ 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 }},
 
-已使用 GROWI {{ appTitle }} 创建帐户。
+已使用 GROWI ({{ appTitle }}) 创建帐户。
 单击下面的链接以激活您的帐户。
 
 {{ url }}
 
+这个链接将在1小时后即{{ expiredAt }}失效。
+
 如果您尚未创建,请忽略此电子邮件。

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

@@ -179,6 +179,7 @@
   "you_can_not_create_page_with_this_name": "您无法使用此名称创建页面",
   "not_allowed_to_see_this_page": "你不能看到这个页面",
   "Confirm": "确定",
+  "Successfully requested": "进程成功接受",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -645,7 +646,7 @@
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_caution": "修改该值后需要重启服务器。",
     "forced_update_desc": "设置已被强行更改。以前的设置: ",
-    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 任何人 > 管理员|作者 > 仅管理员",
+    "page_delete_rights_caution": "\"删除/全部删除\"权限(包括后代页面)被强制强于\"删除/完全删除\"权限。 <br> <br> 仅管理员 > 管理员|作者 > 何人",
 		"Authentication mechanism settings": "身份验证机制设置",
 		"setup_is_not_yet_complete": "安装尚未完成",
 		"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),
       externalAccounts: [],
       apiToken: '',
+      slackMemberId: '',
     };
 
   }
@@ -55,6 +56,7 @@ export default class PersonalContainer extends Container {
         lang: currentUser.lang,
         isGravatarEnabled: currentUser.isGravatarEnabled,
         apiToken: currentUser.apiToken,
+        slackMemberId: currentUser.slackMemberId,
       });
     }
     catch (err) {
@@ -114,6 +116,13 @@ export default class PersonalContainer extends Container {
     this.setState({ email: inputValue });
   }
 
+  /**
+   * Change Slack Member ID
+   */
+  changeSlackMemberId(inputValue) {
+    this.setState({ slackMemberId: inputValue });
+  }
+
   /**
    * Change isEmailPublished
    */
@@ -147,6 +156,7 @@ export default class PersonalContainer extends Container {
         email: this.state.email,
         isEmailPublished: this.state.isEmailPublished,
         lang: this.state.lang,
+        slackMemberId: this.state.slackMemberId,
       });
       const { updatedUser } = response.data;
 
@@ -155,6 +165,7 @@ export default class PersonalContainer extends Container {
         email: updatedUser.email,
         isEmailPublished: updatedUser.isEmailPublished,
         lang: updatedUser.lang,
+        slackMemberId: updatedUser.slackMemberId,
       });
     }
     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-text">
           <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">
               <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="" />

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

@@ -1,6 +1,7 @@
 import React, {
   FC, useState, useCallback, useEffect,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
@@ -16,6 +17,7 @@ type Props = {
   childUserGroups: IUserGroupHasId[],
   isAclEnabled: boolean,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  onRemove?: (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) {
       return;
     }
@@ -86,6 +88,25 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     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
     if (props.onDelete == null) {
       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}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </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}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>

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

@@ -267,6 +267,26 @@ const UserGroupDetailPage: FC = () => {
     }
   }, [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
    */
@@ -337,6 +357,7 @@ const UserGroupDetailPage: FC = () => {
         childUserGroups={grandChildUserGroups}
         isAclEnabled={isAclEnabled ?? false}
         onEdit={showUpdateModal}
+        onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         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 */}
         { !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') }
           </DropdownItem>
         ) }
 
         {/* Duplicate */}
         { !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')}
           </DropdownItem>
         ) }
 
         {/* Move/Rename */}
         { !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')}
           </DropdownItem>
         ) }
 
         {/* Revert */}
         { !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')}
           </DropdownItem>
         ) }
@@ -177,12 +191,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
-              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
               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')}
             </DropdownItem>
           </>

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

@@ -1,18 +1,18 @@
 import React, { useState, useCallback, useEffect } from 'react';
+
 import PropTypes from 'prop-types';
 import StickyEvents from 'sticky-events';
-import loggerFactory from '~/utils/logger';
 
 
 import AppContainer from '~/client/services/AppContainer';
-
-import { usePageCreateModal } from '~/stores/modal';
 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 ReturnTopIcon from './Icons/ReturnTopIcon';
-import { useCurrentPagePath } from '~/stores/context';
+import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:cli:Fab');
 
@@ -55,7 +55,7 @@ const Fab = (props) => {
   function renderPageCreateButton() {
     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
             type="button"
             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 (
-    <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()}
       <div data-testid="grw-fab-return-to-top" className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button

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

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

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

@@ -1,21 +1,22 @@
 
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-
 import {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
 } from 'reactstrap';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
+
 
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
 import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 class AssociateModal extends React.Component {
 
@@ -70,7 +71,7 @@ class AssociateModal extends React.Component {
     const { t } = this.props;
 
     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}>
           { t('admin:user_management.create_external_account') }
         </ModalHeader>

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

@@ -1,14 +1,15 @@
 
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 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 { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import PersonalContainer from '~/client/services/PersonalContainer';
 
 class BasicInfoSettings extends React.Component {
 
@@ -128,10 +129,29 @@ class BasicInfoSettings extends React.Component {
             }
           </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="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')}
             </button>
           </div>

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

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

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

@@ -1,16 +1,19 @@
 
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
 
 import AppContainer from '~/client/services/AppContainer';
 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 DisassociateModal from './DisassociateModal';
+import ExternalAccountRow from './ExternalAccountRow';
 
 class ExternalAccountLinkedMe extends React.Component {
 
@@ -68,7 +71,12 @@ class ExternalAccountLinkedMe extends React.Component {
     return (
       <Fragment>
         <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" />
             Add
           </button>

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

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

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

@@ -1,13 +1,14 @@
 
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
 import AppContainer from '~/client/services/AppContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 class PasswordSettings extends React.Component {
@@ -98,6 +99,9 @@ class PasswordSettings extends React.Component {
           <div className="row mb-3">
             <label htmlFor="oldPassword" className="col-md-3 text-md-right">{ t('personal_settings.current_password') }</label>
             <div className="col-md-5">
+              {/* to prevent autocomplete username into userForm[email] in BasicInfoSettings component */}
+              {/* https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion */}
+              <input type="password" autoComplete="new-password" style={{ display: 'none' }} />
               <input
                 className="form-control"
                 type="password"
@@ -138,6 +142,7 @@ class PasswordSettings extends React.Component {
         <div className="row my-3">
           <div className="offset-5">
             <button
+              data-testid="grw-password-settings-update-button"
               type="button"
               className="btn btn-primary"
               onClick={this.onClickSubmit}

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

@@ -1,15 +1,17 @@
 
 import React, { useMemo } from 'react';
+
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
-import UserSettings from './UserSettings';
-import PasswordSettings from './PasswordSettings';
-import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
+
 import ApiSettings from './ApiSettings';
 import { EditorSettings } from './EditorSettings';
+import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import InAppNotificationSettings from './InAppNotificationSettings';
+import PasswordSettings from './PasswordSettings';
+import UserSettings from './UserSettings';
 
 const PersonalSettings = (props) => {
 
@@ -58,7 +60,9 @@ const PersonalSettings = (props) => {
 
 
   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 { withTranslation } from 'react-i18next';
 
@@ -12,7 +13,7 @@ class UserSettings extends React.Component {
     const { t } = this.props;
 
     return (
-      <Fragment>
+      <div data-testid="grw-user-settings">
         <div className="mb-5">
           <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
           <BasicInfoSettings />
@@ -21,7 +22,7 @@ class UserSettings extends React.Component {
           <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
           <ProfileImageSettings />
         </div>
-      </Fragment>
+      </div>
     );
   }
 

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

@@ -75,14 +75,20 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openPresentationModal(hrefForPresentationModal)}
         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') }
       </DropdownItem>
 
       {/* 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')}
       </DropdownItem>
 
@@ -95,31 +101,44 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         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')}
       </DropdownItem>
 
       <DropdownItem
         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')}
       </DropdownItem>
 
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         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')}
       </DropdownItem>
 
       <DropdownItem divider />
 
       {/* 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>
     </>
   );

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

@@ -1,22 +1,21 @@
 import React, { FC, memo } from 'react';
-import PropTypes from 'prop-types';
 
+import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
-
 import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import { IUser } from '~/interfaces/user';
-import { useIsDeviceSmallerThanMd } from '~/stores/ui';
-import { usePageCreateModal } from '~/stores/modal';
 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 InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
-import PersonalDropdown from './PersonalDropdown';
 import GlobalSearch from './GlobalSearch';
-import InAppNotificationDropdown from '../InAppNotification/InAppNotificationDropdown';
+import PersonalDropdown from './PersonalDropdown';
 
 
 type NavbarRightProps = {
@@ -52,7 +51,7 @@ const NavbarRight: FC<NavbarRightProps> = memo((props: NavbarRightProps) => {
         </button>
       </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 />
       </li>
     </>

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

@@ -48,16 +48,15 @@ function PageEditorModeManager(props) {
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
-    if (isBtnDisabled) {
+    if (isBtnDisabled || !isHackmdEnabled) {
       return;
     }
     if (onPageEditorModeButtonClicked != null) {
       onPageEditorModeButtonClicked(viewType);
     }
-  }, [isBtnDisabled, onPageEditorModeButtonClicked]);
+  }, [isBtnDisabled, isHackmdEnabled, onPageEditorModeButtonClicked]);
 
   return (
     <>
@@ -88,15 +87,22 @@ function PageEditorModeManager(props) {
           />
         )}
         {(!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>
       {isBtnDisabled && (
@@ -104,11 +110,6 @@ function PageEditorModeManager(props) {
           {t('Not available for guest')}
         </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 PropTypes from 'prop-types';
 
+import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { UserPicture } from '@growi/ui';
 
-import { useUserUISettings } from '~/client/services/user-ui-settings';
 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 {
   isUserPreferenceExists,
   isDarkMode as isDarkModeByUtil,
@@ -21,12 +16,14 @@ import {
   updateUserPreference,
   updateUserPreferenceWithOsSettings,
 } 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 SidebarDockIcon from '../Icons/SidebarDockIcon';
+import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SunIcon from '../Icons/SunIcon';
+import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 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"
                         onClick={() => openDescendantPageListModal(currentPath)}
                       >
-                        <PageListIcon />
+                        <div className="grw-page-accessories-control-icon">
+                          <PageListIcon />
+                        </div>
                         {t('page_list')}
                         <span></span> {/* for a count badge */}
                       </button>
@@ -79,13 +81,13 @@ const DisplaySwitcher = (): JSX.Element => {
 
                   {/* Comments */}
                   { getCommentListDom != null && !isTopPagePath && (
-                    <div className="mt-2">
+                    <div className="grw-page-accessories-control mt-2">
                       <button
                         type="button"
                         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)}
                       >
-                        <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></span> {/* for a count badge */}
                       </button>

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

@@ -1,4 +1,3 @@
-
 import React, {
   useEffect, useState, useMemo, useCallback,
 } from 'react';
@@ -35,7 +34,8 @@ const PageCreateModal = (props) => {
   const isReachable = config.isSearchServiceReachable;
   const pathname = path || '';
   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 [todayInput1, setTodayInput1] = useState(t('Memo'));
@@ -46,8 +46,8 @@ const PageCreateModal = (props) => {
 
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   useEffect(() => {
-    setPageNameInput(isCreatablePage(pathname) ? pathUtils.addTrailingSlash(pathname) : '/');
-  }, [pathname]);
+    setPageNameInput(isCreatable ? pathUtils.addTrailingSlash(pathname) : '/');
+  }, [pathname, isCreatable]);
 
   const checkIsUsersHomePageDebounce = useMemo(() => {
     const checkIsUsersHomePage = () => {

+ 25 - 34
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,26 +1,20 @@
 import React, {
-  forwardRef,
-  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef,
+  forwardRef, useState,
+  ForwardRefRenderFunction, memo, useCallback, useImperativeHandle, useRef, useEffect,
 } from 'react';
 
-import { useTranslation } from 'react-i18next';
-import { CustomInput } from 'reactstrap';
 
-import Clamp from 'react-multiline-clamp';
+import { DevidedPagePath } from '@growi/core';
+import { UserPicture, PageListMeta } from '@growi/ui';
 import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+import Clamp from 'react-multiline-clamp';
+import { CustomInput } from 'reactstrap';
 import urljoin from 'url-join';
 
-import { UserPicture, PageListMeta } from '@growi/ui';
-import { DevidedPagePath } from '@growi/core';
-
-import { useSWRxPageInfo } from '../../stores/page';
 
 import { ISelectable } from '~/client/interfaces/selectable-all';
 import { bookmark, unbookmark } from '~/client/services/page-operation';
-import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import {
-  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
-} from '~/stores/modal';
 import {
   IPageInfoAll, IPageInfoForEntity, IPageInfoForListing, IPageWithMeta, isIPageInfoForListing, isIPageInfoForEntity,
 } from '~/interfaces/page';
@@ -29,7 +23,12 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
+import {
+  usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
+} from '~/stores/modal';
+import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 
+import { useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
@@ -49,13 +48,15 @@ type Props = {
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
-    // todo: refactoring variable name to clear what changed
     page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
     forceHideMenuItems,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
   } = props;
 
+  const [likerCount, setLikerCount] = useState(pageData.liker.length);
+  const [bookmarkCount, setBookmarkCount] = useState(pageMeta && pageMeta.bookmarkCount ? pageMeta.bookmarkCount : 0);
+
   const { t } = useTranslation();
 
   const inputRef = useRef<HTMLInputElement>(null);
@@ -97,6 +98,14 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   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
   const clickHandler = useCallback(() => {
@@ -147,22 +156,6 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   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 canRenderRevisionSnippet = revisionShortBody != null;
 
@@ -170,11 +163,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     <li
       key={pageData._id}
       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">
           {/* checkbox */}
           {onCheckboxChanged != null && (

+ 1 - 1
packages/app/src/components/PrivateLegacyPages.tsx

@@ -204,7 +204,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     openModal(
       selectedPages,
       () => {
-        toastSuccess('success');
+        toastSuccess(t('Successfully requested'));
         closeModal();
         mutate();
       },

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

@@ -42,8 +42,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
   return (
     // 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')}
     </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">
           <button
             type="button"
-            className="btn dropdown-toggle search-sort-option-btn py-1"
+            className="btn dropdown-toggle py-1"
             data-toggle="dropdown"
           >
             <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) => {
   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}
       </span>
     </>
@@ -195,6 +195,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
       await mutateChildren();
 
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
       // force open
       setIsOpen(true);
     }

+ 2 - 0
packages/app/src/components/Sidebar/Tag.tsx

@@ -1,5 +1,7 @@
 import React, { FC, useState, useEffect } from 'react';
+
 import { useTranslation } from 'react-i18next';
+
 import TagsList from '../TagsList';
 
 const Tag: FC = () => {

+ 2 - 6
packages/app/src/components/TagCloudBox.tsx

@@ -2,14 +2,10 @@ import React, { FC } from 'react';
 
 import { TagCloud } from 'react-tagcloud';
 
-type Tag = {
-  _id: string,
-  name: string,
-  count: number,
-}
+import { ITagHasCount } from '~/interfaces/tag';
 
 type Props = {
-  tags:Tag[],
+  tags: ITagHasCount[],
   minSize?: number,
   maxSize?: number,
 }

+ 0 - 126
packages/app/src/components/TagsList.jsx

@@ -1,126 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import PaginationWrapper from './PaginationWrapper';
-import TagCloudBox from './TagCloudBox';
-import { apiGet } from '../client/util/apiv1-client';
-import { toastError } from '../client/util/apiNotification';
-
-class TagsList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      tagData: [],
-      activePage: 1,
-      totalTags: 0,
-      pagingLimit: 10,
-    };
-
-    this.handlePage = this.handlePage.bind(this);
-    this.getTagList = this.getTagList.bind(this);
-  }
-
-  async componentWillMount() {
-    await this.getTagList(1);
-  }
-
-  async componentDidUpdate() {
-    if (this.props.isOnReload) {
-      await this.getTagList(this.state.activePage);
-    }
-  }
-
-  async handlePage(selectedPage) {
-    await this.getTagList(selectedPage);
-  }
-
-  async getTagList(selectPageNumber) {
-    const limit = this.state.pagingLimit;
-    const offset = (selectPageNumber - 1) * limit;
-    let res;
-
-    try {
-      res = await apiGet('/tags.list', { limit, offset });
-    }
-    catch (error) {
-      toastError(error);
-    }
-
-    const totalTags = res.totalCount;
-    const tagData = res.data;
-    const activePage = selectPageNumber;
-
-    this.setState({
-      tagData,
-      activePage,
-      totalTags,
-    });
-  }
-
-  /**
-   * generate Elements of Tag
-   *
-   * @param {any} pages Array of pages Model Obj
-   *
-   */
-  generateTagList(tagData) {
-    return tagData.map((data) => {
-      return (
-        <a key={data.name} href={`/_search?q=tag:${data.name}`} className="list-group-item">
-          <i className="icon-tag mr-2"></i>{data.name}
-          <span className="ml-4 list-tag-count badge badge-secondary text-muted">{data.count}</span>
-        </a>
-      );
-    });
-  }
-
-  render() {
-    const { t } = this.props;
-    const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
-
-    return (
-      <>
-        <header className="py-0">
-          <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${this.state.totalTags})`}</h1>
-        </header>
-        <div className="row text-center">
-          <div className="col-12 mb-5 px-5">
-            <TagCloudBox tags={this.state.tagData} minSize={20} />
-          </div>
-          <div className="col-12 tag-list mb-4">
-            <ul className="list-group text-left">
-              {this.generateTagList(this.state.tagData)}
-            </ul>
-            {messageForNoTag}
-          </div>
-          <div className="col-12 tag-list-pagination">
-            <PaginationWrapper
-              activePage={this.state.activePage}
-              changePage={this.handlePage}
-              totalItemsCount={this.state.totalTags}
-              pagingLimit={this.state.pagingLimit}
-              align="center"
-              size="md"
-            />
-          </div>
-        </div>
-      </>
-    );
-  }
-
-}
-
-TagsList.propTypes = {
-  isOnReload: PropTypes.bool,
-  t: PropTypes.func.isRequired, // i18next
-};
-
-TagsList.defaultProps = {
-  isOnReload: false,
-};
-
-export default withTranslation()(TagsList);

+ 85 - 0
packages/app/src/components/TagsList.tsx

@@ -0,0 +1,85 @@
+import React, { FC, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { useSWRxTagsList } from '~/stores/tag';
+
+import PaginationWrapper from './PaginationWrapper';
+import TagCloudBox from './TagCloudBox';
+
+
+const PAGING_LIMIT = 10;
+
+type Props = {
+  isOnReload: boolean
+}
+
+const TagsList: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  const [activePage, setActivePage] = useState<number>(1);
+  const [pagingOffset, setPagingOffset] = useState<number>(0);
+
+  const { data: tagsList, error, mutate } = useSWRxTagsList(PAGING_LIMIT, pagingOffset);
+
+  const handlePage = (selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setPagingOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  };
+
+  useEffect(() => {
+    if (props.isOnReload) {
+      mutate();
+    }
+  }, [mutate, props.isOnReload]);
+
+  const isLoading = tagsList === undefined && error == null;
+  if (isLoading) {
+    return (
+      <div className="text-muted text-center">
+        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <header className="py-0">
+        <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
+      </header>
+      <div className="row text-center">
+        <div className="col-12 mb-5 px-5">
+          <TagCloudBox tags={tagsList?.data || []} minSize={20} />
+        </div>
+        <div className="col-12 tag-list mb-4">
+          <ul className="list-group text-left">
+            {
+              tagsList?.data != null && tagsList.data.length > 0
+                ? tagsList.data.map((tag) => {
+                  return (
+                    <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="list-group-item">
+                      <i className="icon-tag mr-2"></i>{tag.name}
+                      <span className="ml-4 list-tag-count badge badge-secondary text-muted">{tag.count}</span>
+                    </a>
+                  );
+                })
+                : <h3>{ t('You have no tag, You can set tags on pages') }</h3>
+            }
+          </ul>
+        </div>
+        <div className="col-12 tag-list-pagination">
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={handlePage}
+            totalItemsCount={tagsList?.totalCount || 0}
+            pagingLimit={PAGING_LIMIT}
+            align="center"
+            size="md"
+          />
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default TagsList;

+ 8 - 0
packages/app/src/interfaces/tag.ts

@@ -3,7 +3,15 @@ export type ITag = {
   createdAt: Date;
 }
 
+export type ITagHasCount = ITag & { count: number }
+
 export type ITagsSearchApiv1Result = {
   ok: boolean,
   tags: string[]
 }
+
+export type ITagsListApiv1Result = {
+  ok: boolean,
+  data: ITagHasCount[],
+  totalCount: number,
+}

+ 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 */
 
 const debug = require('debug')('growi:models:page');
+
 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 mongoose = require('mongoose');
+const urljoin = require('url-join');
 
 const { isTopPage, isTrashPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
@@ -253,7 +254,7 @@ export const getPageSchema = (crowi) => {
 
     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);
     }
 

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

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

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

@@ -1,7 +1,8 @@
-import mongoose, {
+import {
   Schema, Model, Document,
 } from 'mongoose';
 
+import { addHours } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
 import crypto from 'crypto';
 import { getOrCreateModel } from '@growi/core';
@@ -24,12 +25,20 @@ export interface UserRegistrationOrderModel extends Model<UserRegistrationOrderD
   createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
 }
 
+const expiredAt = (): Date => {
+  return addHours(new Date(), 1);
+};
+
 const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
   token: { type: String, required: true, unique: true },
   email: { type: String, 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);
 

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

@@ -47,6 +47,7 @@ module.exports = function(crowi) {
     name: { type: String },
     username: { type: String, required: true, unique: true },
     email: { type: String, unique: true, sparse: true },
+    slackMemberId: { type: String, unique: true, sparse: true },
     // === Crowi settings
     // username: { type: String, index: true },
     // email: { type: String, required: true, index: true },
@@ -689,6 +690,22 @@ module.exports = function(crowi) {
     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 {
 
     constructor() {

+ 14 - 9
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -1,18 +1,20 @@
+import { format, subSeconds } from 'date-fns';
 import rateLimit from 'express-rate-limit';
 
+import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
 import ErrorV3 from '~/server/models/vo/error-apiv3';
-import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import loggerFactory from '~/utils/logger';
 
-import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
-import httpErrorHandler from '../../middlewares/http-error-handler';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import httpErrorHandler from '../../middlewares/http-error-handler';
+import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { body } = require('express-validator');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 const router = express.Router();
@@ -45,23 +47,23 @@ module.exports = (crowi) => {
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
-  async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
+  async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
     return mailService.send({
       to: email,
-      subject: txtFileName,
+      subject: '[GROWI] Password Reset',
       template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
       vars: {
         appTitle: appService.getAppTitle(),
         email,
         url,
+        expiredAt,
       },
     });
   }
 
   router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
     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();
 
     try {
@@ -76,7 +78,10 @@ module.exports = (crowi) => {
       const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
       const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
       const oneTimeUrl = url.href;
-      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl);
+      const grwTzoffsetSec = crowi.appService.getTzoffset() * 60;
+      const expiredAt = subSeconds(passwordResetOrderData.expiredAt, grwTzoffsetSec);
+      const formattedExpiredAt = format(expiredAt, 'yyyy/MM/dd HH:mm');
+      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, formattedExpiredAt);
       return res.apiv3();
     }
     catch (err) {
@@ -91,7 +96,7 @@ module.exports = (crowi) => {
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
-    const i18n = req.language || grobalLang;
+    const i18n = grobalLang || req.language;
     const { newPassword } = req.body;
 
     const user = await User.findOne({ email });

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

@@ -84,6 +84,7 @@ module.exports = (crowi) => {
         }),
       body('lang').isString().isIn(listLocaleIds()),
       body('isEmailPublished').isBoolean(),
+      body('slackMemberId').optional().isString(),
     ],
     imageType: [
       body('isGravatarEnabled').isBoolean(),
@@ -226,6 +227,7 @@ module.exports = (crowi) => {
       user.email = req.body.email;
       user.lang = req.body.lang;
       user.isEmailPublished = req.body.isEmailPublished;
+      user.slackMemberId = req.body.slackMemberId;
 
       const updatedUser = await user.save();
       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: [
       body('name', 'Group name must be a string').optional().trim().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(),
     ],
     delete: [

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

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

+ 1 - 1
packages/app/src/server/service/page-grant.ts

@@ -74,7 +74,7 @@ class PageGrantService {
     // GRANT_OWNER
     else if (ancestor.grant === Page.GRANT_OWNER) {
       if (target.grantedUserIds?.length !== 1) {
-        throw Error('grantedUserIds must have one user');
+        return false;
       }
 
       if (target.grant !== Page.GRANT_OWNER) { // only GRANT_OWNER page can exist under GRANT_OWNER page

+ 52 - 20
packages/app/src/server/service/page.ts

@@ -2294,23 +2294,25 @@ class PageService {
       throw Error('Restricted pages can not be migrated');
     }
 
-    let updatedPage;
+    let normalizedPage;
 
     // replace if empty page exists
     if (existingPage != null && existingPage.isEmpty) {
-      await Page.replaceTargetWithPage(existingPage, page, true);
-      updatedPage = await Page.findById(page._id);
+      // Inherit descendantCount from the empty page
+      const updatedPage = await Page.findOneAndUpdate({ _id: page._id }, { descendantCount: existingPage.descendantCount }, { new: true });
+      await Page.replaceTargetWithPage(existingPage, updatedPage, true);
+      normalizedPage = await Page.findById(page._id);
     }
     else {
       const parent = await Page.getParentAndFillAncestors(page.path, user);
-      updatedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
+      normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
 
     // Update descendantCount
     const inc = 1;
-    await this.updateDescendantCountOfAncestors(updatedPage.parent, inc, true);
+    await this.updateDescendantCountOfAncestors(normalizedPage.parent, inc, true);
 
-    return updatedPage;
+    return normalizedPage;
   }
 
   async normalizeParentRecursivelyByPages(pages, user): Promise<void> {
@@ -2356,6 +2358,17 @@ class PageService {
         throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
       }
 
+      const Page = mongoose.model('Page') as unknown as PageModel;
+      const { PageQueryBuilder } = Page;
+      const builder = new PageQueryBuilder(Page.findOne());
+      builder.addConditionAsMigrated();
+      builder.addConditionToListByPathsArray([page.path]);
+      const existingPage = await builder.query.exec();
+
+      if (existingPage?.parent != null) {
+        throw Error('This page has already converted.');
+      }
+
       let pageOp;
       try {
         pageOp = await PageOperation.create({
@@ -2371,7 +2384,14 @@ class PageService {
         logger.error('Failed to create PageOperation document.', err);
         throw err;
       }
-      await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+
+      try {
+        await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+      }
+      catch (err) {
+        logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
+        throw err;
+      }
     }
   }
 
@@ -2381,6 +2401,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.findOne(), true);
     builder.addConditionAsMigrated();
+    builder.addConditionToListByPathsArray([page.path]);
     const exPage = await builder.query.exec();
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
 
@@ -2557,18 +2578,9 @@ class PageService {
     return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user);
   }
 
-  private async _normalizeParentRecursively(
-      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
-  ): Promise<void> {
-    const BATCH_SIZE = 100;
-    const PAGES_LIMIT = 1000;
-
-    const socket = this.crowi.socketIoService.getAdminSocket();
-
+  private buildFilterForNormalizeParentRecursively(pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }) {
     const Page = mongoose.model('Page') as unknown as PageModel;
-    const { PageQueryBuilder } = Page;
 
-    // Build filter
     const andFilter: any = {
       $and: [
         {
@@ -2605,9 +2617,26 @@ class PageService {
       ],
     };
 
+    return mergedFilter;
+  }
+
+  private async _normalizeParentRecursively(
+      pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
+  ): Promise<void> {
+    const BATCH_SIZE = 100;
+    const PAGES_LIMIT = 1000;
+
+    const socket = this.crowi.socketIoService.getAdminSocket();
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    // Build filter
+    const matchFilter = this.buildFilterForNormalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser);
+
     let baseAggregation = Page
       .aggregate([
-        { $match: mergedFilter },
+        { $match: matchFilter },
         {
           $project: { // minimize data to fetch
             _id: 1,
@@ -2617,7 +2646,7 @@ class PageService {
       ]);
 
     // Limit pages to get
-    const total = await Page.countDocuments(mergedFilter);
+    const total = await Page.countDocuments(matchFilter);
     if (isFirst) {
       socket.emit(SocketEventName.PMStarted, { total });
     }
@@ -2698,6 +2727,9 @@ class PageService {
               {
                 path: { $regex: new RegExp(`^${parentPathEscaped}(\\/[^/]+)\\/?$`, 'i') }, // see: regexr.com/6889f (e.g. /parent/any_child or /any_level1)
               },
+              {
+                path: { $in: pathOrRegExps.concat(publicPathsToNormalize) },
+              },
               filterForApplicableAncestors,
               grantFiltersByUser,
             ],
@@ -2754,7 +2786,7 @@ class PageService {
 
     await streamToPromise(migratePagesStream);
 
-    if (await Page.exists(mergedFilter) && shouldContinue) {
+    if (await Page.exists(matchFilter) && shouldContinue) {
       return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, nextCount, nextSkiped, 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 elasticsearch7 from '@elastic/elasticsearch7';
 import mongoose from 'mongoose';
-
-import { URL } from 'url';
-
-import { Writable, Transform } from 'stream';
 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 {
   IFormattedSearchResult, ISearchResult, SORT_AXIS, SORT_ORDER,
 } 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';
 
 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 {
-      GRANT_PUBLIC, GRANT_RESTRICTED, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
+      GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = Page;
 
     const grantConditions: any[] = [
       { 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) {
       grantConditions.push(
         { 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;
   }
 
-  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil) {
+  async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, userId) {
     const Page = this.crowi.model('Page');
     const reshapedContentsBody = reshapeContentsBody(contentsBody);
 
@@ -20,7 +20,7 @@ class CreatePageService {
     const normalizedPath = pathUtils.normalizePath(sanitizedPath);
 
     // 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, {});
 
     // 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 BaseSlackCommandHandler = require('./slack-command-handler');
   const handler = new BaseSlackCommandHandler();
+  const { User } = crowi.models;
 
   handler.handleCommand = async function(growiCommand, client, body, respondUtil) {
     await respondUtil.respond({
@@ -32,8 +33,9 @@ module.exports = (crowi) => {
   handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) {
     let result = [];
     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
     const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor);
     // get messages
@@ -43,7 +45,7 @@ module.exports = (crowi) => {
 
     const contentsBody = cleanedContents.join('');
     // 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) {
@@ -137,10 +139,41 @@ module.exports = (crowi) => {
     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) {
     const cleanedContents = [];
     let lastMessage = {};
     const grwTzoffset = crowi.appService.getTzoffset() * 60;
+    await this.injectGrowiUsernameToMessages(messages);
     messages
       .sort((a, b) => {
         return a.ts - b.ts;
@@ -164,8 +197,8 @@ module.exports = (crowi) => {
     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
     // send preview to dm

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

@@ -1,7 +1,7 @@
 {% extends 'page.html' %}
 
 {% 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' %}
 

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

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

+ 14 - 0
packages/app/src/stores/tag.tsx

@@ -0,0 +1,14 @@
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { ITagsListApiv1Result } from '~/interfaces/tag';
+
+import { apiGet } from '../client/util/apiv1-client';
+
+
+export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<ITagsListApiv1Result, Error> => {
+  return useSWRImmutable(
+    ['/tags.list', limit, offset],
+    (endpoint, limit, offset) => apiGet(endpoint, { limit, offset }).then((result: ITagsListApiv1Result) => result),
+  );
+};

+ 14 - 16
packages/app/src/stores/ui.tsx

@@ -1,32 +1,30 @@
 import { RefObject } from 'react';
+
+import { isClient, pagePathUtils } from '@growi/core';
+import { Breakpoint, addBreakpointListener } from '@growi/ui';
+import SimpleBar from 'simplebar-react';
 import {
   useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
-import SimpleBar from 'simplebar-react';
-
-import { Breakpoint, addBreakpointListener } from '@growi/ui';
-import { pagePathUtils } from '@growi/core';
 
+import { IFocusable } from '~/client/interfaces/focusable';
+import { Nullable } from '~/interfaces/common';
 import { SidebarContentsType } from '~/interfaces/ui';
+import { UpdateDescCountData } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
-import { useStaticSWR } from './use-static-swr';
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage,
   useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
 } from './context';
-import { IFocusable } from '~/client/interfaces/focusable';
-import { Nullable } from '~/interfaces/common';
-import { UpdateDescCountData } from '~/interfaces/websocket';
+import { useStaticSWR } from './use-static-swr';
 
 const { isSharedPage } = pagePathUtils;
 
 const logger = loggerFactory('growi:stores:ui');
 
-const isServer = typeof window === 'undefined';
-
 
 /** **********************************************************
  *                          Unions
@@ -55,10 +53,10 @@ export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRRe
  *********************************************************** */
 
 export const useIsMobile = (): SWRResponse<boolean, Error> => {
-  const key = isServer ? null : 'isMobile';
+  const key = isClient() ? 'isMobile' : null;
 
   let configuration;
-  if (!isServer) {
+  if (isClient()) {
     const userAgent = window.navigator.userAgent.toLowerCase();
     configuration = {
       fallbackData: /iphone|ipad|android/.test(userAgent),
@@ -166,11 +164,11 @@ export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
 };
 
 export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
-  const key: Key = isServer ? null : 'isDeviceSmallerThanMd';
+  const key: Key = isClient() ? 'isDeviceSmallerThanMd' : null;
 
   const { cache, mutate } = useSWRConfig();
 
-  if (!isServer) {
+  if (isClient()) {
     const mdOrAvobeHandler = function(this: MediaQueryList): void {
       // sm -> md: matches will be true
       // md -> sm: matches will be false
@@ -190,11 +188,11 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
 };
 
 export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
-  const key: Key = isServer ? null : 'isDeviceSmallerThanLg';
+  const key: Key = isClient() ? 'isDeviceSmallerThanLg' : null;
 
   const { cache, mutate } = useSWRConfig();
 
-  if (!isServer) {
+  if (isClient()) {
     const lgOrAvobeHandler = function(this: MediaQueryList): void {
       // md -> lg: matches will be true
       // lg -> md: matches will be false

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

@@ -8,4 +8,9 @@
       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 {
-        width: auto;
-        padding: 0.1rem 0;
+        min-width: 28px;
+        padding: 0.1rem 0.5rem;
         font-size: 12px;
       }
     }

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

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

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

@@ -150,3 +150,15 @@
     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;
+  }
+
+}

+ 3 - 2
packages/app/src/utils/project-dir-utils.ts

@@ -4,8 +4,9 @@ import fs from 'fs';
 import path from 'path';
 import process from 'process';
 
-const isServer = typeof window === 'undefined';
-const isCurrentDirRoot = isServer && fs.existsSync('./next.config.js');
+import { isServer } from '@growi/core';
+
+const isCurrentDirRoot = isServer() && fs.existsSync('./next.config.js');
 
 export const projectRoot = isCurrentDirRoot
   ? process.cwd()

+ 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', () => {
     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', () => {

+ 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', () => {
     cy.visit('/page');
     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
     cy.wait(1500);
     cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
@@ -105,17 +104,4 @@ context('Open presentation modal', () => {
     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`);
-  });
-
 });

+ 2 - 0
packages/app/test/cypress/integration/3-search/search.spec.ts

@@ -13,6 +13,7 @@ context('Access to search result page', () => {
   it('/_search with "q" param is successfully loaded', () => {
     cy.visit('/_search', { qs: { q: 'labels alerts cards blocks' } });
 
+    cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
 
@@ -24,6 +25,7 @@ context('Access to search result page', () => {
 
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
 
     cy.getByTestid('cb-select').first().click({force: true});
     cy.screenshot(`${ssPrefix}-the-first-checkbox-on`);

+ 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', () => {
     cy.visit('/admin/slack-integration');
     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`);
   });
 

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

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

@@ -0,0 +1,114 @@
+context('Access Home', () => {
+  const ssPrefix = 'home-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  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);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  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').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-user-information-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // 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').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-external-account-4`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // 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').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-password-settings-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // 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').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-api-setting-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // 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').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-editor-setting-2`);
+
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
+
+    // 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').invoke('attr', 'style', 'opacity: 1');
+    cy.screenshot(`${ssPrefix}-in-app-notification-setting-2`);
+  });
+
+});

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

@@ -19,4 +19,15 @@
 module.exports = (on, config) => {
   // `on` is used to hook into various events Cypress emits
   // `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
+  })
 }

+ 283 - 3
packages/app/test/integration/service/v5.migration.test.js

@@ -222,14 +222,12 @@ describe('V5 page migration', () => {
         path: '/normalize_10/normalize_11_gA',
         grant: Page.GRANT_USER_GROUP,
         grantedGroup: groupIdA,
-        grantedUsers: [testUser1._id],
       },
       {
         _id: pageId10,
         path: '/normalize_10/normalize_11_gA/normalize_11_gB',
         grant: Page.GRANT_USER_GROUP,
         grantedGroup: groupIdB,
-        grantedUsers: [testUser1._id],
         parent: pageId8,
         descendantCount: 0,
       },
@@ -342,6 +340,288 @@ describe('V5 page migration', () => {
 
   });
 
+  describe('should normalize only selected pages recursively (while observing the page permission rule)', () => {
+    /*
+     * # Test flow 1
+     * - Existing pages
+     *   - v5 compatible pages
+     *     - /normalize_a (empty)
+     *     - /normalize_a/normalize_b (public)
+     *   - v4 pages
+     *     - /normalize_a (user group)
+     *     - /normalize_c (user group)
+     *
+     * - Normalize /normalize_a (user group)
+     *   - Expect
+     *     - Error should be thrown
+     *
+     *
+     * # Test flow 2
+     * - Existing pages
+     *   - v5 compatible pages
+     *     - /normalize_d (empty)
+     *     - /normalize_d/normalize_e (user group)
+     *   - v4 pages
+     *     - /normalize_d (user group)
+     *     - /normalize_f (user group)
+     *
+     * - Normalize /normalize_d (user group)
+     *   - Expect
+     *     - Normalization succeeds
+     *     - /normalize_f (user group) remains in v4 schema
+     *
+     *
+     * # Test flow 3 (should replace all unnecessary empty pages)
+     * - Existing pages
+     *   - v5 compatible pages
+     *     - / (root)
+     *     - /normalize_g (public)
+     *   - v4 pages
+     *     - /normalize_g/normalize_h (only me)
+     *     - /normalize_g/normalize_i (only me)
+     *     - /normalize_g/normalize_h/normalize_j (only me)
+     *     - /normalize_g/normalize_i/normalize_k (only me)
+     *
+     * - Normalize /normalize_g/normalize_h/normalize_j (only me) & /normalize_g/normalize_i/normalize_k (only me)
+     *   - Expect
+     *     - /normalize_g/normalize_h (empty)
+     *       - parent is /normalize_g (public)
+     *     - /normalize_g/normalize_i (empty)
+     *       - parent is /normalize_g (public)
+     *     - /normalize_g/normalize_h/normalize_j (only me) is normalized
+     *     - /normalize_g/normalize_i/normalize_k (only me) is normalized
+     */
+
+    const public = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
+    const owned = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
+    const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
+
+    const normalized = { parent: { $ne: null } };
+    const notNormalized = { parent: null };
+    const empty = { isEmpty: true };
+
+    beforeAll(async() => {
+      // Prepare data
+      const id1 = new mongoose.Types.ObjectId();
+      const id2 = new mongoose.Types.ObjectId();
+      const id3 = new mongoose.Types.ObjectId();
+      const id4 = new mongoose.Types.ObjectId();
+
+      await Page.insertMany([
+        // 1
+        {
+          _id: id3,
+          path: '/deep_path',
+          grant: Page.GRANT_PUBLIC,
+          parent: rootPage._id,
+        },
+        {
+          _id: id1,
+          path: '/deep_path/normalize_a',
+          isEmpty: true,
+          parent: id3,
+        },
+        {
+          path: '/deep_path/normalize_a/normalize_b',
+          grant: Page.GRANT_PUBLIC,
+          parent: id1,
+        },
+        {
+          path: '/deep_path/normalize_a',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+        {
+          path: '/deep_path/normalize_c',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+
+        // 2
+        {
+          _id: id2,
+          path: '/normalize_d',
+          isEmpty: true,
+          parent: rootPage._id,
+        },
+        {
+          path: '/normalize_d',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+        {
+          path: '/normalize_d/normalize_e',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: id2,
+        },
+        {
+          path: '/normalize_f',
+          grant: Page.GRANT_USER_GROUP,
+          grantedGroup: testUser1GroupId,
+          parent: null,
+        },
+
+        // 3
+        {
+          _id: id4,
+          path: '/normalize_g',
+          parent: rootPage._id,
+        },
+        {
+          path: '/normalize_g/normalize_h',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+        {
+          path: '/normalize_g/normalize_i',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+        {
+          path: '/normalize_g/normalize_h/normalize_j',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+        {
+          path: '/normalize_g/normalize_i/normalize_k',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          parent: null,
+        },
+      ]);
+    });
+
+    test('should not run normalization when the target page is GRANT_USER_GROUP surrounded by public pages', async() => {
+      const mockMainOperation = jest.spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation').mockImplementation(v => v);
+      const _page1 = await Page.findOne(public({ path: '/deep_path/normalize_a', ...empty }));
+      const _page2 = await Page.findOne(public({ path: '/deep_path/normalize_a/normalize_b', ...normalized }));
+      const _page3 = await Page.findOne(testUser1Group({ path: '/deep_path/normalize_a', ...notNormalized }));
+      const _page4 = await Page.findOne(testUser1Group({ path: '/deep_path/normalize_c', ...notNormalized }));
+
+      expect(_page1).not.toBeNull();
+      expect(_page2).not.toBeNull();
+      expect(_page3).not.toBeNull();
+      expect(_page4).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_page3], testUser1);
+
+      expect(mockMainOperation).not.toHaveBeenCalled();
+
+      mockMainOperation.mockRestore();
+    });
+
+    test('should not include siblings', async() => {
+      const _page1 = await Page.findOne(public({ path: '/normalize_d', ...empty }));
+      const _page2 = await Page.findOne(testUser1Group({ path: '/normalize_d/normalize_e', ...normalized }));
+      const _page3 = await Page.findOne(testUser1Group({ path: '/normalize_d', ...notNormalized }));
+      const _page4 = await Page.findOne(testUser1Group({ path: '/normalize_f', ...notNormalized }));
+
+      expect(_page1).not.toBeNull();
+      expect(_page2).not.toBeNull();
+      expect(_page3).not.toBeNull();
+      expect(_page4).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_page3], testUser1);
+
+      const page1 = await Page.findOne(testUser1Group({ path: '/normalize_d/normalize_e' }));
+      const page2 = await Page.findOne(testUser1Group({ path: '/normalize_d' }));
+      const page3 = await Page.findOne(testUser1Group({ path: '/normalize_f' }));
+      const empty4 = await Page.findOne(public({ path: '/normalize_d', ...empty }));
+
+      expect(page1).not.toBeNull();
+      expect(page2).not.toBeNull();
+      expect(page3).not.toBeNull();
+      expect(empty4).toBeNull(); // empty page should be removed
+
+      // Check parent
+      expect(page1.parent).toStrictEqual(page2._id);
+      expect(page2.parent).toStrictEqual(rootPage._id);
+      expect(page3.parent).toBeNull(); // should not be normalized
+
+      // Check descendantCount
+      expect(page1.descendantCount).toBe(0);
+      expect(page2.descendantCount).toBe(1);
+      expect(page3.descendantCount).toBe(0); // should not be normalized
+    });
+
+    test('should replace all unnecessary empty pages and normalization succeeds', async() => {
+      const _pageG = await Page.findOne(public({ path: '/normalize_g', ...normalized }));
+      const _pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h', ...notNormalized }));
+      const _pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i', ...notNormalized }));
+      const _pageGHJ = await Page.findOne(owned({ path: '/normalize_g/normalize_h/normalize_j', ...notNormalized }));
+      const _pageGIK = await Page.findOne(owned({ path: '/normalize_g/normalize_i/normalize_k', ...notNormalized }));
+
+      expect(_pageG).not.toBeNull();
+      expect(_pageGH).not.toBeNull();
+      expect(_pageGI).not.toBeNull();
+      expect(_pageGHJ).not.toBeNull();
+      expect(_pageGIK).not.toBeNull();
+
+      // Normalize
+      await normalizeParentRecursivelyByPages([_pageGHJ, _pageGIK], testUser1);
+
+      const countG = await Page.count({ path: '/normalize_g' });
+      const countGH = await Page.count({ path: '/normalize_g/normalize_h' });
+      const countGI = await Page.count({ path: '/normalize_g/normalize_i' });
+      const countGHJ = await Page.count({ path: '/normalize_g/normalize_h/normalize_j' });
+      const countGIK = await Page.count({ path: '/normalize_g/normalize_i/normalize_k' });
+
+      expect(countG).toBe(1);
+      expect(countGH).toBe(2);
+      expect(countGI).toBe(2);
+      expect(countGHJ).toBe(1);
+      expect(countGIK).toBe(1);
+
+      // -- normalized pages
+      const pageG = await Page.findOne(public({ path: '/normalize_g' }));
+      const emptyGH = await Page.findOne({ path: '/normalize_g/normalize_h', ...empty });
+      const emptyGI = await Page.findOne({ path: '/normalize_g/normalize_i', ...empty });
+      const pageGHJ = await Page.findOne({ path: '/normalize_g/normalize_h/normalize_j' });
+      const pageGIK = await Page.findOne({ path: '/normalize_g/normalize_i/normalize_k' });
+
+      // Check existence
+      expect(pageG).not.toBeNull();
+      expect(pageGHJ).not.toBeNull();
+      expect(pageGIK).not.toBeNull();
+      expect(emptyGH).not.toBeNull();
+      expect(emptyGI).not.toBeNull();
+      // Check parent
+      expect(pageG.parent).toStrictEqual(rootPage._id);
+      expect(emptyGH.parent).toStrictEqual(pageG._id);
+      expect(emptyGI.parent).toStrictEqual(pageG._id);
+      expect(pageGHJ.parent).toStrictEqual(emptyGH._id);
+      expect(pageGIK.parent).toStrictEqual(emptyGI._id);
+      // Check descendantCount
+      expect(pageG.descendantCount).toStrictEqual(2);
+      expect(emptyGH.descendantCount).toStrictEqual(1);
+      expect(emptyGI.descendantCount).toStrictEqual(1);
+      expect(pageGHJ.descendantCount).toStrictEqual(0);
+      expect(pageGIK.descendantCount).toStrictEqual(0);
+
+      // -- not normalized pages
+      const pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h' }));
+      const pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i' }));
+      // Check existence
+      expect(pageGH).not.toBeNull();
+      expect(pageGI).not.toBeNull();
+      // Check parent
+      expect(pageGH.parent).toBeNull(); // should not be normalized
+      expect(pageGI.parent).toBeNull(); // should not be normalized
+      // Check descendantCount
+      expect(pageGH.descendantCount).toStrictEqual(0); // should not be normalized
+      expect(pageGI.descendantCount).toStrictEqual(0); // should not be normalized
+    });
+  });
+
   describe('should normalize only selected pages recursively (especially should NOT normalize non-selected ancestors)', () => {
     /*
      * # Test flow
@@ -506,7 +786,7 @@ describe('V5 page migration', () => {
     });
 
 
-    test('Should normalize pages one by one without including other pages', async() => {
+    test('Should normalize a single page without including other pages', async() => {
       const _owned13 = await Page.findOne(owned({ path: '/normalize_13_owned', ...notNormalized }));
       const _owned14 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
       const _owned15 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.0",
+  "version": "5.0.1-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 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 { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 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 textlintRuleDateWeekdayMismatch from 'textlint-rule-date-weekday-mismatch';
 // 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 textlintRuleUseSiUnits from 'textlint-rule-use-si-units';
 
-import { AsyncLinter, Annotation } from 'codemirror/addon/lint/lint';
 import { loggerFactory } from './utils/logger';
 
 type RulesConfigObj = {

+ 1 - 1
packages/core/package.json

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

+ 3 - 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 _pagePathUtils from './utils/page-path-utils';
 import * as _pageUtils from './utils/page-utils';
+import * as _pathUtils from './utils/path-utils';
 import * as _templateChecker from './utils/template-checker';
-import * as _customTagUtils from './plugin/util/custom-tag-utils';
 
 // export utils
 export const pathUtils = _pathUtils;
@@ -18,4 +18,5 @@ export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './service/localstorage-manager';
 export * from './utils/basic-interceptor';
+export * from './utils/browser-utils';
 export * from './utils/mongoose-utils';

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

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

+ 7 - 0
packages/core/src/utils/browser-utils.ts

@@ -0,0 +1,7 @@
+export const isClient = (): boolean => {
+  return (typeof window !== 'undefined') || (typeof navigator !== 'undefined' && navigator.webdriver);
+};
+
+export const isServer = (): boolean => {
+  return !isClient();
+};

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

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

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

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

+ 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 RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 
 export default (appContainer) => {
   // 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 React from 'react';
 
 // 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 TagCacheManagerFactory from '../util/TagCacheManagerFactory';
 
 // eslint-disable-next-line no-unused-vars
-import styles from '../../css/index.css';
 
 import ExtractedAttachments from './ExtractedAttachments';
 

Some files were not shown because too many files changed in this diff