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

Merge branch 'feat/135772-pdf-page-bulk-export' into feat/155548-157823-pdf-converter-release-ci

Futa Arai 1 год назад
Родитель
Сommit
ea519deb43
100 измененных файлов с 1589 добавлено и 802 удалено
  1. 1 0
      .devcontainer/.gitignore
  2. 9 0
      .devcontainer/compose.yml
  3. 2 0
      .github/workflows/ci-app.yml
  4. 3 3
      .github/workflows/codeql-analysis.yml
  5. 1 1
      .github/workflows/draft-release.yml
  6. 3 3
      .github/workflows/release-rc-scheduled.yml
  7. 2 2
      .github/workflows/release-rc.yml
  8. 4 4
      .github/workflows/release-slackbot-proxy.yml
  9. 4 4
      .github/workflows/release.yml
  10. 2 2
      .github/workflows/reusable-app-create-manifests.yml
  11. 4 14
      .github/workflows/reusable-app-prod.yml
  12. 62 1
      CHANGELOG.md
  13. 4 0
      apps/app/.env.development
  14. 4 0
      apps/app/.env.production
  15. 0 10
      apps/app/.eslintrc.js
  16. 1 0
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  17. 5 1
      apps/app/docker/docker-entrypoint.sh
  18. 15 6
      apps/app/package.json
  19. 43 37
      apps/app/playwright/20-basic-features/comments.spec.ts
  20. 2 5
      apps/app/public/static/locales/en_US/admin.json
  21. 2 1
      apps/app/public/static/locales/en_US/translation.json
  22. 102 105
      apps/app/public/static/locales/fr_FR/admin.json
  23. 16 30
      apps/app/public/static/locales/fr_FR/commons.json
  24. 67 65
      apps/app/public/static/locales/fr_FR/translation.json
  25. 1 4
      apps/app/public/static/locales/ja_JP/admin.json
  26. 2 1
      apps/app/public/static/locales/ja_JP/translation.json
  27. 2 5
      apps/app/public/static/locales/zh_CN/admin.json
  28. 2 1
      apps/app/public/static/locales/zh_CN/translation.json
  29. 1 1
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  30. 0 36
      apps/app/src/client/components/Admin/Security/FacebookSecuritySetting.jsx
  31. 1 1
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.jsx
  32. 0 8
      apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx
  33. 2 1
      apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx
  34. 10 90
      apps/app/src/client/components/ItemsTree/ItemsTree.tsx
  35. 2 3
      apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx
  36. 0 6
      apps/app/src/client/components/LoginForm/LoginForm.module.scss
  37. 1 1
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  38. 0 6
      apps/app/src/client/components/Me/AssociateModal.tsx
  39. 3 2
      apps/app/src/client/components/Me/DisassociateModal.tsx
  40. 6 7
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  41. 8 0
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss
  42. 1 3
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  43. 4 7
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  44. 2 1
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  45. 8 6
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  46. 2 2
      apps/app/src/client/components/TreeItem/interfaces/index.ts
  47. 1 1
      apps/app/src/client/util/bookmark-utils.ts
  48. 1 1
      apps/app/src/components/Layout/BasicLayout.tsx
  49. 1 1
      apps/app/src/components/PageView/PageContentFooter.module.scss
  50. 4 6
      apps/app/src/components/PageView/PageContentFooter.tsx
  51. 1 1
      apps/app/src/components/PageView/PageViewLayout.tsx
  52. 1 1
      apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx
  53. 28 28
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  54. 7 6
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  55. 2 1
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  56. 14 13
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  57. 12 10
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  58. 2 2
      apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts
  59. 16 5
      apps/app/src/features/openai/server/services/assistant/assistant.ts
  60. 1 1
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  61. 1 1
      apps/app/src/features/openai/server/services/client.ts
  62. 4 4
      apps/app/src/features/openai/server/services/cron/thread-deletion-cron.ts
  63. 4 4
      apps/app/src/features/openai/server/services/cron/vector-store-file-deletion-cron.ts
  64. 1 1
      apps/app/src/features/openai/server/services/is-ai-enabled.ts
  65. 3 3
      apps/app/src/features/openai/server/services/openai.ts
  66. 1 0
      apps/app/src/features/opentelemetry/server/index.ts
  67. 76 0
      apps/app/src/features/opentelemetry/server/logger.ts
  68. 67 0
      apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts
  69. 103 0
      apps/app/src/features/opentelemetry/server/node-sdk.ts
  70. 2 2
      apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts
  71. 2 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  72. 3 3
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  73. 2 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  74. 2 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  75. 2 2
      apps/app/src/features/questionnaire/interfaces/condition.ts
  76. 18 0
      apps/app/src/features/questionnaire/interfaces/growi-app-info.ts
  77. 0 58
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  78. 16 3
      apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts
  79. 14 4
      apps/app/src/features/questionnaire/interfaces/questionnaire-answer.ts
  80. 3 2
      apps/app/src/features/questionnaire/server/models/questionnaire-order.ts
  81. 2 2
      apps/app/src/features/questionnaire/server/models/schema/condition.ts
  82. 26 10
      apps/app/src/features/questionnaire/server/models/schema/growi-info.ts
  83. 20 11
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  84. 109 17
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts
  85. 17 7
      apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts
  86. 301 0
      apps/app/src/features/questionnaire/server/service/questionnaire.integ.ts
  87. 8 70
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  88. 9 6
      apps/app/src/features/questionnaire/server/util/condition.ts
  89. 128 0
      apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.spec.ts
  90. 40 0
      apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.ts
  91. 2 1
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  92. 1 0
      apps/app/src/interfaces/activity.ts
  93. 13 1
      apps/app/src/interfaces/attachment.ts
  94. 3 2
      apps/app/src/interfaces/crowi-request.ts
  95. 9 0
      apps/app/src/interfaces/external-auth-provider.ts
  96. 0 7
      apps/app/src/interfaces/page-listing-results.ts
  97. 38 0
      apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js
  98. 24 0
      apps/app/src/migrations/19700101000000-foremost-1010-20250109000000-generate-service-instance-id.js
  99. 2 6
      apps/app/src/migrations/20180927102719-init-serverurl.js
  100. 6 6
      apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js

+ 1 - 0
.devcontainer/.gitignore

@@ -0,0 +1 @@
+.env

+ 9 - 0
.devcontainer/compose.yml

@@ -7,8 +7,12 @@ services:
       - node_modules:/workspace/growi/node_modules
       - node_modules:/workspace/growi/node_modules
       - buildcache_app:/workspace/growi/apps/app/.next
       - buildcache_app:/workspace/growi/apps/app/.next
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
+      - ../../share:/workspace/share:delegated
       - page_bulk_export_tmp:/tmp/page-bulk-export
       - page_bulk_export_tmp:/tmp/page-bulk-export
     tty: true
     tty: true
+    networks:
+    - default
+    - opentelemetry-collector-dev-setup_default
 
 
   mongo:
   mongo:
     image: mongo:6.0
     image: mongo:6.0
@@ -56,3 +60,8 @@ volumes:
   node_modules:
   node_modules:
   buildcache_app:
   buildcache_app:
   page_bulk_export_tmp:
   page_bulk_export_tmp:
+
+networks:
+  default:
+  opentelemetry-collector-dev-setup_default:
+    external: ${OPENTELEMETRY_COLLECTOR_DEV_ENABLED:-false}

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

@@ -6,6 +6,8 @@ on:
       - release/**
       - release/**
       - rc/**
       - rc/**
       - changeset-release/**
       - changeset-release/**
+      - mergify/merge-queue/**
+      - tmp-mergify/merge-queue/**
     paths:
     paths:
       - .github/mergify.yml
       - .github/mergify.yml
       - .github/workflows/ci-app.yml
       - .github/workflows/ci-app.yml

+ 3 - 3
.github/workflows/codeql-analysis.yml

@@ -47,7 +47,7 @@ jobs:
 
 
     # Initializes the CodeQL tools for scanning.
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@v2
+      uses: github/codeql-action/init@v3
       with:
       with:
         languages: ${{ matrix.language }}
         languages: ${{ matrix.language }}
         # If you wish to specify custom queries, you can do so here or in a config file.
         # If you wish to specify custom queries, you can do so here or in a config file.
@@ -58,7 +58,7 @@ jobs:
     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).
     # If this step fails, then you should remove it and run the build manually (see below)
     # If this step fails, then you should remove it and run the build manually (see below)
     - name: Autobuild
     - name: Autobuild
-      uses: github/codeql-action/autobuild@v2
+      uses: github/codeql-action/autobuild@v3
 
 
     # ℹ️ Command-line programs to run using the OS shell.
     # ℹ️ Command-line programs to run using the OS shell.
     # 📚 https://git.io/JvXDl
     # 📚 https://git.io/JvXDl
@@ -72,4 +72,4 @@ jobs:
     #   make release
     #   make release
 
 
     - name: Perform CodeQL Analysis
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v2
+      uses: github/codeql-action/analyze@v3

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

@@ -26,7 +26,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
       - name: Retrieve information from package.json
       - name: Retrieve information from package.json
-        uses: myrotvorets/info-from-package-json-action@2.0.1
+        uses: myrotvorets/info-from-package-json-action@v2.0.2
         id: package-json
         id: package-json
 
 
       - uses: release-drafter/release-drafter@v5
       - uses: release-drafter/release-drafter@v5

+ 3 - 3
.github/workflows/release-rc-scheduled.yml

@@ -23,11 +23,11 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Docker meta for docker.io
     - name: Docker meta for docker.io
-      uses: docker/metadata-action@v4
+      uses: docker/metadata-action@v5
       id: meta
       id: meta
       with:
       with:
         images: docker.io/weseek/growi
         images: docker.io/weseek/growi
@@ -36,7 +36,7 @@ jobs:
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
 
     - name: Docker meta for ghcr.io
     - name: Docker meta for ghcr.io
-      uses: docker/metadata-action@v4
+      uses: docker/metadata-action@v5
       id: meta-ghcr
       id: meta-ghcr
       with:
       with:
         images: ghcr.io/weseek/growi
         images: ghcr.io/weseek/growi

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

@@ -23,11 +23,11 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Docker meta for docker.io
     - name: Docker meta for docker.io
-      uses: docker/metadata-action@v4
+      uses: docker/metadata-action@v5
       id: meta
       id: meta
       with:
       with:
         images: docker.io/weseek/growi
         images: docker.io/weseek/growi

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

@@ -17,14 +17,14 @@ jobs:
         ref: ${{ github.event.pull_request.base.ref }}
         ref: ${{ github.event.pull_request.base.ref }}
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
       with:
       with:
         workingDir: apps/slackbot-proxy
         workingDir: apps/slackbot-proxy
 
 
     - name: Docker meta
     - name: Docker meta
       id: meta
       id: meta
-      uses: docker/metadata-action@v4
+      uses: docker/metadata-action@v5
       with:
       with:
         images: weseek/growi-slackbot-proxy,asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy
         images: weseek/growi-slackbot-proxy,asia.gcr.io/${{ secrets.GCP_PRJ_ID_SLACKBOT_PROXY }}/growi-slackbot-proxy
         tags: |
         tags: |
@@ -36,7 +36,7 @@ jobs:
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
 
     - name: Authenticate to Google Cloud for GROWI.cloud
     - name: Authenticate to Google Cloud for GROWI.cloud
-      uses: google-github-actions/auth@v1
+      uses: google-github-actions/auth@v2
       with:
       with:
         credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
         credentials_json: '${{ secrets.GCP_SA_KEY_SLACKBOT_PROXY }}'
 
 
@@ -110,7 +110,7 @@ jobs:
         turbo run version:prerelease --filter=@growi/slackbot-proxy
         turbo run version:prerelease --filter=@growi/slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
       with:
       with:
         workingDir: apps/slackbot-proxy
         workingDir: apps/slackbot-proxy

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

@@ -40,7 +40,7 @@ jobs:
         sh ./apps/app/bin/github-actions/update-readme.sh
         sh ./apps/app/bin/github-actions/update-readme.sh
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Update Changelog
     - name: Update Changelog
@@ -86,11 +86,11 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Docker meta for docker.io
     - name: Docker meta for docker.io
-      uses: docker/metadata-action@v4
+      uses: docker/metadata-action@v5
       id: meta
       id: meta
       with:
       with:
         images: docker.io/weseek/growi
         images: docker.io/weseek/growi
@@ -180,7 +180,7 @@ jobs:
         turbo run version:prepatch --filter=@growi/slackbot-proxy
         turbo run version:prepatch --filter=@growi/slackbot-proxy
 
 
     - name: Retrieve information from package.json
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@2.0.1
+      uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
     - name: Commit
     - name: Commit

+ 2 - 2
.github/workflows/reusable-app-create-manifests.yml

@@ -29,7 +29,7 @@ jobs:
     steps:
     steps:
     - name: Docker meta for extra-images
     - name: Docker meta for extra-images
       id: meta-extra-images
       id: meta-extra-images
-      uses: docker/metadata-action@v4
+      uses: docker/metadata-action@v5
       with:
       with:
         images: ${{ inputs.registry }}/${{ inputs.image-name }}
         images: ${{ inputs.registry }}/${{ inputs.image-name }}
         sep-tags: ','
         sep-tags: ','
@@ -38,7 +38,7 @@ jobs:
           type=raw,value=${{ inputs.tag-temporary }}-arm64
           type=raw,value=${{ inputs.tag-temporary }}-arm64
 
 
     - name: Login to Container Registry
     - name: Login to Container Registry
-      uses: docker/login-action@v2
+      uses: docker/login-action@v3
       with:
       with:
         registry: ${{ inputs.registry }}
         registry: ${{ inputs.registry }}
         username: wsmoogle
         username: wsmoogle

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

@@ -50,18 +50,6 @@ jobs:
       run: |
       run: |
         pnpm install --frozen-lockfile
         pnpm install --frozen-lockfile
 
 
-    - name: Cache/Restore dist
-      uses: actions/cache@v4
-      with:
-        path: |
-          **/.turbo
-          **/dist
-          **/node_modules/.cache/turbo
-          ${{ github.workspace }}/apps/app/.next
-        key: dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-${{ github.sha }}
-        restore-keys: |
-          dist-app-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
-
     - name: Build
     - name: Build
       working-directory: ./apps/app
       working-directory: ./apps/app
       run: |
       run: |
@@ -181,7 +169,7 @@ jobs:
     container:
     container:
       # Match the Playwright version
       # Match the Playwright version
       # https://github.com/microsoft/playwright/issues/20010
       # https://github.com/microsoft/playwright/issues/20010
-      image: mcr.microsoft.com/playwright:v1.46.0-jammy
+      image: mcr.microsoft.com/playwright:v1.49.1-jammy
 
 
     strategy:
     strategy:
       fail-fast: false
       fail-fast: false
@@ -315,7 +303,9 @@ jobs:
         pnpm install --frozen-lockfile
         pnpm install --frozen-lockfile
 
 
     - name: Merge into HTML Report
     - name: Merge into HTML Report
-      run: pnpm playwright merge-reports --reporter html ./all-blob-reports
+      run: |
+        mkdir -p all-blob-reports
+        pnpm playwright merge-reports --reporter html ./all-blob-reports
 
 
     - name: Upload HTML report
     - name: Upload HTML report
       uses: actions/upload-artifact@v4
       uses: actions/upload-artifact@v4

+ 62 - 1
CHANGELOG.md

@@ -1,9 +1,70 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.1.5...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.1.9...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.1.9](https://github.com/weseek/growi/compare/v7.1.8...v7.1.9) - 2025-02-03
+
+### 💎 Features
+
+* feat: Add error handling for data migration (#9582) @miya
+
+### 🚀 Improvement
+
+* imprv: Data migration script performance (#9599) @miya
+* imprv: Initialization for Passport strategies (#9353) @yuki-takei
+* imprv: Make data migration type safe (#9590) @miya
+* imprv: Printing styles (#9576) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Serializing page data for share link (#9602) @yuki-takei
+
+### 🧰 Maintenance
+
+* ci(deps-dev): bump vite from 5.4.6 to 5.4.12 (#9574) @dependabot
+* ci(deps): bump mongoose from 6.13.0 to 6.13.6 (#9570) @dependabot
+* ci(deps): bump katex from 0.16.11 to 0.16.21 (#9564) @dependabot
+
+## [v7.1.8](https://github.com/weseek/growi/compare/v7.1.7...v7.1.8) - 2025-01-21
+
+### 🐛 Bug Fixes
+
+* fix: Escape page path when generating RegExp to find ancestors children (#9550) @yuki-takei
+
+## [v7.1.7](https://github.com/weseek/growi/compare/v7.1.6...v7.1.7) - 2025-01-16
+
+### 🐛 Bug Fixes
+
+* fix: Unable to select group viewing permissions (#9541) @miya
+* fix: Fix i18n of oidc settings (#9536) @ryu-sato
+
+### 🧰 Maintenance
+
+* support: Update Swagger documentation for the PUT endpoint to update a page (#9529) @tkfm1991
+* ci(deps): bump docker/metadata-action from 4 to 5 (#9181) @dependabot
+* ci(deps): bump github/codeql-action from 2 to 3 (#9180) @dependabot
+* ci(deps): bump next from 14.2.15 to 14.2.21 (#9538) @dependabot
+* ci(deps-dev): bump @marp-team/marp-core from 3.9.0 to 3.9.1 (#9530) @dependabot
+
+## [v7.1.6](https://github.com/weseek/growi/compare/v7.1.5...v7.1.6) - 2024-12-26
+
+### 💎 Features
+
+* feat(ai): Save file to VectorStore in HTML format   (#9462) @miya
+
+### 🐛 Bug Fixes
+
+* fix: remark-lsx pagination (#9513) @miya
+* fix: Spelling miss of external_link in i18n (#9456) @reiji-h
+* fix: Wider copy to clipboard area (#9450) @Ryosei-Fukushima
+* fix: Error when creating pages with deep hierarchy (#9487) @reiji-h
+
+### 🧰 Maintenance
+
+* ci(deps): bump next from 14.2.13 to 14.2.15 (#9501) @dependabot
+
 ## [v7.1.5](https://github.com/weseek/growi/compare/v7.1.4...v7.1.5) - 2024-12-13
 ## [v7.1.5](https://github.com/weseek/growi/compare/v7.1.4...v7.1.5) - 2024-12-13
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 4 - 0
apps/app/.env.development

@@ -30,3 +30,7 @@ QUESTIONNAIRE_SERVER_ORIGIN="http://host.docker.internal:3003"
 # AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
 # AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
 # AUDIT_LOG_ADDITIONAL_ACTIONS=
 # AUDIT_LOG_ADDITIONAL_ACTIONS=
 # AUDIT_LOG_EXCLUDE_ACTIONS=
 # AUDIT_LOG_EXCLUDE_ACTIONS=
+
+# OpenTelemetry Configuration
+OPENTELEMETRY_ENABLED=false
+OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317

+ 4 - 0
apps/app/.env.production

@@ -4,3 +4,7 @@
 ##
 ##
 FORMAT_NODE_LOG=false
 FORMAT_NODE_LOG=false
 MIGRATIONS_DIR=dist/migrations/
 MIGRATIONS_DIR=dist/migrations/
+
+# OpenTelemetry Configuration
+OTEL_TRACES_SAMPLER_ARG=0.1
+

+ 0 - 10
apps/app/.eslintrc.js

@@ -4,7 +4,6 @@ module.exports = {
     'weseek/react',
     'weseek/react',
   ],
   ],
   plugins: [
   plugins: [
-    'regex',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript
@@ -17,15 +16,6 @@ module.exports = {
       name: 'axios',
       name: 'axios',
       message: 'Please use src/utils/axios instead.',
       message: 'Please use src/utils/axios instead.',
     }],
     }],
-    'regex/invalid': ['error', [
-      {
-        regex: '\\?\\<\\!',
-        message: 'Do not use any negative lookbehind',
-      }, {
-        regex: '\\?\\<\\=',
-        message: 'Do not use any Positive lookbehind',
-      },
-    ]],
     '@typescript-eslint/no-var-requires': 'off',
     '@typescript-eslint/no-var-requires': 'off',
 
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 1 - 0
apps/app/bin/swagger-jsdoc/definition-apiv3.js

@@ -81,6 +81,7 @@ module.exports = {
         'UserGroups',
         'UserGroups',
         'Users Management',
         'Users Management',
         'FullTextSearch Management',
         'FullTextSearch Management',
+        'Install',
       ],
       ],
     },
     },
     {
     {

+ 5 - 1
apps/app/docker/docker-entrypoint.sh

@@ -7,8 +7,12 @@ mkdir -p /data/uploads
 if [ ! -e "./public/uploads" ]; then
 if [ ! -e "./public/uploads" ]; then
   ln -s /data/uploads ./public/uploads
   ln -s /data/uploads ./public/uploads
 fi
 fi
-
 chown -R node:node /data/uploads
 chown -R node:node /data/uploads
 chown -h node:node ./public/uploads
 chown -h node:node ./public/uploads
 
 
+# Set permissions for shared directory for bulk export
+mkdir -p /tmp/page-bulk-export
+chown -R node:node /tmp/page-bulk-export
+chmod 700 /tmp/page-bulk-export
+
 exec gosu node /bin/bash -c "$@"
 exec gosu node /bin/bash -c "$@"

+ 15 - 6
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.1.6-RC.0",
+  "version": "7.2.0-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -21,7 +21,6 @@
     "dev:pre:styles": "pnpm run pre:styles --mode dev",
     "dev:pre:styles": "pnpm run pre:styles --mode dev",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate-mongo": "cross-env NODE_ENV=development pnpm run ts-node node_modules/migrate-mongo/bin/migrate-mongo",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
     "dev:migrate": "pnpm run dev:migrate:status > tmp/cache/migration-status.out && pnpm run dev:migrate:up",
-    "dev:migrate:create": "pnpm run dev:migrate-mongo create -f config/migrate-mongo-config.js",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:status": "pnpm run dev:migrate-mongo status -f config/migrate-mongo-config.js",
     "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:up": "pnpm run dev:migrate-mongo up -f config/migrate-mongo-config.js",
     "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
     "dev:migrate:down": "pnpm run dev:migrate-mongo down -f config/migrate-mongo-config.js",
@@ -85,6 +84,15 @@
     "@growi/slack": "workspace:^",
     "@growi/slack": "workspace:^",
     "@growi/pdf-converter-client": "workspace:^",
     "@growi/pdf-converter-client": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@keycloak/keycloak-admin-client": "^18.0.0",
+    "@opentelemetry/api": "^1.9.0",
+    "@opentelemetry/auto-instrumentations-node": "^0.55.1",
+    "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0",
+    "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
+    "@opentelemetry/resources": "^1.28.0",
+    "@opentelemetry/semantic-conventions": "^1.28.0",
+    "@opentelemetry/sdk-metrics": "^1.28.0",
+    "@opentelemetry/sdk-node": "^0.57.0",
+    "@opentelemetry/sdk-trace-node": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@slack/webhook": "^6.0.0",
     "@types/async": "^3.2.24",
     "@types/async": "^3.2.24",
@@ -94,6 +102,7 @@
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
     "axios": "^0.24.0",
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "axios-retry": "^3.2.4",
+    "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
     "body-parser": "^1.20.3",
     "browser-bunyan": "^1.8.0",
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bson-objectid": "^2.0.4",
@@ -136,7 +145,7 @@
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
     "js-tiktoken": "^1.0.15",
     "js-tiktoken": "^1.0.15",
     "js-yaml": "^4.1.0",
     "js-yaml": "^4.1.0",
-    "katex": "^0.16.11",
+    "katex": "^0.16.21",
     "ldapjs": "^3.0.2",
     "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^3.0.3",
     "markdown-table": "^3.0.3",
@@ -151,17 +160,17 @@
     "migrate-mongo": "^11.0.0",
     "migrate-mongo": "^11.0.0",
     "mkdirp": "^1.0.3",
     "mkdirp": "^1.0.3",
     "mongodb": "^4.17.2",
     "mongodb": "^4.17.2",
-    "mongoose": "^6.11.3",
+    "mongoose": "^6.13.6",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-gridfs": "^1.2.42",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
-    "next": "^14.2.15",
+    "next": "^14.2.21",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-i18next": "^15.3.1",
-    "next-superjson": "^1.0.7",
+    "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
     "node-cron": "^3.0.2",

+ 43 - 37
apps/app/playwright/20-basic-features/comments.spec.ts

@@ -1,49 +1,55 @@
 import { test, expect } from '@playwright/test';
 import { test, expect } from '@playwright/test';
 
 
-test('Create comment page', async({ page }) => {
-  await page.goto('/comment');
-  await page.getByTestId('editor-button').click();
-  await page.getByTestId('save-page-btn').click();
-  await expect(page.locator('.page-meta')).toBeVisible();
-});
+test.describe('Comment', () => {
 
 
-test('Successfully add comments', async({ page }) => {
-  const commentText = 'add comment';
-  await page.goto('/comment');
+  // make tests run in serial
+  test.describe.configure({ mode: 'serial' });
 
 
-  // Add comment
-  await page.getByTestId('page-comment-button').click();
-  await page.getByTestId('open-comment-editor-button').click();
-  await page.locator('.cm-content').fill(commentText);
-  await page.getByTestId('comment-submit-button').first().click();
+  test('Create comment page', async({ page }) => {
+    await page.goto('/comment');
+    await page.getByTestId('editor-button').click();
+    await page.getByTestId('save-page-btn').click();
+    await expect(page.locator('.page-meta')).toBeVisible();
+  });
 
 
-  await expect(page.locator('.page-comment-body')).toHaveText(commentText);
-  await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('1');
-});
+  test('Successfully add comments', async({ page }) => {
+    const commentText = 'add comment';
+    await page.goto('/comment');
 
 
-test('Successfully reply comments', async({ page }) => {
-  const commentText = 'reply comment';
-  await page.goto('/comment');
+    // Add comment
+    await page.getByTestId('page-comment-button').click();
+    await page.getByTestId('open-comment-editor-button').click();
+    await page.locator('.cm-content').fill(commentText);
+    await page.getByTestId('comment-submit-button').first().click();
 
 
-  // Reply comment
-  await page.getByTestId('page-comment-button').click();
-  await page.getByTestId('comment-reply-button').click();
-  await page.locator('.cm-content').fill(commentText);
-  await page.getByTestId('comment-submit-button').first().click();
+    await expect(page.locator('.page-comment-body')).toHaveText(commentText);
+    await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('1');
+  });
 
 
-  await expect(page.locator('.page-comment-body').nth(1)).toHaveText(commentText);
-  await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('2');
-});
+  test('Successfully reply comments', async({ page }) => {
+    const commentText = 'reply comment';
+    await page.goto('/comment');
+
+    // Reply comment
+    await page.getByTestId('comment-reply-button').click();
+    await page.locator('.cm-content').fill(commentText);
+    await page.getByTestId('comment-submit-button').first().click();
 
 
-// test('Successfully delete comments', async({ page }) => {
-//   await page.goto('/comment');
+    await expect(page.locator('.page-comment-body').nth(1)).toHaveText(commentText);
+    await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('2');
+  });
 
 
-//   await page.getByTestId('page-comment-button').click();
-//   await page.getByTestId('comment-delete-button').first().click({ force: true });
-//   await expect(page.getByTestId('page-comment-delete-modal')).toBeVisible();
-//   await page.getByTestId('delete-comment-button').click();
+  // test('Successfully delete comments', async({ page }) => {
+  //   await page.goto('/comment');
 
 
-//   await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('0');
-// });
+  //   await page.getByTestId('page-comment-button').click();
+  //   await page.getByTestId('comment-delete-button').first().click({ force: true });
+  //   await expect(page.getByTestId('page-comment-delete-modal')).toBeVisible();
+  //   await page.getByTestId('delete-comment-button').click();
 
 
-// TODO: https://redmine.weseek.co.jp/issues/139520
+  //   await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('0');
+  // });
+
+  // TODO: https://redmine.weseek.co.jp/issues/139520
+
+});

+ 2 - 5
apps/app/public/static/locales/en_US/admin.json

@@ -185,9 +185,6 @@
         "register_5": "Copy and paste your ClientID and Client Secret above",
         "register_5": "Copy and paste your ClientID and Client Secret above",
         "updated_google": "Succeeded to update Google OAuth setting"
         "updated_google": "Succeeded to update Google OAuth setting"
       },
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
       "GitHub": {
         "enable_github": "Enable GitHub OAuth",
         "enable_github": "Enable GitHub OAuth",
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
@@ -201,9 +198,9 @@
         "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
         "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
         "username_detail": "Specification of mappings for <code>username</code> when creating new users",
         "username_detail": "Specification of mappings for <code>username</code> when creating new users",
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
-        "mapping_detail": "Specification of mappings for %s when creating new users",
+        "mapping_detail": "Specification of mappings for {{target}} when creating new users",
         "register_1": "Contact to OIDC IdP Administrator",
         "register_1": "Contact to OIDC IdP Administrator",
-        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
+        "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>{{url}}</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above",
         "register_3": "Copy and paste your ClientID and Client Secret above",
         "updated_oidc": "Succeeded to update OpenID Connect",
         "updated_oidc": "Succeeded to update OpenID Connect",
         "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
         "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"

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

@@ -758,7 +758,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
     "you_cannot_move_this_page_now": "You cannot move this page now",
     "you_cannot_move_this_page_now": "You cannot move this page now",
-    "something_went_wrong_with_moving_page": "Something went wrong with moving page"
+    "something_went_wrong_with_moving_page": "Something went wrong with moving page",
+    "error_retrieving_the_pagetree": "Error occurred while retrieving the PageTree"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",
     "same_page_name_exists": "Same page name exits as「{{pageName}}」",

+ 102 - 105
apps/app/public/static/locales/fr_FR/admin.json

@@ -3,7 +3,7 @@
     "display_name": "Français"
     "display_name": "Français"
   },
   },
   "last_login": "Dernière connexion",
   "last_login": "Dernière connexion",
-  "wiki_management_homepage": "Gestion du wiki",
+  "wiki_management_homepage": "Système",
   "public": "Public",
   "public": "Public",
   "anyone_with_the_link": "Tous les utilisateurs disposant du lien",
   "anyone_with_the_link": "Tous les utilisateurs disposant du lien",
   "specified_users": "Utilisateurs spécifiés",
   "specified_users": "Utilisateurs spécifiés",
@@ -12,7 +12,7 @@
   "optional": "Optionnel",
   "optional": "Optionnel",
   "days": "jours",
   "days": "jours",
   "security_settings": {
   "security_settings": {
-    "security_settings": "Paramètres de sécurité",
+    "security_settings": "Sécurité",
     "scope_of_page_disclosure": "Confidentialité de la page",
     "scope_of_page_disclosure": "Confidentialité de la page",
     "set_point": "Valeur",
     "set_point": "Valeur",
     "Guest Users Access": "Accès invité",
     "Guest Users Access": "Accès invité",
@@ -30,13 +30,13 @@
     "for_example": " Par exemple, pour restreindre l'inscription aux utilisateurs dans le domaine growi.org, ajouter ",
     "for_example": " Par exemple, pour restreindre l'inscription aux utilisateurs dans le domaine growi.org, ajouter ",
     "in_this_case": "; dans ce cas particulier, seul les utilisateurs du domaine growi.org peuvent s'inscrire.",
     "in_this_case": "; dans ce cas particulier, seul les utilisateurs du domaine growi.org peuvent s'inscrire.",
     "insert_single": "Insérer une adresse courriel par ligne",
     "insert_single": "Insérer une adresse courriel par ligne",
-    "page_list_and_search_results": "Liste et recherche de pages",
+    "page_list_and_search_results": "Affichage des pages",
     "page_listing_1": "Liste et recherche de pages<br>restreint à 'Seulement moi'",
     "page_listing_1": "Liste et recherche de pages<br>restreint à 'Seulement moi'",
     "page_listing_1_desc": "Voir les pages restreintes à 'Seulement moi' lors de la recherche",
     "page_listing_1_desc": "Voir les pages restreintes à 'Seulement moi' lors de la recherche",
     "page_listing_2": "Liste et recherche de pages<br>restreint au groupe utilisateur",
     "page_listing_2": "Liste et recherche de pages<br>restreint au groupe utilisateur",
     "page_listing_2_desc": "Voir les pages restreintes au groupe utilisateur lors de la recherche",
     "page_listing_2_desc": "Voir les pages restreintes au groupe utilisateur lors de la recherche",
-    "page_access_rights": "Droits de lecture",
-    "page_delete_rights": "Droits de suppression",
+    "page_access_rights": "Lecture",
+    "page_delete_rights": "Suppression",
     "page_delete": "Suppression de page",
     "page_delete": "Suppression de page",
     "page_delete_completely": "Suppression complète de page",
     "page_delete_completely": "Suppression complète de page",
     "comment_manage_rights": "Droits de gestion des commentaires",
     "comment_manage_rights": "Droits de gestion des commentaires",
@@ -53,8 +53,8 @@
     "anyone": "Tout le monde",
     "anyone": "Tout le monde",
     "user_homepage_deletion": {
     "user_homepage_deletion": {
       "user_homepage_deletion": "Suppression de page d'accueil utilisateur",
       "user_homepage_deletion": "Suppression de page d'accueil utilisateur",
-      "enable_user_homepage_deletion": "Activer la suppression de page d'accueil utilisateur",
-      "enable_force_delete_user_homepage_on_user_deletion": "Lorsqu'un utilisateur est supprimé, sa page d'accueil et ses sous-pages sont supprimées.",
+      "enable_user_homepage_deletion": "Suppression de page d'accueil utilisateur",
+      "enable_force_delete_user_homepage_on_user_deletion": "Supprimer la page d'accueil et ses pages enfants",
       "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
       "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
     },
     },
     "session": "Session",
     "session": "Session",
@@ -89,8 +89,8 @@
       "readonly": "Autoriser (Lecture seule)"
       "readonly": "Autoriser (Lecture seule)"
     },
     },
     "read_only_users_comment": {
     "read_only_users_comment": {
-      "deny": "Refuser (Interdire la gestion des commentaires aux utilisateurs lecture seule)",
-      "accept": "Autoriser (Les utilisateurs lecture seule peuvent gérer les commentaires)"
+      "deny": "Ne peut pas commenter",
+      "accept": "Peut commenter"
     },
     },
     "registration_mode": {
     "registration_mode": {
       "open": "Ouvert (Tout le monde peut s'inscrire)",
       "open": "Ouvert (Tout le monde peut s'inscrire)",
@@ -98,9 +98,9 @@
       "closed": "Fermé (Invitation seulement)"
       "closed": "Fermé (Invitation seulement)"
     },
     },
     "share_link_management": "Gestion des liens de partage",
     "share_link_management": "Gestion des liens de partage",
-    "No_share_links":"Aucun liens de partage",
-    "share_link_notice":"Retirer les liens de partage",
-    "delete_all_share_links":"Supprimer tout les liens de partage",
+    "No_share_links": "Aucun liens de partage",
+    "share_link_notice": "Retirer les liens de partage",
+    "delete_all_share_links": "Supprimer tout les liens de partage",
     "share_link_rights": "Permissions de liens de partage",
     "share_link_rights": "Permissions de liens de partage",
     "enable_link_sharing": "Activer les liens de partage",
     "enable_link_sharing": "Activer les liens de partage",
     "all_share_links": "Liens de partage",
     "all_share_links": "Liens de partage",
@@ -185,9 +185,6 @@
         "register_5": "Copier l'ID client et Secret client ci-dessus",
         "register_5": "Copier l'ID client et Secret client ci-dessus",
         "updated_google": "Paramètres mis à jour"
         "updated_google": "Paramètres mis à jour"
       },
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
       "GitHub": {
         "enable_github": "Activer GitHub OAuth",
         "enable_github": "Activer GitHub OAuth",
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
@@ -201,9 +198,9 @@
         "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
         "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
         "username_detail": "Spécifications des liaisons <code>username</code> lors de la création de nouveaux utilisateurs",
         "username_detail": "Spécifications des liaisons <code>username</code> lors de la création de nouveaux utilisateurs",
         "name_detail": "Spécifications des liaisons <code>name</code> lors de la création de nouveaux utilisateurs",
         "name_detail": "Spécifications des liaisons <code>name</code> lors de la création de nouveaux utilisateurs",
-        "mapping_detail": "Spécifications des liaisons pour %s lors de la création de nouveaux utilisateurs",
+        "mapping_detail": "Spécifications des liaisons pour {{target}} lors de la création de nouveaux utilisateurs",
         "register_1": "Contacter votre administrateur OIDC",
         "register_1": "Contacter votre administrateur OIDC",
-        "register_2": "Configurer l'application OAuth avec l'un des URL de redirection autorisés avec <code>%s</code>",
+        "register_2": "Configurer l'application OAuth avec l'un des URL de redirection autorisés avec <code>{{url}}</code>",
         "register_3": "Copier l'ID client et Secret client ci-dessus",
         "register_3": "Copier l'ID client et Secret client ci-dessus",
         "updated_oidc": "Paramètres mis à jour",
         "updated_oidc": "Paramètres mis à jour",
         "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
         "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
@@ -233,7 +230,7 @@
     "prioritize_webhook_desc": "Activer cette option utilisera les webhook entrants plutôt que Slack.",
     "prioritize_webhook_desc": "Activer cette option utilisera les webhook entrants plutôt que Slack.",
     "slack_app_configuration": "Configuration de l'application Slack",
     "slack_app_configuration": "Configuration de l'application Slack",
     "slack_app_configuration_desc": "Cette méthode n'est pas recommandée, car trop complexe.",
     "slack_app_configuration_desc": "Cette méthode n'est pas recommandée, car trop complexe.",
-    "use_instead":"Utiliser plutôt les webhook entrants Slack",
+    "use_instead": "Utiliser plutôt les webhook entrants Slack",
     "how_to": {
     "how_to": {
       "header": "Comment configurer un webhook entrant?",
       "header": "Comment configurer un webhook entrant?",
       "workspace": "(Dans le Workspace) Ajouter un webhook",
       "workspace": "(Dans le Workspace) Ajouter un webhook",
@@ -278,7 +275,7 @@
     "not_found_global_notification_triggerid": "ID global de notification introuvable"
     "not_found_global_notification_triggerid": "ID global de notification introuvable"
   },
   },
   "full_text_search_management": {
   "full_text_search_management": {
-    "full_text_search_management": "Configuration de la recherche",
+    "full_text_search_management": "Moteur de recherche",
     "elasticsearch_management": "Configuration Elasticsearch",
     "elasticsearch_management": "Configuration Elasticsearch",
     "connection_status": "Statut",
     "connection_status": "Statut",
     "connection_status_label_unconfigured": "UNCONFIGURED",
     "connection_status_label_unconfigured": "UNCONFIGURED",
@@ -300,23 +297,23 @@
     "rebuild_description_1": "Reconstruire l'index est les données de pages",
     "rebuild_description_1": "Reconstruire l'index est les données de pages",
     "rebuild_description_2": "Cela peut prendre un certain temps."
     "rebuild_description_2": "Cela peut prendre un certain temps."
   },
   },
-  "mailer_setup_required":"<a href='/admin/app'>Configuration Email</a> sont requis pour envoyer.",
+  "mailer_setup_required": "La <a href='/admin/app'>configuration du SMTP</a> est requise.",
   "admin_top": {
   "admin_top": {
     "management_wiki": "Configuration du wiki",
     "management_wiki": "Configuration du wiki",
     "system_information": "Information système",
     "system_information": "Information système",
-    "wiki_administrator": "Seuls les administrateurs peuvent accéder à cette page",
+    "wiki_administrator": "Seuls les administrateurs peuvent accéder à cette page.",
     "assign_administrator": "Il est possible d'assigner l'accès administrateur en utilisant le bouton 'Ajouter accès administrateur'",
     "assign_administrator": "Il est possible d'assigner l'accès administrateur en utilisant le bouton 'Ajouter accès administrateur'",
     "package_name": "Nom du paquet",
     "package_name": "Nom du paquet",
     "specified_version": "Version spécifiée",
     "specified_version": "Version spécifiée",
     "installed_version": "Version installée",
     "installed_version": "Version installée",
-    "list_of_env_vars":"Variables d'environnement",
-    "env_var_priority": "Les valeurs de la base de données sont priorisées.",
-    "about_security": "Voir les <a href='/admin/security'>paramètres de sécurité</a> pour les variables d'environnement de sécurité.",
+    "list_of_env_vars": "Variables d'environnement",
+    "env_var_priority": "Les valeurs enregistrées dans la base de données sont priorisées.",
+    "about_security": "Pour les variables des paramètres de sécurité, consulter les <a href='/admin/security'>paramètres de sécurité.</a>",
     "copy_prefilled_host_information": {
     "copy_prefilled_host_information": {
       "default": "Copier les informations",
       "default": "Copier les informations",
       "done": "Copié dans le presse-papier!"
       "done": "Copié dans le presse-papier!"
     },
     },
-    "bug_report": "Soumettre un rapport de bogue",
+    "bug_report": "Informations de diagnostic",
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
@@ -346,36 +343,36 @@
     "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\"."
     "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\"."
   },
   },
   "app_setting": {
   "app_setting": {
-    "site_name": "Nom du site",
+    "site_name": "Nom",
     "sitename_change": "Le nom du site utilisé dans l'en-tête et le titre HTML.",
     "sitename_change": "Le nom du site utilisé dans l'en-tête et le titre HTML.",
     "header_content": "Le contenu entré ici sera affiché dans l'en-tête, etc. ",
     "header_content": "Le contenu entré ici sera affiché dans l'en-tête, etc. ",
     "site_url": {
     "site_url": {
-      "title": "Configuration de l'URL du site",
-      "desc": "Configuration de l'URL du site",
-      "warn": "Certaines fonctionnalitées peuvent ne pas fonctionner tant que l'URL du site n'est pas définie.",
-      "help": "URL complet du site démarrant par <code>http://</code> ou <code>https://</code>.",
+      "title": "Adresse publique",
+      "desc": "Adresse URL publique de l'application.",
+      "warn": "Certaines fonctionnalitées sont restreintes tant que l'URL du site n'est pas définie.",
+      "help": "URL complète démarrant par <code>http://</code> ou <code>https://</code>.",
       "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> ."
       "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> ."
     },
     },
-    "confidential_name": "Nom confidentiel",
+    "confidential_name": "Nom interne",
     "confidential_example": "ex): usage interne seulement",
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
     "default_language": "Langue par défaut",
-    "default_mail_visibility": "Afficher l'adresse courriel pour les nouveaux utilisateurs",
+    "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
     "file_uploading": "Téléversement de fichiers",
     "file_uploading": "Téléversement de fichiers",
-    "enable_files_except_image": "Autorise le téléversement de fichiers de n'importe quel type. Lorsque désactivé, seul les fichiers de type image sont autorisés.",
-    "attach_enable": "Autorise le téléversement de fichiers de n'importe quel type",
+    "enable_files_except_image": "Autoriser tout les types de fichiers",
+    "attach_enable": "Autorise le téléversement de tout les types de fichiers.",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
     "page_bulk_export_warning": "La fonctionnalité d’exportation de pages en masse est disponible pour tous les utilisateurs. Afin de maintenir les ressources du système, nous demandons votre coopération pour utiliser le montant minimum nécessaire. Si vous êtes administrateur, veuillez en informer tous les utilisateurs.",
     "page_bulk_export_warning": "La fonctionnalité d’exportation de pages en masse est disponible pour tous les utilisateurs. Afin de maintenir les ressources du système, nous demandons votre coopération pour utiliser le montant minimum nécessaire. Si vous êtes administrateur, veuillez en informer tous les utilisateurs.",
     "page_bulk_export_storage_period": "Date limite de téléchargement",
     "page_bulk_export_storage_period": "Date limite de téléchargement",
     "update": "Sauvegarder",
     "update": "Sauvegarder",
-    "mail_settings": "Configuration e-mail",
-    "mailer_is_not_set_up": "Paramètres e-mail non configurés.",
+    "mail_settings": "SMTP",
+    "mailer_is_not_set_up": "Paramètres d'envoi de courriels non configurés.",
     "from_e-mail_address": "Adresse courriel <code>from</code>",
     "from_e-mail_address": "Adresse courriel <code>from</code>",
-    "transmission_method":"Méthode de transmission",
-    "smtp_label":"SMTP",
-    "ses_label":"SES(AWS)",
-    "send_test_email": "Envoi d'un courriel d'essai",
+    "transmission_method": "Mode",
+    "smtp_label": "SMTP",
+    "ses_label": "SES(AWS)",
+    "send_test_email": "Courriel d'essai",
     "success_to_send_test_email": "Courriel d'essai envoyé",
     "success_to_send_test_email": "Courriel d'essai envoyé",
     "smtp_settings": "Configuration SMTP",
     "smtp_settings": "Configuration SMTP",
     "host": "Hôte",
     "host": "Hôte",
@@ -384,13 +381,13 @@
     "initialize_mail_settings": "réinitialiser les paramètres e-mail",
     "initialize_mail_settings": "réinitialiser les paramètres e-mail",
     "initialize_mail_modal_header": "Réinitialiser les paramètres e-mail",
     "initialize_mail_modal_header": "Réinitialiser les paramètres e-mail",
     "confirm_to_initialize_mail_settings": "Les valeurs existantes seront écrasées. Réinitialiser les paramètres e-mail?",
     "confirm_to_initialize_mail_settings": "Les valeurs existantes seront écrasées. Réinitialiser les paramètres e-mail?",
-    "file_upload_settings":"Configuration du téléversement",
-    "file_upload_method":"Méthode de téléversement",
-    "file_delivery_method":"Méthode de récupération",
-    "file_delivery_method_redirect":"Rediriger",
-    "file_delivery_method_relay":"Relai interne du système",
-    "file_delivery_method_redirect_info":"Rediriger: Redirige vers une URL signé, performance excellente.",
-    "file_delivery_method_relay_info":"Relai interne du système: Le serveur GROWI sert les fichiers directement au client, sécurité complète.",
+    "file_upload_settings": "Téléversement de fichiers",
+    "file_upload_method": "Mode",
+    "file_delivery_method": "Méthode de récupération",
+    "file_delivery_method_redirect": "Rediriger",
+    "file_delivery_method_relay": "Relai interne du système",
+    "file_delivery_method_redirect_info": "Rediriger: Redirige vers une URL signé, performance excellente.",
+    "file_delivery_method_relay_info": "Relai interne du système: Le serveur GROWI sert les fichiers directement au client, sécurité complète.",
     "fixed_by_env_var": "Défini par une variable d'environnement <code>{{envKey}}={{envVar}}</code>.",
     "fixed_by_env_var": "Défini par une variable d'environnement <code>{{envKey}}={{envVar}}</code>.",
     "gcs_label": "GCP(GCS)",
     "gcs_label": "GCP(GCS)",
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
@@ -416,7 +413,7 @@
     "disable": "Désactiver",
     "disable": "Désactiver",
     "use_env_var_if_empty": "Si la valeur dans la base de données est vide, la valeur de variable d'environnement <code>{{variable}}</code> est utilisé.",
     "use_env_var_if_empty": "Si la valeur dans la base de données est vide, la valeur de variable d'environnement <code>{{variable}}</code> est utilisé.",
     "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> .",
     "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> .",
-    "questionnaire_settings": "Données analytiques",
+    "questionnaire_settings": "Sondages anonymes",
     "questionnaire_settings_explanation": "Paramètres d'activation des données analytiques. L'utilisateur peut choisir ce paramètre individuellement dans \"Autres paramètres\".",
     "questionnaire_settings_explanation": "Paramètres d'activation des données analytiques. L'utilisateur peut choisir ce paramètre individuellement dans \"Autres paramètres\".",
     "about_data_sent": "À propos",
     "about_data_sent": "À propos",
     "docs_link": "https://docs.growi.org/en/admin-guide/management-cookbook/app-settings.html#questionnaire-settings",
     "docs_link": "https://docs.growi.org/en/admin-guide/management-cookbook/app-settings.html#questionnaire-settings",
@@ -428,46 +425,46 @@
     "enable_questionnaire": "Activer les données analytiques"
     "enable_questionnaire": "Activer les données analytiques"
   },
   },
   "markdown_settings": {
   "markdown_settings": {
-    "markdown_settings": "Configuration Markdown",
-    "lineBreak_header": "Configuration du saut de ligne",
-    "lineBreak_desc": "Configuration du saut de ligne.",
+    "markdown_settings": "Markdown",
+    "lineBreak_header": "Saut de ligne",
+    "lineBreak_desc": "Conversion du saut de ligne automatique.",
     "lineBreak_options": {
     "lineBreak_options": {
-      "enable_lineBreak": "Activer le saut de ligne",
+      "enable_lineBreak": "Saut de ligne",
       "enable_lineBreak_desc": "Convertir le saut de ligne<code>&lt;br&gt;</code>en HTML",
       "enable_lineBreak_desc": "Convertir le saut de ligne<code>&lt;br&gt;</code>en HTML",
-      "enable_lineBreak_for_comment": "Activer le saut de ligne dans les commentaires",
+      "enable_lineBreak_for_comment": "Saut de ligne dans les commentaires",
       "enable_lineBreak_for_comment_desc": "Convertir le saut de ligne dans les commentaires<code>&lt;br&gt;</code>en HTML"
       "enable_lineBreak_for_comment_desc": "Convertir le saut de ligne dans les commentaires<code>&lt;br&gt;</code>en HTML"
     },
     },
-    "indent_header": "Configuration de l'indentation",
-    "indent_desc": "Configuration de l'indentation",
+    "indent_header": "Indentation",
+    "indent_desc": "Taille d'indentation dans une page.",
     "indent_options": {
     "indent_options": {
-      "indentSize": "Taille par défaut",
+      "indentSize": "Valeur par défaut",
       "indentSize_desc": "Taille par défaut de l'indentation dans l'éditeur Markdown",
       "indentSize_desc": "Taille par défaut de l'indentation dans l'éditeur Markdown",
-      "disallow_indent_change": "Empêcher le changement de taille d'indentation",
-      "disallow_indent_change_desc": "Forcer l'usage de la taille par défaut"
+      "disallow_indent_change": "Empêcher la modification",
+      "disallow_indent_change_desc": "Impose l'usage de la valeur par défaut définie dans les paramètres"
     },
     },
-    "xss_header": "Configuration prévention XSS",
+    "xss_header": "Prévention des attaques XSS",
     "xss_desc": "Configuration de la prévention des attaques XSS (cross-site scripting).",
     "xss_desc": "Configuration de la prévention des attaques XSS (cross-site scripting).",
     "xss_options": {
     "xss_options": {
-      "enable_xss_prevention": "Activer prévention XSS",
+      "enable_xss_prevention": "Prévention XSS",
       "remove_all_tags": "Retirer tout les tags",
       "remove_all_tags": "Retirer tout les tags",
       "remove_all_tags_desc": "Retire tout les tags HTML et CSS",
       "remove_all_tags_desc": "Retire tout les tags HTML et CSS",
       "recommended_setting": "Paramètres recommandés",
       "recommended_setting": "Paramètres recommandés",
       "custom_whitelist": "Liste autorisée",
       "custom_whitelist": "Liste autorisée",
       "tag_names": "Nom de tags",
       "tag_names": "Nom de tags",
       "tag_attributes": "Attributs de tags",
       "tag_attributes": "Attributs de tags",
-      "import_recommended": "Importer les recommendations {{target}}"
+      "import_recommended": "Importer {{target}}"
     }
     }
   },
   },
   "customize_settings": {
   "customize_settings": {
-    "customize_settings": "Personnalisation",
+    "customize_settings": "Interface",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
-      "title": "Mode par défaut de la barre latérale",
-      "desc": "Le mode d'affichage par défaut de la barre latérale pour les utilisateurs.",
+      "title": "Barre latérale",
+      "desc": "Mode d'affichage et comportement par défaut de la barre latérale.",
       "dock_mode_default_desc": "État initial de la barre latérale lorsque le mode Dock est sélectionné.",
       "dock_mode_default_desc": "État initial de la barre latérale lorsque le mode Dock est sélectionné.",
       "dock_mode_default_open": "Afficher la page comme si elle était ouverte",
       "dock_mode_default_open": "Afficher la page comme si elle était ouverte",
       "dock_mode_default_close": "Afficher la page comme si elle était fermée"
       "dock_mode_default_close": "Afficher la page comme si elle était fermée"
     },
     },
-    "layout": "Agencement",
+    "layout": "Largeur du contenu",
     "layout_options": {
     "layout_options": {
       "default": "Largeur par défaut",
       "default": "Largeur par défaut",
       "expanded": "100%"
       "expanded": "100%"
@@ -487,8 +484,8 @@
       "tab_switch": "Sauvegarder le changement d'onglets",
       "tab_switch": "Sauvegarder le changement d'onglets",
       "tab_switch_desc1": "Sauvegarde l'état de navigation dans le navigateur de l'utilisateur.",
       "tab_switch_desc1": "Sauvegarde l'état de navigation dans le navigateur de l'utilisateur.",
       "tab_switch_desc2": "Lorsque désactivé, la navigation est forcé par l'interface.",
       "tab_switch_desc2": "Lorsque désactivé, la navigation est forcé par l'interface.",
-      "attach_title_header": "Ajouter automatiquement une section h1",
-      "attach_title_header_desc": "Ajoute le chemin de la page en tant que h1 lors de création d'une page.",
+      "attach_title_header": "Ajout automatique de titre",
+      "attach_title_header_desc": "Ajoute le chemin de la page en tant que titre de niveau 1 lors de création d'une page.",
       "list_num_s": "Nombre de pages modales",
       "list_num_s": "Nombre de pages modales",
       "list_num_desc_s": "Nombre de pages affichées sur les modales",
       "list_num_desc_s": "Nombre de pages affichées sur les modales",
       "list_num_m": "Nombre de pages articles",
       "list_num_m": "Nombre de pages articles",
@@ -497,20 +494,20 @@
       "list_num_desc_l": "Nombre de pages affichées lors de la recherche",
       "list_num_desc_l": "Nombre de pages affichées lors de la recherche",
       "list_num_xl": "Nombre de pages articles",
       "list_num_xl": "Nombre de pages articles",
       "list_num_desc_xl": "Nombre de pages affichées dans la 'corbeille' ou '404'.",
       "list_num_desc_xl": "Nombre de pages affichées dans la 'corbeille' ou '404'.",
-      "stale_notification": "Afficher les anciennes notifications",
+      "stale_notification": "Anciennes notifications",
       "stale_notification_desc": "Affiche les notifications sur les pages mises à jour il y a plus d'un an",
       "stale_notification_desc": "Affiche les notifications sur les pages mises à jour il y a plus d'un an",
       "show_all_reply_comments": "Afficher tout les commentaires",
       "show_all_reply_comments": "Afficher tout les commentaires",
       "show_all_reply_comments_desc": "Lorsque désactivé, seul les deux commentaires les plus récents sont affichés",
       "show_all_reply_comments_desc": "Lorsque désactivé, seul les deux commentaires les plus récents sont affichés",
       "select_search_scope_children_as_default": "'Seulement enfant de ce chemin' lors de la recherche",
       "select_search_scope_children_as_default": "'Seulement enfant de ce chemin' lors de la recherche",
       "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche."
       "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche."
     },
     },
-      "presentation": "Présentation",
+    "presentation": "Présentation",
     "presentation_options": {
     "presentation_options": {
-      "enable_marp": "Activer Marp",
-      "enable_marp_desc": "Marp est utilisable dans la visualisation de présentation. Potentiellement vulnérable aux attaques XSS.",
+      "enable_marp": "Marp",
+      "enable_marp_desc": "Marp est un syntaxe utilisable dans la visualisation de présentation. Potentiellement vulnérable aux attaques XSS.",
       "marp_official_site": "Site officiel Marp",
       "marp_official_site": "Site officiel Marp",
       "marp_official_site_link": "https://marp.app",
       "marp_official_site_link": "https://marp.app",
-      "marp_in_growi" : "GROWI Docs - Créer des présentations avec Marp",
+      "marp_in_growi": "GROWI Docs - Créer des présentations avec Marp",
       "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
       "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
     },
     },
     "custom_title": "Titre personnalisé",
     "custom_title": "Titre personnalisé",
@@ -524,10 +521,10 @@
     "write_css": "CSS personnalisé.",
     "write_css": "CSS personnalisé.",
     "ctrl_space": "Ctrl+Space pour l'autocomplétion",
     "ctrl_space": "Ctrl+Space pour l'autocomplétion",
     "custom_script": "Script personnalisé",
     "custom_script": "Script personnalisé",
-    "custom_presentation": "Presentation personnalisé",
+    "custom_presentation": "Mode présentation",
     "write_java": "Code javascript qui sera appliqué au système entier.",
     "write_java": "Code javascript qui sera appliqué au système entier.",
     "reflect_change": "Un rechargement de la page est nécessaire pour afficher les changements.",
     "reflect_change": "Un rechargement de la page est nécessaire pour afficher les changements.",
-    "custom_logo" : "Logo personnalisé",
+    "custom_logo": "Logo personnalisé",
     "default_logo": "Logo par défaut",
     "default_logo": "Logo par défaut",
     "upload_logo": "Téléverser un logo",
     "upload_logo": "Téléverser un logo",
     "current_logo": "Logo actuel",
     "current_logo": "Logo actuel",
@@ -601,9 +598,9 @@
     },
     },
     "import": "Importer",
     "import": "Importer",
     "skip_username_and_email_when_overlapped": "Passe le nom et adresse courriel exactes dans le nouvel environnement",
     "skip_username_and_email_when_overlapped": "Passe le nom et adresse courriel exactes dans le nouvel environnement",
-    "prepare_new_account_for_migration":"Préparer le compte pour la migration",
-    "archive_data_import_detail":"En savoir plus",
-    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
+    "prepare_new_account_for_migration": "Préparer le compte pour la migration",
+    "archive_data_import_detail": "En savoir plus",
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
     "page_skip": "Les pages ayant le nom d'une page déjà existante ne seront pas importées.",
     "page_skip": "Les pages ayant le nom d'une page déjà existante ne seront pas importées.",
     "Directory_hierarchy_tag": "Tag de hiérarchie"
     "Directory_hierarchy_tag": "Tag de hiérarchie"
   },
   },
@@ -673,7 +670,7 @@
     "delete": "Supprimer",
     "delete": "Supprimer",
     "integration_procedure": "Procédure d'intégration",
     "integration_procedure": "Procédure d'intégration",
     "custom_bot_without_proxy_settings": "Bot Personnalisé sans proxy",
     "custom_bot_without_proxy_settings": "Bot Personnalisé sans proxy",
-    "integration_failed":"Échec de l'intégration",
+    "integration_failed": "Échec de l'intégration",
     "reset": "Réinitialiser",
     "reset": "Réinitialiser",
     "reset_all_settings": "Réinitialiser tout les paramètres",
     "reset_all_settings": "Réinitialiser tout les paramètres",
     "delete_slackbot_settings": "Supprimer les paramètres du bot Slack",
     "delete_slackbot_settings": "Supprimer les paramètres du bot Slack",
@@ -720,7 +717,7 @@
       "allow_specified_long": "Autoriser sélectionnés (Depuis les canaux sélectionnés)",
       "allow_specified_long": "Autoriser sélectionnés (Depuis les canaux sélectionnés)",
       "test_connection": "Tester la connexion",
       "test_connection": "Tester la connexion",
       "test_connection_by_pressing_button": "Cliquer sur le bouton pour tester la connexion",
       "test_connection_by_pressing_button": "Cliquer sur le bouton pour tester la connexion",
-      "test_connection_only_public_channel":"Testez la connexion dans un canal publique.",
+      "test_connection_only_public_channel": "Testez la connexion dans un canal publique.",
       "error_check_logs_below": "Une erreur est survenue.",
       "error_check_logs_below": "Une erreur est survenue.",
       "send_message_to_slack_work_space": "Envoyer un message vers l'espace de travail Slack.",
       "send_message_to_slack_work_space": "Envoyer un message vers l'espace de travail Slack.",
       "add_slack_workspace": "Ajouter un espace de travail Slack"
       "add_slack_workspace": "Ajouter un espace de travail Slack"
@@ -749,16 +746,16 @@
     "alert_deplicated": "'Ancienne intégration Slack' sera discontinué dans le futur. Utiliser plutôt <a href='/admin/slack-integration'>les nouveaux paramètres</a> "
     "alert_deplicated": "'Ancienne intégration Slack' sera discontinué dans le futur. Utiliser plutôt <a href='/admin/slack-integration'>les nouveaux paramètres</a> "
   },
   },
   "user_management": {
   "user_management": {
-    "user_management": "Configuration des utilisateurs",
-    "invite_users": "Créer un nouvel utilisateur temporaire",
+    "user_management": "Utilisateurs",
+    "invite_users": "Nouvel utilisateur temporaire",
     "click_twice_same_checkbox": "Il est nécessaire de sélectionner une option.",
     "click_twice_same_checkbox": "Il est nécessaire de sélectionner une option.",
     "status": "Statut",
     "status": "Statut",
     "invite_modal": {
     "invite_modal": {
-      "emails": "Adresse Courriel (Supporte l'usage de plusieurs lignes)",
-      "description1":"Créer des utilisateurs temporaires avec une adresse courriel.",
-      "description2":"Un mot de passe temporaire sera généré..",
-      "invite_thru_email": "Envoyer courriel d'invitation",
-      "mail_setting_link":"<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
+      "emails": "Adresse(s) courriel(s) (Supporte l'usage de plusieurs lignes)",
+      "description1": "Créer des utilisateurs temporaires avec une adresse courriel.",
+      "description2": "Un mot de passe temporaire est généré automatiquement.",
+      "invite_thru_email": "Courriel d'invitation",
+      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
       "valid_email": "Adresse courriel valide requise",
       "valid_email": "Adresse courriel valide requise",
       "temporary_password": "Cette utilisateur a un mot de passe temporaire",
       "temporary_password": "Cette utilisateur a un mot de passe temporaire",
       "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.",
       "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.",
@@ -780,7 +777,7 @@
       "cannot_revoke": "Vous ne pouvez pas révoquer votre propre permission d'administration",
       "cannot_revoke": "Vous ne pouvez pas révoquer votre propre permission d'administration",
       "grant_admin_access": "Ajouter permission administrateur",
       "grant_admin_access": "Ajouter permission administrateur",
       "revoke_read_only_access": "Révoquer permission de lecture",
       "revoke_read_only_access": "Révoquer permission de lecture",
-      "grant_read_only_access": "Ajouter permission de lecture",
+      "grant_read_only_access": "Permission de lecture-seule",
       "send_invitation_email": "Envoyer courriel d'invitation",
       "send_invitation_email": "Envoyer courriel d'invitation",
       "resend_invitation_email": "Renvoyer courriel d'invitation"
       "resend_invitation_email": "Renvoyer courriel d'invitation"
     },
     },
@@ -793,10 +790,10 @@
       "new_password": "Nouveau mot de passe"
       "new_password": "Nouveau mot de passe"
     },
     },
     "external_account": "Configuration des comptes externes",
     "external_account": "Configuration des comptes externes",
-    "external_accounts":"Comptes externes",
-    "create_external_account":"Créer compte externe",
+    "external_accounts": "Comptes externes",
+    "create_external_account": "Créer compte externe",
     "external_account_list": "Liste des comptes externes",
     "external_account_list": "Liste des comptes externes",
-    "external_account_none":"Pas de compte externe",
+    "external_account_none": "Pas de compte externe",
     "invite": "Inviter",
     "invite": "Inviter",
     "invited": "Utilisateur invité",
     "invited": "Utilisateur invité",
     "back_to_user_management": "Gestion des utilisateurs",
     "back_to_user_management": "Gestion des utilisateurs",
@@ -811,17 +808,17 @@
     "current_users": "Utilisateurs:"
     "current_users": "Utilisateurs:"
   },
   },
   "user_group_management": {
   "user_group_management": {
-    "user_group_management": "Configuration des groupes",
-    "create_group": "Créer nouveau groupe",
+    "user_group_management": "Gestion des groupes",
+    "create_group": "Nouveau groupe",
     "add_child_group": "Ajouter groupe enfant",
     "add_child_group": "Ajouter groupe enfant",
     "remove_child_group": "Retirer",
     "remove_child_group": "Retirer",
     "deny_create_group": "Les paramètres actuels ne permettent pas la création du groupe",
     "deny_create_group": "Les paramètres actuels ne permettent pas la création du groupe",
-    "group_name": "Nom du groupe",
+    "group_name": "Nom",
     "group_example": "e.g. : Group1",
     "group_example": "e.g. : Group1",
     "child_user_group": "Groupe utilisateur enfant",
     "child_user_group": "Groupe utilisateur enfant",
-    "parent_group": "Groupe parent",
+    "parent_group": "Parent",
     "select_parent_group": "Sélectionner groupe parent",
     "select_parent_group": "Sélectionner groupe parent",
-    "release_parent_group": "Libérer groupe parent",
+    "release_parent_group": "Retirer groupe parent",
     "add_modal": {
     "add_modal": {
       "description": "L'utilisateur sera ajouté au groupe parent.",
       "description": "L'utilisateur sera ajouté au groupe parent.",
       "add_user": "Ajouter utilisateur au groupe",
       "add_user": "Ajouter utilisateur au groupe",
@@ -831,14 +828,14 @@
       "partial_match": "Correspondance partielle",
       "partial_match": "Correspondance partielle",
       "backward_match": "Correspondance inversée"
       "backward_match": "Correspondance inversée"
     },
     },
-    "group_list": "Liste des groupes",
-    "child_group_list": "Liste des groupes enfants",
+    "group_list": "Groupes",
+    "child_group_list": "Groupes enfants",
     "back_to_list": "Retour à la liste",
     "back_to_list": "Retour à la liste",
-    "basic_info": "Information de base",
-    "user_list": "Liste des utilisateurs",
+    "basic_info": "Création du groupe",
+    "user_list": "Utilisateurs assignés",
     "created_group": "Groupe crée",
     "created_group": "Groupe crée",
     "is_loading_data": "Chargement...",
     "is_loading_data": "Chargement...",
-    "no_pages": "Le groupe n'a pas la permission de voir la page.",
+    "no_pages": "Le groupe n'a pas de pages assignées.",
     "remove_from_group": "Retirer l'utilisateur",
     "remove_from_group": "Retirer l'utilisateur",
     "delete_modal": {
     "delete_modal": {
       "header": "Supprimer groupe",
       "header": "Supprimer groupe",
@@ -859,7 +856,7 @@
     }
     }
   },
   },
   "audit_log_management": {
   "audit_log_management": {
-    "audit_log": "Journal d'audit",
+    "audit_log": "Audit",
     "audit_log_settings": "Configuration des journaux d'audit",
     "audit_log_settings": "Configuration des journaux d'audit",
     "user": "Utilisateur",
     "user": "Utilisateur",
     "username": "Nom d'utilisateur",
     "username": "Nom d'utilisateur",
@@ -889,12 +886,12 @@
   },
   },
   "plugins": {
   "plugins": {
     "plugins": "Plugins",
     "plugins": "Plugins",
-    "plugin_installer": "Configuration de plugins",
+    "plugin_installer": "Installer un plugin",
     "form": {
     "form": {
       "label_url": "URL du plugin",
       "label_url": "URL du plugin",
-      "desc_url": "Les plugins sont installables par URL",
+      "desc_url": "URL vers le code source du plugin. L'URL doit être accessible publiquement.",
       "label_branch": "Branche",
       "label_branch": "Branche",
-      "desc_branch": "Spécification du nom de la branche. Par défaut: `main`"
+      "desc_branch": "Nom de la branche du dépôt"
     },
     },
     "plugin_card": "Plugins",
     "plugin_card": "Plugins",
     "plugin_is_not_installed": "Aucun plugins installés",
     "plugin_is_not_installed": "Aucun plugins installés",
@@ -980,7 +977,7 @@
     "ADMIN_SITE_URL_UPDATE": "Modifier les paramètres d'URL",
     "ADMIN_SITE_URL_UPDATE": "Modifier les paramètres d'URL",
     "ADMIN_MAIL_SMTP_UPDATE": "Modifier les paramètres d'e-mail",
     "ADMIN_MAIL_SMTP_UPDATE": "Modifier les paramètres d'e-mail",
     "ADMIN_MAIL_SES_UPDATE": "Modifier les paramètres d'e-mail (SES)",
     "ADMIN_MAIL_SES_UPDATE": "Modifier les paramètres d'e-mail (SES)",
-    "ADMIN_MAIL_TEST_SUBMIT" : "Envoyer courriel de test",
+    "ADMIN_MAIL_TEST_SUBMIT": "Envoyer courriel de test",
     "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "Modifier paramètres de téléversemetnt de fichiers",
     "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "Modifier paramètres de téléversemetnt de fichiers",
     "ADMIN_PLUGIN_UPDATE": "Mettre à jour les paramètres de plugins",
     "ADMIN_PLUGIN_UPDATE": "Mettre à jour les paramètres de plugins",
     "ADMIN_MAINTENANCEMODE_ENABLED": "Activer mode maintenance",
     "ADMIN_MAINTENANCEMODE_ENABLED": "Activer mode maintenance",

+ 16 - 30
apps/app/public/static/locales/fr_FR/commons.json

@@ -7,7 +7,6 @@
   "Sign out": "Se déconnecter",
   "Sign out": "Se déconnecter",
   "New": "Nouveau",
   "New": "Nouveau",
   "Delete": "Supprimer",
   "Delete": "Supprimer",
-
   "meta": {
   "meta": {
     "display_name": "Français"
     "display_name": "Français"
   },
   },
@@ -28,9 +27,8 @@
     "email_is_already_in_use": "La configuration SMTP est déjà faite."
     "email_is_already_in_use": "La configuration SMTP est déjà faite."
   },
   },
   "headers": {
   "headers": {
-    "app_settings": "Paramètres de l'application"
+    "app_settings": "Application"
   },
   },
-
   "header_search_box": {
   "header_search_box": {
     "label": {
     "label": {
       "All pages": "Toutes les pages",
       "All pages": "Toutes les pages",
@@ -41,30 +39,26 @@
       "This tree": "Enfants de cette arbre"
       "This tree": "Enfants de cette arbre"
     }
     }
   },
   },
-
   "search_method_menu_item": {
   "search_method_menu_item": {
     "search_in_all": "Rechercher dans tout",
     "search_in_all": "Rechercher dans tout",
     "only_children_of_this_tree": "Enfants de cet arbre",
     "only_children_of_this_tree": "Enfants de cet arbre",
     "exact_mutch": "Correspondance exacte"
     "exact_mutch": "Correspondance exacte"
   },
   },
-
   "share_links": {
   "share_links": {
     "Share Link": "Liens de partage",
     "Share Link": "Liens de partage",
     "Page Path": "Chemin de la page",
     "Page Path": "Chemin de la page",
     "expire": "Expiration",
     "expire": "Expiration",
     "description": "Description"
     "description": "Description"
   },
   },
-
   "in_app_notification": {
   "in_app_notification": {
     "notification_list": "Notifications d'application",
     "notification_list": "Notifications d'application",
     "see_all": "Voir tout",
     "see_all": "Voir tout",
-    "no_notification": "Vous n'avez pas de notifications.",
+    "no_notification": "Aucune notification.",
     "all": "Toutes",
     "all": "Toutes",
     "unopend": "Non-lues",
     "unopend": "Non-lues",
     "mark_all_as_read": "Tout marquer comme lu",
     "mark_all_as_read": "Tout marquer comme lu",
     "no_unread_messages": "aucun message non lu"
     "no_unread_messages": "aucun message non lu"
   },
   },
-
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "Accueil",
     "home": "Accueil",
     "settings": "Paramètres",
     "settings": "Paramètres",
@@ -72,24 +66,21 @@
     "sidebar_mode": "Navigation latérale",
     "sidebar_mode": "Navigation latérale",
     "sidebar_mode_editor": "Navigation latérale dans l'éditeur",
     "sidebar_mode_editor": "Navigation latérale dans l'éditeur",
     "use_os_settings": "Utiliser les paramètres système",
     "use_os_settings": "Utiliser les paramètres système",
-    "feedback": "Avis"
+    "feedback": "Sondage"
   },
   },
-
-
   "create_page_dropdown": {
   "create_page_dropdown": {
-    "new_page": "Créer nouvelle page",
-    "open_page_create_modal": "Ouvrir une nouvelle page créer une fenêtre modale",
+    "new_page": "Nouvelle page",
+    "open_page_create_modal": "Modale de création de page",
     "todays": {
     "todays": {
-      "desc": "Créer le mémo du jour",
+      "desc": "Mémo du jour",
       "memo": "mémo"
       "memo": "mémo"
     },
     },
     "template": {
     "template": {
-      "desc": "Créer/modifier page modèle",
-      "children": "Modèle page enfant",
-      "descendants": "Modèle pour descendants"
+      "desc": "Modèles",
+      "children": "Modèle pour page enfant",
+      "descendants": "Modèle pour page adjacentes"
     }
     }
   },
   },
-
   "copy_to_clipboard": {
   "copy_to_clipboard": {
     "Copy to clipboard": "Copier dans le presse-papier",
     "Copy to clipboard": "Copier dans le presse-papier",
     "Page path": "Chemin de la page",
     "Page path": "Chemin de la page",
@@ -99,30 +90,27 @@
     "Markdown link": "Lien Markdown",
     "Markdown link": "Lien Markdown",
     "Append params": "Affixer les paramètres"
     "Append params": "Affixer les paramètres"
   },
   },
-
   "crop_image_modal": {
   "crop_image_modal": {
     "image_crop": "Recadrage d'image",
     "image_crop": "Recadrage d'image",
     "crop": "Recadrer",
     "crop": "Recadrer",
     "save": "Sauvegarder",
     "save": "Sauvegarder",
     "cancel": "Annuler"
     "cancel": "Annuler"
   },
   },
-
   "handsontable_modal": {
   "handsontable_modal": {
-    "title": "Modifier table",
-    "data_import": "Import de données",
+    "title": "Tableau",
+    "data_import": "Importer des données",
     "save": "Sauvegarder",
     "save": "Sauvegarder",
     "cancel": "Annuler",
     "cancel": "Annuler",
-    "done": "Terminer",
+    "done": "Mettre à jour",
     "data_import_form": {
     "data_import_form": {
-      "select_data_format": "Sélectionner format de données",
-      "import_data": "Importer données",
-      "paste_table_data": "Coller les données de la table",
-      "parse_error": "Erreur d'analyse",
+      "select_data_format": "Format",
+      "import_data": "Données du fichier",
+      "paste_table_data": "Coller les données de la fichier",
+      "parse_error": "Erreur lors de l'importation des données",
       "cancel": "Annuler",
       "cancel": "Annuler",
       "import": "Importer"
       "import": "Importer"
     }
     }
   },
   },
-
   "questionnaire_modal": {
   "questionnaire_modal": {
     "required": "Requis",
     "required": "Requis",
     "submit": "Soumettre",
     "submit": "Soumettre",
@@ -146,11 +134,9 @@
     "successfully_submitted": "Questionnaire soumis.",
     "successfully_submitted": "Questionnaire soumis.",
     "thanks_for_answering": "Merci pour votre avis."
     "thanks_for_answering": "Merci pour votre avis."
   },
   },
-
   "not_found_page": {
   "not_found_page": {
     "page_not_exist": "Cette page est introuvable."
     "page_not_exist": "Cette page est introuvable."
   },
   },
-
   "g2g_data_transfer": {
   "g2g_data_transfer": {
     "tab": "Transfert de données",
     "tab": "Transfert de données",
     "data_transfer": "Transfert de données",
     "data_transfer": "Transfert de données",

+ 67 - 65
apps/app/public/static/locales/fr_FR/translation.json

@@ -3,7 +3,7 @@
     "display_name": "Français"
     "display_name": "Français"
   },
   },
   "Help": "Aide",
   "Help": "Aide",
-  "view": "Voir",
+  "View": "Voir",
   "Edit": "Modifier",
   "Edit": "Modifier",
   "Delete": "Supprimer",
   "Delete": "Supprimer",
   "delete_all": "Tout supprimer",
   "delete_all": "Tout supprimer",
@@ -60,7 +60,7 @@
   "Timeline View": "Chronologie",
   "Timeline View": "Chronologie",
   "History": "Historique",
   "History": "Historique",
   "attachment_data": "Pièces jointes",
   "attachment_data": "Pièces jointes",
-  "No_attachments_yet": "Aucunes pièces jointes.",
+  "No_attachments_yet": "Aucune pièce jointe.",
   "Presentation Mode": "Mode présentation",
   "Presentation Mode": "Mode présentation",
   "Not available for guest": "Indisponible pour les invités",
   "Not available for guest": "Indisponible pour les invités",
   "Not available in this version": "Indisponible dans cette version",
   "Not available in this version": "Indisponible dans cette version",
@@ -90,24 +90,25 @@
   "No diff": "Aucune différences",
   "No diff": "Aucune différences",
   "Latest": "Dernière version",
   "Latest": "Dernière version",
   "User ID": "Identifiant utilisateur",
   "User ID": "Identifiant utilisateur",
-  "User Information": "Informations utilisateur",
+  "User Settings": "Paramètres utilisateur",
+  "User Information": "Mon compte",
   "User Activation": "Activation utilisateur",
   "User Activation": "Activation utilisateur",
-  "Basic Info": "Informations de base",
+  "Basic Info": "Informations du compte",
   "Name": "Nom",
   "Name": "Nom",
   "Email": "Adresse courriel",
   "Email": "Adresse courriel",
   "Language": "Langue",
   "Language": "Langue",
   "English": "Anglais",
   "English": "Anglais",
   "Japanese": "Japonais",
   "Japanese": "Japonais",
-  "Set Profile Image": "Sélectionner image de profil",
-  "Upload Image": "Téléverser image",
-  "Current Image": "Image actuelle",
-  "Delete Image": "Supprimer image",
-  "Delete this image?": "Supprimer cette image?",
+  "Set Profile Image": "Photo de profil",
+  "Upload Image": "Photo personalisée",
+  "Current Image": "Photo actuelle",
+  "Delete Image": "Supprimer photo",
+  "Delete this image?": "Supprimer cette photo?",
   "Updated": "Modifié",
   "Updated": "Modifié",
-  "Upload new image": "Téléverser nouvelle image",
+  "Upload new image": "Téléverser une photo",
   "Connected": "Connecté",
   "Connected": "Connecté",
   "Loading": "Chargement...",
   "Loading": "Chargement...",
-  "Disclose E-mail": "Afficher adresse courriel",
+  "Disclose E-mail": "Divulguer adresse courriel",
   "page exists": "cette page est déjà existante",
   "page exists": "cette page est déjà existante",
   "Error occurred": "Une erreur est survenue",
   "Error occurred": "Une erreur est survenue",
   "Input page name": "Nom de la page",
   "Input page name": "Nom de la page",
@@ -122,21 +123,21 @@
   "UserGroup": "Groupe utilisateur",
   "UserGroup": "Groupe utilisateur",
   "Basic Settings": "Paramètres de base",
   "Basic Settings": "Paramètres de base",
   "The contents entered here will be shown in the header etc": "Le contenu entré ici sera visible dans l'en-tête",
   "The contents entered here will be shown in the header etc": "Le contenu entré ici sera visible dans l'en-tête",
-  "Public": "Public",
+  "Public": "Tout le monde",
   "Anyone with the link": "Tous les utilisateurs disposant du lien",
   "Anyone with the link": "Tous les utilisateurs disposant du lien",
   "Specified users only": "Utilisateurs spécifiés",
   "Specified users only": "Utilisateurs spécifiés",
   "Only me": "Seulement moi",
   "Only me": "Seulement moi",
   "Only inside the group": "Utilisateurs du groupe",
   "Only inside the group": "Utilisateurs du groupe",
-  "page_list": "Liste de pages",
+  "page_list": "Pages enfants",
   "comments": "Commentaires",
   "comments": "Commentaires",
   "Reselect the group": "Resélectionner ce groupe",
   "Reselect the group": "Resélectionner ce groupe",
   "Shareable link": "Lien partageable",
   "Shareable link": "Lien partageable",
   "The whitelist of registration permission E-mail address": "Les adresses courriel permises lors de l'inscription",
   "The whitelist of registration permission E-mail address": "Les adresses courriel permises lors de l'inscription",
   "Add tags for this page": "Ajouter des étiquettes",
   "Add tags for this page": "Ajouter des étiquettes",
   "tag_list": "Étiquettes",
   "tag_list": "Étiquettes",
-  "popular_tags": "Étiquettes populaires",
-  "Check All tags": "voir toutes les étiquettes",
-  "You have no tag, You can set tags on pages": "Vous n'avez aucunes étiquettes, vous pouvez assigner des étiquettes aux pages",
+  "popular_tags": "Étiquettes fréquentes",
+  "Check All tags": "Toutes les étiquettes",
+  "You have no tag, You can set tags on pages": "Aucune étiquette existante.",
   "Show latest": "Voir le plus récent",
   "Show latest": "Voir le plus récent",
   "Load latest": "Charger le plus récent",
   "Load latest": "Charger le plus récent",
   "edited this page": "à modifié cette page.",
   "edited this page": "à modifié cette page.",
@@ -147,9 +148,9 @@
   "No bookmarks yet": "Aucuns favoris",
   "No bookmarks yet": "Aucuns favoris",
   "add_bookmark": "Ajouter aux favoris",
   "add_bookmark": "Ajouter aux favoris",
   "remove_bookmark": "Retirer des favoris",
   "remove_bookmark": "Retirer des favoris",
-  "wide_view": "Vue élargie",
+  "wide_view": "Affichage large",
   "Recent Changes": "Modifications récentes",
   "Recent Changes": "Modifications récentes",
-  "Page Tree": "Arbre",
+  "Page Tree": "Arborescence",
   "Bookmarks": "Favoris",
   "Bookmarks": "Favoris",
   "In-App Notification": "Notifications",
   "In-App Notification": "Notifications",
   "original_path": "Chemin originel",
   "original_path": "Chemin originel",
@@ -227,7 +228,7 @@
     "form_help": {}
     "form_help": {}
   },
   },
   "Password": "Mot de passe",
   "Password": "Mot de passe",
-  "Password Settings": "Paramètres de mot passe",
+  "Password Settings": "Sécurité",
   "personal_settings": {
   "personal_settings": {
     "disassociate_external_account": "Dissocier compte externe",
     "disassociate_external_account": "Dissocier compte externe",
     "disassociate_external_account_desc": "Dissocier le compte externe <strong>{{providerType}}</strong> <strong>{{accountId}}</strong>?",
     "disassociate_external_account_desc": "Dissocier le compte externe <strong>{{providerType}}</strong> <strong>{{accountId}}</strong>?",
@@ -241,7 +242,7 @@
   "share_links": {
   "share_links": {
     "Shere this page link to public": "Partager cette page publiquement",
     "Shere this page link to public": "Partager cette page publiquement",
     "share_link_list": "Liens de partage",
     "share_link_list": "Liens de partage",
-    "share_link_management": "Gestion des liens de partage",
+    "share_link_management": "Liens de partage",
     "delete_all_share_links": "Supprimer tout les liens de partage",
     "delete_all_share_links": "Supprimer tout les liens de partage",
     "expire": "Expiration",
     "expire": "Expiration",
     "Days": "Jour",
     "Days": "Jour",
@@ -254,23 +255,23 @@
     "Invalid_Number_of_Date": "Valeurs invalides",
     "Invalid_Number_of_Date": "Valeurs invalides",
     "link_sharing_is_disabled": "Le partage est désactivé"
     "link_sharing_is_disabled": "Le partage est désactivé"
   },
   },
-  "API Settings": "Configuration API",
+  "API Settings": "API GROWI",
   "Other Settings": "Autres paramètres",
   "Other Settings": "Autres paramètres",
-  "API Token Settings": "Paramètres de jetons",
-  "Current API Token": "Jeton d'API actuel",
-  "Update API Token": "Modifier jeton",
+  "API Token Settings": "Jetons d'API",
+  "Current API Token": "Mon jeton d'API",
+  "Update API Token": "Regénérer",
   "in_app_notification_settings": {
   "in_app_notification_settings": {
-    "in_app_notification_settings": "Paramètres de notifications",
+    "in_app_notification_settings": "Notifications",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
     "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
     "default_subscribe_rules": {
     "default_subscribe_rules": {
-      "page_create": "S'abonner à la page lors de sa création."
+      "page_create": "S'abonner aux modifications d'une page lors de sa création."
     }
     }
   },
   },
   "ui_settings": {
   "ui_settings": {
-    "ui_settings": "Paramètres UI",
+    "ui_settings": "Interface",
     "side_bar_mode": {
     "side_bar_mode": {
       "settings": "Paramètres navigation latérale",
       "settings": "Paramètres navigation latérale",
-      "side_bar_mode_setting": "Activer la navigation latérale",
+      "side_bar_mode_setting": "Épingler la navigation latérale",
       "description": "Activer pour toujours afficher la barre de navigation latérale lorsque l'écran est large. Si la largeur d'écran est faible, le cas inverse est applicable."
       "description": "Activer pour toujours afficher la barre de navigation latérale lorsque l'écran est large. Si la largeur d'écran est faible, le cas inverse est applicable."
     }
     }
   },
   },
@@ -278,7 +279,7 @@
     "light": "Clair",
     "light": "Clair",
     "dark": "Sombre",
     "dark": "Sombre",
     "system": "Système",
     "system": "Système",
-    "settings": "Paramètres de thème",
+    "settings": "Thème",
     "description": "Affichage en mode clair, sombre ou selon les paramètres système.<br>Seuls les thèmes supportés seront modifiés."
     "description": "Affichage en mode clair, sombre ou selon les paramètres système.<br>Seuls les thèmes supportés seront modifiés."
   },
   },
   "editor_settings": {
   "editor_settings": {
@@ -331,17 +332,17 @@
   "page_edit": {
   "page_edit": {
     "input_channels": "Canal Slack...",
     "input_channels": "Canal Slack...",
     "theme": "Thème",
     "theme": "Thème",
-    "keymap": "Touches",
+    "keymap": "Raccourcis",
     "indent": "Indentation",
     "indent": "Indentation",
     "paste": {
     "paste": {
       "title": "Comportement du collage",
       "title": "Comportement du collage",
-      "both": "Les deux",
+      "both": "Texte et fichier",
       "text": "Texte seulement",
       "text": "Texte seulement",
       "file": "Fichier seulement"
       "file": "Fichier seulement"
     },
     },
-    "editor_config": "Configuration de l'éditeur",
-    "Show active line": "Montrer la ligne active",
-    "auto_format_table": "Formattage les tables",
+    "editor_config": "Préférences de l'éditeur",
+    "Show active line": "Surligner la ligne active",
+    "auto_format_table": "Formatter les tableaux",
     "overwrite_scopes": "{{operation}} et écraser les scopes des pages enfants",
     "overwrite_scopes": "{{operation}} et écraser les scopes des pages enfants",
     "notice": {
     "notice": {
       "conflict": "Sauvegarde impossible, la page est en cours de modification par un autre utilisateur. Recharger la page."
       "conflict": "Sauvegarde impossible, la page est en cours de modification par un autre utilisateur. Recharger la page."
@@ -370,28 +371,28 @@
   },
   },
   "page_history": {
   "page_history": {
     "revision_list": "Historique des modifications",
     "revision_list": "Historique des modifications",
-    "revision": "version",
+    "revision": "Révision",
     "comparing_source": "Source",
     "comparing_source": "Source",
-    "comparing_target": "Destination",
+    "comparing_target": "Cible",
     "comparing_revisions": "Comparer les modifications",
     "comparing_revisions": "Comparer les modifications",
     "compare_latest": "Comparer avec la version la plus récente",
     "compare_latest": "Comparer avec la version la plus récente",
-    "compare_previous": "Compare avec une version précédente"
+    "compare_previous": "Comparer avec la version précédente"
   },
   },
   "modal_rename": {
   "modal_rename": {
     "label": {
     "label": {
       "Move/Rename page": "Déplacer/renommer page",
       "Move/Rename page": "Déplacer/renommer page",
-      "New page name": "Nom de la page",
+      "New page name": "Nouveau chemin",
       "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
       "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
       "Failed to get exist path": "échec de la récupération du chemin",
       "Failed to get exist path": "échec de la récupération du chemin",
-      "Current page name": "Nom de la page courante",
+      "Current page name": "Chemin actuel",
       "Rename this page only": "Renommer cette page",
       "Rename this page only": "Renommer cette page",
       "Force rename all child pages": "Forcer le renommage des pages",
       "Force rename all child pages": "Forcer le renommage des pages",
       "Other options": "Autres options",
       "Other options": "Autres options",
       "Do not update metadata": "Ne pas modifier les métadonnées",
       "Do not update metadata": "Ne pas modifier les métadonnées",
-      "Redirect": "Rediriger"
+      "Redirect": "Redirection automatique"
     },
     },
     "help": {
     "help": {
-      "redirect": "Rediriger vers la nouvelle page",
+      "redirect": "Redirige automatiquement vers le nouveau chemin de la page.",
       "metadata": "Conserve les métadonnées d'édition de la page",
       "metadata": "Conserve les métadonnées d'édition de la page",
       "recursive": "Déplacer/renommer les pages enfants récursivement"
       "recursive": "Déplacer/renommer les pages enfants récursivement"
     }
     }
@@ -515,7 +516,7 @@
     "initialize_successed": "Initialisation de {{target}} réussie",
     "initialize_successed": "Initialisation de {{target}} réussie",
     "remove_share_link_success": "Suppression de {{shareLinkId}} réussie",
     "remove_share_link_success": "Suppression de {{shareLinkId}} réussie",
     "issue_share_link": "Lien de partage ajouté",
     "issue_share_link": "Lien de partage ajouté",
-    "remove_share_link": "{{count}} liens de partage supprimés",
+    "remove_share_link": "{{count}} liens supprimés",
     "switch_disable_link_sharing_success": "Paramètres des liens de partage modifiés",
     "switch_disable_link_sharing_success": "Paramètres des liens de partage modifiés",
     "failed_to_reset_password": "Échec de la réinitialisation du mot de passe",
     "failed_to_reset_password": "Échec de la réinitialisation du mot de passe",
     "save_succeeded": "Sauvegarde réussie"
     "save_succeeded": "Sauvegarde réussie"
@@ -751,7 +752,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "Renommage impossible lorsque le titre contient '/'",
     "cannot_rename_a_title_that_contains_slash": "Renommage impossible lorsque le titre contient '/'",
     "you_cannot_move_this_page_now": "Déplacement de la page impossible",
     "you_cannot_move_this_page_now": "Déplacement de la page impossible",
-    "something_went_wrong_with_moving_page": "Échec de déplacement de la page"
+    "something_went_wrong_with_moving_page": "Échec de déplacement de la page",
+    "error_retrieving_the_pagetree": "Une erreur s'est produite lors de la récupération de l'arbre des pages"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "Une page avec ce nom 「{{pageName}}」 existe déjà",
     "same_page_name_exists": "Une page avec ce nom 「{{pageName}}」 existe déjà",
@@ -759,9 +761,9 @@
     "select_page_to_see": "Sélectionner une page"
     "select_page_to_see": "Sélectionner une page"
   },
   },
   "user_group": {
   "user_group": {
-    "select_group": "Sélectionner groupe",
-    "belonging_to_no_group": "Appartenance au groupe introuvable.",
-    "manage_user_groups": "Gestion des groupes utilisateurs"
+    "select_group": "Groupes autorisés",
+    "belonging_to_no_group": "Vous n'appartenez à aucun groupe.",
+    "manage_user_groups": "Gestion des groupes"
   },
   },
   "fix_page_grant": {
   "fix_page_grant": {
     "modal": {
     "modal": {
@@ -792,11 +794,11 @@
   "tooltip": {
   "tooltip": {
     "like": "Like!",
     "like": "Like!",
     "cancel_like": "Annuler",
     "cancel_like": "Annuler",
-    "bookmark": "Favori",
-    "cancel_bookmark": "Annuler favori",
-    "receive_notifications": "Recevoir les notifications",
-    "stop_notification": "Stopper les notifications",
-    "footprints": "Visiteurs",
+    "bookmark": "Ajouter aux favoris",
+    "cancel_bookmark": "Retirer des favoris",
+    "receive_notifications": "S'abonner",
+    "stop_notification": "Se désabonner",
+    "footprints": "Lecteurs",
     "login_required": "Connexion requise",
     "login_required": "Connexion requise",
     "operation": {
     "operation": {
       "attention": {
       "attention": {
@@ -810,7 +812,7 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Favoris",
     "bookmarks": "Favoris",
-    "recently_created": "Crée récemment"
+    "recently_created": "Page récentes"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "dossier de favoris",
     "bookmark_folder": "dossier de favoris",
@@ -843,18 +845,18 @@
     "disagree": "En désaccord",
     "disagree": "En désaccord",
     "answer": "Répondre",
     "answer": "Répondre",
     "no_answer": "Aucune réponse",
     "no_answer": "Aucune réponse",
-    "settings": "Configuration du questionnaire",
-    "failed_to_send": "Échec de l'envoi du questionnaire",
-    "denied": "Le questionnaire ne sera plus montré",
-    "personal_settings_explanation": "Les questionnaires de satisfaction seront actifs.",
-    "enable_questionnaire": "Activer questionnaire",
-    "disabled_by_admin": "Questionnaire désactivé par l'administrateur"
+    "settings": "Sondages anonymes",
+    "failed_to_send": "Échec de l'envoi du sondage",
+    "denied": "Les sondages ne seront plus affichés.",
+    "personal_settings_explanation": "Sondages de satisfaction anonymes.",
+    "enable_questionnaire": "Sondages anonymes",
+    "disabled_by_admin": "Sondages anonymes désactivés par l'administrateur"
   },
   },
   "tag_edit_modal": {
   "tag_edit_modal": {
-    "edit_tags": "Modifier étiquettes",
-    "done": "Terminer",
+    "edit_tags": "Étiquettes",
+    "done": "Mettre à jour",
     "tags_input": {
     "tags_input": {
-      "tag_name": "nom de l'étiquette"
+      "tag_name": "Choisir ou créer une étiquette"
     }
     }
   },
   },
   "delete_attachment_modal": {
   "delete_attachment_modal": {
@@ -881,11 +883,11 @@
     "size_l": "Taille: G"
     "size_l": "Taille: G"
   },
   },
   "sync-latest-revision-body": {
   "sync-latest-revision-body": {
-    "menuitem": "Synchroniser le texte de l'éditeur avec le corps de la dernière révision",
-    "confirm": "Delete the draft data being entered into the editor and synchronize the latest text. Are you sure you want to run it?",
+    "menuitem": "Synchroniser avec la dernière révision",
+    "confirm": "Supprime les données en brouillon et synchronise avec la dernière révision. Synchroniser?",
     "alert": "Il se peut que le texte le plus récent n'ait pas été synchronisé. Veuillez recharger et vérifier à nouveau.",
     "alert": "Il se peut que le texte le plus récent n'ait pas été synchronisé. Veuillez recharger et vérifier à nouveau.",
     "success-toaster": "Dernier texte synchronisé",
     "success-toaster": "Dernier texte synchronisé",
-    "skipped-toaster": "Synchronisation ignorée car l'éditeur n'est pas activé. Ouvrir l'éditeur et réessayer.",
-    "error-toaster": "La synchronisation du dernier texte a échoué"
+    "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
+    "error-toaster": "Synchronisation échouée"
   }
   }
 }
 }

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

@@ -194,9 +194,6 @@
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
         "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
         "updated_google": "Google OAuth を更新しました"
         "updated_google": "Google OAuth を更新しました"
       },
       },
-      "Facebook": {
-        "name": "Facebook OAuth"
-      },
       "GitHub": {
       "GitHub": {
         "enable_github": "GitHub OAuth を有効にする",
         "enable_github": "GitHub OAuth を有効にする",
         "name": "GitHub OAuth",
         "name": "GitHub OAuth",
@@ -212,7 +209,7 @@
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
         "register_1": "OIDC IdP Administrator へ接続します。",
         "register_1": "OIDC IdP Administrator へ接続します。",
-        "register_2": "OIDCアプリの認証コールバックURLを<code>%s</code>として登録します。",
+        "register_2": "OIDCアプリの認証コールバックURLを<code>{{url}}</code>として登録します。",
         "register_3": "上記のClientIDとClient Secretをコピー&ペーストしてください。",
         "register_3": "上記のClientIDとClient Secretをコピー&ペーストしてください。",
         "updated_oidc": "OpenID Connect を更新しました",
         "updated_oidc": "OpenID Connect を更新しました",
         "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
         "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"

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

@@ -790,7 +790,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
     "you_cannot_move_this_page_now": "現在、このページを移動することはできません",
-    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました"
+    "something_went_wrong_with_moving_page": "ページの移動に問題が発生しました",
+    "error_retrieving_the_pagetree": "ページツリーの取得中にエラーが発生しました"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",
     "same_page_name_exists": "ページ名 「{{pageName}}」が重複しています",

+ 2 - 5
apps/app/public/static/locales/zh_CN/admin.json

@@ -194,9 +194,6 @@
 				"register_5": "Copy and paste your ClientID and Client Secret above",
 				"register_5": "Copy and paste your ClientID and Client Secret above",
 				"updated_google": "Succeeded to update Google OAuth setting"
 				"updated_google": "Succeeded to update Google OAuth setting"
 			},
 			},
-			"Facebook": {
-				"name": "Facebook OAuth"
-			},
 			"GitHub": {
 			"GitHub": {
 				"enable_github": "Enable GitHub OAuth",
 				"enable_github": "Enable GitHub OAuth",
 				"name": "GitHub OAuth",
 				"name": "GitHub OAuth",
@@ -210,9 +207,9 @@
 				"id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
 				"id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
 				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
 				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
 				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
 				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
-				"mapping_detail": "Specification of mappings for %s when creating new users",
+				"mapping_detail": "Specification of mappings for {{target}} when creating new users",
 				"register_1": "Contact to OIDC IdP Administrator",
 				"register_1": "Contact to OIDC IdP Administrator",
-				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
+				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>{{url}}</code>",
 				"register_3": "Copy and paste your ClientID and Client Secret above",
 				"register_3": "Copy and paste your ClientID and Client Secret above",
 				"updated_oidc": "Succeeded to update OpenID Connect",
 				"updated_oidc": "Succeeded to update OpenID Connect",
         "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
         "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"

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

@@ -760,7 +760,8 @@
   "pagetree": {
   "pagetree": {
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
     "you_cannot_move_this_page_now": "你现在不能移动这个页面",
     "you_cannot_move_this_page_now": "你现在不能移动这个页面",
-    "something_went_wrong_with_moving_page": "移动页面时出了问题"
+    "something_went_wrong_with_moving_page": "移动页面时出了问题",
+    "error_retrieving_the_pagetree": "检索页面树时发生错误"
   },
   },
   "duplicated_page_alert": {
   "duplicated_page_alert": {
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",
     "same_page_name_exists": "页面名称「{{pageName}}」是重复的",

+ 1 - 1
apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx

@@ -16,7 +16,7 @@ export const CustomizeTitle: FC = () => {
 
 
   const { data: customizeTitle } = useCustomizeTitle();
   const { data: customizeTitle } = useCustomizeTitle();
 
 
-  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle);
+  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle ?? '');
 
 
   const onClickSubmit = async() => {
   const onClickSubmit = async() => {
     try {
     try {

+ 0 - 36
apps/app/src/client/components/Admin/Security/FacebookSecuritySetting.jsx

@@ -1,36 +0,0 @@
-import React from 'react';
-
-import { withTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
-class FacebookSecurityManagement extends React.Component {
-
-  render() {
-    const { t } = this.props;
-    return (
-      <>
-        <h2 className="alert-anchor border-bottom">
-          Facebook OAuth { t('admin:security_settings.configuration') }
-        </h2>
-
-        <p className="card custom-card">(TBD)</p>
-      </>
-    );
-  }
-
-}
-
-
-FacebookSecurityManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-};
-
-const FacebookSecurityManagementWrapper = withUnstatedContainers(FacebookSecurityManagement, [AdminGeneralSecurityContainer]);
-
-export default withTranslation()(FacebookSecurityManagementWrapper);

+ 1 - 1
apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -455,7 +455,7 @@ class OidcSecurityManagementContents extends React.Component {
           <div className=" card custom-card bg-body-tertiary">
           <div className=" card custom-card bg-body-tertiary">
             <ol id="collapseHelpForOidcOauth" className="collapse mb-0">
             <ol id="collapseHelpForOidcOauth" className="collapse mb-0">
               <li>{t('security_settings.OAuth.OIDC.register_1')}</li>
               <li>{t('security_settings.OAuth.OIDC.register_1')}</li>
-              <li>{t('security_settings.OAuth.OIDC.register_2')}</li>
+              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.register_2', { url: oidcCallbackUrl }) }} />
               <li>{t('security_settings.OAuth.OIDC.register_3')}</li>
               <li>{t('security_settings.OAuth.OIDC.register_3')}</li>
             </ol>
             </ol>
           </div>
           </div>

+ 0 - 8
apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx

@@ -6,7 +6,6 @@ import { TabContent, TabPane } from 'reactstrap';
 
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 import CustomNav from '../../CustomNavigation/CustomNav';
 
 
-// import FacebookSecuritySetting from './FacebookSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import GoogleSecuritySetting from './GoogleSecuritySetting';
 import GoogleSecuritySetting from './GoogleSecuritySetting';
 import LdapSecuritySetting from './LdapSecuritySetting';
 import LdapSecuritySetting from './LdapSecuritySetting';
@@ -53,10 +52,6 @@ const SecurityManagementContents = () => {
         Icon: () => <span className="growi-custom-icons align-bottom">github</span>,
         Icon: () => <span className="growi-custom-icons align-bottom">github</span>,
         i18n: 'GitHub',
         i18n: 'GitHub',
       },
       },
-      // passport_facebook: {
-      //   Icon: () => <span className="growi-custom-icons align-bottom">facebook</span>,
-      //   i18n: '(TBD) Facebook',
-      // },
     };
     };
   }, []);
   }, []);
 
 
@@ -114,9 +109,6 @@ const SecurityManagementContents = () => {
           <TabPane tabId="passport_github">
           <TabPane tabId="passport_github">
             {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
             {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
           </TabPane>
           </TabPane>
-          {/* <TabPane tabId="passport_facebook">
-            {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />}
-          </TabPane> */}
         </TabContent>
         </TabContent>
       </div>
       </div>
     </div>
     </div>

+ 2 - 1
apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx

@@ -6,6 +6,7 @@ import { useTranslation } from 'next-i18next';
 
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -76,7 +77,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
+          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount<IExternalAuthProviderType>) => {
             return (
             return (
               <tr key={ea._id}>
               <tr key={ea._id}>
                 <td><span>{ea.providerType}</span></td>
                 <td><span>{ea.providerType}</span></td>

+ 10 - 90
apps/app/src/client/components/ItemsTree/ItemsTree.tsx

@@ -1,17 +1,16 @@
 import React, {
 import React, {
-  useEffect, useMemo, useCallback,
+  useEffect, useCallback,
 } from 'react';
 } from 'react';
 
 
 import path from 'path';
 import path from 'path';
 
 
-import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core';
+import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForItem } from '~/interfaces/page';
-import type { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
 import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -19,7 +18,7 @@ import type { IPageForPageDuplicateModal } from '~/stores/modal';
 import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import {
 import {
-  useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
+  useSWRxRootPage, mutatePageTree, mutatePageList,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 import { mutateSearching } from '~/stores/search';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
@@ -35,66 +34,12 @@ const moduleClass = styles['items-tree'] ?? '';
 
 
 const logger = loggerFactory('growi:cli:ItemsTree');
 const logger = loggerFactory('growi:cli:ItemsTree');
 
 
-/*
- * Utility to generate initial node
- */
-const generateInitialNodeBeforeResponse = (targetAndAncestors: Partial<IPageHasId>[]): ItemNode => {
-  const nodes = targetAndAncestors.map((page): ItemNode => {
-    return new ItemNode(page, []);
-  });
-
-  // update children for each node
-  const rootNode = nodes.reduce((child, parent) => {
-    parent.children = [child];
-    return parent;
-  });
-
-  return rootNode;
-};
-
-const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Partial<IPageHasId>[]>, rootNode: ItemNode): ItemNode => {
-  const paths = Object.keys(ancestorsChildren);
-
-  let currentNode = rootNode;
-  paths.every((path) => {
-    // stop rendering when non-migrated pages found
-    if (currentNode == null) {
-      return false;
-    }
-
-    const childPages = ancestorsChildren[path];
-    currentNode.children = ItemNode.generateNodesFromPages(childPages);
-    const nextNode = currentNode.children.filter((node) => {
-      return paths.includes(node.page.path as string);
-    })[0];
-    currentNode = nextNode;
-    return true;
-  });
-
-  return rootNode;
-};
-
-// user defined typeguard to assert the arg is not null
-type RenderingCondition = {
-  ancestorsChildrenResult: AncestorsChildrenResult | undefined,
-  rootPageResult: RootPageResult | undefined,
-}
-type SecondStageRenderingCondition = {
-  ancestorsChildrenResult: AncestorsChildrenResult,
-  rootPageResult: RootPageResult,
-}
-const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondStageRenderingCondition): condition is SecondStageRenderingCondition => {
-  return condition.ancestorsChildrenResult != null && condition.rootPageResult != null;
-};
-
-
 type ItemsTreeProps = {
 type ItemsTreeProps = {
   isEnableActions: boolean
   isEnableActions: boolean
   isReadOnlyUser: boolean
   isReadOnlyUser: boolean
   isWipPageShown?: boolean
   isWipPageShown?: boolean
   targetPath: string
   targetPath: string
-  targetPathOrId?: Nullable<string>
-  targetAndAncestorsData?: TargetAndAncestors
+  targetPathOrId?: string,
   CustomTreeItem: React.FunctionComponent<TreeItemProps>
   CustomTreeItem: React.FunctionComponent<TreeItemProps>
   onClickTreeItem?: (page: IPageForItem) => void;
   onClickTreeItem?: (page: IPageForItem) => void;
 }
 }
@@ -104,14 +49,13 @@ type ItemsTreeProps = {
  */
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
+    targetPath, targetPathOrId, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
   } = props;
   } = props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
   const router = useRouter();
   const router = useRouter();
 
 
-  const { data: ancestorsChildrenResult, error: error1 } = useSWRxPageAncestorsChildren(targetPath, { suspense: true });
-  const { data: rootPageResult, error: error2 } = useSWRxRootPage({ suspense: true });
+  const { data: rootPageResult, error } = useSWRxRootPage({ suspense: true });
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -122,14 +66,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   // for mutation
   // for mutation
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
 
-
-  const renderingCondition = useMemo(() => {
-    return {
-      ancestorsChildrenResult,
-      rootPageResult,
-    };
-  }, [ancestorsChildrenResult, rootPageResult]);
-
   useEffect(() => {
   useEffect(() => {
     if (socket == null) {
     if (socket == null) {
       return;
       return;
@@ -197,34 +133,18 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
   }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
 
 
 
 
-  if (error1 != null || error2 != null) {
-    // TODO: improve message
-    toastError('Error occurred while fetching pages to render PageTree');
+  if (error != null) {
+    toastError(t('pagetree.error_retrieving_the_pagetree'));
     return <></>;
     return <></>;
   }
   }
 
 
-  let initialItemNode;
-  /*
-   * Render second stage
-   */
-  if (isSecondStageRenderingCondition(renderingCondition)) {
-    initialItemNode = generateInitialNodeAfterResponse(
-      renderingCondition.ancestorsChildrenResult.ancestorsChildren,
-      new ItemNode(renderingCondition.rootPageResult.rootPage),
-    );
-  }
-  /*
-   * Before swr response comes back
-   */
-  else if (targetAndAncestorsData != null) {
-    initialItemNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-  }
-
+  const initialItemNode = rootPageResult ? new ItemNode(rootPageResult.rootPage) : null;
   if (initialItemNode != null) {
   if (initialItemNode != null) {
     return (
     return (
       <ul className={`${moduleClass} list-group`}>
       <ul className={`${moduleClass} list-group`}>
         <CustomTreeItem
         <CustomTreeItem
           key={initialItemNode.page.path}
           key={initialItemNode.page.path}
+          targetPath={targetPath}
           targetPathOrId={targetPathOrId}
           targetPathOrId={targetPathOrId}
           itemNode={initialItemNode}
           itemNode={initialItemNode}
           isOpen
           isOpen

+ 2 - 3
apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx

@@ -1,12 +1,12 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
-import { IExternalAuthProviderType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
 const authIcon = {
 const authIcon = {
   [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
   [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
   [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,
   [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,
-  [IExternalAuthProviderType.facebook]: <span className="growi-custom-icons align-bottom">facebook</span>,
   [IExternalAuthProviderType.oidc]: <span className="growi-custom-icons align-bottom">openid</span>,
   [IExternalAuthProviderType.oidc]: <span className="growi-custom-icons align-bottom">openid</span>,
   [IExternalAuthProviderType.saml]: <span className="material-symbols-outlined align-bottom">key</span>,
   [IExternalAuthProviderType.saml]: <span className="material-symbols-outlined align-bottom">key</span>,
 };
 };
@@ -14,7 +14,6 @@ const authIcon = {
 const authLabel = {
 const authLabel = {
   [IExternalAuthProviderType.google]: 'Google',
   [IExternalAuthProviderType.google]: 'Google',
   [IExternalAuthProviderType.github]: 'GitHub',
   [IExternalAuthProviderType.github]: 'GitHub',
-  [IExternalAuthProviderType.facebook]: 'Facebook',
   [IExternalAuthProviderType.oidc]: 'OIDC',
   [IExternalAuthProviderType.oidc]: 'OIDC',
   [IExternalAuthProviderType.saml]: 'SAML',
   [IExternalAuthProviderType.saml]: 'SAML',
 };
 };

+ 0 - 6
apps/app/src/client/components/LoginForm/LoginForm.module.scss

@@ -82,12 +82,6 @@
       --bs-btn-active-bg: #{rgba(#403D3E, 0.7)};
       --bs-btn-active-bg: #{rgba(#403D3E, 0.7)};
     }
     }
 
 
-    .btn-auth-facebook {
-      --bs-btn-bg: #{rgba(#29487d, 0.4)};
-      --bs-btn-hover-bg: #{rgba(#29487d, 0.9)};
-      --bs-btn-active-bg: #{rgba(#29487d, 0.9)};
-    }
-
     .btn-auth-oidc {
     .btn-auth-oidc {
       --bs-btn-bg: #{rgba(#835B1A, 0.4)};
       --bs-btn-bg: #{rgba(#835B1A, 0.4)};
       --bs-btn-hover-bg: #{rgba(#835B1A, 0.8)};
       --bs-btn-hover-bg: #{rgba(#835B1A, 0.8)};

+ 1 - 1
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, useEffect, useCallback,
   useState, useEffect, useCallback,
 } from 'react';
 } from 'react';
 
 
-import type { IExternalAuthProviderType } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -13,6 +12,7 @@ import { useTWithOpt } from '~/client/util/t-with-opt';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 

+ 0 - 6
apps/app/src/client/components/Me/AssociateModal.tsx

@@ -80,12 +80,6 @@ const AssociateModal = (props: Props): JSX.Element => {
             >
             >
               <span className="growi-custom-icons">google</span> (TBD) Google OAuth
               <span className="growi-custom-icons">google</span> (TBD) Google OAuth
             </NavLink>
             </NavLink>
-            {/* <NavLink
-              className={`${activeTab === 4 ? 'active' : ''} d-flex gap-1 align-items-center`}
-              onClick={() => setActiveTab(4)}
-            >
-              <span className="growi-custom-icons">facebook</span> (TBD) Facebook
-            </NavLink> */}
           </Nav>
           </Nav>
           <TabContent activeTab={activeTab}>
           <TabContent activeTab={activeTab}>
             <TabPane tabId={1}>
             <TabPane tabId={1}>

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

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

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

@@ -9,11 +9,10 @@ import type {
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
-import { auto } from '@popperjs/core';
-import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
 import Sticky from 'react-stickynode';
 import Sticky from 'react-stickynode';
 import { Tooltip, DropdownItem, UncontrolledTooltip } from 'reactstrap';
 import { Tooltip, DropdownItem, UncontrolledTooltip } from 'reactstrap';
 
 
@@ -23,11 +22,6 @@ import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import { usePageBulkExportSelectModal } from '~/features/page-bulk-export/client/stores/modal';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
-import {
-  useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsBulkExportPagesEnabled, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
-} from '~/stores-universal/context';
-import { useEditorMode } from '~/stores-universal/ui';
 import {
 import {
   usePageAccessoriesModal, PageAccessoriesModalContents, type IPageForPageDuplicateModal,
   usePageAccessoriesModal, PageAccessoriesModalContents, type IPageForPageDuplicateModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal, usePagePresentationModal,
@@ -41,6 +35,11 @@ import {
   useIsAbleToChangeEditorMode,
   useIsAbleToChangeEditorMode,
   useIsDeviceLargerThanMd,
   useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import {
+  useCurrentPathname,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsBulkExportPagesEnabled, useIsLocalAccountRegistrationEnabled, useIsSharedUser, useShareLinkId,
+} from '~/stores-universal/context';
+import { useEditorMode } from '~/stores-universal/ui';
 
 
 import { NotAvailable } from '../NotAvailable';
 import { NotAvailable } from '../NotAvailable';
 import { Skeleton } from '../Skeleton';
 import { Skeleton } from '../Skeleton';

+ 8 - 0
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss

@@ -13,3 +13,11 @@
     }
     }
   }
   }
 }
 }
+
+@media print {
+  .grw-page-path-nav-sticky :global {
+    .sticky-inner-wrapper {
+      position: static !important;
+    }
+  }
+}

+ 1 - 3
apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx

@@ -13,7 +13,7 @@ import {
 import SimpleBar from 'simplebar-react';
 import SimpleBar from 'simplebar-react';
 
 
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForItem } from '~/interfaces/page';
-import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
 import { usePageSelectModal } from '~/stores/modal';
 import { usePageSelectModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
 
 
@@ -38,7 +38,6 @@ export const PageSelectModal: FC = () => {
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: targetAndAncestorsData } = useTargetAndAncestors();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
 
 
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
@@ -96,7 +95,6 @@ export const PageSelectModal: FC = () => {
                 isReadOnlyUser={!!isReadOnlyUser}
                 isReadOnlyUser={!!isReadOnlyUser}
                 targetPath={targetPath}
                 targetPath={targetPath}
                 targetPathOrId={targetPathOrId}
                 targetPathOrId={targetPathOrId}
-                targetAndAncestorsData={targetAndAncestorsData}
                 onClickTreeItem={onClickTreeItem}
                 onClickTreeItem={onClickTreeItem}
               />
               />
             </div>
             </div>

+ 4 - 7
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -5,10 +5,10 @@ import React, {
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import {
 import {
-  mutatePageTree, mutateRecentlyUpdated, useSWRxPageAncestorsChildren, useSWRxRootPage, useSWRxV5MigrationStatus,
+  mutatePageTree, mutateRecentlyUpdated, useSWRxRootPage, useSWRxV5MigrationStatus,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
 import { useSidebarScrollerRef } from '~/stores/ui';
 import { useSidebarScrollerRef } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -99,14 +99,12 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetId } = useCurrentPageId();
-  const { data: targetAndAncestorsData } = useTargetAndAncestors();
 
 
   const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
   const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
 
 
   const targetPathOrId = targetId || currentPath;
   const targetPathOrId = targetId || currentPath;
   const path = currentPath || '/';
   const path = currentPath || '/';
 
 
-  const { data: ancestorsChildrenResult } = useSWRxPageAncestorsChildren(path, { suspense: true });
   const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const { data: sidebarScrollerRef } = useSidebarScrollerRef();
   const { data: sidebarScrollerRef } = useSidebarScrollerRef();
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
   const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
@@ -144,7 +142,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
   const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
 
 
   useEffect(() => {
   useEffect(() => {
-    if (isInitialScrollCompleted || ancestorsChildrenResult == null || rootPageResult == null) {
+    if (isInitialScrollCompleted || rootPageResult == null) {
       return;
       return;
     }
     }
 
 
@@ -166,7 +164,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
     return () => {
     return () => {
       observer.disconnect();
       observer.disconnect();
     };
     };
-  }, [isInitialScrollCompleted, scrollOnInitDebounced, ancestorsChildrenResult, rootPageResult]);
+  }, [isInitialScrollCompleted, scrollOnInitDebounced, rootPageResult]);
   // *******************************  end  *******************************
   // *******************************  end  *******************************
 
 
 
 
@@ -189,7 +187,6 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
         isWipPageShown={isWipPageShown}
         isWipPageShown={isWipPageShown}
         targetPath={path}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         targetPathOrId={targetPathOrId}
-        targetAndAncestorsData={targetAndAncestorsData}
         CustomTreeItem={PageTreeItem}
         CustomTreeItem={PageTreeItem}
       />
       />
 
 

+ 2 - 1
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -34,7 +34,7 @@ const moduleClass = styles['page-tree-item'] ?? '';
 
 
 const logger = loggerFactory('growi:cli:Item');
 const logger = loggerFactory('growi:cli:Item');
 
 
-export const PageTreeItem: FC<TreeItemProps> = (props) => {
+export const PageTreeItem = (props:TreeItemProps): JSX.Element => {
   const router = useRouter();
   const router = useRouter();
 
 
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
@@ -186,6 +186,7 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
   return (
   return (
     <TreeItemLayout
     <TreeItemLayout
       className={moduleClass}
       className={moduleClass}
+      targetPath={props.targetPath}
       targetPathOrId={props.targetPathOrId}
       targetPathOrId={props.targetPathOrId}
       itemLevel={props.itemLevel}
       itemLevel={props.itemLevel}
       itemNode={props.itemNode}
       itemNode={props.itemNode}

+ 8 - 6
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -22,21 +22,21 @@ type TreeItemLayoutProps = TreeItemProps & {
   indentSize?: number,
   indentSize?: number,
 }
 }
 
 
-export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
+export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
   const {
   const {
     className, itemClassName,
     className, itemClassName,
     indentSize = 10,
     indentSize = 10,
     itemLevel: baseItemLevel = 1,
     itemLevel: baseItemLevel = 1,
-    itemNode, targetPathOrId, isOpen: _isOpen = false,
+    itemNode, targetPath, targetPathOrId, isOpen: _isOpen = false,
     onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
     onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
     isEnableActions, isReadOnlyUser, isWipPageShown = true,
     isEnableActions, isReadOnlyUser, isWipPageShown = true,
     itemRef, itemClass,
     itemRef, itemClass,
     showAlternativeContent,
     showAlternativeContent,
   } = props;
   } = props;
 
 
-  const { page, children } = itemNode;
+  const { page } = itemNode;
 
 
-  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>(children);
+  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>([]);
   const [isOpen, setIsOpen] = useState(_isOpen);
   const [isOpen, setIsOpen] = useState(_isOpen);
 
 
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data } = useSWRxPageChildren(isOpen ? page._id : null);
@@ -84,8 +84,9 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
 
 
   // didMount
   // didMount
   useEffect(() => {
   useEffect(() => {
-    if (hasChildren()) setIsOpen(true);
-  }, [hasChildren]);
+    const isPathToTarget = page.path != null && targetPath.startsWith(page.path) && targetPath !== page.path; // Target Page does not need to be opened
+    if (isPathToTarget) setIsOpen(true);
+  }, [targetPath, page.path]);
 
 
   /*
   /*
    * When swr fetch succeeded
    * When swr fetch succeeded
@@ -108,6 +109,7 @@ export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
     isReadOnlyUser,
     isReadOnlyUser,
     isOpen: false,
     isOpen: false,
     isWipPageShown,
     isWipPageShown,
+    targetPath,
     targetPathOrId,
     targetPathOrId,
     onRenamed,
     onRenamed,
     onClickDuplicateMenuItem,
     onClickDuplicateMenuItem,

+ 2 - 2
apps/app/src/client/components/TreeItem/interfaces/index.ts

@@ -1,5 +1,4 @@
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta } from '@growi/core';
-import type { Nullable } from 'vitest';
 
 
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForPageDuplicateModal } from '~/stores/modal';
 import type { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -23,7 +22,8 @@ export type TreeItemToolProps = TreeItemBaseProps & {
 };
 };
 
 
 export type TreeItemProps = TreeItemBaseProps & {
 export type TreeItemProps = TreeItemBaseProps & {
-  targetPathOrId?: Nullable<string>,
+  targetPath: string,
+  targetPathOrId?:string,
   isOpen?: boolean,
   isOpen?: boolean,
   isWipPageShown?: boolean,
   isWipPageShown?: boolean,
   itemClass?: React.FunctionComponent<TreeItemProps>,
   itemClass?: React.FunctionComponent<TreeItemProps>,

+ 1 - 1
apps/app/src/client/util/bookmark-utils.ts

@@ -16,7 +16,7 @@ export const addNewFolder = async(name: string, parent: string | null): Promise<
 
 
 // Put bookmark to a folder
 // Put bookmark to a folder
 export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
 export const addBookmarkToFolder = async(pageId: string, folderId: string | null): Promise<void> => {
-  await apiv3Post('/bookmark-folder/add-boookmark-to-folder', { pageId, folderId });
+  await apiv3Post('/bookmark-folder/add-bookmark-to-folder', { pageId, folderId });
 };
 };
 
 
 // Delete bookmark folder
 // Delete bookmark folder

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

@@ -48,7 +48,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
   return (
   return (
     <RawLayout className={`${moduleClass} ${className ?? ''}`}>
     <RawLayout className={`${moduleClass} ${className ?? ''}`}>
       <div className="page-wrapper flex-row">
       <div className="page-wrapper flex-row">
-        <div className="z-2">
+        <div className="z-2 d-print-none">
           <Sidebar />
           <Sidebar />
         </div>
         </div>
 
 

+ 1 - 1
apps/app/src/components/PageView/PageContentFooter.module.scss

@@ -1,7 +1,7 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 
 .page-content-footer :global {
 .page-content-footer :global {
-  border-top: solid 1px transparent;
+  border-top: solid 1px var(--bs-border-color);
   .page-meta {
   .page-meta {
     font-size: 0.95em;
     font-size: 0.95em;
   }
   }

+ 4 - 6
apps/app/src/components/PageView/PageContentFooter.tsx

@@ -22,12 +22,10 @@ export const PageContentFooter = (props: PageContentFooterProps): JSX.Element =>
   }
   }
 
 
   return (
   return (
-    <div className={`${styles['page-content-footer']} page-content-footer py-4 d-edit-none d-print-none}`}>
-      <div className="container-lg grw-container-convertible">
-        <div className="page-meta">
-          <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={lastUpdateUser} date={updatedAt} mode="update" locate="footer" />
-        </div>
+    <div className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none d-print-none}`}>
+      <div className="page-meta">
+        <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
+        <AuthorInfo user={lastUpdateUser} date={updatedAt} mode="update" locate="footer" />
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 1 - 1
apps/app/src/components/PageView/PageViewLayout.tsx

@@ -51,7 +51,7 @@ export const PageViewLayout = (props: Props): JSX.Element => {
       </div>
       </div>
 
 
       { footerContents != null && (
       { footerContents != null && (
-        <footer className={`footer d-edit-none wide-gutter-x-lg ${fluidLayoutClass}`}>
+        <footer className={`footer d-edit-none container-lg wide-gutter-x-lg ${fluidLayoutClass}`}>
           {footerContents}
           {footerContents}
         </footer>
         </footer>
       ) }
       ) }

+ 1 - 1
apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx

@@ -42,7 +42,7 @@ const ShareLinkAlert: FC<Props> = (props: Props) => {
   return (
   return (
     <p className={`alert alert-${alertColor} px-4 d-edit-none`}>
     <p className={`alert alert-${alertColor} px-4 d-edit-none`}>
       <span className="material-symbols-outlined me-1">link</span>
       <span className="material-symbols-outlined me-1">link</span>
-      {(expiredAt === null ? <span>{t('page_page.notice.no_deadline')}</span>
+      {(expiredAt == null ? <span>{t('page_page.notice.no_deadline')}</span>
       // eslint-disable-next-line react/no-danger
       // eslint-disable-next-line react/no-danger
         : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
         : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
       )}
       )}

+ 28 - 28
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -30,7 +30,7 @@ interface AuthorizedRequest extends Request {
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
-  const addActivity = generateAddActivityMiddleware(crowi);
+  const addActivity = generateAddActivityMiddleware();
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
@@ -216,14 +216,14 @@ module.exports = (crowi: Crowi): Router => {
 
 
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
     const settings = {
-      ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
-      ldapGroupMembershipAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute'),
-      ldapGroupMembershipAttributeType: configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType'),
-      ldapGroupChildGroupAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute'),
-      autoGenerateUserOnLdapGroupSync: configManager?.getConfig('crowi', 'external-user-group:ldap:autoGenerateUserOnGroupSync'),
-      preserveDeletedLdapGroups: configManager?.getConfig('crowi', 'external-user-group:ldap:preserveDeletedGroups'),
-      ldapGroupNameAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute'),
-      ldapGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute'),
+      ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
+      ldapGroupMembershipAttribute: configManager.getConfig('external-user-group:ldap:groupMembershipAttribute'),
+      ldapGroupMembershipAttributeType: configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType'),
+      ldapGroupChildGroupAttribute: configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute'),
+      autoGenerateUserOnLdapGroupSync: configManager.getConfig('external-user-group:ldap:autoGenerateUserOnGroupSync'),
+      preserveDeletedLdapGroups: configManager.getConfig('external-user-group:ldap:preserveDeletedGroups'),
+      ldapGroupNameAttribute: configManager.getConfig('external-user-group:ldap:groupNameAttribute'),
+      ldapGroupDescriptionAttribute: configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute'),
     };
     };
 
 
     return res.apiv3(settings);
     return res.apiv3(settings);
@@ -231,14 +231,14 @@ module.exports = (crowi: Crowi): Router => {
 
 
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/keycloak/sync-settings', loginRequiredStrictly, adminRequired, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
     const settings = {
-      keycloakHost: configManager?.getConfig('crowi', 'external-user-group:keycloak:host'),
-      keycloakGroupRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm'),
-      keycloakGroupSyncClientRealm: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm'),
-      keycloakGroupSyncClientID: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID'),
-      keycloakGroupSyncClientSecret: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret'),
-      autoGenerateUserOnKeycloakGroupSync: configManager?.getConfig('crowi', 'external-user-group:keycloak:autoGenerateUserOnGroupSync'),
-      preserveDeletedKeycloakGroups: configManager?.getConfig('crowi', 'external-user-group:keycloak:preserveDeletedGroups'),
-      keycloakGroupDescriptionAttribute: configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute'),
+      keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
+      keycloakGroupRealm: configManager.getConfig('external-user-group:keycloak:groupRealm'),
+      keycloakGroupSyncClientRealm: configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm'),
+      keycloakGroupSyncClientID: configManager.getConfig('external-user-group:keycloak:groupSyncClientID'),
+      keycloakGroupSyncClientSecret: configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret'),
+      autoGenerateUserOnKeycloakGroupSync: configManager.getConfig('external-user-group:keycloak:autoGenerateUserOnGroupSync'),
+      preserveDeletedKeycloakGroups: configManager.getConfig('external-user-group:keycloak:preserveDeletedGroups'),
+      keycloakGroupDescriptionAttribute: configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute'),
     };
     };
 
 
     return res.apiv3(settings);
     return res.apiv3(settings);
@@ -269,7 +269,7 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
 
 
     try {
     try {
-      await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+      await configManager.updateConfigs(params, { skipPubsub: true });
       return res.apiv3({}, 204);
       return res.apiv3({}, 204);
     }
     }
     catch (err) {
     catch (err) {
@@ -301,7 +301,7 @@ module.exports = (crowi: Crowi): Router => {
       };
       };
 
 
       try {
       try {
-        await configManager.updateConfigsInTheSameNamespace('crowi', params, true);
+        await configManager.updateConfigs(params, { skipPubsub: true });
         return res.apiv3({}, 204);
         return res.apiv3({}, 204);
       }
       }
       catch (err) {
       catch (err) {
@@ -319,7 +319,7 @@ module.exports = (crowi: Crowi): Router => {
       );
       );
     }
     }
 
 
-    const isLdapEnabled = await configManager.getConfig('crowi', 'security:passport-ldap:isEnabled');
+    const isLdapEnabled = await configManager.getConfig('security:passport-ldap:isEnabled');
     if (!isLdapEnabled) {
     if (!isLdapEnabled) {
       return res.apiv3Err(
       return res.apiv3Err(
         new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
         new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
@@ -349,25 +349,25 @@ module.exports = (crowi: Crowi): Router => {
     }
     }
 
 
     const getAuthProviderType = () => {
     const getAuthProviderType = () => {
-      let kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
+      let kcHost = configManager.getConfig('external-user-group:keycloak:host');
       if (kcHost?.endsWith('/')) {
       if (kcHost?.endsWith('/')) {
         kcHost = kcHost.slice(0, -1);
         kcHost = kcHost.slice(0, -1);
       }
       }
-      const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
+      const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
 
 
       // starts with kcHost, contains kcGroupRealm in path
       // starts with kcHost, contains kcGroupRealm in path
       // see: https://regex101.com/r/3ihDmf/1
       // see: https://regex101.com/r/3ihDmf/1
       const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
       const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
 
 
-      const isOidcEnabled = configManager.getConfig('crowi', 'security:passport-oidc:isEnabled');
-      const oidcIssuerHost = configManager.getConfig('crowi', 'security:passport-oidc:issuerHost');
+      const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
+      const oidcIssuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
 
 
-      if (isOidcEnabled && regex.test(oidcIssuerHost)) return 'oidc';
+      if (isOidcEnabled && oidcIssuerHost != null && regex.test(oidcIssuerHost)) return 'oidc';
 
 
-      const isSamlEnabled = configManager.getConfig('crowi', 'security:passport-saml:isEnabled');
-      const samlEntryPoint = configManager.getConfig('crowi', 'security:passport-saml:entryPoint');
+      const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
+      const samlEntryPoint = configManager.getConfig('security:passport-saml:entryPoint');
 
 
-      if (isSamlEnabled && regex.test(samlEntryPoint)) return 'saml';
+      if (isSamlEnabled && samlEntryPoint != null && regex.test(samlEntryPoint)) return 'saml';
 
 
       return null;
       return null;
     };
     };

+ 7 - 6
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -3,19 +3,20 @@ import type { IUserHasId } from '@growi/core';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import ExternalAccount from '~/server/models/external-account';
 import ExternalAccount from '~/server/models/external-account';
 import S2sMessage from '~/server/models/vo/s2s-message';
 import S2sMessage from '~/server/models/vo/s2s-message';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
-import { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { S2sMessageHandlable } from '~/server/service/s2s-messaging/handlable';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 
 import { configManager } from '../../../../server/service/config-manager';
 import { configManager } from '../../../../server/service/config-manager';
 import { externalAccountService } from '../../../../server/service/external-account';
 import { externalAccountService } from '../../../../server/service/external-account';
-import {
+import type {
   ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
   ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
 } from '../../interfaces/external-user-group';
 } from '../../interfaces/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 
 const logger = loggerFactory('growi:service:external-user-group-sync-service');
 const logger = loggerFactory('growi:service:external-user-group-sync-service');
 
 
@@ -37,7 +38,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 
 
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
 
 
-  authProviderType: string | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
+  authProviderType: IExternalAuthProviderType | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
 
 
   socketIoService: any;
   socketIoService: any;
 
 
@@ -93,7 +94,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     if (this.authProviderType == null) throw new Error('auth provider type is not set');
     if (this.authProviderType == null) throw new Error('auth provider type is not set');
     if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
     if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
 
 
-    const preserveDeletedLdapGroups: boolean = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
+    const preserveDeletedLdapGroups = configManager.getConfig(`external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
     const existingExternalUserGroupIds: string[] = [];
     const existingExternalUserGroupIds: string[] = [];
 
 
     const socket = this.socketIoService?.getAdminSocket();
     const socket = this.socketIoService?.getAdminSocket();
@@ -183,7 +184,7 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     const authProviderType = this.authProviderType;
     const authProviderType = this.authProviderType;
     if (authProviderType == null) throw new Error('auth provider type is not set');
     if (authProviderType == null) throw new Error('auth provider type is not set');
 
 
-    const autoGenerateUserOnGroupSync = configManager?.getConfig('crowi', `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
+    const autoGenerateUserOnGroupSync = configManager.getConfig(`external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
 
 
     const getExternalAccount = async() => {
     const getExternalAccount = async() => {
       if (autoGenerateUserOnGroupSync && externalAccountService != null) {
       if (autoGenerateUserOnGroupSync && externalAccountService != null) {

+ 2 - 1
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts

@@ -146,7 +146,8 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
   };
   };
 
 
   beforeAll(async() => {
   beforeAll(async() => {
-    await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+    await configManager.loadConfigs();
+    await configManager.updateConfigs(configParams, { skipPubsub: true });
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService.init('oidc');
     keycloakUserGroupSyncService.init('oidc');
   });
   });

+ 14 - 13
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -1,13 +1,14 @@
 import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
 import KeycloakAdminClient from '@keycloak/keycloak-admin-client';
-import GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
-import UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
+import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation';
+import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation';
 
 
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 
-import { ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import { ExternalGroupProviderType } from '../../interfaces/external-user-group';
 
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 import ExternalUserGroupSyncService from './external-user-group-sync';
 
 
@@ -22,9 +23,9 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 
 
   kcAdminClient: KeycloakAdminClient;
   kcAdminClient: KeycloakAdminClient;
 
 
-  realm: string; // realm that contains the groups
+  realm: string | undefined; // realm that contains the groups
 
 
-  groupDescriptionAttribute: string; // attribute to map to group description
+  groupDescriptionAttribute: string | undefined; // attribute to map to group description
 
 
   isInitialized = false;
   isInitialized = false;
 
 
@@ -34,10 +35,10 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   }
   }
 
 
   init(authProviderType: 'oidc' | 'saml'): void {
   init(authProviderType: 'oidc' | 'saml'): void {
-    const kcHost = configManager?.getConfig('crowi', 'external-user-group:keycloak:host');
-    const kcGroupRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupRealm');
-    const kcGroupSyncClientRealm = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientRealm');
-    const kcGroupDescriptionAttribute = configManager?.getConfig('crowi', 'external-user-group:keycloak:groupDescriptionAttribute');
+    const kcHost = configManager.getConfig('external-user-group:keycloak:host');
+    const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
+    const kcGroupSyncClientRealm = configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm');
+    const kcGroupDescriptionAttribute = configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute');
 
 
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
     this.realm = kcGroupRealm;
     this.realm = kcGroupRealm;
@@ -70,12 +71,12 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
    * Authenticate to group sync client using client credentials grant type
    * Authenticate to group sync client using client credentials grant type
    */
    */
   private async auth(): Promise<void> {
   private async auth(): Promise<void> {
-    const kcGroupSyncClientID: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientID');
-    const kcGroupSyncClientSecret: string = configManager.getConfig('crowi', 'external-user-group:keycloak:groupSyncClientSecret');
+    const kcGroupSyncClientID = configManager.getConfig('external-user-group:keycloak:groupSyncClientID');
+    const kcGroupSyncClientSecret = configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret');
 
 
     await this.kcAdminClient.auth({
     await this.kcAdminClient.auth({
       grantType: 'client_credentials',
       grantType: 'client_credentials',
-      clientId: kcGroupSyncClientID,
+      clientId: kcGroupSyncClientID ?? '',
       clientSecret: kcGroupSyncClientSecret,
       clientSecret: kcGroupSyncClientSecret,
     });
     });
   }
   }

+ 12 - 10
apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts

@@ -1,12 +1,14 @@
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
-import { ldapService, SearchResultEntry } from '~/server/service/ldap';
-import PassportService from '~/server/service/passport';
-import { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+import type { SearchResultEntry } from '~/server/service/ldap';
+import { ldapService } from '~/server/service/ldap';
+import type PassportService from '~/server/service/passport';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 
+import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
 import {
 import {
-  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, LdapGroupMembershipAttributeType,
+  ExternalGroupProviderType, LdapGroupMembershipAttributeType,
 } from '../../interfaces/external-user-group';
 } from '../../interfaces/external-user-group';
 
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 import ExternalUserGroupSyncService from './external-user-group-sync';
@@ -47,11 +49,11 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
   }
 
 
   override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
   override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
-    const groupChildGroupAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupChildGroupAttribute');
-    const groupMembershipAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttribute');
-    const groupNameAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupNameAttribute');
-    const groupDescriptionAttribute: string = configManager.getConfig('crowi', 'external-user-group:ldap:groupDescriptionAttribute');
-    const groupBase: string = ldapService.getGroupSearchBase();
+    const groupChildGroupAttribute = configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute');
+    const groupMembershipAttribute = configManager.getConfig('external-user-group:ldap:groupMembershipAttribute');
+    const groupNameAttribute = configManager.getConfig('external-user-group:ldap:groupNameAttribute');
+    const groupDescriptionAttribute = configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute');
+    const groupBase = ldapService.getGroupSearchBase();
 
 
     const groupEntries = await ldapService.searchGroupDir();
     const groupEntries = await ldapService.searchGroupDir();
 
 
@@ -117,7 +119,7 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
   }
   }
 
 
   private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
   private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
-    const groupMembershipAttributeType = configManager?.getConfig('crowi', 'external-user-group:ldap:groupMembershipAttributeType');
+    const groupMembershipAttributeType = configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType');
     const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
     const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
     const attrMapName = this.passportService.getLdapAttrNameMappedToName();
     const attrMapName = this.passportService.getLdapAttrNameMappedToName();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();

+ 2 - 2
apps/app/src/features/openai/server/routes/middlewares/certify-ai-service.ts

@@ -8,7 +8,7 @@ import { OpenaiServiceTypes } from '../../../interfaces/ai';
 const logger = loggerFactory('growi:middlewares:certify-ai-service');
 const logger = loggerFactory('growi:middlewares:certify-ai-service');
 
 
 export const certifyAiService = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
 export const certifyAiService = (req: Request, res: Response & { apiv3Err }, next: NextFunction): void => {
-  const aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
+  const aiEnabled = configManager.getConfig('app:aiEnabled');
 
 
   if (!aiEnabled) {
   if (!aiEnabled) {
     const message = 'AI_ENABLED is not true';
     const message = 'AI_ENABLED is not true';
@@ -16,7 +16,7 @@ export const certifyAiService = (req: Request, res: Response & { apiv3Err }, nex
     return res.apiv3Err(message, 403);
     return res.apiv3Err(message, 403);
   }
   }
 
 
-  const openaiServiceType = configManager.getConfig('crowi', 'openai:serviceType');
+  const openaiServiceType = configManager.getConfig('openai:serviceType');
   if (openaiServiceType == null || !OpenaiServiceTypes.includes(openaiServiceType)) {
   if (openaiServiceType == null || !OpenaiServiceTypes.includes(openaiServiceType)) {
     const message = 'AI_SERVICE_TYPE is missing or contains an invalid value';
     const message = 'AI_SERVICE_TYPE is missing or contains an invalid value';
     logger.error(message);
     logger.error(message);

+ 16 - 5
apps/app/src/features/openai/server/services/assistant/assistant.ts

@@ -15,9 +15,20 @@ const AssistantDefaultModelMap: Record<AssistantType, OpenAI.Chat.ChatModel> = {
   [AssistantType.CHAT]: 'gpt-4o-mini',
   [AssistantType.CHAT]: 'gpt-4o-mini',
 };
 };
 
 
+const isValidChatModel = (model: string): model is OpenAI.Chat.ChatModel => {
+  return model.startsWith('gpt-');
+};
+
 const getAssistantModelByType = (type: AssistantType): OpenAI.Chat.ChatModel => {
 const getAssistantModelByType = (type: AssistantType): OpenAI.Chat.ChatModel => {
-  const configKey = `openai:assistantModel:${type.toLowerCase()}`;
-  return configManager.getConfig('crowi', configKey) ?? AssistantDefaultModelMap[type];
+  const configValue = type === AssistantType.SEARCH
+    ? undefined // TODO: add the value for 'openai:assistantModel:search' to config-definition.ts
+    : configManager.getConfig('openai:assistantModel:chat');
+
+  if (typeof configValue === 'string' && isValidChatModel(configValue)) {
+    return configValue;
+  }
+
+  return AssistantDefaultModelMap[type];
 };
 };
 
 
 type AssistantType = typeof AssistantType[keyof typeof AssistantType];
 type AssistantType = typeof AssistantType[keyof typeof AssistantType];
@@ -45,7 +56,7 @@ const findAssistantByName = async(assistantName: string): Promise<OpenAI.Beta.As
 };
 };
 
 
 const getOrCreateAssistant = async(type: AssistantType, nameSuffix?: string): Promise<OpenAI.Beta.Assistant> => {
 const getOrCreateAssistant = async(type: AssistantType, nameSuffix?: string): Promise<OpenAI.Beta.Assistant> => {
-  const appSiteUrl = configManager.getConfig('crowi', 'app:siteUrl');
+  const appSiteUrl = configManager.getConfig('app:siteUrl');
   const assistantName = `GROWI ${type} Assistant for ${appSiteUrl}${nameSuffix != null ? ` ${nameSuffix}` : ''}`;
   const assistantName = `GROWI ${type} Assistant for ${appSiteUrl}${nameSuffix != null ? ` ${nameSuffix}` : ''}`;
   const assistantModel = getAssistantModelByType(type);
   const assistantModel = getAssistantModelByType(type);
 
 
@@ -57,7 +68,7 @@ const getOrCreateAssistant = async(type: AssistantType, nameSuffix?: string): Pr
       }));
       }));
 
 
   // update instructions
   // update instructions
-  const instructions = configManager.getConfig('crowi', 'openai:chatAssistantInstructions');
+  const instructions = configManager.getConfig('openai:chatAssistantInstructions');
   openaiClient.beta.assistants.update(assistant.id, {
   openaiClient.beta.assistants.update(assistant.id, {
     instructions,
     instructions,
     model: assistantModel,
     model: assistantModel,
@@ -75,7 +86,7 @@ const getOrCreateAssistant = async(type: AssistantType, nameSuffix?: string): Pr
 
 
 //   searchAssistant = await getOrCreateAssistant(AssistantType.SEARCH);
 //   searchAssistant = await getOrCreateAssistant(AssistantType.SEARCH);
 //   openaiClient.beta.assistants.update(searchAssistant.id, {
 //   openaiClient.beta.assistants.update(searchAssistant.id, {
-//     instructions: configManager.getConfig('crowi', 'openai:searchAssistantInstructions'),
+//     instructions: configManager.getConfig('openai:searchAssistantInstructions'),
 //     tools: [{ type: 'file_search' }],
 //     tools: [{ type: 'file_search' }],
 //   });
 //   });
 
 

+ 1 - 1
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -13,7 +13,7 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
 
 
   constructor() {
   constructor() {
     // Retrieve OpenAI related values from environment variables
     // Retrieve OpenAI related values from environment variables
-    const apiKey = configManager.getConfig('crowi', 'openai:apiKey');
+    const apiKey = configManager.getConfig('openai:apiKey');
 
 
     const isValid = [apiKey].every(value => value != null);
     const isValid = [apiKey].every(value => value != null);
     if (!isValid) {
     if (!isValid) {

+ 1 - 1
apps/app/src/features/openai/server/services/client.ts

@@ -3,5 +3,5 @@ import OpenAI from 'openai';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 
 
 export const openaiClient = new OpenAI({
 export const openaiClient = new OpenAI({
-  apiKey: configManager?.getConfig('crowi', 'openai:apiKey'), // This is the default and can be omitted
+  apiKey: configManager.getConfig('openai:apiKey'), // This is the default and can be omitted
 });
 });

+ 4 - 4
apps/app/src/features/openai/server/services/cron/thread-deletion-cron.ts

@@ -37,10 +37,10 @@ export class ThreadDeletionCronService {
     }
     }
 
 
     this.openaiService = openaiService;
     this.openaiService = openaiService;
-    this.threadDeletionCronExpression = configManager.getConfig('crowi', 'openai:threadDeletionCronExpression');
-    this.threadDeletionCronMaxMinutesUntilRequest = configManager.getConfig('crowi', 'app:openaiThreadDeletionCronMaxMinutesUntilRequest');
-    this.threadDeletionBarchSize = configManager.getConfig('crowi', 'openai:threadDeletionBarchSize');
-    this.threadDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:threadDeletionApiCallInterval');
+    this.threadDeletionCronExpression = configManager.getConfig('openai:threadDeletionCronExpression');
+    this.threadDeletionCronMaxMinutesUntilRequest = configManager.getConfig('app:openaiThreadDeletionCronMaxMinutesUntilRequest');
+    this.threadDeletionBarchSize = configManager.getConfig('openai:threadDeletionBarchSize');
+    this.threadDeletionApiCallInterval = configManager.getConfig('openai:threadDeletionApiCallInterval');
 
 
     this.cronJob?.stop();
     this.cronJob?.stop();
     this.cronJob = this.generateCronJob();
     this.cronJob = this.generateCronJob();

+ 4 - 4
apps/app/src/features/openai/server/services/cron/vector-store-file-deletion-cron.ts

@@ -36,10 +36,10 @@ export class VectorStoreFileDeletionCronService {
     }
     }
 
 
     this.openaiService = openaiService;
     this.openaiService = openaiService;
-    this.vectorStoreFileDeletionCronExpression = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionCronExpression');
-    this.vectorStoreFileDeletionCronMaxMinutesUntilRequest = configManager.getConfig('crowi', 'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest');
-    this.vectorStoreFileDeletionBarchSize = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionBarchSize');
-    this.vectorStoreFileDeletionApiCallInterval = configManager.getConfig('crowi', 'openai:vectorStoreFileDeletionApiCallInterval');
+    this.vectorStoreFileDeletionCronExpression = configManager.getConfig('openai:vectorStoreFileDeletionCronExpression');
+    this.vectorStoreFileDeletionCronMaxMinutesUntilRequest = configManager.getConfig('app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest');
+    this.vectorStoreFileDeletionBarchSize = configManager.getConfig('openai:vectorStoreFileDeletionBarchSize');
+    this.vectorStoreFileDeletionApiCallInterval = configManager.getConfig('openai:vectorStoreFileDeletionApiCallInterval');
 
 
     this.cronJob?.stop();
     this.cronJob?.stop();
     this.cronJob = this.generateCronJob();
     this.cronJob = this.generateCronJob();

+ 1 - 1
apps/app/src/features/openai/server/services/is-ai-enabled.ts

@@ -1,3 +1,3 @@
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 
 
-export const isAiEnabled = (): boolean => configManager.getConfig('crowi', 'app:aiEnabled');
+export const isAiEnabled = (): boolean => configManager.getConfig('app:aiEnabled');

+ 3 - 3
apps/app/src/features/openai/server/services/openai.ts

@@ -50,7 +50,7 @@ export interface IOpenaiService {
 class OpenaiService implements IOpenaiService {
 class OpenaiService implements IOpenaiService {
 
 
   private get client() {
   private get client() {
-    const openaiServiceType = configManager.getConfig('crowi', 'openai:serviceType');
+    const openaiServiceType = configManager.getConfig('openai:serviceType');
     return getClient({ openaiServiceType });
     return getClient({ openaiServiceType });
   }
   }
 
 
@@ -364,8 +364,8 @@ export const getOpenaiService = (): IOpenaiService | undefined => {
     return instance;
     return instance;
   }
   }
 
 
-  const aiEnabled = configManager.getConfig('crowi', 'app:aiEnabled');
-  const openaiServiceType = configManager.getConfig('crowi', 'openai:serviceType');
+  const aiEnabled = configManager.getConfig('app:aiEnabled');
+  const openaiServiceType = configManager.getConfig('openai:serviceType');
   if (aiEnabled && openaiServiceType != null && OpenaiServiceTypes.includes(openaiServiceType)) {
   if (aiEnabled && openaiServiceType != null && OpenaiServiceTypes.includes(openaiServiceType)) {
     instance = new OpenaiService();
     instance = new OpenaiService();
     return instance;
     return instance;

+ 1 - 0
apps/app/src/features/opentelemetry/server/index.ts

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

+ 76 - 0
apps/app/src/features/opentelemetry/server/logger.ts

@@ -0,0 +1,76 @@
+import { diag, type DiagLogger } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:diag');
+
+
+class DiagLoggerBunyanAdapter implements DiagLogger {
+
+  private parseMessage(message: string, args: unknown[]): [logMessage: string, data: object] {
+    let logMessage = message;
+    let data = {};
+
+    // check whether the message is a JSON string
+    try {
+      const parsedMessage = JSON.parse(message);
+      if (typeof parsedMessage === 'object' && parsedMessage !== null) {
+        data = parsedMessage;
+        // if parsed successfully, use 'message' property as log message
+        logMessage = 'message' in data && typeof data.message === 'string'
+          ? data.message
+          : message;
+      }
+    }
+    catch (e) {
+      // do nothing if the message is not a JSON string
+    }
+
+    // merge additional data
+    if (args.length > 0) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const argsData = (args as any).reduce((acc, arg) => {
+        if (typeof arg === 'string') {
+          try {
+            const parsed = JSON.parse(arg);
+            return { ...acc, ...parsed };
+          }
+          catch (e) {
+            return { ...acc, additionalInfo: arg };
+          }
+        }
+        return { ...acc, ...arg };
+      }, {});
+      data = { ...data, ...argsData };
+    }
+
+    return [logMessage, data];
+  }
+
+  error(message: string, ...args): void {
+    logger.error(...this.parseMessage(message, args));
+  }
+
+  warn(message: string, ...args): void {
+    logger.warn(...this.parseMessage(message, args));
+  }
+
+  info(message: string, ...args): void {
+    logger.info(...this.parseMessage(message, args));
+  }
+
+  debug(message: string, ...args): void {
+    logger.debug(...this.parseMessage(message, args));
+  }
+
+  verbose(message: string, ...args): void {
+    logger.trace(...this.parseMessage(message, args));
+  }
+
+}
+
+
+export const initLogger = (): void => {
+  // Enable global logger for OpenTelemetry
+  diag.setLogger(new DiagLoggerBunyanAdapter());
+};

+ 67 - 0
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -0,0 +1,67 @@
+import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
+import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
+import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
+import { Resource, type IResource } from '@opentelemetry/resources';
+import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
+import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
+import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, SEMRESATTRS_SERVICE_INSTANCE_ID } from '@opentelemetry/semantic-conventions';
+
+import { getGrowiVersion } from '~/utils/growi-version';
+
+type Configuration = Partial<NodeSDKConfiguration> & {
+  resource: IResource;
+};
+
+let resource: Resource;
+let configuration: Configuration;
+
+export const generateNodeSDKConfiguration = (serviceInstanceId?: string): Configuration => {
+  if (configuration == null) {
+    const version = getGrowiVersion();
+
+    resource = new Resource({
+      [ATTR_SERVICE_NAME]: 'growi',
+      [ATTR_SERVICE_VERSION]: version,
+    });
+
+    configuration = {
+      resource,
+      traceExporter: new OTLPTraceExporter(),
+      metricReader: new PeriodicExportingMetricReader({
+        exporter: new OTLPMetricExporter(),
+      }),
+      instrumentations: [getNodeAutoInstrumentations({
+        '@opentelemetry/instrumentation-bunyan': {
+          enabled: false,
+        },
+        // disable fs instrumentation since this generates very large amount of traces
+        // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
+        '@opentelemetry/instrumentation-fs': {
+          enabled: false,
+        },
+      })],
+    };
+  }
+
+  if (serviceInstanceId != null) {
+    configuration.resource = resource.merge(new Resource({
+      [SEMRESATTRS_SERVICE_INSTANCE_ID]: serviceInstanceId,
+    }));
+  }
+
+  return configuration;
+};
+
+// public async shutdownInstrumentation(): Promise<void> {
+//   await this.sdkInstance.shutdown();
+
+//   // メモ: 以下の restart コードは動かない
+//   // span/metrics ともに何も出なくなる
+//   // そもそも、restart するような使い方が出来なさそう?
+//   // see: https://github.com/open-telemetry/opentelemetry-specification/issues/27/
+//   // const sdk = new NodeSDK({...});
+//   // sdk.start();
+//   // await sdk.shutdown().catch(console.error);
+//   // const newSdk = new NodeSDK({...});
+//   // newSdk.start();
+// }

+ 103 - 0
apps/app/src/features/opentelemetry/server/node-sdk.ts

@@ -0,0 +1,103 @@
+import { ConfigSource } from '@growi/core/dist/interfaces';
+import type { NodeSDK } from '@opentelemetry/sdk-node';
+
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:server');
+
+
+let sdkInstance: NodeSDK;
+
+/**
+ * Overwrite "OTEL_SDK_DISABLED" env var before sdk.start() is invoked if needed.
+ * Since otel library sees it.
+ */
+function overwriteSdkDisabled(): void {
+  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+
+  if (instrumentationEnabled && (
+    process.env.OTEL_SDK_DISABLED === 'true'
+    || process.env.OTEL_SDK_DISABLED === '1'
+  )) {
+    logger.warn("OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.");
+    process.env.OTEL_SDK_DISABLED = 'false';
+    return;
+  }
+
+  if (!instrumentationEnabled && (
+    process.env.OTEL_SDK_DISABLED === 'false'
+    || process.env.OTEL_SDK_DISABLED === '0'
+  )) {
+    logger.warn("OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.");
+    process.env.OTEL_SDK_DISABLED = 'true';
+    return;
+  }
+
+}
+
+export const startInstrumentation = async(): Promise<void> => {
+  if (sdkInstance != null) {
+    logger.warn('OpenTelemetry instrumentation already started');
+    return;
+  }
+
+  // load configs from env
+  await configManager.loadConfigs({ source: ConfigSource.env });
+
+  overwriteSdkDisabled();
+
+  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  if (instrumentationEnabled) {
+
+    logger.info(`GROWI now collects anonymous telemetry.
+
+This data is used to help improve GROWI, but you can opt-out at any time.
+
+For more information, see https://docs.growi.org/en/admin-guide/telemetry.html.
+`);
+
+    // initialize global logger for development
+    const isDev = process.env.NODE_ENV === 'development';
+    if (isDev) {
+      const { initLogger } = await import('./logger');
+      initLogger();
+    }
+
+    // instanciate NodeSDK
+    const { NodeSDK } = await import('@opentelemetry/sdk-node');
+    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+
+    sdkInstance = new NodeSDK(generateNodeSDKConfiguration());
+    sdkInstance.start();
+  }
+};
+
+export const initServiceInstanceId = async(): Promise<void> => {
+  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+
+  if (instrumentationEnabled) {
+    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+
+    const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
+      ?? configManager.getConfig('app:serviceInstanceId');
+
+    // overwrite resource
+    const updatedResource = generateNodeSDKConfiguration(serviceInstanceId).resource;
+    (sdkInstance as any).resource = updatedResource;
+  }
+};
+
+// public async shutdownInstrumentation(): Promise<void> {
+//   await this.sdkInstance.shutdown();
+
+//   // メモ: 以下の restart コードは動かない
+//   // span/metrics ともに何も出なくなる
+//   // そもそも、restart するような使い方が出来なさそう?
+//   // see: https://github.com/open-telemetry/opentelemetry-specification/issues/27/
+//   // const sdk = new NodeSDK({...});
+//   // sdk.start();
+//   // await sdk.shutdown().catch(console.error);
+//   // const newSdk = new NodeSDK({...});
+//   // newSdk.start();
+// }

+ 2 - 2
apps/app/src/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron.ts

@@ -16,11 +16,11 @@ const logger = loggerFactory('growi:service:check-page-bulk-export-job-in-progre
 class CheckPageBulkExportJobInProgressCronService extends CronService {
 class CheckPageBulkExportJobInProgressCronService extends CronService {
 
 
   override getCronSchedule(): string {
   override getCronSchedule(): string {
-    return configManager.getConfig('crowi', 'app:checkPageBulkExportJobInProgressCronSchedule');
+    return configManager.getConfig('app:checkPageBulkExportJobInProgressCronSchedule');
   }
   }
 
 
   override async executeJob(): Promise<void> {
   override async executeJob(): Promise<void> {
-    const isBulkExportPagesEnabled = configManager.getConfig('crowi', 'app:isBulkExportPagesEnabled');
+    const isBulkExportPagesEnabled = configManager.getConfig('app:isBulkExportPagesEnabled');
     if (!isBulkExportPagesEnabled) return;
     if (!isBulkExportPagesEnabled) return;
 
 
     const pageBulkExportJobInProgress = await PageBulkExportJob.findOne({
     const pageBulkExportJobInProgress = await PageBulkExportJob.findOne({

+ 2 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts

@@ -51,7 +51,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
     beforeEach(async() => {
     beforeEach(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:bulkExportJobExpirationSeconds': 86400 }); // 1 day
+      await configManager.updateConfig('app:bulkExportJobExpirationSeconds', 86400); // 1 day
 
 
       await PageBulkExportJob.insertMany([
       await PageBulkExportJob.insertMany([
         {
         {
@@ -104,7 +104,7 @@ describe('PageBulkExportJobCleanUpCronService', () => {
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId3 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
     const jobId4 = new mongoose.Types.ObjectId();
     beforeEach(async() => {
     beforeEach(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:bulkExportDownloadExpirationSeconds': 86400 }); // 1 day
+      await configManager.updateConfig('app:bulkExportDownloadExpirationSeconds', 86400); // 1 day
 
 
       await PageBulkExportJob.insertMany([
       await PageBulkExportJob.insertMany([
         {
         {

+ 3 - 3
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts

@@ -26,7 +26,7 @@ class PageBulkExportJobCleanUpCronService extends CronService {
   }
   }
 
 
   override getCronSchedule(): string {
   override getCronSchedule(): string {
-    return configManager.getConfig('crowi', 'app:pageBulkExportJobCleanUpCronSchedule');
+    return configManager.getConfig('app:pageBulkExportJobCleanUpCronSchedule');
   }
   }
 
 
   override async executeJob(): Promise<void> {
   override async executeJob(): Promise<void> {
@@ -41,7 +41,7 @@ class PageBulkExportJobCleanUpCronService extends CronService {
    * Delete bulk export jobs which are on-going and has passed the limit time for execution
    * Delete bulk export jobs which are on-going and has passed the limit time for execution
    */
    */
   async deleteExpiredExportJobs() {
   async deleteExpiredExportJobs() {
-    const exportJobExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportJobExpirationSeconds');
+    const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
     const expiredExportJobs = await PageBulkExportJob.find({
     const expiredExportJobs = await PageBulkExportJob.find({
       $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
       $or: Object.values(PageBulkExportJobInProgressStatus).map(status => ({ status })),
       createdAt: { $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000) },
       createdAt: { $lt: new Date(Date.now() - exportJobExpirationSeconds * 1000) },
@@ -56,7 +56,7 @@ class PageBulkExportJobCleanUpCronService extends CronService {
    * Delete bulk export jobs which have completed but the due time for downloading has passed
    * Delete bulk export jobs which have completed but the due time for downloading has passed
    */
    */
   async deleteDownloadExpiredExportJobs() {
   async deleteDownloadExpiredExportJobs() {
-    const downloadExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportDownloadExpirationSeconds');
+    const downloadExpirationSeconds = configManager.getConfig('app:bulkExportDownloadExpirationSeconds');
     const thresholdDate = new Date(Date.now() - downloadExpirationSeconds * 1000);
     const thresholdDate = new Date(Date.now() - downloadExpirationSeconds * 1000);
     const downloadExpiredExportJobs = await PageBulkExportJob.find({
     const downloadExpiredExportJobs = await PageBulkExportJob.find({
       status: PageBulkExportJobStatus.completed,
       status: PageBulkExportJobStatus.completed,

+ 2 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -77,11 +77,11 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
     super();
     super();
     this.crowi = crowi;
     this.crowi = crowi;
     this.activityEvent = crowi.event('activity');
     this.activityEvent = crowi.event('activity');
-    this.parallelExecLimit = configManager.getConfig('crowi', 'app:pageBulkExportParallelExecLimit');
+    this.parallelExecLimit = configManager.getConfig('app:pageBulkExportParallelExecLimit');
   }
   }
 
 
   override getCronSchedule(): string {
   override getCronSchedule(): string {
-    return configManager.getConfig('crowi', 'app:pageBulkExportJobCronSchedule');
+    return configManager.getConfig('app:pageBulkExportJobCronSchedule');
   }
   }
 
 
   override async executeJob(): Promise<void> {
   override async executeJob(): Promise<void> {

+ 2 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -18,7 +18,7 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
     throw new Error('createdAt is not set');
     throw new Error('createdAt is not set');
   }
   }
 
 
-  const exportJobExpirationSeconds = configManager.getConfig('crowi', 'app:bulkExportJobExpirationSeconds');
+  const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
   const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
   const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
   let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
   let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
 
 
@@ -42,7 +42,7 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
 
 
     const res = await pdfCtrlSyncJobStatus({
     const res = await pdfCtrlSyncJobStatus({
       jobId: pageBulkExportJob._id.toString(), expirationDate: bulkExportJobExpirationDate.toISOString(), status: pdfConvertStatus,
       jobId: pageBulkExportJob._id.toString(), expirationDate: bulkExportJobExpirationDate.toISOString(), status: pdfConvertStatus,
-    }, { baseURL: configManager.getConfig('crowi', 'app:pageBulkExportPdfConverterUrl') });
+    }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUrl') });
 
 
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {
       pageBulkExportJob.status = PageBulkExportJobStatus.uploading;
       pageBulkExportJob.status = PageBulkExportJobStatus.uploading;

+ 2 - 2
apps/app/src/features/questionnaire/interfaces/condition.ts

@@ -1,7 +1,7 @@
 import type { HasObjectId } from '@growi/core';
 import type { HasObjectId } from '@growi/core';
+import type { GrowiServiceType } from '@growi/core/dist/consts';
 
 
-import { GrowiServiceType } from './growi-info';
-import { UserType } from './user-info';
+import type { UserType } from './user-info';
 
 
 
 
 interface UserCondition {
 interface UserCondition {

+ 18 - 0
apps/app/src/features/questionnaire/interfaces/growi-app-info.ts

@@ -0,0 +1,18 @@
+import type { IGrowiAdditionalInfo, IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { AttachmentMethodType } from '~/interfaces/attachment';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
+
+export type IGrowiAppAdditionalInfo = IGrowiAdditionalInfo & {
+  attachmentType: AttachmentMethodType
+  activeExternalAccountTypes?: IExternalAuthProviderType[]
+}
+
+// legacy properties (extracted from additionalInfo for growi-questionnaire)
+// see: https://gitlab.weseek.co.jp/tech/growi/growi-questionnaire
+export type IGrowiAppInfoLegacy = Omit<IGrowiInfo<IGrowiAppAdditionalInfo>, 'additionalInfo'>
+  & IGrowiAppAdditionalInfo
+  & {
+    appSiteUrlHashed: string,
+  };

+ 0 - 58
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -1,58 +0,0 @@
-import * as os from 'node:os';
-
-import { IExternalAuthProviderType } from '@growi/core';
-
-export const GrowiServiceType = {
-  cloud: 'cloud',
-  privateCloud: 'private-cloud',
-  onPremise: 'on-premise',
-  others: 'others',
-} as const;
-export const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
-export const GrowiAttachmentType = {
-  aws: 'aws',
-  gcs: 'gcs',
-  gcp: 'gcp',
-  azure: 'azure',
-  gridfs: 'gridfs',
-  mongo: 'mongo',
-  mongodb: 'mongodb',
-  local: 'local',
-  none: 'none',
-} as const;
-export const GrowiDeploymentType = {
-  officialHelmChart: 'official-helm-chart',
-  growiDockerCompose: 'growi-docker-compose',
-  node: 'node',
-  others: 'others',
-} as const;
-export const GrowiExternalAuthProviderType = IExternalAuthProviderType;
-
-export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
-type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
-export type GrowiAttachmentType = typeof GrowiAttachmentType[keyof typeof GrowiAttachmentType]
-export type GrowiDeploymentType = typeof GrowiDeploymentType[keyof typeof GrowiDeploymentType]
-export type GrowiExternalAuthProviderType = typeof GrowiExternalAuthProviderType[keyof typeof GrowiExternalAuthProviderType]
-
-interface IGrowiOSInfo {
-  type?: ReturnType<typeof os.type>
-  platform?: ReturnType<typeof os.platform>
-  arch?: ReturnType<typeof os.arch>
-  totalmem?: ReturnType<typeof os.totalmem>
-}
-
-export interface IGrowiInfo {
-  version: string
-  appSiteUrl?: string
-  appSiteUrlHashed: string
-  installedAt: Date
-  installedAtByOldestUser: Date
-  type: GrowiServiceType
-  currentUsersCount: number
-  currentActiveUsersCount: number
-  wikiType: GrowiWikiType
-  attachmentType: GrowiAttachmentType
-  activeExternalAccountTypes?: GrowiExternalAuthProviderType[]
-  osInfo?: IGrowiOSInfo
-  deploymentType?: GrowiDeploymentType
-}

+ 16 - 3
apps/app/src/features/questionnaire/interfaces/proactive-questionnaire-answer.ts

@@ -1,11 +1,24 @@
-import { IGrowiInfo } from './growi-info';
-import { IUserInfo } from './user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from './growi-app-info';
+import type { IUserInfo } from './user-info';
 
 
 
 
 export interface IProactiveQuestionnaireAnswer {
 export interface IProactiveQuestionnaireAnswer {
   satisfaction: number,
   satisfaction: number,
   commentText: string,
   commentText: string,
-  growiInfo: IGrowiInfo,
+  growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>,
+  userInfo: IUserInfo,
+  answeredAt: Date,
+  lengthOfExperience?: string,
+  position?: string,
+  occupation?: string,
+}
+
+export interface IProactiveQuestionnaireAnswerLegacy {
+  satisfaction: number,
+  commentText: string,
+  growiInfo: IGrowiAppInfoLegacy,
   userInfo: IUserInfo,
   userInfo: IUserInfo,
   answeredAt: Date,
   answeredAt: Date,
   lengthOfExperience?: string,
   lengthOfExperience?: string,

+ 14 - 4
apps/app/src/features/questionnaire/interfaces/questionnaire-answer.ts

@@ -1,11 +1,21 @@
-import { IAnswer } from './answer';
-import { IGrowiInfo } from './growi-info';
-import { IUserInfo } from './user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { IAnswer } from './answer';
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from './growi-app-info';
+import type { IUserInfo } from './user-info';
 
 
 export interface IQuestionnaireAnswer<ID = string> {
 export interface IQuestionnaireAnswer<ID = string> {
   answers: IAnswer[]
   answers: IAnswer[]
   answeredAt: Date
   answeredAt: Date
-  growiInfo: IGrowiInfo
+  growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>
+  userInfo: IUserInfo
+  questionnaireOrder: ID
+}
+
+export interface IQuestionnaireAnswerLegacy<ID = string> {
+  answers: IAnswer[]
+  answeredAt: Date
+  growiInfo: IGrowiAppInfoLegacy,
   userInfo: IUserInfo
   userInfo: IUserInfo
   questionnaireOrder: ID
   questionnaireOrder: ID
 }
 }

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

@@ -1,8 +1,9 @@
-import { Model, Schema, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
 
 
 import conditionSchema from './schema/condition';
 import conditionSchema from './schema/condition';
 import questionSchema from './schema/question';
 import questionSchema from './schema/question';

+ 2 - 2
apps/app/src/features/questionnaire/server/models/schema/condition.ts

@@ -1,7 +1,7 @@
+import { GrowiServiceType } from '@growi/core/dist/consts';
 import { Schema } from 'mongoose';
 import { Schema } from 'mongoose';
 
 
-import { ICondition } from '../../../interfaces/condition';
-import { GrowiServiceType } from '../../../interfaces/growi-info';
+import type { ICondition } from '../../../interfaces/condition';
 import { UserType } from '../../../interfaces/user-info';
 import { UserType } from '../../../interfaces/user-info';
 
 
 const conditionSchema = new Schema<ICondition>({
 const conditionSchema = new Schema<ICondition>({

+ 26 - 10
apps/app/src/features/questionnaire/server/models/schema/growi-info.ts

@@ -1,21 +1,27 @@
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { GrowiWikiType } from '@growi/core/dist/interfaces';
 import { Schema } from 'mongoose';
 import { Schema } from 'mongoose';
 
 
-import {
-  GrowiAttachmentType, GrowiDeploymentType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiWikiType, IGrowiInfo,
-} from '../../../interfaces/growi-info';
+import type { IGrowiAppAdditionalInfo } from '~/features/questionnaire/interfaces/growi-app-info';
+import { AttachmentMethodType } from '~/interfaces/attachment';
+import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 
-export const growiInfoSchema = new Schema<IGrowiInfo>({
-  version: { type: String, required: true },
-  appSiteUrl: { type: String },
-  appSiteUrlHashed: { type: String, required: true },
+const growiAdditionalInfoSchema = new Schema<IGrowiAppAdditionalInfo>({
   installedAt: { type: Date, required: true },
   installedAt: { type: Date, required: true },
   installedAtByOldestUser: { type: Date, required: true },
   installedAtByOldestUser: { type: Date, required: true },
-  type: { type: String, required: true, enum: Object.values(GrowiServiceType) },
   currentUsersCount: { type: Number, required: true },
   currentUsersCount: { type: Number, required: true },
   currentActiveUsersCount: { type: Number, required: true },
   currentActiveUsersCount: { type: Number, required: true },
+  attachmentType: { type: String, required: true, enum: Object.values(AttachmentMethodType) },
+  activeExternalAccountTypes: [{ type: String, enum: Object.values(IExternalAuthProviderType) }],
+});
+
+export const growiInfoSchema = new Schema<IGrowiInfo<IGrowiAppAdditionalInfo> & IGrowiAppAdditionalInfo>({
+  version: { type: String, required: true },
+  appSiteUrl: { type: String },
+  serviceInstanceId: { type: String, required: true },
+  type: { type: String, required: true, enum: Object.values(GrowiServiceType) },
   wikiType: { type: String, required: true, enum: Object.values(GrowiWikiType) },
   wikiType: { type: String, required: true, enum: Object.values(GrowiWikiType) },
-  attachmentType: { type: String, required: true, enum: Object.values(GrowiAttachmentType) },
-  activeExternalAccountTypes: [{ type: String, enum: Object.values(GrowiExternalAuthProviderType) }],
   osInfo: {
   osInfo: {
     type: { type: String },
     type: { type: String },
     platform: String,
     platform: String,
@@ -23,4 +29,14 @@ export const growiInfoSchema = new Schema<IGrowiInfo>({
     totalmem: Number,
     totalmem: Number,
   },
   },
   deploymentType: { type: String, enum: (<(string | null)[]>Object.values(GrowiDeploymentType)).concat([null]) },
   deploymentType: { type: String, enum: (<(string | null)[]>Object.values(GrowiDeploymentType)).concat([null]) },
+  additionalInfo: growiAdditionalInfoSchema,
+
+  // legacy properties (extracted from additionalInfo for growi-questionnaire)
+  // see: https://gitlab.weseek.co.jp/tech/growi/growi-questionnaire
+  installedAt: { type: Date },
+  installedAtByOldestUser: { type: Date },
+  currentUsersCount: { type: Number },
+  currentActiveUsersCount: { type: Number },
+  attachmentType: { type: String, enum: Object.values(AttachmentMethodType) },
+  activeExternalAccountTypes: [{ type: String, enum: Object.values(IExternalAuthProviderType) }],
 });
 });

+ 20 - 11
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -6,6 +6,8 @@ import { body, validationResult } from 'express-validator';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import { configManager } from '~/server/service/config-manager';
+import { growiInfoService } from '~/server/service/growi-info';
 import axios from '~/utils/axios';
 import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -16,6 +18,7 @@ import { StatusType } from '../../../interfaces/questionnaire-answer-status';
 import ProactiveQuestionnaireAnswer from '../../models/proactive-questionnaire-answer';
 import ProactiveQuestionnaireAnswer from '../../models/proactive-questionnaire-answer';
 import QuestionnaireAnswer from '../../models/questionnaire-answer';
 import QuestionnaireAnswer from '../../models/questionnaire-answer';
 import QuestionnaireAnswerStatus from '../../models/questionnaire-answer-status';
 import QuestionnaireAnswerStatus from '../../models/questionnaire-answer-status';
+import { convertToLegacyFormat, getSiteUrlHashed } from '../../util/convert-to-legacy-format';
 
 
 
 
 const logger = loggerFactory('growi:routes:apiv3:questionnaire');
 const logger = loggerFactory('growi:routes:apiv3:questionnaire');
@@ -59,8 +62,8 @@ module.exports = (crowi: Crowi): Router => {
   };
   };
 
 
   router.get('/orders', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/orders', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
-    const userInfo = crowi.questionnaireService!.getUserInfo(req.user ?? null, growiInfo.appSiteUrlHashed);
+    const growiInfo = await growiInfoService.getGrowiInfo(true);
+    const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
 
 
     try {
     try {
       const questionnaireOrders = await crowi.questionnaireService!.getQuestionnaireOrdersToShow(userInfo, growiInfo, req.user?._id ?? null);
       const questionnaireOrders = await crowi.questionnaireService!.getQuestionnaireOrdersToShow(userInfo, growiInfo, req.user?._id ?? null);
@@ -74,15 +77,16 @@ module.exports = (crowi: Crowi): Router => {
   });
   });
 
 
   router.get('/is-enabled', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/is-enabled', accessTokenParser, loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    const isEnabled = crowi.configManager!.getConfig('crowi', 'questionnaire:isQuestionnaireEnabled');
+    const isEnabled = configManager.getConfig('questionnaire:isQuestionnaireEnabled');
     return res.apiv3({ isEnabled });
     return res.apiv3({ isEnabled });
   });
   });
 
 
   router.post('/proactive/answer', accessTokenParser, loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
   router.post('/proactive/answer', accessTokenParser, loginRequired, validators.proactiveAnswer, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async() => {
     const sendQuestionnaireAnswer = async() => {
-      const questionnaireServerOrigin = crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
-      const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
-      const userInfo = crowi.questionnaireService!.getUserInfo(req.user ?? null, growiInfo.appSiteUrlHashed);
+      const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
+      const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
+      const growiInfo = await growiInfoService.getGrowiInfo(true);
+      const userInfo = crowi.questionnaireService.getUserInfo(req.user ?? null, getSiteUrlHashed(growiInfo.appSiteUrl));
 
 
       const proactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
       const proactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
         satisfaction: req.body.satisfaction,
         satisfaction: req.body.satisfaction,
@@ -95,8 +99,10 @@ module.exports = (crowi: Crowi): Router => {
         answeredAt: new Date(),
         answeredAt: new Date(),
       };
       };
 
 
+      const proactiveQuestionnaireAnswerLegacy = convertToLegacyFormat(proactiveQuestionnaireAnswer, isAppSiteUrlHashed);
+
       try {
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswer);
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive`, proactiveQuestionnaireAnswerLegacy);
       }
       }
       catch (err) {
       catch (err) {
         if (err.request != null) {
         if (err.request != null) {
@@ -126,9 +132,10 @@ module.exports = (crowi: Crowi): Router => {
 
 
   router.put('/answer', accessTokenParser, loginRequired, validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
   router.put('/answer', accessTokenParser, loginRequired, validators.answer, async(req: AuthorizedRequest, res: ApiV3Response) => {
     const sendQuestionnaireAnswer = async(user: IUserHasId, answers: IAnswer[]) => {
     const sendQuestionnaireAnswer = async(user: IUserHasId, answers: IAnswer[]) => {
-      const questionnaireServerOrigin = crowi.configManager?.getConfig('crowi', 'app:questionnaireServerOrigin');
-      const growiInfo = await crowi.questionnaireService!.getGrowiInfo();
-      const userInfo = crowi.questionnaireService!.getUserInfo(user, growiInfo.appSiteUrlHashed);
+      const questionnaireServerOrigin = crowi.configManager.getConfig('app:questionnaireServerOrigin');
+      const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
+      const growiInfo = await growiInfoService.getGrowiInfo(true);
+      const userInfo = crowi.questionnaireService.getUserInfo(user, getSiteUrlHashed(growiInfo.appSiteUrl));
 
 
       const questionnaireAnswer: IQuestionnaireAnswer = {
       const questionnaireAnswer: IQuestionnaireAnswer = {
         growiInfo,
         growiInfo,
@@ -138,8 +145,10 @@ module.exports = (crowi: Crowi): Router => {
         questionnaireOrder: req.body.questionnaireOrderId,
         questionnaireOrder: req.body.questionnaireOrderId,
       };
       };
 
 
+      const questionnaireAnswerLegacy = convertToLegacyFormat(questionnaireAnswer, isAppSiteUrlHashed);
+
       try {
       try {
-        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswer);
+        await axios.post(`${questionnaireServerOrigin}/questionnaire-answer`, questionnaireAnswerLegacy);
       }
       }
       catch (err) {
       catch (err) {
         if (err.request != null) {
         if (err.request != null) {

+ 109 - 17
apps/app/src/features/questionnaire/server/service/questionnaire-cron.integ.ts

@@ -1,16 +1,19 @@
+import { GrowiDeploymentType, GrowiServiceType, GrowiWikiType } from '@growi/core';
 // eslint-disable-next-line no-restricted-imports
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import axios from 'axios';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
-
-import type { IProactiveQuestionnaireAnswer } from '../../interfaces/proactive-questionnaire-answer';
-import type { IQuestionnaireAnswer } from '../../interfaces/questionnaire-answer';
+import type {
+  IProactiveQuestionnaireAnswer, IProactiveQuestionnaireAnswerLegacy,
+} from '../../interfaces/proactive-questionnaire-answer';
+import type { IQuestionnaireAnswer, IQuestionnaireAnswerLegacy } from '../../interfaces/questionnaire-answer';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
-import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answer';
-import QuestionnaireAnswer from '../models/questionnaire-answer';
-import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
-import QuestionnaireOrder from '../models/questionnaire-order';
+import ProactiveQuestionnaireAnswer from '../../server/models/proactive-questionnaire-answer';
+import QuestionnaireAnswer from '../../server/models/questionnaire-answer';
+import QuestionnaireAnswerStatus from '../../server/models/questionnaire-answer-status';
+import QuestionnaireOrder from '../../server/models/questionnaire-order';
+import { AttachmentMethodType } from '../../../../../src/interfaces/attachment';
 
 
 import questionnaireCronService from './questionnaire-cron';
 import questionnaireCronService from './questionnaire-cron';
 
 
@@ -140,8 +143,7 @@ describe('QuestionnaireCronService', () => {
 
 
   beforeAll(async() => {
   beforeAll(async() => {
     await configManager.loadConfigs();
     await configManager.loadConfigs();
-    await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:questionnaireCronMaxHoursUntilRequest': 0 });
-
+    await configManager.updateConfig('app:questionnaireCronMaxHoursUntilRequest', 0);
     await User.create({
     await User.create({
       name: 'Example for Questionnaire Service Test',
       name: 'Example for Questionnaire Service Test',
       username: 'questionnaire cron test user',
       username: 'questionnaire cron test user',
@@ -276,14 +278,58 @@ describe('QuestionnaireCronService', () => {
       answeredAt: new Date(),
       answeredAt: new Date(),
       growiInfo: {
       growiInfo: {
         version: '1.0',
         version: '1.0',
-        appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        appSiteUrl: 'https://example.com',
+        serviceInstanceId: '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
+        osInfo: {
+          type: 'Linux',
+          platform: 'linux',
+          arch: 'x64',
+          totalmem: 8589934592,
+        },
+        additionalInfo: {
+          installedAt: new Date('2000-01-01'),
+          installedAtByOldestUser: new Date('2020-01-01'),
+          currentUsersCount: 100,
+          currentActiveUsersCount: 50,
+          attachmentType: AttachmentMethodType.aws,
+        },
+      },
+      userInfo: {
+        userIdHash: '542bcc3bc5bc61b840017a18',
+        type: 'general',
+        userCreatedAt: new Date(),
+      },
+      questionnaireOrder: '63a8354837e7aa378e16f0b1',
+    };
+
+    const validQuestionnaireAnswerLegacy: IQuestionnaireAnswerLegacy = {
+      answers: [{
+        question: '63c6da88143e531d95346188',
+        value: '1',
+      }],
+      answeredAt: new Date(),
+      growiInfo: {
+        version: '1.0',
+        appSiteUrl: 'https://example.com',
+        appSiteUrlHashed: 'hashed',
+        serviceInstanceId: '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
         installedAt: new Date('2000-01-01'),
         installedAt: new Date('2000-01-01'),
         installedAtByOldestUser: new Date('2020-01-01'),
         installedAtByOldestUser: new Date('2020-01-01'),
-        type: 'cloud',
         currentUsersCount: 100,
         currentUsersCount: 100,
         currentActiveUsersCount: 50,
         currentActiveUsersCount: 50,
-        wikiType: 'open',
-        attachmentType: 'aws',
+        osInfo: {
+          type: 'Linux',
+          platform: 'linux',
+          arch: 'x64',
+          totalmem: 8589934592,
+        },
+        attachmentType: AttachmentMethodType.aws,
       },
       },
       userInfo: {
       userInfo: {
         userIdHash: '542bcc3bc5bc61b840017a18',
         userIdHash: '542bcc3bc5bc61b840017a18',
@@ -297,6 +343,8 @@ describe('QuestionnaireCronService', () => {
       validQuestionnaireAnswer,
       validQuestionnaireAnswer,
       validQuestionnaireAnswer,
       validQuestionnaireAnswer,
       validQuestionnaireAnswer,
       validQuestionnaireAnswer,
+      validQuestionnaireAnswerLegacy,
+      validQuestionnaireAnswerLegacy,
     ]);
     ]);
 
 
     const validProactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
     const validProactiveQuestionnaireAnswer: IProactiveQuestionnaireAnswer = {
@@ -304,14 +352,55 @@ describe('QuestionnaireCronService', () => {
       commentText: 'answer text',
       commentText: 'answer text',
       growiInfo: {
       growiInfo: {
         version: '1.0',
         version: '1.0',
-        appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        appSiteUrl: 'https://example.com',
+        serviceInstanceId: '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
+        osInfo: {
+          type: 'Linux',
+          platform: 'linux',
+          arch: 'x64',
+          totalmem: 8589934592,
+        },
+        additionalInfo: {
+          installedAt: new Date('2000-01-01'),
+          installedAtByOldestUser: new Date('2020-01-01'),
+          currentUsersCount: 100,
+          currentActiveUsersCount: 50,
+          attachmentType: AttachmentMethodType.aws,
+        },
+      },
+      userInfo: {
+        userIdHash: '542bcc3bc5bc61b840017a18',
+        type: 'general',
+        userCreatedAt: new Date(),
+      },
+      answeredAt: new Date(),
+    };
+    const validProactiveQuestionnaireAnswerLegacy: IProactiveQuestionnaireAnswerLegacy = {
+      satisfaction: 1,
+      commentText: 'answer text',
+      growiInfo: {
+        version: '1.0',
+        appSiteUrl: 'https://example.com',
+        appSiteUrlHashed: 'hashed',
+        serviceInstanceId: '1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed',
+        type: GrowiServiceType.cloud,
+        wikiType: GrowiWikiType.open,
+        deploymentType: GrowiDeploymentType.others,
+        osInfo: {
+          type: 'Linux',
+          platform: 'linux',
+          arch: 'x64',
+          totalmem: 8589934592,
+        },
+        // legacy properties
         installedAt: new Date('2000-01-01'),
         installedAt: new Date('2000-01-01'),
         installedAtByOldestUser: new Date('2020-01-01'),
         installedAtByOldestUser: new Date('2020-01-01'),
-        type: 'cloud',
         currentUsersCount: 100,
         currentUsersCount: 100,
         currentActiveUsersCount: 50,
         currentActiveUsersCount: 50,
-        wikiType: 'open',
-        attachmentType: 'aws',
+        attachmentType: AttachmentMethodType.aws,
       },
       },
       userInfo: {
       userInfo: {
         userIdHash: '542bcc3bc5bc61b840017a18',
         userIdHash: '542bcc3bc5bc61b840017a18',
@@ -325,6 +414,8 @@ describe('QuestionnaireCronService', () => {
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
       validProactiveQuestionnaireAnswer,
+      validProactiveQuestionnaireAnswerLegacy,
+      validProactiveQuestionnaireAnswerLegacy,
     ]);
     ]);
 
 
     questionnaireCronService.startCron();
     questionnaireCronService.startCron();
@@ -344,6 +435,7 @@ describe('QuestionnaireCronService', () => {
     const savedOrders = await QuestionnaireOrder.find()
     const savedOrders = await QuestionnaireOrder.find()
       .select('-condition._id -questions._id -questions.createdAt -questions.updatedAt')
       .select('-condition._id -questions._id -questions.createdAt -questions.updatedAt')
       .sort({ _id: 1 });
       .sort({ _id: 1 });
+
     expect(JSON.parse(JSON.stringify(savedOrders))).toEqual([
     expect(JSON.parse(JSON.stringify(savedOrders))).toEqual([
       {
       {
         _id: '63a8354837e7aa378e16f0b1',
         _id: '63a8354837e7aa378e16f0b1',

+ 17 - 7
apps/app/src/features/questionnaire/server/service/questionnaire-cron.ts

@@ -10,6 +10,7 @@ import ProactiveQuestionnaireAnswer from '../models/proactive-questionnaire-answ
 import QuestionnaireAnswer from '../models/questionnaire-answer';
 import QuestionnaireAnswer from '../models/questionnaire-answer';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireOrder from '../models/questionnaire-order';
 import QuestionnaireOrder from '../models/questionnaire-order';
+import { convertToLegacyFormat } from '../util/convert-to-legacy-format';
 
 
 const axios = require('axios').default;
 const axios = require('axios').default;
 
 
@@ -27,14 +28,15 @@ class QuestionnaireCronService extends CronService {
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
   sleep = (msec: number): Promise<void> => new Promise(resolve => setTimeout(resolve, msec));
 
 
   override getCronSchedule(): string {
   override getCronSchedule(): string {
-    return configManager.getConfig('crowi', 'app:questionnaireCronSchedule');
+    return configManager.getConfig('app:questionnaireCronSchedule');
   }
   }
 
 
   override async executeJob(): Promise<void> {
   override async executeJob(): Promise<void> {
     // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
     // sleep for a random amount to scatter request time from GROWI apps to questionnaire server
     await this.sleepBeforeJob();
     await this.sleepBeforeJob();
 
 
-    const questionnaireServerOrigin = configManager.getConfig('crowi', 'app:questionnaireServerOrigin');
+    const questionnaireServerOrigin = configManager.getConfig('app:questionnaireServerOrigin');
+    const isAppSiteUrlHashed = configManager.getConfig('questionnaire:isAppSiteUrlHashed');
 
 
     const fetchQuestionnaireOrders = async(): Promise<IQuestionnaireOrder[]> => {
     const fetchQuestionnaireOrders = async(): Promise<IQuestionnaireOrder[]> => {
       const response = await axios.get(`${questionnaireServerOrigin}/questionnaire-order/index`);
       const response = await axios.get(`${questionnaireServerOrigin}/questionnaire-order/index`);
@@ -56,15 +58,23 @@ class QuestionnaireCronService extends CronService {
 
 
     const resendQuestionnaireAnswers = async() => {
     const resendQuestionnaireAnswers = async() => {
       const questionnaireAnswers = await QuestionnaireAnswer.find()
       const questionnaireAnswers = await QuestionnaireAnswer.find()
-        .select('-_id -answers._id  -growiInfo._id -userInfo._id');
+        .select('-_id -answers._id  -growiInfo._id -userInfo._id')
+        .lean();
       const proactiveQuestionnaireAnswers = await ProactiveQuestionnaireAnswer.find()
       const proactiveQuestionnaireAnswers = await ProactiveQuestionnaireAnswer.find()
-        .select('-_id -growiInfo._id -userInfo._id');
+        .select('-_id -growiInfo._id -userInfo._id')
+        .lean();
 
 
-      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/batch`, { questionnaireAnswers })
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/batch`, {
+        // convert to legacy format
+        questionnaireAnswers: questionnaireAnswers.map(answer => convertToLegacyFormat(answer, isAppSiteUrlHashed)),
+      })
         .then(async() => {
         .then(async() => {
           await QuestionnaireAnswer.deleteMany();
           await QuestionnaireAnswer.deleteMany();
         });
         });
-      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive/batch`, { proactiveQuestionnaireAnswers })
+      axios.post(`${questionnaireServerOrigin}/questionnaire-answer/proactive/batch`, {
+        // convert to legacy format
+        proactiveQuestionnaireAnswers: proactiveQuestionnaireAnswers.map(answer => convertToLegacyFormat(answer, isAppSiteUrlHashed)),
+      })
         .then(async() => {
         .then(async() => {
           await ProactiveQuestionnaireAnswer.deleteMany();
           await ProactiveQuestionnaireAnswer.deleteMany();
         });
         });
@@ -82,7 +92,7 @@ class QuestionnaireCronService extends CronService {
   }
   }
 
 
   private async sleepBeforeJob() {
   private async sleepBeforeJob() {
-    const maxHoursUntilRequest = configManager.getConfig('crowi', 'app:questionnaireCronMaxHoursUntilRequest');
+    const maxHoursUntilRequest = configManager.getConfig('app:questionnaireCronMaxHoursUntilRequest');
     const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
     const maxSecondsUntilRequest = maxHoursUntilRequest * 60 * 60;
 
 
     const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);
     const secToSleep = getRandomIntInRange(0, maxSecondsUntilRequest);

+ 301 - 0
apps/app/src/features/questionnaire/server/service/questionnaire.integ.ts

@@ -0,0 +1,301 @@
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { mock } from 'vitest-mock-extended';
+
+import pkg from '^/package.json';
+
+
+import type UserEvent from '~/server/events/user';
+import { configManager } from '~/server/service/config-manager';
+
+import type Crowi from '../../../../server/crowi';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
+import { StatusType } from '../../interfaces/questionnaire-answer-status';
+import { UserType } from '../../interfaces/user-info';
+import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
+import QuestionnaireOrder from '../models/questionnaire-order';
+
+import QuestionnaireService from './questionnaire';
+
+
+describe('QuestionnaireService', () => {
+  const appVersion = pkg.version;
+
+  let questionnaireService: QuestionnaireService;
+
+  let User;
+  let user;
+
+  beforeAll(async() => {
+
+    await configManager.loadConfigs();
+
+    const crowiMock = mock<Crowi>({
+      version: appVersion,
+      event: vi.fn().mockImplementation((eventName) => {
+        if (eventName === 'user') {
+          return mock<UserEvent>({
+            on: vi.fn(),
+          });
+        }
+      }),
+    });
+    const userModelFactory = (await import('~/server/models/user')).default;
+    User = userModelFactory(crowiMock);
+
+    await User.deleteMany({}); // clear users
+    user = await User.create({
+      name: 'Example for Questionnaire Service Test',
+      username: 'questionnaire test user',
+      email: 'questionnaireTestUser@example.com',
+      password: 'usertestpass',
+      createdAt: '2000-01-01',
+    });
+
+    questionnaireService = new QuestionnaireService(crowiMock);
+  });
+
+  describe('getUserInfo', () => {
+    test('Should get correct user info when user given', () => {
+      const userInfo = questionnaireService.getUserInfo(user, 'growiurlhashfortest');
+      expect(userInfo).not.toBeNull();
+      assert(userInfo != null);
+
+      expect(userInfo.type).equal(UserType.general);
+      assert(userInfo.type === UserType.general);
+
+      expect(userInfo.userIdHash).toBeTruthy();
+      expect(userInfo.userIdHash).not.toBe(user._id);
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      delete (userInfo as any).userIdHash;
+
+      expect(userInfo).toEqual({ type: 'general', userCreatedAt: new Date('2000-01-01') });
+    });
+
+    test('Should get correct user info when user is null', () => {
+      const userInfo = questionnaireService.getUserInfo(null, '');
+      expect(userInfo).toEqual({ type: 'guest' });
+    });
+  });
+
+  describe('getQuestionnaireOrdersToShow', () => {
+    let doc1;
+    let doc2;
+    let doc3;
+    let doc4;
+    let doc5;
+    let doc6;
+    let doc7;
+    let doc8;
+    let doc9;
+    let doc10;
+    let doc11;
+    let doc12;
+
+    beforeAll(async() => {
+      const questionnaireToBeShown = {
+        shortTitle: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        title: {
+          ja_JP: 'GROWI に関するアンケート',
+          en_US: 'Questions about GROWI',
+        },
+        showFrom: '2022-12-11',
+        showUntil: '2100-12-12',
+        condition: {
+          user: {
+            types: ['general'],
+            daysSinceCreation: {
+              moreThanOrEqualTo: 365,
+              lessThanOrEqualTo: 365 * 1000,
+            },
+          },
+          growi: {
+            types: ['on-premise'],
+            versionRegExps: [appVersion],
+          },
+        },
+        createdAt: '2023-01-01',
+        updatedAt: '2023-01-01',
+      };
+
+      // insert initial db data
+      doc1 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert finished data
+      doc2 = await QuestionnaireOrder.create({
+        ...questionnaireToBeShown,
+        showFrom: '2020-12-11',
+        showUntil: '2021-12-12',
+      });
+      // insert data for admin or guest
+      doc3 = await QuestionnaireOrder.create({
+        ...questionnaireToBeShown,
+        condition: {
+          user: {
+            types: ['admin', 'guest'],
+          },
+          growi: {
+            types: ['on-premise'],
+            versionRegExps: [appVersion],
+          },
+        },
+      });
+      // insert answered data
+      doc4 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert skipped data
+      doc5 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert denied data
+      doc6 = await QuestionnaireOrder.create(questionnaireToBeShown);
+      // insert data for different growi type
+      doc7 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['cloud'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+      // insert data for different growi version
+      doc8 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: ['1.0.0-alpha'],
+            },
+          },
+        },
+      );
+      // insert data for users that used GROWI for less than or equal to a year
+      doc9 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                lessThanOrEqualTo: 365,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+      // insert data for users that used GROWI for more than or equal to 1000 years
+      doc10 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 365 * 1000,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+      // insert data for users that used GROWI for more than a month and less than 6 months
+      doc11 = await QuestionnaireOrder.create(
+        {
+          ...questionnaireToBeShown,
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 30,
+                lessThanOrEqualTo: 30 * 6,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+        },
+      );
+
+      await QuestionnaireAnswerStatus.insertMany([
+        {
+          user: user._id,
+          questionnaireOrderId: doc4._id,
+          status: StatusType.answered,
+        },
+        {
+          user: user._id,
+          questionnaireOrderId: doc5._id,
+          status: StatusType.skipped,
+        },
+        {
+          user: user._id,
+          questionnaireOrderId: doc6._id,
+          status: StatusType.skipped,
+        },
+      ]);
+    });
+
+    test('Should get questionnaire orders to show', async() => {
+      const growiInfo = mock<IGrowiInfo<IGrowiAppAdditionalInfo>>({
+        type: 'on-premise',
+        version: appVersion,
+      });
+      const userInfo = questionnaireService.getUserInfo(user, 'appSiteUrlHashed');
+
+      const questionnaireOrderDocuments = await questionnaireService.getQuestionnaireOrdersToShow(userInfo, growiInfo, user._id);
+
+      expect(questionnaireOrderDocuments[0].toObject()).toMatchObject(
+        {
+          __v: 0,
+          shortTitle: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          title: {
+            ja_JP: 'GROWI に関するアンケート',
+            en_US: 'Questions about GROWI',
+          },
+          showFrom: new Date('2022-12-11'),
+          showUntil: new Date('2100-12-12'),
+          questions: [],
+          condition: {
+            user: {
+              types: ['general'],
+              daysSinceCreation: {
+                moreThanOrEqualTo: 365,
+                lessThanOrEqualTo: 365 * 1000,
+              },
+            },
+            growi: {
+              types: ['on-premise'],
+              versionRegExps: [appVersion],
+            },
+          },
+          createdAt: new Date('2023-01-01'),
+          updatedAt: new Date('2023-01-01'),
+        },
+      );
+
+    });
+
+  });
+
+});

+ 8 - 70
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -1,18 +1,13 @@
 import crypto from 'crypto';
 import crypto from 'crypto';
-import * as os from 'node:os';
 
 
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
 
 
+import type Crowi from '~/server/crowi';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-// eslint-disable-next-line import/no-named-as-default
-import { Config } from '~/server/models/config';
-import { aclService } from '~/server/service/acl';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import type { IGrowiInfo } from '../../interfaces/growi-info';
-import {
-  GrowiWikiType, GrowiExternalAuthProviderType, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
-} from '../../interfaces/growi-info';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { StatusType } from '../../interfaces/questionnaire-answer-status';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import { type IUserInfo, UserType } from '../../interfaces/user-info';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
@@ -25,72 +20,13 @@ const logger = loggerFactory('growi:service:questionnaire');
 
 
 class QuestionnaireService {
 class QuestionnaireService {
 
 
-  crowi: any;
+  crowi: Crowi;
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(crowi) {
+  constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async getGrowiInfo(): Promise<IGrowiInfo> {
-    const User = this.crowi.model('User');
-
-    const appSiteUrl = this.crowi.appService.getSiteUrl();
-    const hasher = crypto.createHash('sha256');
-    hasher.update(appSiteUrl);
-    const appSiteUrlHashed = hasher.digest('hex');
-
-    // Get the oldest user who probably installed this GROWI.
-    // https://mongoosejs.com/docs/6.x/docs/api.html#model_Model-findOne
-    // https://stackoverflow.com/questions/13443069/mongoose-findone-with-sorting
-    const user = await User.findOne({ createdAt: { $ne: null } }).sort({ createdAt: 1 });
-
-    const installedAtByOldestUser = user ? user.createdAt : null;
-
-    const appInstalledConfig = await Config.findOne({ key: 'app:installed' });
-    const installedAt = appInstalledConfig != null && appInstalledConfig.createdAt != null ? appInstalledConfig.createdAt : installedAtByOldestUser;
-
-    const currentUsersCount = await User.countDocuments();
-    const currentActiveUsersCount = await User.countActiveUsers();
-
-    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`);
-    });
-
-    const typeStr = this.crowi.configManager.getConfig('crowi', 'app:serviceType');
-    const type = Object.values(GrowiServiceType).includes(typeStr) ? typeStr : null;
-
-    const attachmentTypeStr = this.crowi.configManager.getConfig('crowi', 'app:fileUploadType');
-    const attachmentType = Object.values(GrowiAttachmentType).includes(attachmentTypeStr) ? attachmentTypeStr : null;
-
-    const deploymentTypeStr = this.crowi.configManager.getConfig('crowi', 'app:deploymentType');
-    const deploymentType = Object.values(GrowiDeploymentType).includes(deploymentTypeStr) ? deploymentTypeStr : null;
-
-    return {
-      version: this.crowi.version,
-      osInfo: {
-        type: os.type(),
-        platform: os.platform(),
-        arch: os.arch(),
-        totalmem: os.totalmem(),
-      },
-      appSiteUrl: this.crowi.configManager.getConfig('crowi', 'questionnaire:isAppSiteUrlHashed') ? null : appSiteUrl,
-      appSiteUrlHashed,
-      installedAt,
-      installedAtByOldestUser,
-      type,
-      currentUsersCount,
-      currentActiveUsersCount,
-      wikiType,
-      attachmentType,
-      activeExternalAccountTypes,
-      deploymentType,
-    };
-  }
-
   getUserInfo(user: IUserHasId | null, appSiteUrlHashed: string): IUserInfo {
   getUserInfo(user: IUserHasId | null, appSiteUrlHashed: string): IUserInfo {
     if (user != null) {
     if (user != null) {
       const hasher = crypto.createHmac('sha256', appSiteUrlHashed);
       const hasher = crypto.createHmac('sha256', appSiteUrlHashed);
@@ -106,7 +42,9 @@ class QuestionnaireService {
     return { type: UserType.guest };
     return { type: UserType.guest };
   }
   }
 
 
-  async getQuestionnaireOrdersToShow(userInfo: IUserInfo, growiInfo: IGrowiInfo, userId: ObjectIdLike | null): Promise<QuestionnaireOrderDocument[]> {
+  async getQuestionnaireOrdersToShow(
+      userInfo: IUserInfo, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>, userId: ObjectIdLike | null,
+  ): Promise<QuestionnaireOrderDocument[]> {
     const currentDate = new Date();
     const currentDate = new Date();
 
 
     let questionnaireOrders = await QuestionnaireOrder.find({
     let questionnaireOrders = await QuestionnaireOrder.find({

+ 9 - 6
apps/app/src/features/questionnaire/server/util/condition.ts

@@ -1,7 +1,10 @@
-import { ICondition } from '../../interfaces/condition';
-import { IGrowiInfo } from '../../interfaces/growi-info';
-import { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
-import { IUserInfo, UserType } from '../../interfaces/user-info';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+
+import type { ICondition } from '../../interfaces/condition';
+import type { IGrowiAppAdditionalInfo } from '../../interfaces/growi-app-info';
+import type { IQuestionnaireOrder } from '../../interfaces/questionnaire-order';
+import type { IUserInfo } from '../../interfaces/user-info';
+import { UserType } from '../../interfaces/user-info';
 
 
 
 
 const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
 const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
@@ -39,7 +42,7 @@ const checkUserInfo = (condition: ICondition, userInfo: IUserInfo): boolean => {
   return true;
   return true;
 };
 };
 
 
-const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo): boolean => {
+const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>): boolean => {
   const { growi: { types, versionRegExps } } = condition;
   const { growi: { types, versionRegExps } } = condition;
 
 
   if (!types.includes(growiInfo.type)) {
   if (!types.includes(growiInfo.type)) {
@@ -53,7 +56,7 @@ const checkGrowiInfo = (condition: ICondition, growiInfo: IGrowiInfo): boolean =
   return true;
   return true;
 };
 };
 
 
-export const isShowableCondition = (order: IQuestionnaireOrder, userInfo: IUserInfo, growiInfo: IGrowiInfo): boolean => {
+export const isShowableCondition = (order: IQuestionnaireOrder, userInfo: IUserInfo, growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo>): boolean => {
   const { condition } = order;
   const { condition } = order;
 
 
   if (!checkUserInfo(condition, userInfo)) {
   if (!checkUserInfo(condition, userInfo)) {

+ 128 - 0
apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.spec.ts

@@ -0,0 +1,128 @@
+import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts';
+import type { IGrowiInfo } from '@growi/core/dist/interfaces';
+import { GrowiWikiType } from '@growi/core/dist/interfaces';
+import {
+  describe, test, expect,
+} from 'vitest';
+import { mock } from 'vitest-mock-extended';
+
+import { AttachmentMethodType } from '../../../../interfaces/attachment';
+import type { IGrowiAppAdditionalInfo, IGrowiAppInfoLegacy } from '../../interfaces/growi-app-info';
+
+import { convertToLegacyFormat } from './convert-to-legacy-format';
+
+describe('convertToLegacyFormat', () => {
+  test('should return same object when input is already in legacy format', () => {
+    const growiInfoLegacy: IGrowiAppInfoLegacy = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      appSiteUrlHashed: '100680ad546ce6a577f42f52df33b4cfdca756859e664b8d7de329b150d09ce9',
+      serviceInstanceId: 'service-instance-id',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+      osInfo: {
+        type: 'Linux',
+        platform: 'linux',
+        arch: 'x64',
+        totalmem: 8589934592,
+      },
+
+      // legacy properties
+      installedAt: new Date(),
+      installedAtByOldestUser: new Date(),
+      currentUsersCount: 1,
+      currentActiveUsersCount: 1,
+      attachmentType: AttachmentMethodType.local,
+    };
+
+    const legacyData = {
+      someData: 'test',
+      growiInfo: growiInfoLegacy,
+    };
+
+    const result = convertToLegacyFormat(legacyData);
+    expect(result).toStrictEqual(legacyData);
+  });
+
+  test('should convert new format to legacy format', () => {
+    const installedAt = new Date();
+    const installedAtByOldestUser = new Date();
+
+    const growiInfo: IGrowiInfo<IGrowiAppAdditionalInfo> = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      serviceInstanceId: 'service-instance-id',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+      osInfo: {
+        type: 'Linux',
+        platform: 'linux',
+        arch: 'x64',
+        totalmem: 8589934592,
+      },
+      additionalInfo: {
+        installedAt,
+        installedAtByOldestUser,
+        currentUsersCount: 1,
+        currentActiveUsersCount: 1,
+        attachmentType: AttachmentMethodType.local,
+      },
+    };
+    const newFormatData = {
+      someData: 'test',
+      growiInfo,
+    };
+
+    const growiInfoLegacy: IGrowiAppInfoLegacy = {
+      version: '1.0.0',
+      appSiteUrl: 'https://example.com',
+      appSiteUrlHashed: '100680ad546ce6a577f42f52df33b4cfdca756859e664b8d7de329b150d09ce9',
+      serviceInstanceId: 'service-instance-id',
+      type: GrowiServiceType.cloud,
+      wikiType: GrowiWikiType.open,
+      deploymentType: GrowiDeploymentType.others,
+      osInfo: {
+        type: 'Linux',
+        platform: 'linux',
+        arch: 'x64',
+        totalmem: 8589934592,
+      },
+
+      // legacy properties
+      installedAt,
+      installedAtByOldestUser,
+      currentUsersCount: 1,
+      currentActiveUsersCount: 1,
+      attachmentType: AttachmentMethodType.local,
+    };
+    const expected = {
+      someData: 'test',
+      growiInfo: growiInfoLegacy,
+    };
+
+    const result = convertToLegacyFormat(newFormatData);
+    expect(result).toStrictEqual(expected);
+  });
+
+  test('should convert new format and omit appSiteUrl', () => {
+    // arrange
+    const growiInfo = mock<IGrowiInfo<IGrowiAppAdditionalInfo>>({
+      appSiteUrl: 'https://example.com',
+      additionalInfo: {
+        installedAt: new Date(),
+        installedAtByOldestUser: new Date(),
+        currentUsersCount: 1,
+        currentActiveUsersCount: 1,
+        attachmentType: AttachmentMethodType.local,
+      },
+    });
+
+    // act
+    const result = convertToLegacyFormat({ growiInfo }, true);
+
+    // assert
+    expect(result.growiInfo.appSiteUrl).toBeUndefined();
+  });
+});

+ 40 - 0
apps/app/src/features/questionnaire/server/util/convert-to-legacy-format.ts

@@ -0,0 +1,40 @@
+import assert from 'assert';
+import crypto from 'crypto';
+
+import type { IGrowiAppInfoLegacy } from '../../interfaces/growi-app-info';
+
+
+type IHasGrowiAppInfoLegacy<T> = T & {
+  growiInfo: IGrowiAppInfoLegacy;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function isLegacy<T extends { growiInfo: any }>(data: T): data is IHasGrowiAppInfoLegacy<T> {
+  return !('additionalInfo' in data.growiInfo);
+}
+
+export function getSiteUrlHashed(siteUrl: string): string {
+  const hasher = crypto.createHash('sha256');
+  hasher.update(siteUrl);
+  return hasher.digest('hex');
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function convertToLegacyFormat<T extends { growiInfo: any }>(questionnaireAnswer: T, isAppSiteUrlHashed = false): IHasGrowiAppInfoLegacy<T> {
+  if (isLegacy(questionnaireAnswer)) {
+    return questionnaireAnswer;
+  }
+
+  const { additionalInfo, appSiteUrl, ...rest } = questionnaireAnswer.growiInfo;
+  assert(additionalInfo != null);
+
+  return {
+    ...questionnaireAnswer,
+    growiInfo: {
+      appSiteUrl: isAppSiteUrlHashed ? undefined : appSiteUrl,
+      appSiteUrlHashed: getSiteUrlHashed(appSiteUrl),
+      ...rest,
+      ...additionalInfo,
+    },
+  };
+}

+ 2 - 1
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -8,6 +8,7 @@ import { param, query } from 'express-validator';
 
 
 import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
+import type Crowi from '~/server/crowi';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -32,7 +33,7 @@ const validator = {
 let presetTemplateSummaries: TemplateSummary[];
 let presetTemplateSummaries: TemplateSummary[];
 
 
 
 
-module.exports = (crowi) => {
+module.exports = (crowi: Crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
 
   router.get('/', loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {
   router.get('/', loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {

+ 1 - 0
apps/app/src/interfaces/activity.ts

@@ -378,6 +378,7 @@ export const ActionGroupSize = {
   Medium: 'MEDIUM',
   Medium: 'MEDIUM',
   Large: 'LARGE',
   Large: 'LARGE',
 } as const;
 } as const;
+export type ActionGroupSize = typeof ActionGroupSize[keyof typeof ActionGroupSize];
 
 
 export const SmallActionGroup = {
 export const SmallActionGroup = {
   ACTION_USER_LOGIN_WITH_LOCAL,
   ACTION_USER_LOGIN_WITH_LOCAL,

+ 13 - 1
apps/app/src/interfaces/attachment.ts

@@ -1,7 +1,19 @@
-import type { IAttachmentHasId } from '@growi/core';
+import type { IAttachmentHasId } from '@growi/core/dist/interfaces';
 
 
 import type { PaginateResult } from './mongoose-utils';
 import type { PaginateResult } from './mongoose-utils';
 
 
+export const AttachmentMethodType = {
+  aws: 'aws',
+  gcs: 'gcs',
+  gcp: 'gcp',
+  azure: 'azure',
+  gridfs: 'gridfs',
+  mongo: 'mongo',
+  mongodb: 'mongodb',
+  local: 'local',
+  none: 'none',
+} as const;
+export type AttachmentMethodType = typeof AttachmentMethodType[keyof typeof AttachmentMethodType]
 
 
 export type IResAttachmentList = {
 export type IResAttachmentList = {
   paginateResult: PaginateResult<IAttachmentHasId>
   paginateResult: PaginateResult<IAttachmentHasId>

+ 3 - 2
apps/app/src/interfaces/crowi-request.ts

@@ -2,13 +2,14 @@ import type { IUser } from '@growi/core';
 import type { Request } from 'express';
 import type { Request } from 'express';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 
 
+import type Crowi from '~/server/crowi';
+
 
 
 export interface CrowiProperties {
 export interface CrowiProperties {
 
 
   user?: HydratedDocument<IUser>,
   user?: HydratedDocument<IUser>,
 
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  crowi: any,
+  crowi: Crowi,
 
 
   session: any,
   session: any,
 
 

+ 9 - 0
apps/app/src/interfaces/external-auth-provider.ts

@@ -0,0 +1,9 @@
+export const IExternalAuthProviderType = {
+  ldap: 'ldap',
+  saml: 'saml',
+  oidc: 'oidc',
+  google: 'google',
+  github: 'github',
+} as const;
+
+export type IExternalAuthProviderType = typeof IExternalAuthProviderType[keyof typeof IExternalAuthProviderType]

+ 0 - 7
apps/app/src/interfaces/page-listing-results.ts

@@ -18,13 +18,6 @@ export interface ChildrenResult {
   children: Partial<IPageForItem>[]
   children: Partial<IPageForItem>[]
 }
 }
 
 
-
-export interface TargetAndAncestors {
-  targetAndAncestors: Partial<IPageForItem>[]
-  rootPage: Partial<IPageForItem>,
-}
-
-
 export interface V5MigrationStatus {
 export interface V5MigrationStatus {
   isV5Compatible : boolean,
   isV5Compatible : boolean,
   migratablePagesCount: number
   migratablePagesCount: number

+ 38 - 0
apps/app/src/migrations/19700101000000-foremost-1000-20241123211930-remove-index-for-ns-from-configs.js

@@ -0,0 +1,38 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:remove-index-for-ns-from-configs');
+
+async function dropIndexIfExists(db, collectionName, indexName) {
+  // check existence of the collection
+  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  if (items.length === 0) {
+    return;
+  }
+
+  const collection = await db.collection(collectionName);
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    await mongoose.connect(getMongoUri(), mongoOptions);
+
+    // drop index
+    await dropIndexIfExists(db, 'configs', 'ns_1_key_1');
+
+    // create index
+    const collection = await db.collection('configs');
+    await collection.createIndex({ key: 1 }, { unique: true });
+  },
+
+  async down() {
+    // No rollback
+  },
+};

+ 24 - 0
apps/app/src/migrations/19700101000000-foremost-1010-20250109000000-generate-service-instance-id.js

@@ -0,0 +1,24 @@
+import mongoose from 'mongoose';
+import { v4 as uuidv4 } from 'uuid';
+
+import { configManager } from '~/server/service/config-manager';
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:migrate:generate-service-instance-id');
+
+module.exports = {
+  async up(db) {
+    logger.info('Generate serviceInstanceId for the system');
+    await mongoose.connect(getMongoUri(), mongoOptions);
+
+    await configManager.loadConfigs();
+
+    await configManager.updateConfig('app:serviceInstanceId', uuidv4(), { skipPubsub: true });
+  },
+
+  async down() {
+    // No rollback
+  },
+};

+ 2 - 6
apps/app/src/migrations/20180927102719-init-serverurl.js

@@ -24,7 +24,6 @@ module.exports = {
 
 
     // find 'app:siteUrl'
     // find 'app:siteUrl'
     const siteUrlConfig = await Config.findOne({
     const siteUrlConfig = await Config.findOne({
-      ns: 'crowi',
       key: 'app:siteUrl',
       key: 'app:siteUrl',
     });
     });
     // exit if exists
     // exit if exists
@@ -35,7 +34,6 @@ module.exports = {
 
 
     // find all callbackUrls
     // find all callbackUrls
     const configs = await Config.find({
     const configs = await Config.find({
-      ns: 'crowi',
       $or: [
       $or: [
         { key: 'security:passport-github:callbackUrl' },
         { key: 'security:passport-github:callbackUrl' },
         { key: 'security:passport-google:callbackUrl' },
         { key: 'security:passport-google:callbackUrl' },
@@ -63,11 +61,10 @@ module.exports = {
     }
     }
 
 
     if (siteUrl != null) {
     if (siteUrl != null) {
-      const ns = 'crowi';
       const key = 'app:siteUrl';
       const key = 'app:siteUrl';
       await Config.findOneAndUpdate(
       await Config.findOneAndUpdate(
-        { ns, key },
-        { ns, key, value: JSON.stringify(siteUrl) },
+        { key },
+        { key, value: JSON.stringify(siteUrl) },
         { upsert: true },
         { upsert: true },
       );
       );
       logger.info('Migration has successfully applied');
       logger.info('Migration has successfully applied');
@@ -80,7 +77,6 @@ module.exports = {
 
 
     // remote 'app:siteUrl'
     // remote 'app:siteUrl'
     await Config.findOneAndDelete({
     await Config.findOneAndDelete({
-      ns: 'crowi',
       key: 'app:siteUrl',
       key: 'app:siteUrl',
     });
     });
 
 

+ 6 - 6
apps/app/src/migrations/20190618055300-abolish-crowi-classic-auth.js

@@ -15,18 +15,18 @@ module.exports = {
     // enable passport and delete configs for crowi classic auth
     // enable passport and delete configs for crowi classic auth
     await Promise.all([
     await Promise.all([
       Config.findOneAndUpdate(
       Config.findOneAndUpdate(
-        { ns: 'crowi', key: 'security:isEnabledPassport' },
-        { ns: 'crowi', key: 'security:isEnabledPassport', value: JSON.stringify(true) },
+        { key: 'security:isEnabledPassport' },
+        { key: 'security:isEnabledPassport', value: JSON.stringify(true) },
         { upsert: true },
         { upsert: true },
       ),
       ),
       Config.findOneAndUpdate(
       Config.findOneAndUpdate(
-        { ns: 'crowi', key: 'google:clientId' },
-        { ns: 'crowi', key: 'google:clientId', value: JSON.stringify(null) },
+        { key: 'google:clientId' },
+        { key: 'google:clientId', value: JSON.stringify(null) },
         { upsert: true },
         { upsert: true },
       ),
       ),
       Config.findOneAndUpdate(
       Config.findOneAndUpdate(
-        { ns: 'crowi', key: 'google:clientSecret' },
-        { ns: 'crowi', key: 'google:clientSecret', value: JSON.stringify(null) },
+        { key: 'google:clientSecret' },
+        { key: 'google:clientSecret', value: JSON.stringify(null) },
         { upsert: true },
         { upsert: true },
       ),
       ),
     ]);
     ]);

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