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

Merge branch 'master' into imprv/111416-125483-show-modal-when-click-button

soumaeda 2 лет назад
Родитель
Сommit
4c07d2eb6e
100 измененных файлов с 1203 добавлено и 792 удалено
  1. 7 0
      .github/release-drafter-dev-6.2.x.yml
  2. 7 0
      .github/release-drafter-master.yml
  3. 2 1
      .github/workflows/auto-labeling.yml
  4. 2 0
      .github/workflows/ci-app-prod.yml
  5. 3 3
      .github/workflows/ci-app.yml
  6. 3 3
      .github/workflows/ci-slackbot-proxy.yml
  7. 11 2
      .github/workflows/draft-release.yml
  8. 1 1
      .github/workflows/release-slackbot-proxy.yml
  9. 2 2
      .github/workflows/release.yml
  10. 8 15
      .github/workflows/reusable-app-prod.yml
  11. 1 1
      .github/workflows/reusable-app-reg-suit.yml
  12. 4 4
      .mergify.yml
  13. 74 1
      CHANGELOG.md
  14. 1 0
      apps/app/cypress.config.ts
  15. 3 2
      apps/app/package.json
  16. 1 1
      apps/app/public/static/locales/en_US/translation.json
  17. 1 1
      apps/app/public/static/locales/ja_JP/translation.json
  18. 1 1
      apps/app/public/static/locales/zh_CN/translation.json
  19. 0 34
      apps/app/src/client/services/ShowPageAccessoriesModal.tsx
  20. 2 4
      apps/app/src/client/services/page-operation.ts
  21. 4 0
      apps/app/src/client/services/renderer/renderer.tsx
  22. 1 1
      apps/app/src/components/Admin/Security/ShareLinkSetting.tsx
  23. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  24. 1 1
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  25. 23 26
      apps/app/src/components/Comments.tsx
  26. 6 16
      apps/app/src/components/InstallerForm.tsx
  27. 6 13
      apps/app/src/components/InvitedForm.tsx
  28. 2 2
      apps/app/src/components/Layout/BasicLayout.tsx
  29. 46 9
      apps/app/src/components/LoginForm.tsx
  30. 2 2
      apps/app/src/components/Me/DisassociateModal.tsx
  31. 6 8
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  32. 2 3
      apps/app/src/components/Navbar/PageEditorModeManager.jsx
  33. 1 1
      apps/app/src/components/NotCreatablePage.tsx
  34. 14 9
      apps/app/src/components/Page/PageView.tsx
  35. 4 11
      apps/app/src/components/Page/RevisionLoader.tsx
  36. 0 1
      apps/app/src/components/Page/RevisionRenderer.tsx
  37. 0 0
      apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.module.scss
  38. 25 57
      apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  39. 2 2
      apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx
  40. 8 12
      apps/app/src/components/PageAccessoriesModal/PageHistory.tsx
  41. 1 3
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLink.tsx
  42. 0 0
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx
  43. 1 1
      apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  44. 1 0
      apps/app/src/components/PageAccessoriesModal/ShareLink/index.ts
  45. 79 0
      apps/app/src/components/PageAccessoriesModal/hooks.tsx
  46. 1 0
      apps/app/src/components/PageAccessoriesModal/index.ts
  47. 9 6
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  48. 27 33
      apps/app/src/components/PageComment.tsx
  49. 6 1
      apps/app/src/components/PageComment/CommentControl.tsx
  50. 21 3
      apps/app/src/components/PageComment/CommentEditor.tsx
  51. 2 2
      apps/app/src/components/PageEditor.tsx
  52. 4 0
      apps/app/src/components/PageList/PageListItemS.module.scss
  53. 18 4
      apps/app/src/components/PageList/PageListItemS.tsx
  54. 10 2
      apps/app/src/components/PageSideContents.tsx
  55. 2 3
      apps/app/src/components/PrivateLegacyPages.tsx
  56. 1 1
      apps/app/src/components/ReactMarkdownComponents/Header.tsx
  57. 16 0
      apps/app/src/components/ReactMarkdownComponents/LightBox.tsx
  58. 4 5
      apps/app/src/components/SavePageControls.tsx
  59. 2 1
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  60. 50 77
      apps/app/src/components/SearchPage/SearchResultContent.tsx
  61. 7 7
      apps/app/src/components/ShareLinkPageView.tsx
  62. 1 1
      apps/app/src/components/Sidebar.tsx
  63. 5 12
      apps/app/src/components/Sidebar/SidebarNav.tsx
  64. 9 3
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  65. 3 7
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  66. 3 2
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  67. 0 11
      apps/app/src/interfaces/external-account.ts
  68. 5 2
      apps/app/src/pages/[[...path]].page.tsx
  69. 7 0
      apps/app/src/pages/me/[[...path]].page.tsx
  70. 6 2
      apps/app/src/pages/share/[[...path]].page.tsx
  71. 4 11
      apps/app/src/pages/utils/commons.ts
  72. 38 1
      apps/app/src/server/models/page.ts
  73. 2 1
      apps/app/src/server/routes/apiv3/index.js
  74. 65 0
      apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts
  75. 169 154
      apps/app/src/server/routes/apiv3/security-settings/index.js
  76. 11 9
      apps/app/src/server/routes/next.ts
  77. 2 0
      apps/app/src/server/service/page.ts
  78. 18 1
      apps/app/src/stores/context.tsx
  79. 13 4
      apps/app/src/stores/modal.tsx
  80. 3 4
      apps/app/src/stores/personal-settings.tsx
  81. 5 3
      apps/app/src/stores/search.tsx
  82. 30 31
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  83. 135 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts
  84. 2 4
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts
  85. 0 61
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--username-mention.cy.ts
  86. 10 6
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts
  87. 3 6
      apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts
  88. 6 5
      apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts
  89. 1 7
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  90. 0 13
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts
  91. 21 0
      apps/app/test/cypress/support/assertions.ts
  92. 37 19
      apps/app/test/cypress/support/commands.ts
  93. 2 0
      apps/app/test/cypress/support/index.ts
  94. 4 1
      apps/app/test/cypress/tsconfig.json
  95. 1 1
      apps/app/test/integration/service/questionnaire.test.ts
  96. 1 1
      apps/slackbot-proxy/package.json
  97. 8 8
      package.json
  98. 1 1
      packages/core/package.json
  99. 21 0
      packages/core/src/interfaces/external-account.ts
  100. 1 0
      packages/core/src/interfaces/index.ts

+ 7 - 0
.github/release-drafter-dev-6.2.x.yml

@@ -0,0 +1,7 @@
+_extends: growi:.github/release-drafter.yml
+
+prerelease: true
+
+# Filter previous releases to consider only those with the tags starts with 'v6.2'
+include-pre-releases: true
+tag-prefix: v6.2

+ 7 - 0
.github/release-drafter-master.yml

@@ -0,0 +1,7 @@
+_extends: growi:.github/release-drafter.yml
+
+prerelease: true
+
+# Filter previous releases to consider only those with the master branch
+include-pre-releases: true
+filter-by-commitish: true

+ 2 - 1
.github/workflows/pr-to-master.yml → .github/workflows/auto-labeling.yml

@@ -1,9 +1,10 @@
-name: PR to master
+name: Auto-labeling
 
 on:
   pull_request:
     branches:
       - master
+      - dev/*.*.*
     # Only following types are handled by the action, but one can default to all as well
     types: [opened, reopened, edited, synchronize]
 

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

@@ -4,6 +4,7 @@ on:
   push:
     branches:
       - master
+      - dev/6.*.x
     paths:
       - .github/workflows/ci-app-prod.yml
       - .github/workflows/reusable-app-prod.yml
@@ -18,6 +19,7 @@ on:
   pull_request:
     branches:
       - master
+      - dev/6.*.x
     types: [opened, reopened, synchronize]
     paths:
       - .github/workflows/ci-app-prod.yml

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

@@ -60,7 +60,7 @@ jobs:
 
       - name: Install dependencies
         run: |
-          yarn global add turbo
+          yarn global add turbo@1.10.9
           yarn --frozen-lockfile
 
       - name: Lint
@@ -131,7 +131,7 @@ jobs:
 
       - name: Install dependencies
         run: |
-          yarn global add turbo
+          yarn global add turbo@1.10.9
           yarn --frozen-lockfile
 
       - name: Test
@@ -213,7 +213,7 @@ jobs:
 
       - name: Install dependencies
         run: |
-          yarn global add turbo
+          yarn global add turbo@1.10.9
           yarn --frozen-lockfile
 
       - name: turbo run dev:ci

+ 3 - 3
.github/workflows/ci-slackbot-proxy.yml

@@ -62,7 +62,7 @@ jobs:
 
     - name: Install dependencies
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
         yarn --frozen-lockfile
 
     - name: Lint
@@ -136,7 +136,7 @@ jobs:
 
     - name: Install dependencies
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
         yarn --frozen-lockfile
 
     - name: yarn dev:ci
@@ -200,7 +200,7 @@ jobs:
 
     - name: Install turbo
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
 
     - name: Prune repositories
       run: |

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

@@ -4,6 +4,8 @@ on:
   push:
     branches:
       - master
+      - dev/*.*.*
+
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
@@ -27,10 +29,17 @@ jobs:
         uses: myrotvorets/info-from-package-json-action@1.2.0
         id: package-json
 
-      # Drafts your next Release notes as Pull Requests are merged into "master"
+      - name: Determine config file
+        id: determine-config-name
+        run: |
+          BRANCH_NAME="${{ github.ref_name }}"
+          BRANCH_NAME_REPLACED=${BRANCH_NAME/\//-}
+          echo "value=release-drafter-$BRANCH_NAME_REPLACED.yml" >> $GITHUB_OUTPUT
+
       - uses: release-drafter/release-drafter@v5
         id: release-drafter
         with:
+          config-name: ${{ steps.determine-config-name.outputs.value }}
           name: v${{ steps.package-json.outputs.packageVersion }}
           tag: v${{ steps.package-json.outputs.packageVersion }}
           version: ${{ steps.package-json.outputs.packageVersion }}
@@ -60,7 +69,7 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current
-          GIT_PR_RELEASE_BRANCH_STAGING: master
+          GIT_PR_RELEASE_BRANCH_STAGING: ${{ github.ref_name }}
           GIT_PR_RELEASE_TEMPLATE: .github/git-pr-release-template.erb
           GIT_PR_RELEASE_TITLE: Release v${{ steps.release-version.outputs.RELEASE_VERSION }}
           GIT_PR_RELEASE_BODY: ${{ needs.update-release-draft.outputs.RELEASE_DRAFT_BODY }}

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

@@ -108,7 +108,7 @@ jobs:
 
     - name: Install dependencies
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
         yarn --frozen-lockfile
 
     - name: Bump versions for next RC

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

@@ -30,7 +30,7 @@ jobs:
 
     - name: Install dependencies
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
         yarn --frozen-lockfile
 
     - name: Bump versions
@@ -93,7 +93,7 @@ jobs:
 
     - name: Install dependencies
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
         yarn --frozen-lockfile
 
     - name: Bump versions for next RC

+ 8 - 15
.github/workflows/reusable-app-prod.yml

@@ -36,7 +36,7 @@ jobs:
 
     - name: Install turbo
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
 
     - name: Prune repositories
       run: |
@@ -59,15 +59,17 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Restore dist
-      uses: actions/cache/restore@v3
+      uses: actions/cache@v3
       with:
         path: |
+          node_modules/.cache/turbo
           **/.turbo
           **/dist
           ${{ github.workspace }}/apps/app/.next
-        key: dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
+        key: dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-${{ github.sha }}
         restore-keys: |
-          dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.ref_name }}-
+          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Build
       working-directory: ./apps/app
@@ -117,15 +119,6 @@ jobs:
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-    - name: Cache dist
-      uses: actions/cache/save@v3
-      with:
-        path: |
-          **/.turbo
-          **/dist
-          ${{ github.workspace }}/apps/app/.next
-        key: dist-app-prod-${{ runner.OS }}-node${{ matrix.node-version }}-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
-
 
   launch-prod:
     needs: [build-prod]
@@ -154,7 +147,7 @@ jobs:
 
     - name: Install turbo
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
 
     - name: Prune repositories
       run: |
@@ -245,7 +238,7 @@ jobs:
 
     - name: Install turbo
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
 
     - name: Prune repositories
       run: |

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

@@ -62,7 +62,7 @@ jobs:
 
     - name: Install turbo
       run: |
-        yarn global add turbo
+        yarn global add turbo@1.10.9
 
     - name: Prune repositories
       run: |

+ 4 - 4
.mergify.yml

@@ -3,11 +3,11 @@ pull_request_rules:
     conditions:
       - author = dependabot[bot]
       - '#approved-reviews-by >= 1'
-      - check-success = "lint (16.x)"
-      - check-success = "test (16.x)"
-      - check-success = "launch-dev (16.x)"
-      - check-success = "test-prod-node14 / launch-prod"
+      - check-success = "lint (18.x)"
+      - check-success = "test (18.x)"
+      - check-success = "launch-dev (18.x)"
       - check-success = "test-prod-node16 / launch-prod"
+      - check-success = "test-prod-node18 / launch-prod"
     actions:
       merge:
         method: merge

+ 74 - 1
CHANGELOG.md

@@ -1,9 +1,82 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.6...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.10...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.1.10](https://github.com/weseek/growi/compare/v6.1.9...v6.1.10) - 2023-08-01
+
+### 🐛 Bug Fixes
+
+- fix: Unsaved comments remain in the editor when transitioning to a new page  (#7912) @miya
+- fix: SWR settings for searching (#7940) @yuki-takei
+- fix: Auto-scroll search result content (#7935) @yuki-takei
+
+## [v6.1.9](https://github.com/weseek/growi/compare/v6.1.8...v6.1.9) - 2023-07-31
+
+### 💎 Features
+
+- feat: LightBox for enlargement of image (#7899) @WNomunomu
+
+### 🚀 Improvement
+
+- imprv: Do not use loadConfigs in skipSSR (#7929) @jam411
+- imprv: Improve default behavior of skipSSR (#7927) @jam411
+- imprv: Improved Design for Bookmarks Sidebar (#7886) @soumaeda
+
+### 🐛 Bug Fixes
+
+- fix: Page creation and update process (#7925) @yuki-takei
+- fix: Sidebar doesn't show the link to the administration panel when logged in as an Admin (#7914) @miya
+- fix: Improve page data mutation after renaming and deleting by GrowiContextualSubNavigation (#7926) @yuki-takei
+- fix: Revert dynamic import. (#7923) @TatsuyaIse
+- fix: Questionnaire wikiType (#7907) @TatsuyaIse
+
+## [v6.1.8](https://github.com/weseek/growi/compare/v6.1.7...v6.1.8) - 2023-07-24
+
+### 💎 Features
+
+- feat: Add plugin badge to TemplateModal's list of templates (#7897) @TatsuyaIse
+
+### 🚀 Improvement
+
+- imprv: Replace isAdmin with usersAdminHooks (#7840) @WNomunomu
+- imprv: Show alert for trashed pages only when the page is not empty (#7903) @TatsuyaIse
+- imprv: Template name truncation (#7898) @TatsuyaIse
+
+### 🐛 Bug Fixes
+
+- fix: Cancel a comment will cancel all comments (#7804) @mudana-grune
+
+### 🧰 Maintenance
+
+- ci(deps-dev): bump vite from 4.3.8 to 4.4.5 (#7901) @dependabot
+- ci(deps): bump semver from 5.7.1 to 5.7.2 (#7867) @dependabot
+- ci(deps): bump mongoose from 6.11.1 to 6.11.3 (#7891) @dependabot
+- ci(deps): bump word-wrap from 1.2.3 to 1.2.4 (#7892) @dependabot
+
+## [v6.1.7](https://github.com/weseek/growi/compare/v6.1.6...v6.1.7) - 2023-07-19
+
+### 💎 Features
+
+- feat: Authentication settings cannot be disabled if there will be no administrator user available to log in (#7761) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv: Show spinner while installing and logging-in (#7823) @soumaeda
+- imprv: Routing with next link (#7880) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Auto popup PageAccessoriesModal and show page history (#7888) @yuki-takei
+- fix: Auto-scroll does not work when accessing the page when the header string is CJK (#7882) @yuki-takei
+- fix: Avoid unnecessary next routing (#7863) @miya
+- fix: Work put back page on bookmark sidebar (#7698) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Render SearchPageBase in CSR (#7889) @yuki-takei
+
 ## [v6.1.6](https://github.com/weseek/growi/compare/v6.1.5...v6.1.6) - 2023-07-12
 
 ### 🐛 Bug Fixes

+ 1 - 0
apps/app/cypress.config.ts

@@ -22,6 +22,7 @@ export default defineConfig({
   fixturesFolder: 'test/cypress/fixtures',
   screenshotsFolder: 'test/cypress/screenshots',
   videosFolder: 'test/cypress/videos',
+  video: false,
 
   viewportWidth: 1400,
   viewportHeight: 1024,

+ 3 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.7-RC.0",
+  "version": "6.1.11-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -131,7 +131,7 @@
     "method-override": "^3.0.0",
     "migrate-mongo": "^8.2.3",
     "mkdirp": "^1.0.3",
-    "mongoose": "^6.5.0",
+    "mongoose": "^6.11.3",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
@@ -235,6 +235,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-regex": "^1.8.0",
     "font-awesome": "^4.7.0",
+    "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.11.0",
     "jest": "^29.5.0",

+ 1 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -165,7 +165,7 @@
   "folder_name": "Folder name",
   "field": "field",
   "not_creatable_page": {
-    "could_not_creata_path": "Couldn't create path."
+    "message": "Page contents cannot be created in this path."
   },
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."

+ 1 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -166,7 +166,7 @@
   "folder_name": "フォルダ名",
   "field": "フィールド",
   "not_creatable_page": {
-    "could_not_creata_path": "パスを作成できませんでした。"
+    "message": "このパスではページ コンテンツを作成できません。"
   },
   "custom_navigation": {
     "no_pages_under_this_page": "このページの配下にはページが存在しません。"

+ 1 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -172,7 +172,7 @@
   "folder_name": "文件夹名称",
   "field": "字段",
   "not_creatable_page": {
-    "could_not_creata_path": "无法创建路径"
+    "message": "无法在此路径中创建页面内容。"
   },
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."

+ 0 - 34
apps/app/src/client/services/ShowPageAccessoriesModal.tsx

@@ -1,34 +0,0 @@
-import React, { useEffect, useState } from 'react';
-
-import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
-
-function getURLQueryParamValue(key: string) {
-// window.location.href is page URL;
-  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
-  return queryStr.get(key);
-}
-
-const queryCompareFormat = new RegExp(/([a-z0-9]){24}...([a-z0-9]){24}/);
-
-const ShowPageAccessoriesModal = (): JSX.Element => {
-  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
-  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
-  useEffect(() => {
-    const pageIdParams = getURLQueryParamValue('compare');
-    if (status == null || status.isOpened === true) {
-      return;
-    }
-    if (isArleadyMounted === true) {
-      return;
-    }
-    if (pageIdParams != null) {
-      if (queryCompareFormat.test(pageIdParams)) {
-        openPageAccessories(PageAccessoriesModalContents.PageHistory);
-      }
-    }
-    setIsArleadyMounted(true);
-  }, [openPageAccessories, status, isArleadyMounted]);
-  return <></>;
-};
-
-export default ShowPageAccessoriesModal;

+ 2 - 4
apps/app/src/client/services/page-operation.ts

@@ -1,6 +1,6 @@
 import { useCallback } from 'react';
 
-import { SubscriptionStatusType, Nullable } from '@growi/core';
+import { SubscriptionStatusType, type Nullable } from '@growi/core';
 import urljoin from 'url-join';
 
 import { OptionsToSave } from '~/interfaces/page-operation';
@@ -153,10 +153,8 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
     // markdown = pageEditor.getMarkdown();
     // }
 
-    const isNoRevisionPage = pageId != null && revisionId == null;
-
     let res;
-    if (pageId == null || isNoRevisionPage) {
+    if (pageId == null || revisionId == null) {
       res = await createPage(path, markdown, options);
     }
     else {

+ 4 - 0
apps/app/src/client/services/renderer/renderer.tsx

@@ -16,6 +16,7 @@ import type { Pluggable } from 'unified';
 
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
+import { LightBox } from '~/components/ReactMarkdownComponents/LightBox';
 import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
@@ -113,6 +114,7 @@ export const generateViewOptions = (
     components.table = TableWithEditButton;
     components.mermaid = mermaid.MermaidViewer;
     components.attachment = RichAttachment;
+    components.img = LightBox;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -218,6 +220,7 @@ export const generateSimpleViewOptions = (
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.attachment = RichAttachment;
+    components.img = LightBox;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -295,6 +298,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
     components.attachment = RichAttachment;
+    components.img = LightBox;
   }
 
   if (config.isEnabledXssPrevention) {

+ 1 - 1
apps/app/src/components/Admin/Security/ShareLinkSetting.tsx

@@ -8,8 +8,8 @@ import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurit
 import { apiv3Delete } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
+import ShareLinkList from '../../PageAccessoriesModal/ShareLink/ShareLinkList';
 import PaginationWrapper from '../../PaginationWrapper';
-import ShareLinkList from '../../ShareLink/ShareLinkList';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -64,7 +64,7 @@ $grw-bookmark-item-padding-left: 35px;
 
     .grw-bookmark-item-list{
       min-width: 30px;
-      height: 35px;
+      height: 50px;
 
       .picture {
         width: 16px;

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -161,7 +161,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
               validationTarget={ValidationTarget.PAGE}
             />
           )
-          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} />}
+          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} isNarrowView />}
 
         <div className='grw-foldertree-control'>
           <PageItemControl

+ 23 - 26
apps/app/src/components/Comments.tsx

@@ -1,11 +1,12 @@
-import React, { useEffect, useRef } from 'react';
+import React, { useEffect, useMemo, useRef } from 'react';
 
 import { type IRevisionHasId, pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
+import { debounce } from 'throttle-debounce';
 
-import { ROOT_ELEM_ID as PageCommentRootElemId, type PageCommentProps } from '~/components/PageComment';
+import { type PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
-import { useIsTrashPage } from '~/stores/page';
+import { useIsTrashPage, useSWRMUTxPageInfo } from '~/stores/page';
 
 import { useCurrentUser } from '../stores/context';
 
@@ -32,35 +33,27 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   } = props;
 
   const { mutate } = useSWRxPageComment(pageId);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
   const { data: isDeleted } = useIsTrashPage();
   const { data: currentUser } = useCurrentUser();
 
   const pageCommentParentRef = useRef<HTMLDivElement>(null);
 
+  const onLoadedDebounced = useMemo(() => debounce(500, () => onLoaded?.()), [onLoaded]);
+
   useEffect(() => {
     const parent = pageCommentParentRef.current;
     if (parent == null) return;
 
-    const observerCallback = (mutationRecords:MutationRecord[]) => {
-      mutationRecords.forEach((record:MutationRecord) => {
-        const target = record.target as HTMLElement;
-
-        for (const child of Array.from(target.children)) {
-          const childId = (child as HTMLElement).id;
-          if (childId === PageCommentRootElemId) {
-            onLoaded?.();
-            break;
-          }
-        }
-
-      });
-    };
-
-    const observer = new MutationObserver(observerCallback);
-    observer.observe(parent, { childList: true });
-    return () => {
-      observer.disconnect();
-    };
+    const observer = new MutationObserver(() => {
+      onLoadedDebounced();
+    });
+    observer.observe(parent, { childList: true, subtree: true });
+
+    // no cleanup function -- 2023.07.31 Yuki Takei
+    // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
+    // > You can call observe() multiple times on the same MutationObserver
+    // > to watch for changes to different parts of the DOM tree and/or different types of changes.
   }, [onLoaded]);
 
   const isTopPagePath = isTopPage(pagePath);
@@ -69,6 +62,11 @@ export const Comments = (props: CommentsProps): JSX.Element => {
     return <></>;
   }
 
+  const onCommentButtonClickHandler = () => {
+    mutate();
+    mutatePageInfo();
+  };
+
   return (
     <div className="page-comments-row mt-5 py-4 d-edit-none d-print-none">
       <div className="container-lg">
@@ -80,15 +78,14 @@ export const Comments = (props: CommentsProps): JSX.Element => {
             currentUser={currentUser}
             isReadOnly={false}
             titleAlign="left"
-            hideIfEmpty={false}
           />
         </div>
-        { !isDeleted && (
+        {!isDeleted && (
           <div id="page-comment-write">
             <CommentEditor
               pageId={pageId}
               isForNewComment
-              onCommentButtonClicked={mutate}
+              onCommentButtonClicked={onCommentButtonClickHandler}
               revisionId={revision._id}
             />
           </div>

+ 6 - 16
apps/app/src/components/InstallerForm.tsx

@@ -19,7 +19,7 @@ const InstallerForm = memo((): JSX.Element => {
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
 
   const [isValidUserName, setValidUserName] = useState(true);
-  const [isSubmittingDisabled, setSubmittingDisabled] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
   const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
 
   const checkUserName = useCallback(async(event) => {
@@ -42,18 +42,7 @@ const InstallerForm = memo((): JSX.Element => {
   const submitHandler: FormEventHandler = useCallback(async(e: any) => {
     e.preventDefault();
 
-    if (isSubmittingDisabled) {
-      return;
-    }
-
-    setSubmittingDisabled(true);
-    setTimeout(() => {
-      setSubmittingDisabled(false);
-    }, 3000);
-
-    if (e.target.elements == null) {
-      return;
-    }
+    setIsLoading(true);
 
     const formData = e.target.elements;
 
@@ -81,6 +70,7 @@ const InstallerForm = memo((): JSX.Element => {
     catch (errs) {
       const err = errs[0];
       const code = err.code;
+      setIsLoading(false);
 
       if (code === 'failed_to_login_after_install') {
         toastError(t('installer.failed_to_login_after_install'));
@@ -89,7 +79,7 @@ const InstallerForm = memo((): JSX.Element => {
 
       toastError(t('installer.failed_to_install'));
     }
-  }, [isSubmittingDisabled, currentLocale, router, t]);
+  }, [currentLocale, router, t]);
 
   const hasErrorClass = isValidUserName ? '' : ' has-error';
   const unavailableUserId = isValidUserName
@@ -220,10 +210,10 @@ const InstallerForm = memo((): JSX.Element => {
               type="submit"
               className="btn-fill btn btn-register"
               id="register"
-              disabled={isSubmittingDisabled}
+              disabled={isLoading}
             >
               <div className="eff"></div>
-              <span className="btn-label"><i className="icon-user-follow" /></span>
+              <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} /></span>
               <span className="btn-label-text">{ t('Create') }</span>
             </button>
           </div>

+ 6 - 13
apps/app/src/components/InvitedForm.tsx

@@ -18,13 +18,14 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
   const { data: user } = useCurrentUser();
-  const [isConnectSuccess, setIsConnectSuccess] = useState<boolean>(false);
   const [loginErrors, setLoginErrors] = useState<Error[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
 
   const { invitedFormUsername, invitedFormName } = props;
 
   const submitHandler = useCallback(async(e) => {
     e.preventDefault();
+    setIsLoading(true);
 
     const formData = e.target.elements;
 
@@ -42,25 +43,17 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
     try {
       const res = await apiv3Post('/invited', { invitedForm });
-      setIsConnectSuccess(true);
       const { redirectTo } = res.data;
       router.push(redirectTo ?? '/');
     }
     catch (err) {
       setLoginErrors(err);
+      setIsLoading(false);
     }
   }, [router]);
 
   const formNotification = useCallback(() => {
 
-    if (isConnectSuccess) {
-      return (
-        <p className="alert alert-success">
-          <strong>{ t('message.successfully_connected') }</strong><br></br>
-        </p>
-      );
-    }
-
     return (
       <>
         { loginErrors != null && loginErrors.length > 0 ? (
@@ -77,7 +70,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         ) }
       </>
     );
-  }, [isConnectSuccess, loginErrors, t]);
+  }, [loginErrors, t]);
 
   if (user == null) {
     return <></>;
@@ -154,9 +147,9 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* Create Button */}
         <div className="input-group justify-content-center d-flex mt-4">
-          <button type="submit" className="btn btn-fill" id="register">
+          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
             <div className="eff"></div>
-            <span className="btn-label"><i className="icon-user-follow"></i></span>
+            <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} /></span>
             <span className="btn-label-text">{t('Create')}</span>
           </button>
         </div>

+ 2 - 2
apps/app/src/components/Layout/BasicLayout.tsx

@@ -23,7 +23,7 @@ const PageDuplicateModal = dynamic(() => import('../PageDuplicateModal'), { ssr:
 const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
-const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
+const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
@@ -41,7 +41,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <GrowiNavbar />
 
         <div className="page-wrapper d-flex d-print-block">
-          <div className="grw-sidebar-wrapper" data-testid="grw-sidebar-wrapper">
+          <div className="grw-sidebar-wrapper">
             <Sidebar />
           </div>
 

+ 46 - 9
apps/app/src/components/LoginForm.tsx

@@ -48,6 +48,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   // states
   const [isRegistering, setIsRegistering] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
   // For Login
   const [usernameForLogin, setUsernameForLogin] = useState('');
   const [passwordForLogin, setPasswordForLogin] = useState('');
@@ -93,6 +94,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const handleLoginWithLocalSubmit = useCallback(async(e) => {
     e.preventDefault();
     resetLoginErrors();
+    setIsLoading(true);
 
     const loginForm = {
       username: usernameForLogin,
@@ -112,6 +114,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     catch (err) {
       const errs = toArrayIfNot(err);
       setLoginErrors(errs);
+      setIsLoading(false);
     }
     return;
 
@@ -176,6 +179,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
     return (
       <>
+        {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
+        {/* Import font-awesome to prevent MongoStore.js "Unable to find the session to touch" error */}
+        <div className='sr-only'>
+          <i className="fa fa-spinner fa-pulse" />
+        </div>
+        {/* !! - END OF HIDDEN ELEMENT - !! */}
         {isLdapSetupFailed && (
           <div className="alert alert-warning small">
             <strong><i className="icon-fw icon-info"></i>{t('login.enabled_ldap_has_configuration_problem')}</strong><br/>
@@ -214,10 +223,16 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
 
           <div className="input-group my-4">
-            <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
+            <button
+              type="submit"
+              id="login"
+              className="btn btn-fill rounded-0 login mx-auto"
+              data-testid="btnSubmitForLogin"
+              disabled={isLoading}
+            >
               <div className="eff"></div>
               <span className="btn-label">
-                <i className="icon-login"></i>
+                <i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-login'} />
               </span>
               <span className="btn-label-text">{t('Sign in')}</span>
             </button>
@@ -225,8 +240,18 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [generateDangerouslySetErrors, generateSafelySetErrors, handleLoginWithLocalSubmit,
-      isLdapSetupFailed, loginErrors, props, separateErrorsBasedOnErrorCode, t]);
+  }, [
+    props,
+    separateErrorsBasedOnErrorCode,
+    loginErrors,
+    generateDangerouslySetErrors,
+    generateSafelySetErrors,
+    isLdapSetupFailed,
+    t,
+    handleLoginWithLocalSubmit,
+    isLoading,
+  ]);
+
 
   const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
@@ -295,6 +320,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     e.preventDefault();
     setEmailForRegistrationOrder('');
     setIsSuccessToRagistration(false);
+    setIsLoading(true);
 
     const registerForm = {
       username: usernameForRegister,
@@ -323,6 +349,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       if (err != null || err.length > 0) {
         setRegisterErrors(err);
       }
+      setIsLoading(false);
     }
     return;
   }, [usernameForRegister, nameForRegister, emailForRegister, passwordForRegister, resetRegisterErrors, router, isEmailAuthenticationEnabled]);
@@ -478,11 +505,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <button
               className="btn btn-fill rounded-0"
               id="register"
-              disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}
+              disabled={(!isMailerSetup && isEmailAuthenticationEnabled) || isLoading}
             >
               <div className="eff"></div>
               <span className="btn-label">
-                <i className="icon-user-follow"></i>
+                <i className={isLoading ? 'fa fa-spinner fa-pulse mr-1' : 'icon-user-follow'} />
               </span>
               <span className="btn-label-text">{submitText}</span>
             </button>
@@ -493,7 +520,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <div className="row">
           <div className="text-right col-12 mt-2 py-2">
-            <a href="#login" id="login" className="link-switch" onClick={switchForm}>
+            <a
+              href="#login"
+              id="login"
+              className="link-switch"
+              style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
+              onClick={switchForm}>
               <i className="icon-fw icon-login"></i>
               {t('Sign in is here')}
             </a>
@@ -503,7 +535,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     );
   }, [
     t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
-    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit,
+    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit, isLoading,
   ]);
 
   if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {
@@ -529,7 +561,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 {/* Sign up link */}
                 {isRegistrationEnabled && (
                   <div className="text-right mb-2">
-                    <a href="#register" id="register" className="link-switch" onClick={switchForm}>
+                    <a
+                      href="#register"
+                      id="register"
+                      className="link-switch"
+                      style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
+                      onClick={switchForm}>
                       <i className="ti ti-check-box"></i> {t('Sign up is here')}
                     </a>
                   </div>

+ 2 - 2
apps/app/src/components/Me/DisassociateModal.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback } from 'react';
 
+import type { IExternalAccountHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -9,13 +10,12 @@ import {
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { IExternalAccount } from '~/interfaces/external-account';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 type Props = {
   isOpen: boolean,
   onClose: () => void,
-  accountForDisassociate: IExternalAccount,
+  accountForDisassociate: IExternalAccountHasId,
 }
 
 

+ 6 - 8
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -27,6 +27,7 @@ import {
 import {
   useSWRMUTxCurrentPage, useSWRxTagsInfo, useCurrentPageId, useIsNotFound, useTemplateTagData, useSWRxPageInfo,
 } from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
   useIsAbleToChangeEditorMode, useIsAbleToShowPageAuthors,
@@ -283,12 +284,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     return;
   }, [mutatePageTagsForEditors]);
 
-  const reload = useCallback(() => {
-    if (currentPathname != null) {
-      router.push(currentPathname);
-    }
-  }, [currentPathname, router]);
-
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       router.push(toPath);
@@ -298,10 +293,12 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
-      reload();
+      mutateCurrentPage();
+      mutatePageInfo();
+      mutatePageTree();
     };
     openRenameModal(page, { onRenamed: renamedHandler });
-  }, [openRenameModal, reload]);
+  }, [mutateCurrentPage, mutatePageInfo, openRenameModal]);
 
   const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
     const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
@@ -321,6 +318,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
       mutateCurrentPage();
       mutatePageInfo();
+      mutatePageTree();
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
   }, [currentPathname, mutateCurrentPage, openDeleteModal, router, mutatePageInfo]);

+ 2 - 3
apps/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { useCurrentUser, useHackmdUri } from '~/stores/context';
+import { useIsAdmin, useHackmdUri } from '~/stores/context';
 import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 import styles from './PageEditorModeManager.module.scss';
@@ -47,10 +47,9 @@ function PageEditorModeManager(props) {
 
   const { t } = useTranslation();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-  const { data: currentUser } = useCurrentUser();
   const { data: hackmdUri } = useHackmdUri();
 
-  const isAdmin = currentUser?.admin;
+  const { data: isAdmin } = useIsAdmin();
   const isHackmdEnabled = hackmdUri != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
 

+ 1 - 1
apps/app/src/components/NotCreatablePage.tsx

@@ -10,7 +10,7 @@ export const NotCreatablePage: FC = () => {
       <div className="col-md-12">
         <h2 className="text-muted">
           <i className="icon-ban mr-1" aria-hidden="true"></i>
-          { t('not_creatable_page.could_not_creata_path') }
+          { t('not_creatable_page.message') }
         </h2>
       </div>
     </div>

+ 14 - 9
apps/app/src/components/Page/PageView.tsx

@@ -83,7 +83,7 @@ export const PageView = (props: Props): JSX.Element => {
 
     const targetId = hash.slice(1);
 
-    const target = document.getElementById(targetId);
+    const target = document.getElementById(decodeURIComponent(targetId));
     target?.scrollIntoView();
 
   }, [isCommentsLoaded]);
@@ -111,11 +111,16 @@ export const PageView = (props: Props): JSX.Element => {
     ? (
       <>
         <div id="comments-container" ref={commentsContainerRef}>
-          <Comments pageId={page._id} pagePath={pagePath} revision={page.revision} onLoaded={() => setCommentsLoaded(true)} />
+          <Comments
+            pageId={page._id}
+            pagePath={pagePath}
+            revision={page.revision}
+            onLoaded={() => setCommentsLoaded(true)}
+          />
         </div>
-        { (isUsersHomePagePath && page.creator != null) && (
-          <UsersHomePageFooter creatorId={page.creator._id}/>
-        ) }
+        {(isUsersHomePagePath && page.creator != null) && (
+          <UsersHomePageFooter creatorId={page.creator._id} />
+        )}
         <PageContentFooter page={page} />
       </>
     )
@@ -144,15 +149,15 @@ export const PageView = (props: Props): JSX.Element => {
     >
       <PageAlerts />
 
-      { specialContents }
-      { specialContents == null && (
+      {specialContents}
+      {specialContents == null && (
         <>
-          { (isUsersHomePagePath && page?.creator != null) && <UserInfo author={page.creator} /> }
+          {(isUsersHomePagePath && page?.creator != null) && <UserInfo author={page.creator} />}
           <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
             <Contents />
           </div>
         </>
-      ) }
+      )}
 
     </MainPane>
   );

+ 4 - 11
apps/app/src/components/Page/RevisionLoader.tsx

@@ -20,11 +20,6 @@ export type RevisionLoaderProps = {
 
 const logger = loggerFactory('growi:Page:RevisionLoader');
 
-// Always render '#revision-loader' for MutationObserver of SearchResultContent
-const RevisionLoaderRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Element => (
-  <div id={ROOT_ELEM_ID} {...props}>{props.children}</div>
-);
-
 /**
  * Load data from server and render RevisionBody component
  */
@@ -76,11 +71,9 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
   }
 
   return (
-    <RevisionLoaderRoot>
-      <RevisionRenderer
-        rendererOptions={rendererOptions}
-        markdown={markdown}
-      />
-    </RevisionLoaderRoot>
+    <RevisionRenderer
+      rendererOptions={rendererOptions}
+      markdown={markdown}
+    />
   );
 };

+ 0 - 1
apps/app/src/components/Page/RevisionRenderer.tsx

@@ -8,7 +8,6 @@ import loggerFactory from '~/utils/logger';
 
 import 'katex/dist/katex.min.css';
 
-
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
 type Props = {

+ 0 - 0
apps/app/src/components/PageAccessoriesModal.module.scss → apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.module.scss


+ 25 - 57
apps/app/src/components/PageAccessoriesModal.tsx → apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -1,6 +1,7 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useMemo, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import {
   Modal, ModalBody, ModalHeader,
 } from 'reactstrap';
@@ -10,25 +11,26 @@ import {
 } from '~/stores/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 
-import { CustomNavTab } from './CustomNavigation/CustomNav';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
-import ExpandOrContractButton from './ExpandOrContractButton';
-import AttachmentIcon from './Icons/AttachmentIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-import PageAttachment from './PageAttachment';
-import { PageHistory, getQueryParam } from './PageHistory';
-import ShareLink from './ShareLink/ShareLink';
+import { CustomNavTab } from '../CustomNavigation/CustomNav';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+import AttachmentIcon from '../Icons/AttachmentIcon';
+import HistoryIcon from '../Icons/HistoryIcon';
+import ShareLinkIcon from '../Icons/ShareLinkIcon';
+
+import { useAutoOpenModalByQueryParam } from './hooks';
 
 import styles from './PageAccessoriesModal.module.scss';
 
-const PageAccessoriesModal = (): JSX.Element => {
 
-  const { t } = useTranslation();
+const PageAttachment = dynamic(() => import('./PageAttachment'), { ssr: false });
+const PageHistory = dynamic(() => import('./PageHistory').then(mod => mod.PageHistory), { ssr: false });
+const ShareLink = dynamic(() => import('./ShareLink').then(mod => mod.ShareLink), { ssr: false });
+
 
-  const [activeTab, setActiveTab] = useState<PageAccessoriesModalContents>();
-  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
-  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+export const PageAccessoriesModal = (): JSX.Element => {
+
+  const { t } = useTranslation();
 
   const [isWindowExpanded, setIsWindowExpanded] = useState(false);
 
@@ -37,46 +39,16 @@ const PageAccessoriesModal = (): JSX.Element => {
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isLinkSharingDisabled } = useDisableLinkSharing();
 
-  const { data: status, mutate, close } = usePageAccessoriesModal();
-
-  // activate tab when open
-  useEffect(() => {
-    if (status == null) return;
+  const { data: status, close, selectContents } = usePageAccessoriesModal();
 
-    const { isOpened, activatedContents } = status;
-    if (isOpened && activatedContents != null) {
-      setActiveTab(activatedContents);
-    }
-  }, [status]);
-
-  // Set sourceRevisionId and targetRevisionId as state with valid object id string
-  useEffect(() => {
-    const queryParams = getQueryParam();
-    // https://regex101.com/r/YHTDsr/1
-    const regex = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
-
-    if (queryParams == null || !regex.test(queryParams)) {
-      return;
-    }
-
-    const matches = queryParams.match(regex);
-
-    if (matches == null) {
-      return;
-    }
-
-    const [, sourceRevisionId, targetRevisionId] = matches;
-    setSourceRevisionId(sourceRevisionId);
-    setTargetRevisionId(targetRevisionId);
-    mutate({ isOpened: true });
-  }, [mutate]);
+  useAutoOpenModalByQueryParam();
 
   const navTabMapping = useMemo(() => {
     return {
       [PageAccessoriesModalContents.PageHistory]: {
         Icon: HistoryIcon,
         Content: () => {
-          return <PageHistory onClose={close} sourceRevisionId={sourceRevisionId} targetRevisionId={targetRevisionId}/>;
+          return <PageHistory onClose={close} />;
         },
         i18n: t('History'),
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
@@ -97,7 +69,7 @@ const PageAccessoriesModal = (): JSX.Element => {
         isLinkEnabled: () => !isGuestUser && !isReadOnlyUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [t, close, sourceRevisionId, targetRevisionId, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
+  }, [t, close, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
 
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">
@@ -112,7 +84,7 @@ const PageAccessoriesModal = (): JSX.Element => {
     </div>
   ), [close, isWindowExpanded]);
 
-  if (status == null || activeTab == null) {
+  if (status == null || status.activatedContents == null) {
     return <></>;
   }
 
@@ -128,20 +100,16 @@ const PageAccessoriesModal = (): JSX.Element => {
     >
       <ModalHeader className="p-0" toggle={close} close={buttons}>
         <CustomNavTab
-          activeTab={activeTab}
+          activeTab={status.activatedContents}
           navTabMapping={navTabMapping}
           breakpointToHideInactiveTabsDown="md"
-          onNavSelected={(v: PageAccessoriesModalContents) => {
-            setActiveTab(v);
-          }}
+          onNavSelected={selectContents}
           hideBorderBottom
         />
       </ModalHeader>
       <ModalBody className="overflow-auto grw-modal-body-style">
-        <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} />
+        <CustomTabContent activeTab={status.activatedContents} navTabMapping={navTabMapping} />
       </ModalBody>
     </Modal>
   );
 };
-
-export default PageAccessoriesModal;

+ 2 - 2
apps/app/src/components/PageAttachment.tsx → apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx

@@ -9,8 +9,8 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
-import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
-import PaginationWrapper from './PaginationWrapper';
+import { PageAttachmentList } from '../PageAttachment/PageAttachmentList';
+import PaginationWrapper from '../PaginationWrapper';
 
 // Utility
 const checkIfFileInUse = (markdown: string, attachment): boolean => {

+ 8 - 12
apps/app/src/components/PageHistory.tsx → apps/app/src/components/PageAccessoriesModal/PageHistory.tsx

@@ -3,34 +3,30 @@ import React from 'react';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import { PageRevisionTable } from './PageHistory/PageRevisionTable';
+import { PageRevisionTable } from '../PageHistory/PageRevisionTable';
+
+import { useAutoComparingRevisionsByQueryParam } from './hooks';
 
 const logger = loggerFactory('growi:PageHistory');
 
 type PageHistoryProps = {
-  sourceRevisionId?: string,
-  targetRevisionId?: string
   onClose: () => void
 }
 
-// Get string from 'compare' query params
-export const getQueryParam = (): string | null => {
-  const query: URLSearchParams = new URL(window.location.href).searchParams;
-  return query.get('compare');
-};
-
 export const PageHistory: React.FC<PageHistoryProps> = (props: PageHistoryProps) => {
-  const { sourceRevisionId, targetRevisionId, onClose } = props;
+  const { onClose } = props;
 
   const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const comparingRevisions = useAutoComparingRevisionsByQueryParam();
+
   return (
     <div className="revision-history" data-testid="page-history">
       {currentPageId != null && currentPagePath != null && (
         <PageRevisionTable
-          sourceRevisionId={sourceRevisionId}
-          targetRevisionId={targetRevisionId}
+          sourceRevisionId={comparingRevisions?.sourceRevisionId}
+          targetRevisionId={comparingRevisions?.targetRevisionId}
           currentPageId={currentPageId}
           currentPagePath={currentPagePath}
           onClose={onClose}

+ 1 - 3
apps/app/src/components/ShareLink/ShareLink.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLink.tsx

@@ -12,7 +12,7 @@ import { useSWRxSharelink } from '~/stores/share-link';
 import { ShareLinkForm } from './ShareLinkForm';
 import ShareLinkList from './ShareLinkList';
 
-const ShareLink = (): JSX.Element => {
+export const ShareLink = (): JSX.Element => {
   const { t } = useTranslation();
   const [isOpenShareLinkForm, setIsOpenShareLinkForm] = useState<boolean>(false);
 
@@ -73,5 +73,3 @@ const ShareLink = (): JSX.Element => {
     </div>
   );
 };
-
-export default ShareLink;

+ 0 - 0
apps/app/src/components/ShareLink/ShareLinkForm.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx


+ 1 - 1
apps/app/src/components/ShareLink/ShareLinkList.tsx → apps/app/src/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 
-import CopyDropdown from '../Page/CopyDropdown';
+import CopyDropdown from '../../Page/CopyDropdown';
 
 
 type ShareLinkTrProps = {

+ 1 - 0
apps/app/src/components/PageAccessoriesModal/ShareLink/index.ts

@@ -0,0 +1 @@
+export * from './ShareLink';

+ 79 - 0
apps/app/src/components/PageAccessoriesModal/hooks.tsx

@@ -0,0 +1,79 @@
+import { useEffect, useState } from 'react';
+
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+
+function getURLQueryParamValue(key: string) {
+// window.location.href is page URL;
+  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
+  return queryStr.get(key);
+}
+
+// https://regex101.com/r/YHTDsr/1
+const queryCompareFormat = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
+
+
+export const useAutoOpenModalByQueryParam = (): void => {
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+
+  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
+
+  useEffect(() => {
+    if (isArleadyMounted) {
+      return;
+    }
+
+    if (status == null || status.isOpened === true) {
+      return;
+    }
+
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (pageIdParams != null) {
+      const matches = pageIdParams.match(queryCompareFormat);
+
+      if (matches == null) {
+        return;
+      }
+
+      // open History
+      openPageAccessories(PageAccessoriesModalContents.PageHistory);
+    }
+
+    setIsArleadyMounted(true);
+  }, [openPageAccessories, status, isArleadyMounted]);
+
+};
+
+type ComparingRevisionIds = {
+  sourceRevisionId: string,
+  targetRevisionId: string,
+}
+
+export const useAutoComparingRevisionsByQueryParam = (): ComparingRevisionIds | null => {
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+
+  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
+  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+
+  useEffect(() => {
+    if (isArleadyMounted) {
+      return;
+    }
+
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (pageIdParams != null) {
+      const matches = pageIdParams.match(queryCompareFormat);
+
+      if (matches != null) {
+        const [, source, target] = matches;
+        setSourceRevisionId(source);
+        setTargetRevisionId(target);
+      }
+    }
+
+    setIsArleadyMounted(true);
+  }, [isArleadyMounted]);
+
+  return sourceRevisionId != null && targetRevisionId != null
+    ? { sourceRevisionId, targetRevisionId }
+    : null;
+};

+ 1 - 0
apps/app/src/components/PageAccessoriesModal/index.ts

@@ -0,0 +1 @@
+export * from './PageAccessoriesModal';

+ 9 - 6
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -42,10 +42,11 @@ export const TrashPageAlert = (): JSX.Element => {
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
-
+  const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
 
   const openPutbackPageModalHandler = useCallback(() => {
-    if (pageId == null || pagePath == null) {
+    // User cannot operate empty page.
+    if (isEmptyPage) {
       return;
     }
     const putBackedHandler = () => {
@@ -62,10 +63,11 @@ export const TrashPageAlert = (): JSX.Element => {
       }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router]);
+  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router, isEmptyPage]);
 
   const openPageDeleteModalHandler = useCallback(() => {
-    if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
+    // User cannot operate empty page.
+    if (isEmptyPage) {
       return;
     }
     const pageToDelete = {
@@ -77,7 +79,7 @@ export const TrashPageAlert = (): JSX.Element => {
       meta: pageInfo,
     };
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId]);
+  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId, isEmptyPage]);
 
   const renderTrashPageManagementButtons = useCallback(() => {
     return (
@@ -103,7 +105,8 @@ export const TrashPageAlert = (): JSX.Element => {
     );
   }, [openPageDeleteModalHandler, openPutbackPageModalHandler, pageInfo?.isAbleToDeleteCompletely, t]);
 
-  if (!isTrashPage) {
+  // Show this alert only for non-empty pages in trash.
+  if (!isTrashPage || isEmptyPage) {
     return <></>;
   }
 

+ 27 - 33
apps/app/src/components/PageComment.tsx

@@ -9,6 +9,7 @@ import { Button } from 'reactstrap';
 import { apiPost } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/toastr';
 import { RendererOptions } from '~/interfaces/renderer-options';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
@@ -23,13 +24,6 @@ import { ReplyComments } from './PageComment/ReplyComments';
 
 import styles from './PageComment.module.scss';
 
-export const ROOT_ELEM_ID = 'page-comments' as const;
-
-// Always render '#page-comments' for MutationObserver of SearchResultContent
-const PageCommentRoot = (props: React.HTMLAttributes<HTMLDivElement>): JSX.Element => (
-  <div id={ROOT_ELEM_ID} {...props}>{props.children}</div>
-);
-
 
 export type PageCommentProps = {
   rendererOptions?: RendererOptions,
@@ -39,14 +33,13 @@ export type PageCommentProps = {
   currentUser: any,
   isReadOnly: boolean,
   titleAlign?: 'center' | 'left' | 'right',
-  hideIfEmpty?: boolean,
 }
 
-export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
+export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
 
   const {
     rendererOptions: rendererOptionsByProps,
-    pageId, pagePath, revision, currentUser, isReadOnly, titleAlign, hideIfEmpty,
+    pageId, pagePath, revision, currentUser, isReadOnly, titleAlign,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
@@ -56,6 +49,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
   const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
   const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
   const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
@@ -84,7 +78,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const onDeleteCommentAfterOperation = useCallback(() => {
     onCancelDeleteComment();
     mutate();
-  }, [mutate, onCancelDeleteComment]);
+    mutatePageInfo();
+  }, [mutate, onCancelDeleteComment, mutatePageInfo]);
 
   const onDeleteComment = useCallback(async() => {
     if (commentToBeDeleted == null) return;
@@ -92,7 +87,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
       await apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
       onDeleteCommentAfterOperation();
     }
-    catch (error:unknown) {
+    catch (error: unknown) {
       setErrorMessageOnDelete(error as string);
       toastError(`error: ${error}`);
     }
@@ -100,14 +95,22 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
   const removeShowEditorId = useCallback((commentId: string) => {
     setShowEditorIds((previousState) => {
-      const previousShowEditorIds = new Set(...previousState);
-      previousShowEditorIds.delete(commentId);
-      return previousShowEditorIds;
+      return new Set([...previousState].filter(id => id !== commentId));
     });
   }, []);
 
-  if (hideIfEmpty && comments?.length === 0) {
-    return <PageCommentRoot />;
+  const onReplyButtonClickHandler = useCallback((commentId: string) => {
+    setShowEditorIds(previousState => new Set([...previousState, commentId]));
+  }, []);
+
+  const onCommentButtonClickHandler = useCallback((commentId: string) => {
+    removeShowEditorId(commentId);
+    mutate();
+    mutatePageInfo();
+  }, [removeShowEditorId, mutate, mutatePageInfo]);
+
+  if (comments?.length === 0) {
+    return <></>;
   }
 
   let commentTitleClasses = 'border-bottom py-3 mb-3';
@@ -116,12 +119,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
 
   if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
-    if (hideIfEmpty) {
-      return <PageCommentRoot />;
-    }
-    return (
-      <></>
-    );
+    return <></>;
   }
 
   const revisionId = getIdForRef(revision);
@@ -158,12 +156,12 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   );
 
   return (
-    <PageCommentRoot className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
+    <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
       <div className="container-lg">
         <div className="page-comments">
           <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
           <div className="page-comments-list" id="page-comments-list">
-            { commentsExceptReply.map((comment) => {
+            {commentsExceptReply.map((comment) => {
 
               const defaultCommentThreadClasses = 'page-comment-thread pb-5';
               const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
@@ -180,13 +178,12 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                       <NotAvailableForGuest>
                         <NotAvailableForReadOnlyUser>
                           <Button
+                            data-testid="comment-reply-button"
                             outline
                             color="secondary"
                             size="sm"
                             className="btn-comment-reply"
-                            onClick={() => {
-                              setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
-                            }}
+                            onClick={() => onReplyButtonClickHandler(comment._id)}
                           >
                             <i className="icon-fw icon-action-undo"></i> Reply
                           </Button>
@@ -201,10 +198,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                       onCancelButtonClicked={() => {
                         removeShowEditorId(comment._id);
                       }}
-                      onCommentButtonClicked={() => {
-                        removeShowEditorId(comment._id);
-                        mutate();
-                      }}
+                      onCommentButtonClicked={() => onCommentButtonClickHandler(comment._id)}
                       revisionId={revisionId}
                     />
                   )}
@@ -224,7 +218,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
           confirmToDelete={onDeleteComment}
         />
       )}
-    </PageCommentRoot>
+    </div>
   );
 });
 

+ 6 - 1
apps/app/src/components/PageComment/CommentControl.tsx

@@ -16,7 +16,12 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
       <button type="button" className="btn btn-link p-2" onClick={onClickEditBtn}>
         <i className="ti ti-pencil"></i>
       </button>
-      <button type="button" className="btn btn-link p-2 mr-2" onClick={onClickDeleteBtn}>
+      <button
+        data-testid="comment-delete-button"
+        type="button"
+        className="btn btn-link p-2 mr-2"
+        onClick={onClickDeleteBtn}
+      >
         <i className="ti ti-close"></i>
       </button>
     </div>

+ 21 - 3
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useState, useRef, useEffect,
 } from 'react';
 
+import { useRouter } from 'next/router';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import dynamic from 'next/dynamic';
 import {
@@ -85,6 +86,20 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const editorRef = useRef<IEditorMethods>(null);
 
+  const router = useRouter();
+
+  // UnControlled CodeMirror value is not reset on page transition, so explicitly set the value to the initial value
+  const onRouterChangeComplete = useCallback(() => {
+    editorRef.current?.setValue('');
+  }, []);
+
+  useEffect(() => {
+    router.events.on('routeChangeComplete', onRouterChangeComplete);
+    return () => {
+      router.events.off('routeChangeComplete', onRouterChangeComplete);
+    };
+  }, [onRouterChangeComplete, router.events]);
+
   const handleSelect = useCallback((activeTab: string) => {
     setActiveTab(activeTab);
   }, []);
@@ -109,6 +124,8 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, []);
 
   const initializeEditor = useCallback(async() => {
+    const editingCommentsNum = comment !== '' ? await decrementEditingCommentsNum() : undefined;
+
     setComment('');
     setActiveTab('comment_editor');
     setError(undefined);
@@ -116,11 +133,11 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     // reset value
     if (editorRef.current == null) { return }
     editorRef.current.setValue('');
-    const editingCommentsNum = await decrementEditingCommentsNum();
-    if (editingCommentsNum === 0) {
+
+    if (editingCommentsNum != null && editingCommentsNum === 0) {
       mutateIsEnabledUnsavedWarning(false); // must be after clearing comment or else onChange will override bool
     }
-  }, [initializeSlackEnabled, mutateIsEnabledUnsavedWarning, decrementEditingCommentsNum]);
+  }, [initializeSlackEnabled, comment, decrementEditingCommentsNum, mutateIsEnabledUnsavedWarning]);
 
   const cancelButtonClickedHandler = useCallback(() => {
     // change state to not ready
@@ -277,6 +294,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     );
     const submitButton = (
       <Button
+        data-testid="comment-submit-button"
         outline
         color="primary"
         className="btn btn-outline-primary rounded-pill"

+ 2 - 2
apps/app/src/components/PageEditor.tsx

@@ -112,7 +112,7 @@ const PageEditor = React.memo((): JSX.Element => {
 
   // TODO: remove workaround
   // for https://redmine.weseek.co.jp/issues/125923
-  const [createdPageRevisionIdWithAttachment, setCreatedPageRevisionIdWithAttachment] = useState('');
+  const [createdPageRevisionIdWithAttachment, setCreatedPageRevisionIdWithAttachment] = useState();
 
   // TODO: remove workaround
   // for https://redmine.weseek.co.jp/issues/125923
@@ -158,7 +158,7 @@ const PageEditor = React.memo((): JSX.Element => {
   // TODO: remove workaround
   // for https://redmine.weseek.co.jp/issues/125923
   useEffect(() => {
-    setCreatedPageRevisionIdWithAttachment('');
+    setCreatedPageRevisionIdWithAttachment(undefined);
   }, [router]);
 
   useEffect(() => {

+ 4 - 0
apps/app/src/components/PageList/PageListItemS.module.scss

@@ -0,0 +1,4 @@
+.page-title {
+  flex: 1;
+  line-height: 1.2;
+}

+ 18 - 4
apps/app/src/components/PageList/PageListItemS.tsx

@@ -4,20 +4,26 @@ import { PageListMeta } from '@growi/ui/dist/components/PagePath/PageListMeta';
 import { PagePathLabel } from '@growi/ui/dist/components/PagePath/PagePathLabel';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import Link from 'next/link';
+import Clamp from 'react-multiline-clamp';
 
 import { IPageHasId } from '~/interfaces/page';
 
+import styles from './PageListItemS.module.scss';
 
 type PageListItemSProps = {
   page: IPageHasId,
   noLink?: boolean,
-  pageTitle?: string,
+  pageTitle?: string
+  isNarrowView?: boolean,
 }
 
 export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
 
   const {
-    page, noLink = false, pageTitle,
+    page,
+    noLink = false,
+    pageTitle,
+    isNarrowView = false,
   } = props;
 
   const path = pageTitle != null ? pageTitle : page.path;
@@ -30,9 +36,17 @@ export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
   return (
     <>
       <UserPicture user={page.lastUpdateUser} noLink={noLink} />
-      {pagePathElement}
+      {isNarrowView ? (
+        <Clamp lines={2}>
+          <div className={`mx-2 ${styles['page-title']} ${noLink ? 'text-break' : ''}`}>
+            {pagePathElement}
+          </div>
+        </Clamp>
+      ) : (
+        pagePathElement
+      )}
       <span className="ml-2">
-        <PageListMeta page={page} />
+        <PageListMeta page={page} shouldSpaceOutIcon />
       </span>
     </>
   );

+ 10 - 2
apps/app/src/components/PageSideContents.tsx

@@ -4,7 +4,9 @@ import { IPageHasId, pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { Link } from 'react-scroll';
 
+import { IPageInfoForOperation } from '~/interfaces/page';
 import { useDescendantsPageListModal } from '~/stores/modal';
+import { useSWRxPageInfo } from '~/stores/page';
 
 import CountBadge from './Common/CountBadge';
 import { ContentLinkButtons } from './ContentLinkButtons';
@@ -29,6 +31,8 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
   const { page, isSharedUser } = props;
 
+  const { data: pageInfo } = useSWRxPageInfo(page._id);
+
   const pagePath = page.path;
   const isTopPagePath = isTopPage(pagePath);
   const isUsersHomePagePath = isUsersHomePage(pagePath);
@@ -51,7 +55,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             {t('page_list')}
 
             {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
-            { !isTrash ? <CountBadge count={page?.descendantCount} offset={1} /> : <div className='px-2'></div>}
+            { !isTrash && pageInfo != null
+              ? <CountBadge count={(pageInfo as IPageInfoForOperation).descendantCount} offset={1} />
+              : <div className='px-2'></div>}
           </button>
         )}
       </div>
@@ -67,7 +73,9 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             >
               <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
               <span>Comments</span>
-              <CountBadge count={page.commentCount} />
+              { pageInfo != null
+                ? <CountBadge count={(pageInfo as IPageInfoForOperation).commentCount} />
+                : <div className='px-2'></div>}
             </button>
           </Link>
         </div>

+ 2 - 3
apps/app/src/components/PrivateLegacyPages.tsx

@@ -14,7 +14,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
-import { useCurrentUser } from '~/stores/context';
+import { useIsAdmin } from '~/stores/context';
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
@@ -191,9 +191,8 @@ ConvertByPathModal.displayName = 'ConvertByPathModal';
 
 const PrivateLegacyPages = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data: currentUser } = useCurrentUser();
 
-  const isAdmin = currentUser?.admin;
+  const { data: isAdmin } = useIsAdmin();
 
   const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);

+ 1 - 1
apps/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -75,7 +75,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const activateByHash = useCallback((url: string) => {
     try {
       const hash = (new URL(url, 'https://example.com')).hash.slice(1);
-      setActive(hash === id);
+      setActive(decodeURIComponent(hash) === id);
     }
     catch (err) {
       logger.debug(err);

+ 16 - 0
apps/app/src/components/ReactMarkdownComponents/LightBox.tsx

@@ -0,0 +1,16 @@
+import React, { useState } from 'react';
+
+import FsLightbox from 'fslightbox-react';
+
+export const LightBox = (props) => {
+  const [toggler, setToggler] = useState(false);
+  return (
+    <>
+      <img src={props.src} alt={props.alt} onClick={() => setToggler(!toggler)}/>
+      <FsLightbox
+        toggler={toggler}
+        sources={[props.src]}
+      />
+    </>
+  );
+};

+ 4 - 5
apps/app/src/components/SavePageControls.tsx

@@ -14,7 +14,7 @@ import {
   useIsEditable, useIsAclEnabled,
 } from '~/stores/context';
 import { useWaitingSaveProcessing } from '~/stores/editor';
-import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -38,11 +38,10 @@ export type SavePageControlsProps = {
 export const SavePageControls = (props: SavePageControlsProps): JSX.Element | null => {
   const { slackChannels } = props;
   const { t } = useTranslation();
-  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPage } = useSWRxCurrentPage();
   const { data: isEditable } = useIsEditable();
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
-  const { data: pageId } = useCurrentPageId();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
@@ -72,8 +71,8 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
 
   const { grant, grantedGroup } = grantData;
 
-  const isRootPage = isTopPage(currentPagePath ?? '');
-  const labelSubmitButton = pageId == null ? t('Create') : t('Update');
+  const isRootPage = isTopPage(currentPage?.path ?? '');
+  const labelSubmitButton = (currentPage != null && !currentPage.isEmpty) ? t('Update') : t('Create');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
   return (

+ 2 - 1
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -17,6 +17,8 @@ import { mutatePageTree } from '~/stores/page-listing';
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
+// Do not import with next/dynamic
+// see: https://github.com/weseek/growi/pull/7923
 import { SearchResultList } from './SearchResultList';
 
 import styles from './SearchPageBase.module.scss';
@@ -43,7 +45,6 @@ type Props = {
   searchPager: React.ReactNode,
 }
 
-
 const SearchResultContent = dynamic(() => import('./SearchResultContent').then(mod => mod.SearchResultContent), {
   ssr: false,
   loading: () => <></>,

+ 50 - 77
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { animateScroll } from 'react-scroll';
 import { DropdownItem } from 'reactstrap';
-
+import { debounce } from 'throttle-debounce';
 
 import { exportAsMarkdown, updateContentWidth } from '~/client/services/page-operation';
 import { toastSuccess } from '~/client/util/toastr';
@@ -25,8 +25,8 @@ import { mutateSearching } from '~/stores/search';
 import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import type { GrowiSubNavigationProps } from '../Navbar/GrowiSubNavigation';
 import type { SubNavButtonsProps } from '../Navbar/SubNavButtons';
-import { ROOT_ELEM_ID as RevisionLoaderRoomElemId, type RevisionLoaderProps } from '../Page/RevisionLoader';
-import { ROOT_ELEM_ID as PageCommentRootElemId, type PageCommentProps } from '../PageComment';
+import { type RevisionLoaderProps } from '../Page/RevisionLoader';
+import { type PageCommentProps } from '../PageComment';
 import type { PageContentFooterProps } from '../PageContentFooter';
 
 import styles from './SearchResultContent.module.scss';
@@ -61,7 +61,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 };
 
 const SCROLL_OFFSET_TOP = 30;
-const MUTATION_OBSERVER_CONFIG = { childList: true }; // omit 'subtree: true'
+const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true }; // omit 'subtree: true'
 
 type Props ={
   pageWithMeta : IPageWithSearchMeta,
@@ -70,72 +70,41 @@ type Props ={
   forceHideMenuItems?: ForceHideMenuItems,
 }
 
-const scrollToFirstHighlightedKeyword = (scrollElement: HTMLElement): boolean => {
+const scrollToFirstHighlightedKeyword = (scrollElement: HTMLElement): void => {
   // use querySelector to intentionally get the first element found
   const toElem = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
   if (toElem == null) {
-    return false;
+    return;
   }
 
-  animateScroll.scrollTo(toElem.offsetTop - SCROLL_OFFSET_TOP, {
+  const distance = toElem.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top - SCROLL_OFFSET_TOP;
+  animateScroll.scrollMore(distance, {
     containerId: scrollElement.id,
     duration: 200,
   });
-  return true;
 };
+const scrollToFirstHighlightedKeywordDebounced = debounce(500, scrollToFirstHighlightedKeyword);
 
 export const SearchResultContent: FC<Props> = (props: Props) => {
 
   const scrollElementRef = useRef<HTMLDivElement|null>(null);
 
-  const [isRevisionLoaded, setRevisionLoaded] = useState(false);
-  const [isPageCommentLoaded, setPageCommentLoaded] = useState(false);
-
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
     const scrollElement = scrollElementRef.current;
-    if (scrollElement == null) return;
 
-    const observerCallback = (mutationRecords:MutationRecord[]) => {
-      mutationRecords.forEach((record:MutationRecord) => {
-        const target = record.target as HTMLElement;
-
-        // turn on boolean if loaded
-        Array.from(target.children).forEach((child) => {
-          const childId = (child as HTMLElement).id;
-          if (childId === RevisionLoaderRoomElemId) {
-            setRevisionLoaded(true);
-          }
-          else if (childId === PageCommentRootElemId) {
-            setPageCommentLoaded(true);
-          }
-        });
-      });
-    };
+    if (scrollElement == null) return;
 
-    const observer = new MutationObserver(observerCallback);
+    const observer = new MutationObserver(() => {
+      scrollToFirstHighlightedKeywordDebounced(scrollElement);
+    });
     observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
-    return () => {
-      observer.disconnect();
-    };
-  }, []);
-
-  useEffect(() => {
-    if (!isRevisionLoaded || !isPageCommentLoaded) {
-      return;
-    }
-    if (scrollElementRef.current == null) {
-      return;
-    }
 
-    const scrollElement = scrollElementRef.current;
-    const isScrollProcessed = scrollToFirstHighlightedKeyword(scrollElement);
-    // retry after 1000ms if highlighted element is absense
-    if (!isScrollProcessed) {
-      setTimeout(() => scrollToFirstHighlightedKeyword(scrollElement), 1000);
-    }
-
-  }, [isPageCommentLoaded, isRevisionLoaded]);
+    // no cleanup function -- 2023.07.31 Yuki Takei
+    // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
+    // > You can call observe() multiple times on the same MutationObserver
+    // > to watch for changes to different parts of the DOM tree and/or different types of changes.
+  });
   // *******************************  end  *******************************
 
   const {
@@ -234,40 +203,44 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   }, [page, isExpandContentWidth, showPageControlDropdown, forceHideMenuItems, isContainerFluid,
       duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
 
-  // return if page or growiRenderer is null
-  if (page == null || rendererOptions == null) return <></>;
+  const isRenderable = page != null && rendererOptions != null;
 
   return (
     <div key={page._id} data-testid="search-result-content" className={`search-result-content ${styles['search-result-content']} d-flex flex-column`}>
       <div className="grw-page-path-text-muted-container">
-        <GrowiSubNavigation
-          pagePath={page.path}
-          pageId={page._id}
-          rightComponent={RightComponent}
-          isCompactMode
-          additionalClasses={['px-4']}
-        />
+        { isRenderable && (
+          <GrowiSubNavigation
+            pagePath={page.path}
+            pageId={page._id}
+            rightComponent={RightComponent}
+            isCompactMode
+            additionalClasses={['px-4']}
+          />
+        ) }
       </div>
       <div id="search-result-content-body-container" className="search-result-content-body-container" ref={scrollElementRef}>
-        {/* RevisionLoader will render '#revision-loader' after loaded */}
-        <RevisionLoader
-          rendererOptions={rendererOptions}
-          pageId={page._id}
-          revisionId={page.revision}
-        />
-        {/* PageComment will render '#page-comment' after loaded */}
-        <PageComment
-          rendererOptions={rendererOptions}
-          pageId={page._id}
-          pagePath={page.path}
-          revision={page.revision}
-          currentUser={currentUser}
-          isReadOnly
-          hideIfEmpty
-        />
-        <PageContentFooter
-          page={page}
-        />
+        { isRenderable && (
+          <RevisionLoader
+            rendererOptions={rendererOptions}
+            pageId={page._id}
+            revisionId={page.revision}
+          />
+        )}
+        { isRenderable && (
+          <PageComment
+            rendererOptions={rendererOptions}
+            pageId={page._id}
+            pagePath={page.path}
+            revision={page.revision}
+            currentUser={currentUser}
+            isReadOnly
+          />
+        )}
+        { isRenderable && (
+          <PageContentFooter
+            page={page}
+          />
+        )}
       </div>
     </div>
   );

+ 7 - 7
apps/app/src/components/ShareLink/ShareLinkPageView.tsx → apps/app/src/components/ShareLinkPageView.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo } from 'react';
+import React, { useMemo } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
@@ -10,17 +10,17 @@ import { useIsNotFound } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
-import { MainPane } from '../Layout/MainPane';
-import RevisionRenderer from '../Page/RevisionRenderer';
-import ShareLinkAlert from '../Page/ShareLinkAlert';
-import type { PageSideContentsProps } from '../PageSideContents';
+import { MainPane } from './Layout/MainPane';
+import RevisionRenderer from './Page/RevisionRenderer';
+import ShareLinkAlert from './Page/ShareLinkAlert';
+import type { PageSideContentsProps } from './PageSideContents';
 
 
 const logger = loggerFactory('growi:Page');
 
 
-const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
-const ForbiddenPage = dynamic(() => import('../ForbiddenPage'), { ssr: false });
+const PageSideContents = dynamic<PageSideContentsProps>(() => import('./PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
+const ForbiddenPage = dynamic(() => import('./ForbiddenPage'), { ssr: false });
 
 
 type Props = {

+ 1 - 1
apps/app/src/components/Sidebar.tsx

@@ -297,7 +297,7 @@ const Sidebar = memo((): JSX.Element => {
   const isOpenClass = `${isDrawerOpened ? 'open' : ''}`;
   return (
     <>
-      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`}>
+      <div className={`${grwSidebarClass} ${sidebarModeClass} ${isOpenClass} d-print-none`} data-testid="grw-sidebar">
         <div className="data-layout-container">
           <div
             className='navigation transition-enabled'

+ 5 - 12
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -1,12 +1,12 @@
 import React, {
-  FC, memo, useCallback, useEffect, useState,
+  FC, memo, useCallback,
 } from 'react';
 
 import Link from 'next/link';
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser, useGrowiCloudUri } from '~/stores/context';
+import { useIsAdmin, useGrowiCloudUri } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 import styles from './SidebarNav.module.scss';
@@ -82,21 +82,14 @@ type Props = {
 }
 
 export const SidebarNav: FC<Props> = (props: Props) => {
-
-  const { data: currentUser } = useCurrentUser();
+  const { data: isAdmin } = useIsAdmin();
   const { data: growiCloudUri } = useGrowiCloudUri();
 
-  const [isAdmin, setAdmin] = useState(false);
-
   const { onItemSelected } = props;
 
-  useEffect(() => {
-    setAdmin(currentUser?.admin === true);
-  }, [currentUser?.admin]);
-
   return (
-    <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`} data-vrt-blackout-sidebar-nav>
-      <div className="grw-sidebar-nav-primary-container">
+    <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`}>
+      <div className="grw-sidebar-nav-primary-container" data-vrt-blackout-sidebar-nav>
         {/* eslint-disable max-len */}
         <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />
         <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />

+ 9 - 3
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -68,7 +68,10 @@ const TemplateListGroupItem: React.FC<TemplateSummaryItemProps> = ({
       onClick={onClick}
       aria-current="true"
     >
-      <h4 className="mb-1">{localizedTemplate.title}</h4>
+      <h4 className="mb-1 d-flex">
+        <span className="d-inline-block text-truncate">{localizedTemplate.title}</span>
+        {localizedTemplate.pluginId != null ? <i className="icon-fw icon-puzzle ml-2 text-muted small"></i> : ''}
+      </h4>
       <p className="mb-2">{localizedTemplate.desc}</p>
       { templateLocales != null && Array.from(templateLocales).map(locale => (
         <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
@@ -94,7 +97,10 @@ const TemplateDropdownItem: React.FC<TemplateSummaryItemProps> = ({
       onClick={onClick}
       className="px-4 py-3"
     >
-      <h4 className="mb-1 text-wrap">{localizedTemplate.title}</h4>
+      <h4 className="mb-1 d-flex">
+        <span className="d-inline-block text-truncate">{localizedTemplate.title}</span>
+        {localizedTemplate.pluginId != null ? <i className="icon-fw icon-puzzle ml-2 text-muted small"></i> : ''}
+      </h4>
       <p className="mb-1 text-wrap">{localizedTemplate.desc}</p>
       { templateLocales != null && Array.from(templateLocales).map(locale => (
         <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
@@ -217,7 +223,7 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
                   })() }
                 </span>
               </DropdownToggle>
-              <DropdownMenu role="menu" className={`p-0 ${styles['dm-templates']}`}>
+              <DropdownMenu role="menu" className={`p-0 mw-100 ${styles['dm-templates']}`}>
                 { templateSummaries != null && templateSummaries.map((templateSummary) => {
                   const templateId = constructTemplateId(templateSummary);
 

+ 3 - 7
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -1,5 +1,7 @@
 import * as os from 'node:os';
 
+import { IExternalAuthProviderType } from '@growi/core';
+
 export const GrowiServiceType = {
   cloud: 'cloud',
   privateCloud: 'private-cloud',
@@ -23,13 +25,7 @@ export const GrowiDeploymentType = {
   node: 'node',
   others: 'others',
 } as const;
-export const GrowiExternalAuthProviderType = {
-  ldap: 'ldap',
-  saml: 'saml',
-  oicd: 'oidc',
-  google: 'google',
-  github: 'github',
-} as const;
+export const GrowiExternalAuthProviderType = IExternalAuthProviderType;
 
 export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
 type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]

+ 3 - 2
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -3,6 +3,7 @@ import * as os from 'node:os';
 
 import { IUserHasId } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import { aclService } from '~/server/service/acl';
 
 import {
   GrowiWikiType, GrowiExternalAuthProviderType, IGrowiInfo, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
@@ -33,8 +34,8 @@ class QuestionnaireService {
     const currentUsersCount = await User.countDocuments();
     const currentActiveUsersCount = await User.countActiveUsers();
 
-    const wikiMode = this.crowi.configManager.getConfig('crowi', 'security:wikiMode');
-    const wikiType = wikiMode === 'private' ? GrowiWikiType.closed : GrowiWikiType.open;
+    const isGuestAllowedToRead = aclService.isGuestAllowedToRead();
+    const wikiType = isGuestAllowedToRead ? GrowiWikiType.open : GrowiWikiType.closed;
 
     const activeExternalAccountTypes: GrowiExternalAuthProviderType[] = Object.values(GrowiExternalAuthProviderType).filter((type) => {
       return this.crowi.configManager.getConfig('crowi', `security:passport-${type}:isEnabled`);

+ 0 - 11
apps/app/src/interfaces/external-account.ts

@@ -1,11 +0,0 @@
-import { Ref } from '@growi/core';
-
-import { IUser } from '~/interfaces/user';
-
-
-export type IExternalAccount<ID = string> = {
-  _id: ID,
-  providerType: string,
-  accountId: string,
-  user: Ref<IUser>,
-}

+ 5 - 2
apps/app/src/pages/[[...path]].page.tsx

@@ -169,6 +169,7 @@ type Props = CommonProps & {
   isIndentSizeForced: boolean,
   disableLinkSharing: boolean,
   skipSSR: boolean,
+  ssrMaxRevisionBodyLength: number,
 
   grantData?: IPageGrantData,
 
@@ -437,7 +438,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 
   const Page = crowi.model('Page') as PageModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService } = crowi;
+  const { pageService, configManager } = crowi;
 
   let currentPathname = props.currentPathname;
 
@@ -476,7 +477,8 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   if (page != null) {
     page.initLatestRevisionField(revisionId);
     props.isLatestRevision = page.isLatestRevision();
-    props.skipSSR = await skipSSR(page);
+    const ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+    props.skipSSR = await skipSSR(page, ssrMaxRevisionBodyLength);
     await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
   }
 
@@ -602,6 +604,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 
+  props.ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
 }
 
 /**

+ 7 - 0
apps/app/src/pages/me/[[...path]].page.tsx

@@ -72,6 +72,13 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
 
   const getTargetPageToRender = (pagesMap, keys): {title: string, component: JSX.Element} => {
     return keys.reduce((pagesMap, key) => {
+      const page = pagesMap[key];
+      if (page == null) {
+        return {
+          title: 'NotFoundPage',
+          component: <h2>{t('commons:not_found_page.page_not_exist')}</h2>,
+        };
+      }
       return pagesMap[key];
     }, pagesMap);
   };

+ 6 - 2
apps/app/src/pages/share/[[...path]].page.tsx

@@ -12,7 +12,7 @@ import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
-import { ShareLinkPageView } from '~/components/ShareLink/ShareLinkPageView';
+import { ShareLinkPageView } from '~/components/ShareLinkPageView';
 import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -44,6 +44,7 @@ type Props = CommonProps & {
   drawioUri: string | null,
   rendererConfig: RendererConfig,
   skipSSR: boolean,
+  ssrMaxRevisionBodyLength: number,
 };
 
 type IShareLinkRelatedPage = IPagePopulatedToShowRevision & PageDocument;
@@ -178,6 +179,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
+
+  props.ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
 }
 
 async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
@@ -234,7 +237,8 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     }
     else {
       props.isNotFound = false;
-      props.skipSSR = await skipSSR(shareLink.relatedPage);
+      const ssrMaxRevisionBodyLength = crowi.configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+      props.skipSSR = await skipSSR(shareLink.relatedPage, ssrMaxRevisionBodyLength);
       props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();

+ 4 - 11
apps/app/src/pages/utils/commons.ts

@@ -172,23 +172,16 @@ export const useInitSidebarConfig = (sidebarConfig: ISidebarConfig, userUISettin
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 };
 
-
-export const skipSSR = async(page: PageDocument): Promise<boolean> => {
+export const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: number): Promise<boolean> => {
   if (!isServer()) {
     throw new Error('This method is not available on the client-side');
   }
 
-  // page document only stores the bodyLength of the latest revision
-  if (!page.isLatestRevision() || page.latestRevisionBodyLength == null) {
-    return true;
-  }
+  const latestRevisionBodyLength = await page.getLatestRevisionBodyLength();
 
-  const { configManager } = await import('~/server/service/config-manager');
-  await configManager.loadConfigs();
-  const ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
-  if (ssrMaxRevisionBodyLength < page.latestRevisionBodyLength) {
+  if (latestRevisionBodyLength == null) {
     return true;
   }
 
-  return false;
+  return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
 };

+ 38 - 1
apps/app/src/server/models/page.ts

@@ -1,8 +1,11 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
+import assert from 'assert';
 import nodePath from 'path';
 
-import { HasObjectId, pagePathUtils, pathUtils } from '@growi/core';
+import {
+  HasObjectId, isPopulated, pagePathUtils, pathUtils,
+} from '@growi/core';
 import { collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils/collect-ancestor-paths';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, {
@@ -40,6 +43,8 @@ const STATUS_DELETED = 'deleted';
 
 export interface PageDocument extends IPage, Document {
   [x:string]: any // for obsolete methods
+  getLatestRevisionBodyLength(): Promise<number | null | undefined>
+  calculateAndUpdateLatestRevisionBodyLength(this: PageDocument): Promise<void>
 }
 
 
@@ -959,6 +964,38 @@ schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promi
   return ancestors[0];
 };
 
+/*
+ * get latest revision body length
+ */
+schema.methods.getLatestRevisionBodyLength = async function(this: PageDocument): Promise<number | null | undefined> {
+  if (!this.isLatestRevision() || this.revision == null) {
+    return null;
+  }
+
+  if (this.latestRevisionBodyLength == null) {
+    await this.calculateAndUpdateLatestRevisionBodyLength();
+  }
+
+  return this.latestRevisionBodyLength;
+};
+
+/*
+ * calculate and update latestRevisionBodyLength
+ */
+schema.methods.calculateAndUpdateLatestRevisionBodyLength = async function(this: PageDocument): Promise<void> {
+  if (!this.isLatestRevision() || this.revision == null) {
+    logger.error('revision field is required.');
+    return;
+  }
+
+  // eslint-disable-next-line rulesdir/no-populate
+  const populatedPageDocument = await this.populate<PageDocument>('revision', 'body');
+
+  assert(isPopulated(populatedPageDocument.revision));
+
+  this.latestRevisionBodyLength = populatedPageDocument.revision.body.length;
+  await this.save();
+};
 
 export type PageCreateOptions = {
   format?: string

+ 2 - 1
apps/app/src/server/routes/apiv3/index.js

@@ -9,6 +9,7 @@ import * as registerFormValidator from '../../middlewares/register-form-validato
 import g2gTransfer from './g2g-transfer';
 import importRoute from './import';
 import pageListing from './page-listing';
+import securitySettings from './security-settings';
 import * as userActivation from './user-activation';
 
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
@@ -38,7 +39,7 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/export', require('./export')(crowi));
   routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
-  routerForAdmin.use('/security-setting', require('./security-setting')(crowi));
+  routerForAdmin.use('/security-setting', securitySettings(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));

+ 65 - 0
apps/app/src/server/routes/apiv3/security-settings/checkSetupStrategiesHasAdmin.ts

@@ -0,0 +1,65 @@
+import type { IExternalAuthProviderType } from '@growi/core';
+import mongoose from 'mongoose';
+
+interface AggregateResult {
+  count: number;
+}
+
+const checkLocalStrategyHasAdmin = async(): Promise<boolean> => {
+  const User = mongoose.model('User') as any;
+
+  const localAdmins: AggregateResult[] = await User.aggregate([
+    {
+      $match: {
+        admin: true,
+        status: User.STATUS_ACTIVE,
+        password: { $exists: true },
+      },
+    },
+    { $count: 'count' },
+  ]).exec();
+
+  return localAdmins.length > 0 && localAdmins[0].count > 0;
+};
+
+const checkExternalStrategiesHasAdmin = async(setupExternalStrategies: IExternalAuthProviderType[]): Promise<boolean> => {
+  const User = mongoose.model('User') as any;
+
+  const externalAdmins: AggregateResult[] = await User.aggregate([
+    { $match: { admin: true, status: User.STATUS_ACTIVE } },
+    {
+      $lookup: {
+        from: 'externalaccounts',
+        localField: '_id',
+        foreignField: 'user',
+        as: 'externalAccounts',
+      },
+    },
+    {
+      $match: {
+        'externalAccounts.providerType': { $in: setupExternalStrategies },
+      },
+    },
+    { $count: 'count' },
+  ]).exec();
+
+  return externalAdmins.length > 0 && externalAdmins[0].count > 0;
+};
+
+export const checkSetupStrategiesHasAdmin = async(setupStrategies: (IExternalAuthProviderType | 'local')[]): Promise<boolean> => {
+  if (setupStrategies.includes('local')) {
+    const isLocalStrategyHasAdmin = await checkLocalStrategyHasAdmin();
+    if (isLocalStrategyHasAdmin) {
+      return true;
+    }
+  }
+
+  const setupExternalStrategies = setupStrategies.filter(strategy => strategy !== 'local') as IExternalAuthProviderType[];
+  if (setupExternalStrategies.length === 0) {
+    return false;
+  }
+
+  const isExternalStrategiesHasAdmin = await checkExternalStrategiesHasAdmin(setupExternalStrategies);
+
+  return isExternalStrategiesHasAdmin;
+};

+ 169 - 154
apps/app/src/server/routes/apiv3/security-setting.js → apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -1,13 +1,15 @@
 import { ErrorV3 } from '@growi/core';
+import xss from 'xss';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
-import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { checkSetupStrategiesHasAdmin } from './checkSetupStrategiesHasAdmin';
 
 
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
@@ -309,14 +311,14 @@ const validator = {
  *            description: local account automatically linked the email matched
  */
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
   const activityEvent = crowi.event('activity');
 
   async function updateAndReloadStrategySettings(authId, params) {
-    const { configManager, passportService } = crowi;
+    const { passportService } = crowi;
 
     // update config without publishing S2sMessage
     await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
@@ -348,100 +350,100 @@ module.exports = (crowi) => {
     const securityParams = {
       generalSetting: {
         restrictGuestMode: crowi.aclService.getGuestModeValue(),
-        pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
-        pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
-        pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
-        pageRecursiveCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
-        hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
-        hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        wikiMode: await crowi.configManager.getConfig('crowi', 'security:wikiMode'),
-        sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
+        pageDeletionAuthority: await configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
+        pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
+        pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
+        pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
+        hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
+        wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
+        sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
       shareLinkSetting: {
-        disableLinkSharing: await crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+        disableLinkSharing: await configManager.getConfig('crowi', 'security:disableLinkSharing'),
       },
       localSetting: {
-        useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
-        registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
-        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
-        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
+        useOnlyEnvVarsForSomeOptions: await configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
+        registrationMode: await configManager.getConfig('crowi', 'security:registrationMode'),
+        registrationWhitelist: await configManager.getConfig('crowi', 'security:registrationWhitelist'),
+        isPasswordResetEnabled: await configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       },
       generalAuth: {
-        isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
-        isLdapEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isEnabled'),
-        isSamlEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
-        isOidcEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
-        isGoogleEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-google:isEnabled'),
-        isGitHubEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
+        isLocalEnabled: await configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
+        isLdapEnabled: await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled'),
+        isSamlEnabled: await configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
+        isOidcEnabled: await configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
+        isGoogleEnabled: await configManager.getConfig('crowi', 'security:passport-google:isEnabled'),
+        isGitHubEnabled: await configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
       },
       ldapAuth: {
-        serverUrl: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
-        isUserBind: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
-        ldapBindDN: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
-        ldapBindDNPassword: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
-        ldapSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
-        ldapAttrMapUsername: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
-        ldapAttrMapMail: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
-        ldapAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
-        ldapGroupSearchBase: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
-        ldapGroupSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
-        ldapGroupDnProperty: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
+        serverUrl: await configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
+        isUserBind: await configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
+        ldapBindDN: await configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
+        ldapBindDNPassword: await configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
+        ldapSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
+        ldapAttrMapUsername: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
+        ldapAttrMapMail: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
+        ldapAttrMapName: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
+        ldapGroupSearchBase: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
+        ldapGroupSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
+        ldapGroupDnProperty: await configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
       },
       samlAuth: {
         missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
-        useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions'),
-        samlEntryPoint: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
-        samlEnvVarEntryPoint: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:entryPoint'),
-        samlIssuer: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
-        samlEnvVarIssuer: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:issuer'),
-        samlCert: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
-        samlEnvVarCert: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:cert'),
-        samlAttrMapId: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
-        samlEnvVarAttrMapId: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapId'),
-        samlAttrMapUsername: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
-        samlEnvVarAttrMapUsername: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapUsername'),
-        samlAttrMapMail: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
-        samlEnvVarAttrMapMail: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapMail'),
-        samlAttrMapFirstName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
-        samlEnvVarAttrMapFirstName: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapFirstName'),
-        samlAttrMapLastName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
-        samlEnvVarAttrMapLastName: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapLastName'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
-        samlABLCRule: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:ABLCRule'),
-        samlEnvVarABLCRule: await crowi.configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:ABLCRule'),
+        useOnlyEnvVarsForSomeOptions: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:useOnlyEnvVarsForSomeOptions'),
+        samlEntryPoint: await configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
+        samlEnvVarEntryPoint: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:entryPoint'),
+        samlIssuer: await configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
+        samlEnvVarIssuer: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:issuer'),
+        samlCert: await configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
+        samlEnvVarCert: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:cert'),
+        samlAttrMapId: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
+        samlEnvVarAttrMapId: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapId'),
+        samlAttrMapUsername: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
+        samlEnvVarAttrMapUsername: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapUsername'),
+        samlAttrMapMail: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
+        samlEnvVarAttrMapMail: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapMail'),
+        samlAttrMapFirstName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
+        samlEnvVarAttrMapFirstName: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapFirstName'),
+        samlAttrMapLastName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
+        samlEnvVarAttrMapLastName: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:attrMapLastName'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
+        samlABLCRule: await configManager.getConfigFromDB('crowi', 'security:passport-saml:ABLCRule'),
+        samlEnvVarABLCRule: await configManager.getConfigFromEnvVars('crowi', 'security:passport-saml:ABLCRule'),
       },
       oidcAuth: {
-        oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
-        oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
-        oidcAuthorizationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
-        oidcTokenEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
-        oidcRevocationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
-        oidcIntrospectionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
-        oidcUserInfoEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
-        oidcEndSessionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
-        oidcRegistrationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
-        oidcJWKSUri: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
-        oidcClientId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
-        oidcClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
-        oidcAttrMapId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
-        oidcAttrMapUserName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
-        oidcAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
-        oidcAttrMapEmail: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
+        oidcProviderName: await configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
+        oidcIssuerHost: await configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
+        oidcAuthorizationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
+        oidcTokenEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
+        oidcRevocationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
+        oidcIntrospectionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
+        oidcUserInfoEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
+        oidcEndSessionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
+        oidcRegistrationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
+        oidcJWKSUri: await configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
+        oidcClientId: await configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
+        oidcClientSecret: await configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
+        oidcAttrMapId: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
+        oidcAttrMapUserName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
+        oidcAttrMapName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
+        oidcAttrMapEmail: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
       },
       googleOAuth: {
-        googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
-        googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
+        googleClientId: await configManager.getConfig('crowi', 'security:passport-google:clientId'),
+        googleClientSecret: await configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
       },
       githubOAuth: {
-        githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
-        githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
+        githubClientId: await configManager.getConfig('crowi', 'security:passport-github:clientId'),
+        githubClientSecret: await configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
       },
     };
     return res.apiv3({ securityParams });
@@ -489,13 +491,22 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Can not turn everything off'), 405);
     }
 
+    if (!isEnabled) {
+      const isSetupStrategiesHasAdmin = await checkSetupStrategiesHasAdmin(setupStrategies);
+
+      // Return an error when disabling an strategy when there are no setup strategies with admin-enabled login
+      if (!isSetupStrategiesHasAdmin) {
+        return res.apiv3Err(new ErrorV3('Must have admin enabled authentication method'), 405);
+      }
+    }
+
     const enableParams = { [`security:passport-${authId}:isEnabled`]: isEnabled };
 
     try {
       await updateAndReloadStrategySettings(authId, enableParams);
 
       const responseParams = {
-        [`security:passport-${authId}:isEnabled`]: await crowi.configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
+        [`security:passport-${authId}:isEnabled`]: await configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
       };
       switch (authId) {
         case 'local':
@@ -624,22 +635,22 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Delete config values are not correct.', 'delete_config_not_normalized'));
     }
 
-    const wikiMode = await crowi.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = await configManager.getConfig('crowi', 'security:wikiMode');
     if (wikiMode === 'private' || wikiMode === 'public') {
       logger.debug('security:restrictGuestMode will not be changed because wiki mode is forced to set');
       delete updateData['security:restrictGuestMode'];
     }
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
+      await configManager.updateConfigsInTheSameNamespace('crowi', updateData);
       const securitySettingParams = {
-        sessionMaxAge: await crowi.configManager.getConfig('crowi', 'security:sessionMaxAge'),
-        restrictGuestMode: await crowi.configManager.getConfig('crowi', 'security:restrictGuestMode'),
-        pageDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
-        pageCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
-        pageRecursiveDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
-        pageRecursiveCompleteDeletionAuthority: await crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
-        hideRestrictedByOwner: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
-        hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
+        sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
+        restrictGuestMode: await configManager.getConfig('crowi', 'security:restrictGuestMode'),
+        pageDeletionAuthority: await configManager.getConfig('crowi', 'security:pageDeletionAuthority'),
+        pageCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority'),
+        pageRecursiveDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority'),
+        pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
+        hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
+        hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };
@@ -680,9 +691,9 @@ module.exports = (crowi) => {
       'security:disableLinkSharing': req.body.disableLinkSharing,
     };
     try {
-      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', updateData);
+      await configManager.updateConfigsInTheSameNamespace('crowi', updateData);
       const securitySettingParams = {
-        disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
+        disableLinkSharing: configManager.getConfig('crowi', 'security:disableLinkSharing'),
       };
       // eslint-disable-next-line max-len
       const parameters = { action: updateData['security:disableLinkSharing'] ? SupportedAction.ACTION_ADMIN_REJECT_SHARE_LINK : SupportedAction.ACTION_ADMIN_PERMIT_SHARE_LINK };
@@ -789,20 +800,24 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/LocalSetting'
    */
   router.put('/local-setting', loginRequiredStrictly, adminRequired, addActivity, validator.localSetting, apiV3FormValidator, async(req, res) => {
-    const requestParams = {
-      'security:registrationMode': req.body.registrationMode,
-      'security:registrationWhitelist': req.body.registrationWhitelist,
-      'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
-      'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
-    };
     try {
+      const sanitizedRegistrationWhitelist = req.body.registrationWhitelist
+        .map(line => xss(line, { stripIgnoreTag: true }));
+
+      const requestParams = {
+        'security:registrationMode': req.body.registrationMode,
+        'security:registrationWhitelist': sanitizedRegistrationWhitelist,
+        'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
+        'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
+      };
+
       await updateAndReloadStrategySettings('local', requestParams);
 
       const localSettingParams = {
-        registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
-        isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
-        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
+        registrationMode: await configManager.getConfig('crowi', 'security:registrationMode'),
+        registrationWhitelist: await configManager.getConfig('crowi', 'security:registrationWhitelist'),
+        isPasswordResetEnabled: await configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_ID_PASS_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -856,18 +871,18 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('ldap', requestParams);
 
       const securitySettingParams = {
-        serverUrl: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
-        isUserBind: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
-        ldapBindDN: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
-        ldapBindDNPassword: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
-        ldapSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
-        ldapAttrMapUsername: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
-        ldapAttrMapMail: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
-        ldapAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
-        ldapGroupSearchBase: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
-        ldapGroupSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
-        ldapGroupDnProperty: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
+        serverUrl: await configManager.getConfig('crowi', 'security:passport-ldap:serverUrl'),
+        isUserBind: await configManager.getConfig('crowi', 'security:passport-ldap:isUserBind'),
+        ldapBindDN: await configManager.getConfig('crowi', 'security:passport-ldap:bindDN'),
+        ldapBindDNPassword: await configManager.getConfig('crowi', 'security:passport-ldap:bindDNPassword'),
+        ldapSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:searchFilter'),
+        ldapAttrMapUsername: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapUsername'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'),
+        ldapAttrMapMail: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapMail'),
+        ldapAttrMapName: await configManager.getConfig('crowi', 'security:passport-ldap:attrMapName'),
+        ldapGroupSearchBase: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchBase'),
+        ldapGroupSearchFilter: await configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
+        ldapGroupDnProperty: await configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_LDAP_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -910,7 +925,7 @@ module.exports = (crowi) => {
     for (const configKey of crowi.passportService.mandatoryConfigKeysForSaml) {
       const key = configKey.replace('security:passport-saml:', '');
       const formValue = req.body[key];
-      if (crowi.configManager.getConfigFromEnvVars('crowi', configKey) === null && formValue == null) {
+      if (configManager.getConfigFromEnvVars('crowi', configKey) === null && formValue == null) {
         const formItemName = req.t(`security_setting.form_item_name.${key}`);
         invalidValues.push(req.t('form_validation.required', formItemName));
       }
@@ -950,17 +965,17 @@ module.exports = (crowi) => {
 
       const securitySettingParams = {
         missingMandatoryConfigKeys: await crowi.passportService.getSamlMissingMandatoryConfigKeys(),
-        samlEntryPoint: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
-        samlIssuer: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
-        samlCert: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
-        samlAttrMapId: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
-        samlAttrMapUsername: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
-        samlAttrMapMail: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
-        samlAttrMapFirstName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
-        samlAttrMapLastName: await crowi.configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
-        samlABLCRule: await crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule'),
+        samlEntryPoint: await configManager.getConfigFromDB('crowi', 'security:passport-saml:entryPoint'),
+        samlIssuer: await configManager.getConfigFromDB('crowi', 'security:passport-saml:issuer'),
+        samlCert: await configManager.getConfigFromDB('crowi', 'security:passport-saml:cert'),
+        samlAttrMapId: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapId'),
+        samlAttrMapUsername: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapUsername'),
+        samlAttrMapMail: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapMail'),
+        samlAttrMapFirstName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapFirstName'),
+        samlAttrMapLastName: await configManager.getConfigFromDB('crowi', 'security:passport-saml:attrMapLastName'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
+        samlABLCRule: await configManager.getConfig('crowi', 'security:passport-saml:ABLCRule'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_SAML_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -1020,24 +1035,24 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('oidc', requestParams);
 
       const securitySettingParams = {
-        oidcProviderName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
-        oidcIssuerHost: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
-        oidcAuthorizationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
-        oidcTokenEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
-        oidcRevocationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
-        oidcIntrospectionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
-        oidcUserInfoEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
-        oidcEndSessionEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
-        oidcRegistrationEndpoint: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
-        oidcJWKSUri: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
-        oidcClientId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
-        oidcClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
-        oidcAttrMapId: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
-        oidcAttrMapUserName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
-        oidcAttrMapName: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
-        oidcAttrMapEmail: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
+        oidcProviderName: await configManager.getConfig('crowi', 'security:passport-oidc:providerName'),
+        oidcIssuerHost: await configManager.getConfig('crowi', 'security:passport-oidc:issuerHost'),
+        oidcAuthorizationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:authorizationEndpoint'),
+        oidcTokenEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:tokenEndpoint'),
+        oidcRevocationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:revocationEndpoint'),
+        oidcIntrospectionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:introspectionEndpoint'),
+        oidcUserInfoEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:userInfoEndpoint'),
+        oidcEndSessionEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:endSessionEndpoint'),
+        oidcRegistrationEndpoint: await configManager.getConfig('crowi', 'security:passport-oidc:registrationEndpoint'),
+        oidcJWKSUri: await configManager.getConfig('crowi', 'security:passport-oidc:jwksUri'),
+        oidcClientId: await configManager.getConfig('crowi', 'security:passport-oidc:clientId'),
+        oidcClientSecret: await configManager.getConfig('crowi', 'security:passport-oidc:clientSecret'),
+        oidcAttrMapId: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapId'),
+        oidcAttrMapUserName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapUserName'),
+        oidcAttrMapName: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapName'),
+        oidcAttrMapEmail: await configManager.getConfig('crowi', 'security:passport-oidc:attrMapMail'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_OIDC_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -1083,9 +1098,9 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('google', requestParams);
 
       const securitySettingParams = {
-        googleClientId: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientId'),
-        googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
-        isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
+        googleClientId: await configManager.getConfig('crowi', 'security:passport-google:clientId'),
+        googleClientSecret: await configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
+        isSameEmailTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
@@ -1130,9 +1145,9 @@ module.exports = (crowi) => {
       await updateAndReloadStrategySettings('github', requestParams);
 
       const securitySettingParams = {
-        githubClientId: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientId'),
-        githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
-        isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
+        githubClientId: await configManager.getConfig('crowi', 'security:passport-github:clientId'),
+        githubClientSecret: await configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
+        isSameUsernameTreatedAsIdenticalUser: await configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GITHUB_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);

+ 11 - 9
apps/app/src/server/routes/next.ts

@@ -1,23 +1,25 @@
-import {
-  Request, Response,
-} from 'express';
+import type { IncomingMessage } from 'http';
+
+import type { NextServer, RequestHandler } from 'next/dist/server/next';
 
 type Crowi = {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  nextApp: any,
+  nextApp: NextServer,
 }
 
-type CrowiReq = Request & {
+type CrowiReq = IncomingMessage & {
   crowi: Crowi,
 }
 
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const delegator = (crowi: Crowi) => {
+type NextDelegatorResult = {
+  delegateToNext: RequestHandler,
+};
+
+const delegator = (crowi: Crowi): NextDelegatorResult => {
 
   const { nextApp } = crowi;
   const handle = nextApp.getRequestHandler();
 
-  const delegateToNext = (req: CrowiReq, res: Response): void => {
+  const delegateToNext: RequestHandler = (req: CrowiReq, res): Promise<void> => {
     req.crowi = crowi;
     return handle(req, res);
   };

+ 2 - 0
apps/app/src/server/service/page.ts

@@ -2319,6 +2319,8 @@ class PageService {
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),
+      descendantCount: page.descendantCount,
+      commentCount: page.commentCount,
     };
 
   }

+ 18 - 1
apps/app/src/stores/context.tsx

@@ -1,5 +1,5 @@
 import type { ColorScheme, IUserHasId } from '@growi/core';
-import { SWRResponse } from 'swr';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { SupportedActionType } from '~/interfaces/activity';
@@ -229,6 +229,23 @@ export const useIsReadOnlyUser = (): SWRResponse<boolean, Error> => {
   );
 };
 
+export const useIsAdmin = (): SWRResponse<boolean, Error> => {
+  const { data: currentUser, isLoading } = useCurrentUser();
+
+  return useSWR(
+    isLoading ? null : ['isAdminUser', currentUser?._id, currentUser?.admin],
+    ([, , isAdmin]) => isAdmin ?? false,
+    {
+      fallbackData: currentUser?.admin ?? false,
+      keepPreviousData: true,
+      // disable all revalidation but revalidateIfStale
+      revalidateOnMount: false,
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
+  );
+};
+
 export const useIsEditable = (): SWRResponse<boolean, Error> => {
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();

+ 13 - 4
apps/app/src/stores/modal.tsx

@@ -359,6 +359,7 @@ type PageAccessoriesModalStatus = {
 type PageAccessoriesModalUtils = {
   open(activatedContents: PageAccessoriesModalContents): void
   close(): void
+  selectContents(activatedContents: PageAccessoriesModalContents): void
 }
 
 export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatus, Error> & PageAccessoriesModalUtils => {
@@ -366,9 +367,8 @@ export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatu
   const initialStatus = { isOpened: false };
   const swrResponse = useStaticSWR<PageAccessoriesModalStatus, Error>('pageAccessoriesModalStatus', undefined, { fallbackData: initialStatus });
 
-  return {
-    ...swrResponse,
-    open: (activatedContents: PageAccessoriesModalContents) => {
+  return Object.assign(swrResponse, {
+    open: (activatedContents) => {
       if (swrResponse.data == null) {
         return;
       }
@@ -383,7 +383,16 @@ export const usePageAccessoriesModal = (): SWRResponse<PageAccessoriesModalStatu
       }
       swrResponse.mutate({ isOpened: false });
     },
-  };
+    selectContents: (activatedContents) => {
+      if (swrResponse.data == null) {
+        return;
+      }
+      swrResponse.mutate({
+        isOpened: swrResponse.data.isOpened,
+        activatedContents,
+      });
+    },
+  });
 };
 
 /*

+ 3 - 4
apps/app/src/stores/personal-settings.tsx

@@ -1,8 +1,7 @@
-import { ErrorV3 } from '@growi/core';
+import type { IExternalAccountHasId, IExternalAuthProviderType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 
-import { IExternalAccount } from '~/interfaces/external-account';
 import { IUser } from '~/interfaces/user';
 import { useIsGuestUser } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
@@ -31,7 +30,7 @@ export type IPersonalSettingsInfoOption = {
   sync: () => void,
   updateBasicInfo: () => Promise<void>,
   associateLdapAccount: (account: { username: string, password: string }) => Promise<void>,
-  disassociateLdapAccount: (account: { providerType: string, accountId: string }) => Promise<void>,
+  disassociateLdapAccount: (account: { providerType: IExternalAuthProviderType, accountId: string }) => Promise<void>,
 }
 
 export const usePersonalSettings = (config?: SWRConfiguration): SWRResponse<IUser, Error> & IPersonalSettingsInfoOption => {
@@ -104,7 +103,7 @@ export const usePersonalSettings = (config?: SWRConfiguration): SWRResponse<IUse
   };
 };
 
-export const useSWRxPersonalExternalAccounts = (): SWRResponse<IExternalAccount[], Error> => {
+export const useSWRxPersonalExternalAccounts = (): SWRResponse<IExternalAccountHasId[], Error> => {
   return useSWR(
     '/personal-setting/external-accounts',
     endpoint => apiv3Get(endpoint).then(response => response.data.externalAccounts),

+ 5 - 3
apps/app/src/stores/search.tsx

@@ -1,5 +1,4 @@
-import { mutate, SWRResponse } from 'swr';
-import useSWRImmutable from 'swr/immutable';
+import useSWR, { mutate, SWRResponse } from 'swr';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { IFormattedSearchResult, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
@@ -67,7 +66,7 @@ export const useSWRxSearch = (
 
   const isKeywordValid = keyword != null && keyword.length > 0;
 
-  const swrResult = useSWRImmutable(
+  const swrResult = useSWR(
     isKeywordValid ? ['/search', keyword, fixedConfigurations] : null,
     ([endpoint, , fixedConfigurations]) => {
       const {
@@ -86,6 +85,9 @@ export const useSWRxSearch = (
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
       ).then(result => result as IFormattedSearchResult);
     },
+    {
+      revalidateOnFocus: false,
+    },
   );
 
   return {

+ 30 - 31
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -7,7 +7,7 @@ function openEditor() {
     });
     // until
     return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-  })
+  });
   cy.get('.CodeMirror').should('be.visible');
 }
 
@@ -23,20 +23,20 @@ context('Access to page', () => {
 
   it('/Sandbox is successfully loaded', () => {
     cy.visit('/Sandbox');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
     // cy.get('.toc-link').should('be.visible');
 
-    cy.collapseSidebar(true, true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox`);
   });
 
   // TODO: https://redmine.weseek.co.jp/issues/109939
   it('/Sandbox with anchor hash is successfully loaded', () => {
     cy.visit('/Sandbox#headers');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
@@ -45,18 +45,21 @@ context('Access to page', () => {
     // hide fab
     cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
+    // assert the element is in viewport
+    cy.get('#headers').should('be.inViewport');
+
     // remove animation for screenshot
     // remove 'blink' class because ::after element cannot be operated
     // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
     cy.get('#headers').invoke('removeClass', 'blink');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
@@ -64,69 +67,67 @@ context('Access to page', () => {
 
     cy.get('.math').should('be.visible');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     cy.getByTestid('navbar-editor').should('be.visible');
     cy.get('.grw-editor-navbar-bottom').should('be.visible');
     cy.getByTestid('save-page-btn').should('be.visible');
     cy.get('.grw-grant-selector').should('be.visible');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-Sandbox-edit-page`);
   })
 
   const body1 = 'hello';
   const body2 = ' world!';
-  it('View and Edit contents are successfully loaded', () => {
+  it('Edit and save with save-page-btn', () => {
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
     openEditor();
 
     // check edited contents after save
-    cy.get('.CodeMirror textarea').type(body1, { force: true });
-    cy.get('.CodeMirror-code').contains(body1);
-    cy.get('.page-editor-preview-body').contains(body1);
+    cy.appendTextToEditorUntilContains(body1);
+    cy.get('.page-editor-preview-body').should('contain.text', body1);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
     cy.get('.wiki').should('be.visible');
     cy.get('.wiki').children().first().should('have.text', body1);
+    cy.screenshot(`${ssPrefix}-edit-and-save-with-save-page-btn`);
   })
 
-  it('Editing contents are successfully loaded with shortcut key', () => {
+  it('Edit and save with shortcut key', () => {
     const savePageShortcutKey = '{ctrl+s}';
 
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
     openEditor();
 
-    cy.get('.CodeMirror-code').contains(body1);
-
     // check editing contents with shortcut key
-    cy.get('.CodeMirror textarea').type(body2, { force: true });
-    cy.get('.CodeMirror-code').contains(body1+body2);
-    cy.get('.page-editor-preview-body').contains(body1+body2);
+    cy.appendTextToEditorUntilContains(body2);
+    cy.get('.page-editor-preview-body').should('contain.text', body1+body2);
     cy.get('.CodeMirror').click().type(savePageShortcutKey);
-    cy.get('.CodeMirror-code').contains(body1+body2);
-    cy.get('.page-editor-preview-body').contains(body1+body2);
+    cy.get('.CodeMirror-code').should('contain.text', body1+body2);
+    cy.get('.page-editor-preview-body').should('contain.text', body1+body2);
+    cy.screenshot(`${ssPrefix}-edit-and-save-with-shortcut-key`);
   })
 
   it('/user/admin is successfully loaded', () => {
     cy.visit('/user/admin');
+    cy.collapseSidebar(true);
 
-    cy.waitUntilSkeletonDisappear();
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
     // cy.get('.toc-link').should('be.visible');
 
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(2000); // wait for calcViewHeight and rendering
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-user-admin`);
   });
 
@@ -172,7 +173,7 @@ context('Access to special pages', () => {
   it('/trash is successfully loaded', () => {
     cy.visit('/trash');
 
-    cy.getByTestid('trash-page-list').contains('There are no pages under this page.');
+    cy.getByTestid('trash-page-list').should('contain.text', 'There are no pages under this page.');
 
     cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-trash`);
@@ -191,7 +192,7 @@ context('Access to special pages', () => {
 
     cy.getByTestid('tags-page').within(() => {
       cy.getByTestid('grw-tags-list').should('be.visible');
-      cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
+      cy.getByTestid('grw-tags-list').should('contain.text', 'You have no tag, You can set tags on pages');
     });
 
     cy.collapseSidebar(true);
@@ -279,9 +280,8 @@ context('Access to Template Editing Mode', () => {
       cy.screenshot(`${ssPrefix}-open-template-page-for-children-in-editor-mode`);
     });
 
-    cy.get('.CodeMirror textarea').type(templateBody1, { force: true });
-    cy.get('.CodeMirror-code').contains(templateBody1);
-    cy.get('.page-editor-preview-body').contains(templateBody1);
+    cy.appendTextToEditorUntilContains(templateBody1);
+    cy.get('.page-editor-preview-body').should('contain.text', templateBody1);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
   });
@@ -314,9 +314,8 @@ context('Access to Template Editing Mode', () => {
       cy.screenshot(`${ssPrefix}-open-template-page-for-descendants-in-editor-mode`);
     })
 
-    cy.get('.CodeMirror textarea').type(templateBody2, { force: true });
-    cy.get('.CodeMirror-code').contains(templateBody2);
-    cy.get('.page-editor-preview-body').contains(templateBody2);
+    cy.appendTextToEditorUntilContains(templateBody2);
+    cy.get('.page-editor-preview-body').should('contain.text', templateBody2);
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
   });

+ 135 - 0
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts

@@ -0,0 +1,135 @@
+context('Comment', () => {
+  const ssPrefix = 'comments-';
+  let commentCount = 0;
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+
+    // visit page
+    cy.visit('/comment');
+    cy.collapseSidebar(true, true);
+  })
+
+  it('Create comment page', () => {
+    // save page
+    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@pageEditorModeManager').within(() => {
+        cy.get('button:nth-child(2)').click();
+      });
+      // until
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    });
+    cy.get('.CodeMirror').should('be.visible');
+
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.getByTestid('save-page-btn').click();
+  })
+
+  it('Successfully add comments', () => {
+    const commetText = 'add comment';
+
+    cy.getByTestid('page-comment-button').click();
+
+    // Open comment editor
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('open-comment-editor-button').click();
+      // wait until
+      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+    });
+
+    cy.get('.CodeMirror').type(commetText);
+    cy.getByTestid("comment-submit-button").eq(0).click();
+
+    // Check update comment count
+    commentCount += 1
+    cy.getByTestid('page-comment-button').contains(commentCount);
+    cy.screenshot(`${ssPrefix}1-add-comments`);
+  });
+
+  it('Successfully reply comments', () => {
+    const commetText = 'reply comment';
+
+    cy.getByTestid('page-comment-button').click();
+
+    // Open reply comment editor
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('comment-reply-button').eq(0).click();
+      // wait until
+      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+    });
+
+    cy.get('.CodeMirror').type(commetText);
+    cy.getByTestid("comment-submit-button").eq(0).click();
+
+    // Check update comment count
+    commentCount += 1
+    cy.getByTestid('page-comment-button').contains(commentCount);
+    cy.screenshot(`${ssPrefix}2-reply-comments`);
+  });
+
+  it('Successfully delete comments', () => {
+
+    cy.getByTestid('page-comment-button').click();
+
+    cy.get('.page-comments').should('be.visible');
+    cy.getByTestid('comment-delete-button').eq(0).click({force: true});
+    cy.get('.modal-content').then($elem => $elem.is(':visible'));
+    cy.get('.modal-footer > button:nth-child(3)').click();
+
+    // Check update comment count
+    commentCount -= 2
+    cy.getByTestid('page-comment-button').contains(commentCount);
+    cy.screenshot(`${ssPrefix}3-delete-comments`);
+  });
+
+  // Mention username in comment
+  it('Successfully mention username in comment', () => {
+    const username = '@adm';
+
+    cy.getByTestid('page-comment-button').click();
+
+    // Open comment editor
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('open-comment-editor-button').click();
+      // wait until
+      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+    });
+
+    cy.appendTextToEditorUntilContains(username);
+
+    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}4-mention-username-found`) });
+    // Click on mentioned username
+    cy.get('.CodeMirror-hints > li').first().click();
+    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}5-mention-username-mentioned`) });
+  });
+
+  it('Username not found when mention username in comment', () => {
+    const username = '@user';
+
+    cy.getByTestid('page-comment-button').click();
+
+    // Open comment editor
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('open-comment-editor-button').click();
+      // wait until
+      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+    });
+
+    cy.appendTextToEditorUntilContains(username);
+
+    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}6-mention-username-not-found`) });
+    // Click on username not found hint
+    cy.get('.CodeMirror-hints > li').first().click();
+    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}7-mention-no-username-mentioned`) });
+  });
+
+})

+ 2 - 4
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts

@@ -120,6 +120,7 @@ context('Page Accessories Modal', () => {
     });
 
     cy.visit('/');
+    cy.collapseSidebar(true, true);
 
     cy.waitUntil(() => {
       // do
@@ -141,7 +142,6 @@ context('Page Accessories Modal', () => {
 
     cy.getByTestid('page-history').should('be.visible');
 
-    cy.collapseSidebar(true, true);
     cy.waitUntilSpinnerDisappear();
     cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
   });
@@ -154,7 +154,6 @@ context('Page Accessories Modal', () => {
     cy.waitUntilSpinnerDisappear();
     cy.getByTestid('page-attachment').should('be.visible').contains('No attachments yet.');
 
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap4`);
   });
 
@@ -167,7 +166,6 @@ context('Page Accessories Modal', () => {
     cy.getByTestid('page-accessories-modal').should('be.visible');
     cy.getByTestid('share-link-management').should('be.visible');
 
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap4`);
   });
 });
@@ -186,6 +184,7 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     const tag = 'we';
 
     cy.visit('/Sandbox/Bootstrap4');
+    cy.collapseSidebar(true);
 
     // Add tag
     cy.get('#edit-tags-btn-wrapper-for-tooltip').as('edit-tag-tooltip').should('be.visible');
@@ -198,7 +197,6 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
       return cy.get('#edit-tag-modal').then($elem => $elem.is(':visible'));
     });
 
-    cy.collapseSidebar(true);
     cy.get('#edit-tag-modal').should('be.visible').screenshot(`${ssPrefix}1-edit-tag-input`);
 
     cy.get('#edit-tag-modal').should('be.visible').within(() => {

+ 0 - 61
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--username-mention.cy.ts

@@ -1,61 +0,0 @@
-context('Mention username in comment', () => {
-  const ssPrefix = 'mention-username-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    // Visit /Sandbox
-    cy.visit('/Sandbox');
-    cy.waitUntilSkeletonDisappear();
-
-    cy.collapseSidebar(true, true);
-
-    // Go to comment page
-    cy.getByTestid('page-comment-button').click();
-
-    // Open comment editor
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('open-comment-editor-button').click();
-      // wait until
-      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-    });
-
-  });
-
-  it('Successfully mention username in comment', () => {
-    const username = '@adm';
-
-    cy.waitUntil(() => {
-      // do
-      cy.get('.CodeMirror').type(username);
-      // wait until
-      return cy.get('.CodeMirror-hints').then($elem => $elem.is(':visible'));
-    });
-
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}1-username-found`) });
-    // Click on mentioned username
-    cy.get('.CodeMirror-hints > li').first().click();
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}2-username-mentioned`) });
-  });
-
-  it('Username not found when mention username in comment', () => {
-    const username = '@user';
-
-    cy.waitUntil(() => {
-      // do
-      cy.get('.CodeMirror').type(username);
-      // wait until
-      return cy.get('.CodeMirror-hints').then($elem => $elem.is(':visible'));
-    });
-
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}3-username-not-found`) });
-    // Click on username not found hint
-    cy.get('.CodeMirror-hints > li').first().click();
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}4-no-username-mentioned`) });
-  });
-
-});

+ 10 - 6
apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts

@@ -11,23 +11,27 @@ context('Access to page by guest', () => {
 
   // TODO: https://redmine.weseek.co.jp/issues/109939
   it('/Sandbox with anchor hash is successfully loaded', () => {
-    cy.visit('/Sandbox#Headers');
-    cy.waitUntilSkeletonDisappear();
+    cy.visit('/Sandbox#headers');
+    cy.collapseSidebar(true);
 
     // hide fab
     cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
 
+    // assert the element is in viewport
+    cy.get('#headers').should('be.inViewport');
+
     // remove animation for screenshot
     // remove 'blink' class because ::after element cannot be operated
     // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
     cy.get('#headers').invoke('removeClass', 'blink');
 
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
-    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
 
     // for check download toc data
     // https://redmine.weseek.co.jp/issues/111384
@@ -35,15 +39,15 @@ context('Access to page by guest', () => {
 
     cy.get('.math').should('be.visible');
 
-    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
   it('/Sandbox with edit is successfully loaded', () => {
     cy.visit('/Sandbox#edit');
-    cy.waitUntilSkeletonDisappear();
-
     cy.collapseSidebar(true);
+
+    cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-with-edit-hash`);
   })
 

+ 3 - 6
apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts

@@ -14,14 +14,11 @@ context('Access to sharelink by guest', () => {
     // open dropdown
     cy.waitUntil(() => {
       // do
-      cy.getByTestid('grw-contextual-sub-nav').should('be.visible').within(() => {
-        cy.waitUntilSkeletonDisappear();
-        cy.getByTestid('open-page-item-control-btn').find('button').first().as('btn').click();
+      cy.get('#grw-subnav-container').within(() => {
+        cy.getByTestid('open-page-item-control-btn', { timeout: 14000 }).find('button').click({force: true});
       });
       // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('.dropdown-menu.show').is(':visible');
-      });
+      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
     });
 
     // open modal

+ 6 - 5
apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts

@@ -11,6 +11,7 @@ context('PageCreateModal', () => {
 
   it("PageCreateModal is shown and closed successfully", () => {
     cy.visit('/');
+    cy.collapseSidebar(true, true);
 
     cy.waitUntil(() => {
       // do
@@ -24,13 +25,13 @@ context('PageCreateModal', () => {
       cy.get('button.close').click();
     });
 
-    cy.collapseSidebar(true, true);
     cy.screenshot(`${ssPrefix}page-create-modal-closed`);
   });
 
   it("Successfully Create Today's page", () => {
     const pageName = "Today's page";
     cy.visit('/');
+    cy.collapseSidebar(true);
 
     cy.waitUntil(() => {
       // do
@@ -55,7 +56,6 @@ context('PageCreateModal', () => {
     });
     cy.get('.layout-root').should('not.have.class', 'editing');
 
-    cy.collapseSidebar(true);
     cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}create-today-page`);
   });
@@ -64,6 +64,7 @@ context('PageCreateModal', () => {
     const pageName = 'child';
 
     cy.visit('/foo/bar');
+    cy.collapseSidebar(true);
 
     cy.waitUntil(() => {
       // do
@@ -94,12 +95,12 @@ context('PageCreateModal', () => {
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
 
     cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
   });
 
   it('Trying to create template page under the root page fail', () => {
     cy.visit('/');
+    cy.collapseSidebar(true);
 
     cy.waitUntil(() => {
       // do
@@ -116,8 +117,9 @@ context('PageCreateModal', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
+
     cy.screenshot(`${ssPrefix}create-template-for-children-error`);
+
     cy.get('.Toastify__toast').should('be.visible').within(() => {
       cy.get('.Toastify__close-button').should('be.visible').click();
       cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
@@ -129,7 +131,6 @@ context('PageCreateModal', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
     cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
   });
 

+ 1 - 7
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -21,11 +21,6 @@ describe('Access to sidebar', () => {
       beforeEach(() => {
         cy.visit('/');
 
-        // Workaround for waitinig initial open/close interaction
-        // TODO: remove this cy.wait() after SSR without the initial interaction is implemented
-        // eslint-disable-next-line cypress/no-unnecessary-waiting
-        cy.wait(2000);
-
         // Since this is a sidebar test, call collapseSidebar in beforeEach.
         cy.collapseSidebar(false);
       });
@@ -212,7 +207,7 @@ describe('Access to sidebar', () => {
         it('Successfully redirect to editor', () => {
           const content = '# HELLO \n ## Hello\n ### Hello';
 
-          cy.get('.grw-sidebar-content-header > h3').find('a').click();
+          cy.get('.grw-sidebar-content-header > h3 > a').should('be.visible').click();
 
           cy.get('.layout-root').should('have.class', 'editing');
           cy.get('.CodeMirror textarea').type(content, {force: true});
@@ -220,7 +215,6 @@ describe('Access to sidebar', () => {
           cy.screenshot(`${ssPrefix}custom-sidebar-2-redirect-to-editor`, { blackout: blackoutOverride });
 
           cy.getByTestid('save-page-btn').click();
-          cy.get('.layout-root').should('not.have.class', 'editing');
         });
 
         it('Successfully create custom sidebar content', () => {

+ 0 - 13
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts

@@ -9,28 +9,15 @@ const blackoutOverride = [
 context('Switch sidebar mode', () => {
   const ssPrefix = 'switch-sidebar-mode-';
 
-  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('Switching sidebar mode', () => {
     cy.visit('/');
-    // This test uses collapseSidebar here, because this test for the sidebar.
-    cy.collapseSidebar(true)
     cy.get('.grw-apperance-mode-dropdown').first().click();
 
     cy.get('[for="swSidebarMode"]').click({force: true});

+ 21 - 0
apps/app/test/cypress/support/assertions.ts

@@ -0,0 +1,21 @@
+// from https://github.com/cypress-io/cypress/issues/877#issuecomment-538708750
+const isInViewport = (_chai) => {
+  function assertIsInViewport() {
+
+    const subject = this._obj;
+
+    const bottom = Cypress.config("viewportWidth");
+    const rect = subject[0].getBoundingClientRect();
+
+    this.assert(
+      rect.top < bottom && rect.bottom < bottom,
+      "expected #{this} to be in viewport",
+      "expected #{this} to not be in viewport",
+      this._obj
+    )
+  }
+
+  _chai.Assertion.addMethod('inViewport', assertIsInViewport)
+};
+
+chai.use(isInViewport);

+ 37 - 19
apps/app/test/cypress/support/commands.ts

@@ -70,31 +70,49 @@ Cypress.Commands.add('waitUntilSpinnerDisappear', () => {
 });
 
 Cypress.Commands.add('collapseSidebar', (isCollapsed: boolean, waitUntilSaving = false) => {
-  cy.getByTestid('grw-sidebar-wrapper', { timeout: 5000 }).within(() => {
+  cy.getByTestid('grw-sidebar').within(($sidebar) => {
+
     // skip if .grw-sidebar-dock does not exist
-    if (isHidden(Cypress.$('.grw-sidebar-dock'))) {
+    if (!$sidebar.hasClass('grw-sidebar-dock')) {
+      return;
+    }
+
+  });
+
+  cy.getByTestid('grw-sidebar').should('be.visible').within(() => {
+
+    const isSidebarContextualNavigationHidden = isHiddenByTestId('grw-contextual-navigation-sub');
+    if (isSidebarContextualNavigationHidden === isCollapsed) {
       return;
     }
 
-    // process only when Dock Mode
-    cy.get('.grw-sidebar-dock').within(() => {
-      const isSidebarContextualNavigationHidden = isHiddenByTestId('grw-contextual-navigation-sub');
-      if (isSidebarContextualNavigationHidden === isCollapsed) {
-        return;
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid("grw-navigation-resize-button").click({force: true});
+      // wait until saving UserUISettings
+      if (waitUntilSaving) {
+        // eslint-disable-next-line cypress/no-unnecessary-waiting
+        cy.wait(1500);
       }
 
-      cy.waitUntil(() => {
-        // do
-        cy.getByTestid("grw-navigation-resize-button").click({force: true});
-        // wait until saving UserUISettings
-        if (waitUntilSaving) {
-          // eslint-disable-next-line cypress/no-unnecessary-waiting
-          cy.wait(1500);
-        }
-
-        // wait until
-        return cy.getByTestid('grw-contextual-navigation-sub').then($contents => isHidden($contents) === isCollapsed);
-      });
+      // wait until
+      return cy.getByTestid('grw-contextual-navigation-sub').then($contents => isHidden($contents) === isCollapsed);
     });
   });
+
+});
+
+Cypress.Commands.add('appendTextToEditorUntilContains', (inputText: string) => {
+  const lines: string[] = [];
+  cy.waitUntil(() => {
+    // do
+    cy.get('.CodeMirror textarea').type(inputText, { force: true });
+    // until
+    return cy.get('.CodeMirror-line')
+      .each(($item) => {
+        lines.push($item.text());
+      }).then(() => {
+        return lines.join('\n').endsWith(inputText);
+      });
+  });
 });

+ 2 - 0
apps/app/test/cypress/support/index.ts

@@ -14,6 +14,7 @@
 // ***********************************************************
 
 // Import commands.js using ES2015 syntax:
+import './assertions'
 import './commands'
 import './screenshot'
 
@@ -39,6 +40,7 @@ declare global {
        collapseSidebar(isCollapsed: boolean, waitUntilSaving?: boolean): Chainable<void>,
        waitUntilSkeletonDisappear(): Chainable<void>,
        waitUntilSpinnerDisappear(): Chainable<void>,
+       appendTextToEditorUntilContains(inputText: string): Chainable<void>
     }
   }
 }

+ 4 - 1
apps/app/test/cypress/tsconfig.json

@@ -4,7 +4,10 @@
     "noEmit": true,
     // be explicit about types included
     // to avoid clashing with Jest types
-    "types": ["cypress"]
+    "types": ["cypress"],
+    // turn off sourceMap
+    // see: https://github.com/cypress-io/cypress/issues/26203
+    "sourceMap": false
   },
   "include": [
     "../../node_modules/cypress",

+ 1 - 1
apps/app/test/integration/service/questionnaire.test.ts

@@ -53,7 +53,7 @@ describe('QuestionnaireService', () => {
         deploymentType: 'growi-docker-compose',
         type: 'on-premise',
         version: crowi.version,
-        wikiType: 'open',
+        wikiType: 'closed',
       });
     });
 

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.7-slackbot-proxy.0",
+  "version": "6.1.11-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",

+ 8 - 8
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.1.7-RC.0",
+  "version": "6.1.11-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -52,7 +52,7 @@
     "@swc-node/register": "^1.6.2",
     "@swc/core": "^1.3.36",
     "@swc/helpers": "^0.4.14",
-    "@testing-library/cypress": "^8.0.2",
+    "@testing-library/cypress": "^9.0.0",
     "@types/css-modules": "^1.0.2",
     "@types/eslint": "^8.37.0",
     "@types/estree": "^1.0.1",
@@ -60,10 +60,10 @@
     "@types/path-browserify": "^1.0.0",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
-    "@vitejs/plugin-react": "^3.1.0",
+    "@vitejs/plugin-react": "^4.0.3",
     "@vitest/coverage-c8": "^0.31.1",
     "@vitest/ui": "^0.31.1",
-    "cypress": "^12.0.1",
+    "cypress": "^12.17.2",
     "cypress-wait-until": "^1.7.2",
     "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
@@ -83,15 +83,15 @@
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
-    "reg-suit": "^0.12.1",
+    "reg-suit": "^0.12.2",
     "shx": "^0.3.4",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
     "ts-node-dev": "^2.0.0",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "~4.9",
-    "vite": "^4.3.8",
-    "vite-plugin-dts": "^2.0.0-beta.0",
+    "typescript": "~5.0.0",
+    "vite": "^4.4.0",
+    "vite-plugin-dts": "^2.3.0",
     "vite-tsconfig-paths": "^4.2.0",
     "vitest": "^0.31.4",
     "vitest-mock-extended": "^1.1.3"

+ 1 - 1
packages/core/package.json

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

+ 21 - 0
packages/core/src/interfaces/external-account.ts

@@ -0,0 +1,21 @@
+import type { Ref } from './common';
+import type { HasObjectId } from './has-object-id';
+import type { IUser } from './user';
+
+export const IExternalAuthProviderType = {
+  ldap: 'ldap',
+  saml: 'saml',
+  oicd: 'oidc',
+  google: 'google',
+  github: 'github',
+} as const;
+
+export type IExternalAuthProviderType = typeof IExternalAuthProviderType[keyof typeof IExternalAuthProviderType]
+
+export type IExternalAccount = {
+  providerType: IExternalAuthProviderType,
+  accountId: string,
+  user: Ref<IUser>,
+}
+
+export type IExternalAccountHasId = IExternalAccount & HasObjectId

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -1,6 +1,7 @@
 export * from './attachment';
 export * from './color-scheme';
 export * from './common';
+export * from './external-account';
 export * from './growi-facade';
 export * from './growi-theme-metadata';
 export * from './has-object-id';

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