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

Merge branch 'master' into feat/163220-171243-enable-page-bulk-export-for-growi-cloud

Futa Arai 4 месяцев назад
Родитель
Сommit
be133829b7
100 измененных файлов с 3081 добавлено и 1158 удалено
  1. 1 1
      .changeset/config.json
  2. 2 1
      .devcontainer/app/devcontainer.json
  3. 1 1
      .devcontainer/compose.extend.template.yml
  4. 1 1
      .devcontainer/compose.yml
  5. 1 1
      .github/ISSUE_TEMPLATE/bug-report.md
  6. 1 1
      .github/ISSUE_TEMPLATE/config.yml
  7. 5 5
      .github/mergify.yml
  8. 7 7
      .github/workflows/ci-app-prod.yml
  9. 1 1
      .github/workflows/ci-app.yml
  10. 2 2
      .github/workflows/ci-pdf-converter.yml
  11. 3 3
      .github/workflows/ci-slackbot-proxy.yml
  12. 1 1
      .github/workflows/list-unhealthy-branches.yml
  13. 4 2
      .github/workflows/release-pdf-converter.yml
  14. 4 3
      .github/workflows/release-rc-scheduled.yml
  15. 33 10
      .github/workflows/release-rc.yml
  16. 1 1
      .github/workflows/release-slackbot-proxy.yml
  17. 2 2
      .github/workflows/release-subpackages.yml
  18. 50 17
      .github/workflows/release.yml
  19. 1 1
      .github/workflows/reusable-app-build-image.yml
  20. 5 2
      .github/workflows/reusable-app-create-manifests.yml
  21. 2 2
      .github/workflows/reusable-app-prod.yml
  22. 1 1
      .github/workflows/reusable-app-reg-suit.yml
  23. 1 1
      .mcp.json
  24. 0 14
      .roo/mcp.json
  25. 1 0
      .serena/.gitignore
  26. 398 0
      .serena/memories/apps-app-admin-forms-react-hook-form-migration-guide.md
  27. 186 0
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  28. 1 1
      .serena/memories/project_overview.md
  29. 13 7
      .serena/memories/suggested_commands.md
  30. 6 1
      .vscode/settings.json
  31. 251 98
      CHANGELOG.md
  32. 1 1
      LICENSE
  33. 14 14
      README.md
  34. 14 14
      README_JP.md
  35. 1 1
      THIRD-PARTY-NOTICES.md
  36. 25 10
      apps/app/.eslintrc.js
  37. 1 2
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  38. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  39. 3 3
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  40. 416 0
      apps/app/bin/print-memory-consumption.ts
  41. 0 14
      apps/app/config/cdn.js
  42. 1 1
      apps/app/config/migrate-mongo-config.js
  43. 1 0
      apps/app/config/next-i18next.config.js
  44. 2 2
      apps/app/docker/Dockerfile
  45. 10 10
      apps/app/docker/README.md
  46. 44 44
      apps/app/docker/codebuild/.terraform.lock.hcl
  47. 1 1
      apps/app/docker/codebuild/buildspec.yml
  48. 1 1
      apps/app/docker/codebuild/codebuild.tf
  49. 1 1
      apps/app/docker/codebuild/main.tf
  50. 1 1
      apps/app/docker/codebuild/oidc.tf
  51. 2 2
      apps/app/next.config.js
  52. 8 7
      apps/app/package.json
  53. 0 1
      apps/app/playwright.config.ts
  54. 13 5
      apps/app/playwright/10-installer/install.spec.ts
  55. 101 73
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  56. 17 9
      apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts
  57. 10 7
      apps/app/playwright/20-basic-features/click-page-icons.spec.ts
  58. 13 9
      apps/app/playwright/20-basic-features/comments.spec.ts
  59. 16 6
      apps/app/playwright/20-basic-features/create-page-button.spec.ts
  60. 9 5
      apps/app/playwright/20-basic-features/presentation.spec.ts
  61. 35 13
      apps/app/playwright/20-basic-features/sticky-features.spec.ts
  62. 25 15
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  63. 9 9
      apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts
  64. 10 5
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  65. 38 33
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  66. 13 9
      apps/app/playwright/23-editor/saving.spec.ts
  67. 8 4
      apps/app/playwright/23-editor/template-modal.spec.ts
  68. 31 17
      apps/app/playwright/23-editor/with-navigation.spec.ts
  69. 82 68
      apps/app/playwright/30-search/search.spect.ts
  70. 29 19
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  71. 20 16
      apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts
  72. 1 2
      apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts
  73. 26 18
      apps/app/playwright/60-home/home.spec.ts
  74. 1 1
      apps/app/playwright/auth.setup.ts
  75. 8 4
      apps/app/playwright/utils/CollapseSidebar.ts
  76. 1 2
      apps/app/playwright/utils/Login.ts
  77. 40 40
      apps/app/public/images/icons/favicon/manifest.json
  78. 1 1
      apps/app/public/static/locales/en_US/admin.json
  79. 13 2
      apps/app/public/static/locales/en_US/translation.json
  80. 1 1
      apps/app/public/static/locales/fr_FR/admin.json
  81. 13 2
      apps/app/public/static/locales/fr_FR/translation.json
  82. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  83. 15 4
      apps/app/public/static/locales/ja_JP/translation.json
  84. 1 1
      apps/app/public/static/locales/ko_KR/admin.json
  85. 13 2
      apps/app/public/static/locales/ko_KR/translation.json
  86. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  87. 13 2
      apps/app/public/static/locales/zh_CN/translation.json
  88. 50 29
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  89. 12 36
      apps/app/src/client/components/Admin/App/AwsSetting.tsx
  90. 21 43
      apps/app/src/client/components/Admin/App/AzureSetting.tsx
  91. 113 236
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  92. 41 0
      apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts
  93. 14 28
      apps/app/src/client/components/Admin/App/GcsSetting.tsx
  94. 63 19
      apps/app/src/client/components/Admin/App/MailSetting.tsx
  95. 20 7
      apps/app/src/client/components/Admin/App/MaskedInput.tsx
  96. 14 14
      apps/app/src/client/components/Admin/App/SesSetting.tsx
  97. 22 9
      apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx
  98. 13 12
      apps/app/src/client/components/Admin/App/SmtpSetting.tsx
  99. 395 0
      apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts
  100. 141 0
      apps/app/src/client/components/Admin/App/useFileUploadSettings.ts

+ 1 - 1
.changeset/config.json

@@ -1,6 +1,6 @@
 {
 {
   "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
   "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
-  "changelog": ["@changesets/changelog-github", { "repo": "weseek/growi" }],
+  "changelog": ["@changesets/changelog-github", { "repo": "growilabs/growi" }],
   "commit": false,
   "commit": false,
   "fixed": [],
   "fixed": [],
   "linked": [],
   "linked": [],

+ 2 - 1
.devcontainer/app/devcontainer.json

@@ -8,7 +8,7 @@
 
 
   "features": {
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
     "ghcr.io/devcontainers/features/node:1": {
-      "version": "22.17.0"
+      "version": "20.18.3"
     }
     }
   },
   },
 
 
@@ -35,6 +35,7 @@
         "vitest.explorer",
         "vitest.explorer",
         "ms-playwright.playwright",
         "ms-playwright.playwright",
         // git/github
         // git/github
+        "codeinklingon.git-worktree-menu",
         "github.vscode-pull-request-github",
         "github.vscode-pull-request-github",
         "mhutchie.git-graph",
         "mhutchie.git-graph",
         "eamodio.gitlens",
         "eamodio.gitlens",

+ 1 - 1
.devcontainer/compose.extend.template.yml

@@ -3,7 +3,7 @@
 services:
 services:
   pdf-converter:
   pdf-converter:
     # enabling devcontainer 'features' was not working for secondary devcontainer (https://github.com/devcontainers/features/issues/1175)
     # enabling devcontainer 'features' was not working for secondary devcontainer (https://github.com/devcontainers/features/issues/1175)
-    image: mcr.microsoft.com/vscode/devcontainers/javascript-node:1-22
+    image: mcr.microsoft.com/vscode/devcontainers/javascript-node:1-20
     volumes:
     volumes:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
       - pnpm-store:/workspace/.pnpm-store
       - pnpm-store:/workspace/.pnpm-store

+ 1 - 1
.devcontainer/compose.yml

@@ -21,7 +21,7 @@ services:
       - /data/db
       - /data/db
 
 
   # This container requires '../../growi-docker-compose' repository
   # This container requires '../../growi-docker-compose' repository
-  #   cloned from https://github.com/weseek/growi-docker-compose.git
+  #   cloned from https://github.com/growilabs/growi-docker-compose.git
   elasticsearch:
   elasticsearch:
     build:
     build:
       context: ../../growi-docker-compose/elasticsearch/v9
       context: ../../growi-docker-compose/elasticsearch/v9

+ 1 - 1
.github/ISSUE_TEMPLATE/bug-report.md

@@ -18,7 +18,7 @@ Environment
 |Using Docker|yes/no|
 |Using Docker|yes/no|
 |Using [growi-docker-compose][growi-docker-compose]|yes/no|
 |Using [growi-docker-compose][growi-docker-compose]|yes/no|
 
 
-[growi-docker-compose]: https://github.com/weseek/growi-docker-compose
+[growi-docker-compose]: https://github.com/growilabs/growi-docker-compose
 
 
 *(Accessing https://{GROWI_HOST}/admin helps you to fill in above versions)*
 *(Accessing https://{GROWI_HOST}/admin helps you to fill in above versions)*
 
 

+ 1 - 1
.github/ISSUE_TEMPLATE/config.yml

@@ -1,7 +1,7 @@
 blank_issues_enabled: false
 blank_issues_enabled: false
 contact_links:
 contact_links:
   - name: User request or Suggestions
   - name: User request or Suggestions
-    url: https://github.com/weseek/growi/discussions
+    url: https://github.com/growilabs/growi/discussions
     about: If you have feature requests or suggestions, you can create a new discussion and consider it with the community.
     about: If you have feature requests or suggestions, you can create a new discussion and consider it with the community.
   - name: Questions
   - name: Questions
     url: https://communityinviter.com/apps/wsgrowi/invite/
     url: https://communityinviter.com/apps/wsgrowi/invite/

+ 5 - 5
.github/mergify.yml

@@ -7,17 +7,17 @@ queue_rules:
       - check-success ~= ci-app-launch-dev
       - check-success ~= ci-app-launch-dev
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node22 /
+      - -check-failure ~= test-prod-node20 /
     merge_conditions:
     merge_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - check-success ~= ci-app-test
       - check-success ~= ci-app-launch-dev
       - check-success ~= ci-app-launch-dev
-      - check-success ~= test-prod-node22 / build-prod
-      - check-success ~= test-prod-node22 / launch-prod
-      - check-success ~= test-prod-node22 / run-playwright
+      - check-success = test-prod-node20 / build-prod
+      - check-success ~= test-prod-node20 / launch-prod
+      - check-success ~= test-prod-node20 / run-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node22 /
+      - -check-failure ~= test-prod-node20 /
 
 
 pull_request_rules:
 pull_request_rules:
   - name: Automatic queue to merge
   - name: Automatic queue to merge

+ 7 - 7
.github/workflows/ci-app-prod.yml

@@ -39,8 +39,8 @@ concurrency:
 
 
 jobs:
 jobs:
 
 
-  test-prod-node20:
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+  test-prod-node18:
+    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
     if: |
     if: |
       ( github.event_name == 'push'
       ( github.event_name == 'push'
         || github.base_ref == 'master'
         || github.base_ref == 'master'
@@ -48,14 +48,14 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
     with:
-      node-version: 20.x
+      node-version: 18.x
       skip-e2e-test: true
       skip-e2e-test: true
     secrets:
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
 
 
-  test-prod-node22:
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+  test-prod-node20:
+    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
     if: |
     if: |
       ( github.event_name == 'push'
       ( github.event_name == 'push'
         || github.base_ref == 'master'
         || github.base_ref == 'master'
@@ -63,7 +63,7 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
     with:
-      node-version: 22.x
+      node-version: 20.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
     secrets:
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -71,7 +71,7 @@ jobs:
   # run-reg-suit-node20:
   # run-reg-suit-node20:
   #   needs: [test-prod-node20]
   #   needs: [test-prod-node20]
 
 
-  #   uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@master
+  #   uses: growilabs/growi/.github/workflows/reusable-app-reg-suit.yml@master
 
 
   #   if: always()
   #   if: always()
 
 

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

@@ -44,7 +44,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4

+ 2 - 2
.github/workflows/ci-pdf-converter.yml

@@ -29,7 +29,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -104,7 +104,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4

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

@@ -30,7 +30,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -85,7 +85,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     services:
     services:
       mysql:
       mysql:
@@ -163,7 +163,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     services:
     services:
       mysql:
       mysql:

+ 1 - 1
.github/workflows/list-unhealthy-branches.yml

@@ -16,7 +16,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '20'
+        node-version: '18'
 
 
     - name: List branches
     - name: List branches
       id: list-branches
       id: list-branches

+ 4 - 2
.github/workflows/release-pdf-converter.yml

@@ -28,7 +28,9 @@ jobs:
         images: growilabs/pdf-converter
         images: growilabs/pdf-converter
         tags: |
         tags: |
           type=raw,value=latest
           type=raw,value=latest
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}.{{patch}}
 
 
     - name: Login to docker.io registry
     - name: Login to docker.io registry
       run: |
       run: |
@@ -72,7 +74,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4

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

@@ -46,9 +46,9 @@ jobs:
 
 
 
 
   build-image-rc:
   build-image-rc:
-    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@master
     with:
     with:
-      image-name: weseek/growi
+      image-name: growilabs/growi
       tag-temporary: latest-rc
       tag-temporary: latest-rc
     secrets:
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
@@ -57,11 +57,12 @@ jobs:
   publish-image-rc:
   publish-image-rc:
     needs: [determine-tags, build-image-rc]
     needs: [determine-tags, build-image-rc]
 
 
-    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
     with:
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io
       registry: docker.io
       image-name: weseek/growi
       image-name: weseek/growi
+      docker-registry-username: wsmoogle
       tag-temporary: latest-rc
       tag-temporary: latest-rc
     secrets:
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

+ 33 - 10
.github/workflows/release-rc.yml

@@ -17,7 +17,8 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     outputs:
     outputs:
-      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_WESEEK: ${{ steps.meta-weseek.outputs.tags }}
+      TAGS_GROWILABS: ${{ steps.meta-growilabs.outputs.tags }}
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -26,9 +27,9 @@ jobs:
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       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 weseek/growi
       uses: docker/metadata-action@v5
       uses: docker/metadata-action@v5
-      id: meta
+      id: meta-weseek
       with:
       with:
         images: docker.io/weseek/growi
         images: docker.io/weseek/growi
         sep-tags: ','
         sep-tags: ','
@@ -36,25 +37,47 @@ jobs:
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
 
+    - name: Docker meta for growilabs/growi
+      uses: docker/metadata-action@v5
+      id: meta-growilabs
+      with:
+        images: docker.io/growilabs/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
 
   build-image-rc:
   build-image-rc:
-    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@master
     with:
     with:
-      image-name: weseek/growi
+      image-name: growilabs/growi
       tag-temporary: latest-rc
       tag-temporary: latest-rc
     secrets:
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
 
 
 
-  publish-image-rc:
+  publish-rc-image-for-growilabs:
     needs: [determine-tags, build-image-rc]
     needs: [determine-tags, build-image-rc]
 
 
-    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
     with:
-      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      tags: ${{ needs.determine-tags.outputs.TAGS_GROWILABS }}
       registry: docker.io
       registry: docker.io
-      image-name: weseek/growi
+      image-name: 'growilabs/growi'
+      docker-registry-username: 'growimoogle'
       tag-temporary: latest-rc
       tag-temporary: latest-rc
     secrets:
     secrets:
-      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
 
 
+  publish-rc-image-for-weseek:
+    needs: [determine-tags, build-image-rc]
+
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_WESEEK }}
+      registry: docker.io
+      image-name: 'growilabs/growi'
+      docker-registry-username: 'wsmoogle'
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

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

@@ -92,7 +92,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '20'
+        node-version: '18'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies

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

@@ -32,7 +32,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '22'
+        node-version: '20'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
@@ -75,7 +75,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '22'
+        node-version: '20'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies

+ 50 - 17
.github/workflows/release.yml

@@ -1,3 +1,4 @@
+# TODO: https://redmine.weseek.co.jp/issues/171293
 name: Release
 name: Release
 
 
 on:
 on:
@@ -26,7 +27,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '22'
+        node-version: '20'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies
@@ -80,7 +81,8 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     outputs:
     outputs:
-      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_WESEEK: ${{ steps.meta-weseek.outputs.tags }}
+      TAGS_GROWILABS: ${{ steps.meta-growilabs.outputs.tags }}
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -89,9 +91,9 @@ jobs:
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       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 weseek/growi
       uses: docker/metadata-action@v5
       uses: docker/metadata-action@v5
-      id: meta
+      id: meta-weseek
       with:
       with:
         images: docker.io/weseek/growi
         images: docker.io/weseek/growi
         sep-tags: ','
         sep-tags: ','
@@ -101,36 +103,67 @@ jobs:
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
 
+    - name: Docker meta for growilabs/growi
+      uses: docker/metadata-action@v5
+      id: meta-growilabs
+      with:
+        images: docker.io/growilabs/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=latest
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
 
   build-app-image:
   build-app-image:
     needs: create-github-release
     needs: create-github-release
 
 
-    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@master
     with:
     with:
       source-version: refs/tags/v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
       source-version: refs/tags/v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
-      image-name: weseek/growi
+      image-name: growilabs/growi
       tag-temporary: latest
       tag-temporary: latest
     secrets:
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
 
+  publish-app-image-for-growilabs:
+    needs: [determine-tags, build-app-image]
+
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GROWILABS }}
+      registry: docker.io
+      image-name: 'growilabs/growi'
+      docker-registry-username: 'growimoogle'
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
 
 
-  publish-app-image:
+  publish-app-image-for-weseek:
     needs: [determine-tags, build-app-image]
     needs: [determine-tags, build-app-image]
 
 
-    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
     with:
-      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      tags: ${{ needs.determine-tags.outputs.TAGS_WESEEK }}
       registry: docker.io
       registry: docker.io
-      image-name: weseek/growi
+      image-name: 'growilabs/growi'
+      docker-registry-username: 'wsmoogle'
       tag-temporary: latest
       tag-temporary: latest
     secrets:
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
 
 
-
   post-publish:
   post-publish:
-    needs: [create-github-release, publish-app-image]
+    needs: [create-github-release, publish-app-image-for-growilabs, publish-app-image-for-weseek]
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
+    strategy:
+      matrix:
+        include:
+          - repository: weseek/growi
+            username: wsmoogle
+          - repository: growilabs/growi
+            username: growimoogle
+
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
       with:
       with:
@@ -139,9 +172,9 @@ jobs:
     - name: Update Docker Hub Description
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v4
       uses: peter-evans/dockerhub-description@v4
       with:
       with:
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
-        repository: weseek/growi
+        username: ${{ matrix.username }}
+        password: ${{ (matrix.repository == 'weseek/growi' && secrets.DOCKER_REGISTRY_PASSWORD) || (matrix.repository == 'growilabs/growi' && secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE) || 'INVALID_SECRET' }}
+        repository: ${{ matrix.repository }}
         readme-filepath: ./apps/app/docker/README.md
         readme-filepath: ./apps/app/docker/README.md
 
 
     - name: Slack Notification
     - name: Slack Notification
@@ -153,7 +186,7 @@ jobs:
 
 
 
 
   create-pr-for-next-rc:
   create-pr-for-next-rc:
-    needs: [create-github-release, publish-app-image]
+    needs: [create-github-release, publish-app-image-for-growilabs, publish-app-image-for-weseek]
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     steps:
     steps:
@@ -165,7 +198,7 @@ jobs:
 
 
     - uses: actions/setup-node@v4
     - uses: actions/setup-node@v4
       with:
       with:
-        node-version: '22'
+        node-version: '20'
         cache: 'pnpm'
         cache: 'pnpm'
 
 
     - name: Install dependencies
     - name: Install dependencies

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -8,7 +8,7 @@ on:
         default: ${{ github.sha }}
         default: ${{ github.sha }}
       image-name:
       image-name:
         type: string
         type: string
-        default: weseek/growi
+        default: growilabs/growi
       tag-temporary:
       tag-temporary:
         type: string
         type: string
         default: latest
         default: latest

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

@@ -11,7 +11,10 @@ on:
         default: 'docker.io'
         default: 'docker.io'
       image-name:
       image-name:
         type: string
         type: string
-        default: weseek/growi
+        default: growilabs/growi
+      docker-registry-username:
+        type: string
+        default: growimoogle
       tag-temporary:
       tag-temporary:
         type: string
         type: string
         default: latest
         default: latest
@@ -41,7 +44,7 @@ jobs:
       uses: docker/login-action@v3
       uses: docker/login-action@v3
       with:
       with:
         registry: ${{ inputs.registry }}
         registry: ${{ inputs.registry }}
-        username: wsmoogle
+        username: ${{ inputs.docker-registry-username }}
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
 
 
     - name: Create and push manifest images
     - name: Create and push manifest images

+ 2 - 2
.github/workflows/reusable-app-prod.yml

@@ -1,4 +1,4 @@
-name: Reusable build app workflow for production
+name: Reusable build and test app for production
 
 
 on:
 on:
   workflow_call:
   workflow_call:
@@ -16,7 +16,7 @@ on:
       node-version:
       node-version:
         required: true
         required: true
         type: string
         type: string
-        default: 20.x
+        default: 22.x
       skip-e2e-test:
       skip-e2e-test:
         type: boolean
         type: boolean
         default: false
         default: false

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

@@ -32,7 +32,7 @@ jobs:
 
 
   run-reg-suit:
   run-reg-suit:
     # use secrets for "VRT" environment
     # use secrets for "VRT" environment
-    # https://github.com/weseek/growi/settings/environments/376165508/edit
+    # https://github.com/growilabs/growi/settings/environments/376165508/edit
     environment: VRT
     environment: VRT
 
 
     if: ${{ !inputs.skip-reg-suit }}
     if: ${{ !inputs.skip-reg-suit }}

+ 1 - 1
.mcp.json

@@ -14,7 +14,7 @@
         "--context",
         "--context",
         "ide-assistant",
         "ide-assistant",
         "--project",
         "--project",
-        "/workspace/growi"
+        "."
       ],
       ],
       "env": {}
       "env": {}
     }
     }

+ 0 - 14
.roo/mcp.json

@@ -1,14 +0,0 @@
-{
-  "mcpServers": {
-    "fetch": {
-      "command": "uvx",
-      "args": ["mcp-server-fetch"],
-      "alwaysAllow": ["fetch"]
-    },
-    "context7": {
-      "type": "streamable-http",
-      "url": "https://mcp.context7.com/mcp",
-      "alwaysAllow": ["resolve-library-id", "get-library-docs"]
-    }
-  }
-}

+ 1 - 0
.serena/.gitignore

@@ -0,0 +1 @@
+/cache

+ 398 - 0
.serena/memories/apps-app-admin-forms-react-hook-form-migration-guide.md

@@ -0,0 +1,398 @@
+# Admin フォーム - React Hook Form 移行ガイドライン
+
+## プロジェクトコンテキスト
+
+### 現状 (2025年10月時点)
+**✅ PR #10051 完了: Admin フォームの IME 問題は100%解決済み**
+
+全27ファイルが React Hook Form に移行完了し、以下の問題を解決:
+1. ✅ **日本語 IME 入力の問題**: 非制御コンポーネント化により完全解決
+2. ✅ **空値更新の問題**: 完全解決
+3. ⏳ **レガシーライブラリ問題**: Unstated は現在も使用中(次のステップで解決予定)
+
+### 最終目標 (理想像)
+- React Hook Form を利用(✅ 完了)
+- Unstated を完全に廃止(⏳ 次のステップ)
+- グローバルステートは Jotai で管理(⏳ 次のステップ)
+
+### 現在の構成 (中間地点)
+**React Hook Form + Unstated Container のハイブリッド構成**
+
+この構成により:
+1. ✅ IME 入力問題を解決(非制御コンポーネント化)
+2. ✅ 空値更新問題を解決
+3. ✅ Container は残しているが、将来的に Jotai への移行パスを確保
+4. ✅ 段階的な移行によりリグレッションを最小化
+
+## 移行パターン(確立済み)
+
+### 基本的なフォームセットアップ
+
+```typescript
+import { useForm } from 'react-hook-form';
+
+type FormData = {
+  fieldName: string;
+  // ... 他のフィールド
+};
+
+const {
+  register,
+  handleSubmit,
+  reset,
+} = useForm<FormData>();
+```
+
+**重要**: `defaultValues` は指定しない。`useEffect` で `reset()` を呼ぶため不要。
+
+### フォーム値の復元
+
+Container の state とフォームを同期するため、`useEffect` で `reset()` を使用:
+
+```typescript
+useEffect(() => {
+  reset({
+    fieldName: container.state.fieldName || '',
+    // ... 他のフィールド
+  });
+}, [container.state.fieldName, reset]);
+```
+
+### Container を使ったフォーム送信
+
+```typescript
+const onSubmit = useCallback(async(data: FormData) => {
+  try {
+    // 重要: API 呼び出し前に setState の完了を待つ
+    await Promise.all([
+      container.changeField1(data.field1),
+      container.changeField2(data.field2),
+    ]);
+    
+    await container.updateHandler();
+    toastSuccess(t('updated_successfully'));
+  }
+  catch (err) {
+    toastError(err);
+  }
+}, [container, t]);
+
+return (
+  <form onSubmit={handleSubmit(onSubmit)}>
+    {/* フォームフィールド */}
+  </form>
+);
+```
+
+## 重要な注意点
+
+### ⚠️ 1. API 呼び出し前に Container の setState を await する(最重要!)
+
+**問題**: Unstated Container の `setState` は非同期処理です。`change*()` メソッドの後に `await` せずに API ハンドラーを即座に呼ぶと、API リクエストは**古い/古びた値**で送信されます。
+
+❌ **間違い:**
+```typescript
+container.changeSiteUrl(data.siteUrl);
+await container.updateHandler(); // 古い値が送信される!
+```
+
+✅ **正しい:**
+```typescript
+await container.changeSiteUrl(data.siteUrl);
+await container.updateHandler(); // 新しい値が送信される
+```
+
+複数フィールドの場合は `Promise.all()` を使用:
+```typescript
+await Promise.all([
+  container.changeTitle(data.title),
+  container.changeConfidential(data.confidential),
+]);
+await container.updateHandler();
+```
+
+### 2. ラジオボタンの値の型の一致
+
+**問題**: ラジオボタンは**文字列**の値を持ちますが、Container の state は boolean かもしれません。型が一致しないと、選択状態の復元ができません。
+
+❌ **間違い:**
+```typescript
+// HTML: <input type="radio" value="true" />
+reset({
+  isEmailPublished: true, // boolean - 文字列 "true" とマッチしない
+});
+```
+
+✅ **正しい:**
+```typescript
+reset({
+  isEmailPublished: String(container.state.isEmailPublished ?? true),
+});
+```
+
+### 3. チェックボックスの値の扱い
+
+チェックボックスは boolean 値を直接使えます(変換不要):
+```typescript
+reset({
+  fileUpload: container.state.fileUpload ?? false,
+});
+```
+
+### 4. リアルタイム Container 更新に watch() を使わない
+
+**削除したパターン**: フォームの変更を `watch()` と `useEffect` でリアルタイムに Container に同期し戻すのは不要で、複雑さを増すだけです。
+
+❌ **これはやらない:**
+```typescript
+const watchedValues = watch();
+useEffect(() => {
+  container.changeField(watchedValues.field);
+}, [watchedValues]);
+```
+
+✅ **submit 時だけ更新:**
+- Container の state は最終的な API リクエストにのみ使用される
+- `onSubmit` で API ハンドラーを呼ぶ前に更新すればよい
+
+### 5. フォームフィールドの disabled vs readOnly
+
+**問題**: `disabled` フィールドはフォーム送信データから除外されます。
+
+フィールドを編集不可にしたいが、フォームデータには含めたい場合:
+- `disabled` の代わりに `readOnly` を使用
+- または属性を削除して Container/API レイヤーで処理
+
+### 6. defaultValues を指定しない
+
+`useForm()` の引数に `defaultValues` を渡さないこと。
+
+理由:
+- `useEffect` で `reset()` を呼んでいるため、初期値はそちらで設定される
+- コードの重複を避ける
+- 他のファイルとパターンを統一
+
+```typescript
+// ❌ 冗長
+const { register, reset } = useForm({
+  defaultValues: { field: container.state.field }
+});
+useEffect(() => {
+  reset({ field: container.state.field });
+}, [container.state.field]);
+
+// ✅ シンプル
+const { register, reset } = useForm();
+useEffect(() => {
+  reset({ field: container.state.field });
+}, [container.state.field]);
+```
+
+## 高度なパターン
+
+### モジュラーコンポーネント設計(SecuritySetting の例)
+
+大規模なフォームは、複数の小さなコンポーネントに分割することを推奨します。
+
+**親コンポーネント(統合):**
+```typescript
+type FormData = {
+  sessionMaxAge: string;
+  // Container で管理される他のフィールドは不要
+};
+
+const Parent: React.FC<Props> = ({ container }) => {
+  const { register, handleSubmit, reset } = useForm<FormData>();
+
+  useEffect(() => {
+    reset({
+      sessionMaxAge: container.state.sessionMaxAge || '',
+    });
+  }, [reset, container.state.sessionMaxAge]);
+
+  const onSubmit = useCallback(async(data: FormData) => {
+    try {
+      // React Hook Form で管理されているフィールドのみ更新
+      await container.setSessionMaxAge(data.sessionMaxAge);
+      // 全ての設定を保存(Container 管理のフィールドも含む)
+      await container.updateGeneralSecuritySetting();
+      toastSuccess(t('updated'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [container, t]);
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)}>
+      {/* React Hook Form 管理のフィールド */}
+      <SessionMaxAgeSettings register={register} t={t} />
+      
+      {/* Container 直接管理のフィールド */}
+      <PageListDisplaySettings container={container} t={t} />
+      <PageAccessRightsSettings container={container} t={t} />
+      
+      <button type="submit">{t('Update')}</button>
+    </form>
+  );
+};
+```
+
+**子コンポーネント(React Hook Form 管理):**
+```typescript
+type Props = {
+  register: UseFormRegister<{ sessionMaxAge: string }>;
+  t: (key: string) => string;
+};
+
+export const SessionMaxAgeSettings: React.FC<Props> = ({ register, t }) => {
+  return (
+    <input
+      className="form-control"
+      type="text"
+      {...register('sessionMaxAge')}
+      placeholder="2592000000"
+    />
+  );
+};
+```
+
+**子コンポーネント(Container 直接管理):**
+```typescript
+type Props = {
+  container: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const PageListDisplaySettings: React.FC<Props> = ({ container, t }) => {
+  return (
+    <select
+      className="form-control"
+      value={container.state.currentOwnerRestrictionDisplayMode}
+      onChange={(e) => container.changeOwnerRestrictionDisplayMode(e.target.value)}
+    >
+      <option value="Displayed">{t('Displayed')}</option>
+      <option value="Hidden">{t('Hidden')}</option>
+    </select>
+  );
+};
+```
+
+### 統一された Submit ボタン
+
+複数のセクションを持つフォームでも、Submit ボタンは1つに統一:
+- React Hook Form のフィールドは `onSubmit` で処理
+- Container 管理のフィールドは既に state に反映されている
+- 1つの `updateHandler()` で全て保存
+
+## テストチェックリスト
+
+フォーム移行後に必ずテストすること:
+
+1. ✅ **日本語 IME 入力と漢字変換** - 最も重要!
+2. ✅ **ページリロード後にフォームの値が正しく復元される**
+3. ✅ **空値を送信できる**(フィールドをクリアできる)
+4. ✅ **フォーム送信で現在の入力値が送信される**(古い/古びた値ではない)
+5. ✅ **ラジオボタンとチェックボックスが正しく復元される**
+6. ✅ **複数セクションがある場合、全ての設定が1つの Submit で保存される**
+
+## PR #10051 の成果
+
+全27ファイルを React Hook Form に移行完了:
+
+### 主要な成果
+1. **企業認証システム**: LDAP (10フィールド)、OIDC (16フィールド)、SAML (9フィールド)
+2. **SecuritySetting のモジュラー化**: 636行のクラスコンポーネント → 8つの Function Component
+3. **セキュリティ設定**: LocalSecurity (1フィールド)、Import (4フィールド)
+4. **カスタマイズ**: CustomizeCss (1フィールド)、Slack (2フィールド)
+5. **その他**: 17ファイル
+
+### アーキテクチャの改善
+- TypeScript 完全対応
+- PropTypes 廃止
+- Function Component への統一
+- モジュラー設計の採用
+- テスト容易性の向上
+
+## 将来の移行パス: Unstated から Jotai へ
+
+### フェーズ 1: React Hook Form 移行(✅ 完了)
+- 全ての Admin フォームを React Hook Form に移行
+- IME 問題と空値問題を解決
+- 非制御コンポーネントパターンを確立
+
+### フェーズ 2: Jotai 導入準備(次のステップ)
+1. **Container の分析**
+   - どの state が本当にグローバルである必要があるか特定
+   - ローカル state で十分なものを useState に移行
+
+2. **API レイヤーの分離**
+   - Container の `update*Handler()` メソッドを独立した API 関数に抽出
+   - `apps/app/src/client/util/apiv3-client.ts` パターンに従う
+
+3. **段階的な Container の削除**
+   - 小さな Container から始める
+   - Jotai atom で置き換え
+   - 各ステップでテストを実行
+
+### フェーズ 3: 完全な Jotai 移行(最終目標)
+```typescript
+// 理想的な最終形態
+import { atom, useAtom } from 'jotai';
+import { useForm } from 'react-hook-form';
+
+// グローバル state
+const sessionMaxAgeAtom = atom<string>('');
+
+const SecuritySetting = () => {
+  const [sessionMaxAge, setSessionMaxAge] = useAtom(sessionMaxAgeAtom);
+  const { register, handleSubmit, reset } = useForm();
+
+  useEffect(() => {
+    reset({ sessionMaxAge });
+  }, [sessionMaxAge, reset]);
+
+  const onSubmit = async(data: FormData) => {
+    // 直接 API 呼び出し
+    await apiv3Put('/admin/security-settings', {
+      sessionMaxAge: data.sessionMaxAge,
+      // ... 他の設定
+    });
+    
+    // Jotai state を更新
+    setSessionMaxAge(data.sessionMaxAge);
+    toastSuccess('Updated');
+  };
+
+  return <form onSubmit={handleSubmit(onSubmit)}>{/* ... */}</form>;
+};
+```
+
+## 適用可能な範囲
+
+このガイドラインは以下の Admin フォームに適用可能:
+
+- Unstated Container でグローバルステートを管理しているフォーム
+- `apps/app/src/client/services/Admin*Container.js` 配下の Container を使用しているフォーム
+- `/admin` ルート配下のコンポーネント
+- 将来的に Jotai に移行予定のフォーム
+
+## 関連ファイル
+
+### 現在使用中
+- Container 群: `apps/app/src/client/services/Admin*Container.js`
+- ボタンコンポーネント: `apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx`
+- React Hook Form: v7.45.4
+
+### 将来導入予定
+- Jotai: グローバル state 管理
+- SWR または React Query: サーバー state 管理(検討中)
+
+## 参考実装
+
+以下のファイルがベストプラクティスの参考になります:
+
+1. **モジュラー構造**: `apps/app/src/client/components/Admin/Security/SecuritySetting/`
+2. **React Hook Form 基本**: `apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx`
+3. **複雑なフォーム**: `apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx`
+4. **既存の良い実装**: `apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx`

+ 186 - 0
.serena/memories/apps-app-pagetree-performance-refactor-plan.md

@@ -0,0 +1,186 @@
+# PageTree パフォーマンス改善リファクタ計画 - 現実的戦略
+
+## 🎯 目標
+現在のパフォーマンス問題を解決:
+- **問題**: 5000件の兄弟ページで初期レンダリングが重い
+- **目標**: 表示速度を10-20倍改善、UX維持
+
+## ✅ 戦略2: API軽量化 - **完了済み**
+
+### 実装済み内容
+- **ファイル**: `apps/app/src/server/service/page-listing/page-listing.ts:77`
+- **変更内容**: `.select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')` を追加
+- **型定義**: `apps/app/src/interfaces/page.ts` の `IPageForTreeItem` 型も対応済み
+- **追加改善**: 計画にはなかった `wip` フィールドも最適化対象に含める
+
+### 実現できた効果
+- **データサイズ**: 推定 500バイト → 約100バイト(5倍軽量化)
+- **ネットワーク転送**: 5000ページ時 2.5MB → 500KB程度に削減
+- **状況**: **実装完了・効果発現中**
+
+---
+
+## 🚀 戦略1: 既存アーキテクチャ活用 + headless-tree部分導入 - **現実的戦略**
+
+### 前回のreact-window失敗原因
+1. **動的itemCount**: ツリー展開時にアイテム数が変化→react-windowの前提と衝突
+2. **非同期ローディング**: APIレスポンス待ちでフラット化不可
+3. **複雑な状態管理**: SWRとreact-windowの状態同期が困難
+
+### 現実的制約の認識
+**ItemsTree/TreeItemLayoutは廃止困難**:
+- **CustomTreeItemの出し分け**: `PageTreeItem` vs `TreeItemForModal`  
+- **共通副作用処理**: rename/duplicate/delete時のmutation処理
+- **多箇所からの利用**: PageTree, PageSelectModal, AiAssistant等
+
+## 📋 修正された実装戦略: **ハイブリッドアプローチ**
+
+### **核心アプローチ**: ItemsTreeを**dataProvider**として活用
+
+**既存の責務は保持しつつ、内部実装のみheadless-tree化**:
+
+1. **ItemsTree**: UIロジック・副作用処理はそのまま
+2. **TreeItemLayout**: 個別アイテムレンダリングはそのまま  
+3. **データ管理**: 内部でheadless-treeを使用(SWR → headless-tree)
+4. **Virtualization**: ItemsTree内部にreact-virtualを導入
+
+### **実装計画: 段階的移行**
+
+#### **Phase 1: データ層のheadless-tree化**
+
+**ファイル**: `ItemsTree.tsx`
+```typescript
+// Before: 複雑なSWR + 子コンポーネント管理
+const tree = useTree<IPageForTreeItem>({
+  rootItemId: initialItemNode.page._id,
+  dataLoader: {
+    getItem: async (itemId) => {
+      const response = await apiv3Get('/page-listing/item', { id: itemId });
+      return response.data;
+    },
+    getChildren: async (itemId) => {
+      const response = await apiv3Get('/page-listing/children', { id: itemId });
+      return response.data.children.map(child => child._id);
+    },
+  },
+  features: [asyncDataLoaderFeature],
+});
+
+// 既存のCustomTreeItemに渡すためのアダプター
+const adaptedNodes = tree.getItems().map(item => 
+  new ItemNode(item.getItemData())
+);
+
+return (
+  <ul className={`${moduleClass} list-group`}>
+    {adaptedNodes.map(node => (
+      <CustomTreeItem
+        key={node.page._id}
+        itemNode={node}
+        // ... 既存のpropsをそのまま渡す
+        onRenamed={onRenamed}
+        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+        onClickDeleteMenuItem={onClickDeleteMenuItem}
+      />
+    ))}
+  </ul>
+);
+```
+
+#### **Phase 2: Virtualization導入**
+
+**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張)
+```typescript
+const virtualizer = useVirtualizer({
+  count: adaptedNodes.length,
+  getScrollElement: () => containerRef.current,
+  estimateSize: () => 40,
+});
+
+return (
+  <div ref={containerRef} className="tree-container">
+    <div style={{ height: virtualizer.getTotalSize() }}>
+      {virtualizer.getVirtualItems().map(virtualItem => {
+        const node = adaptedNodes[virtualItem.index];
+        return (
+          <div
+            key={node.page._id}
+            style={{
+              position: 'absolute',
+              top: virtualItem.start,
+              height: virtualItem.size,
+              width: '100%',
+            }}
+          >
+            <CustomTreeItem
+              itemNode={node}
+              // ... 既存props
+            />
+          </div>
+        );
+      })}
+    </div>
+  </div>
+);
+```
+
+#### **Phase 3 (将来): 完全なheadless-tree移行**
+
+最終的にはdrag&drop、selection等のUI機能もheadless-treeに移行可能ですが、**今回のスコープ外**。
+
+## 📁 現実的なファイル変更まとめ
+
+| アクション | ファイル | 内容 | スコープ |
+|---------|---------|------|------|
+| ✅ **完了** | **apps/app/src/server/service/page-listing/page-listing.ts** | selectクエリ追加 | API軽量化 |
+| ✅ **完了** | **apps/app/src/interfaces/page.ts** | IPageForTreeItem型定義 | API軽量化 |
+| 🔄 **修正** | **src/client/components/ItemsTree/ItemsTree.tsx** | headless-tree + virtualization導入 | **今回の核心** |
+| 🆕 **新規** | **src/client/components/ItemsTree/usePageTreeDataLoader.ts** | データローダー分離 | 保守性向上 |
+| ⚠️ **保持** | **src/client/components/TreeItem/TreeItemLayout.tsx** | 既存のまま(後方互換) | 既存責務保持 |
+| ⚠️ **部分削除** | **src/stores/page-listing.tsx** | useSWRxPageChildren削除 | 重複排除 |
+
+**新規ファイル**: 1個(データローダー分離のみ)  
+**変更ファイル**: 2個(ItemsTree改修 + store整理)  
+**削除ファイル**: 0個(既存アーキテクチャ尊重)
+
+---
+
+## 🎯 実装優先順位
+
+**✅ Phase 1**: API軽量化(低リスク・即効性) - **完了**
+
+**📋 Phase 2-A**: ItemsTree内部のheadless-tree化
+- **工数**: 2-3日
+- **リスク**: 低(外部IF変更なし)
+- **効果**: 非同期ローディング最適化、キャッシュ改善
+
+**📋 Phase 2-B**: Virtualization導入  
+- **工数**: 2-3日
+- **リスク**: 低(内部実装のみ)
+- **効果**: レンダリング性能10-20倍改善
+
+**現在の効果**: API軽量化により 5倍のデータ転送量削減済み  
+**Phase 2完了時の予想効果**: 初期表示速度 20-50倍改善
+
+---
+
+## 🏗️ 実装方針: **既存アーキテクチャ尊重**
+
+**基本方針**:
+- **既存のCustomTreeItem責務**は保持(rename/duplicate/delete等)
+- **データ管理層のみ**をheadless-tree化  
+- **外部インターフェース**は変更せず、内部最適化に集中
+- **段階的移行**で低リスク実装
+
+**今回のスコープ**:
+- ✅ 非同期データローディング最適化
+- ✅ Virtualizationによる大量要素対応  
+- ❌ drag&drop/selection(将来フェーズ)
+- ❌ 既存アーキテクチャの破壊的変更
+
+---
+
+## 技術的参考資料
+- **headless-tree**: https://headless-tree.lukasbach.com/ (データ管理層のみ利用)
+- **react-virtual**: @tanstack/react-virtualを使用  
+- **アプローチ**: 既存ItemsTree内部でheadless-tree + virtualizationをハイブリッド活用

+ 1 - 1
.serena/memories/project_overview.md

@@ -8,7 +8,7 @@ GROWIは、マークダウンを使用したチームコラボレーションソ
 - **バージョン**: 7.3.0-RC.0
 - **バージョン**: 7.3.0-RC.0
 - **ライセンス**: MIT
 - **ライセンス**: MIT
 - **作者**: Yuki Takei <yuki@weseek.co.jp>
 - **作者**: Yuki Takei <yuki@weseek.co.jp>
-- **リポジトリ**: https://github.com/weseek/growi.git
+- **リポジトリ**: https://github.com/growilabs/growi.git
 - **公式サイト**: https://growi.org
 - **公式サイト**: https://growi.org
 
 
 ## 主な特徴
 ## 主な特徴

+ 13 - 7
.serena/memories/suggested_commands.md

@@ -11,7 +11,7 @@ pnpm install
 ## 開発サーバー
 ## 開発サーバー
 ```bash
 ```bash
 # メインアプリケーション開発モード
 # メインアプリケーション開発モード
-cd apps/app && pnpm run dev
+cd /workspace/growi/apps/app && pnpm run dev
 
 
 # ルートから起動(本番用ビルド後)
 # ルートから起動(本番用ビルド後)
 pnpm start
 pnpm start
@@ -31,20 +31,26 @@ turbo run build
 
 
 ## Lint・フォーマット
 ## Lint・フォーマット
 ```bash
 ```bash
+# 全てのLint実行
+pnpm run lint
+```
+
+## apps/app の Lint・フォーマット
+```bash
 # 【推奨】Biome実行(lint + format)
 # 【推奨】Biome実行(lint + format)
-pnpm run lint:biome
+cd /workspace/growi/apps/app pnpm run lint:biome
 
 
 # 【過渡期】ESLint実行(廃止予定)
 # 【過渡期】ESLint実行(廃止予定)
-pnpm run lint:eslint
+cd /workspace/growi/apps/app pnpm run lint:eslint
 
 
 # Stylelint実行
 # Stylelint実行
-pnpm run lint:styles
+cd /workspace/growi/apps/app pnpm run lint:styles
 
 
-# 全てのLint実行(過渡期対応)
-pnpm run lint
+# 全てのLint実行
+cd /workspace/growi/apps/app pnpm run lint
 
 
 # TypeScript型チェック
 # TypeScript型チェック
-pnpm run lint:typecheck
+cd /workspace/growi/apps/app pnpm run lint:typecheck
 ```
 ```
 
 
 ## テスト
 ## テスト

+ 6 - 1
.vscode/settings.json

@@ -11,6 +11,10 @@
     "editor.defaultFormatter": "biomejs.biome"
     "editor.defaultFormatter": "biomejs.biome"
   },
   },
 
 
+  "[json]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],
   "stylelint.validate": ["css", "less", "scss"],
@@ -96,6 +100,7 @@
     {
     {
       "text": "Always write commit messages in English."
       "text": "Always write commit messages in English."
     }
     }
-  ]
+  ],
+  "git-worktree-menu.worktreeDir": "/workspace"
 
 
 }
 }

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


+ 1 - 1
LICENSE

@@ -1,6 +1,6 @@
 MIT License
 MIT License
 
 
-Copyright (c) 2018 WESEEK, Inc.
+Copyright (c) 2018 GROWI, Inc.
 
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
 of this software and associated documentation files (the "Software"), to deal

+ 14 - 14
README.md

@@ -6,7 +6,7 @@
   </a>
   </a>
 </p>
 </p>
 <p align="center">
 <p align="center">
-  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://github.com/growilabs/growi/releases/latest"><img src="https://img.shields.io/github/release/growilabs/growi.svg" alt="Latest version"></a>
   <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
   <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
 </p>
 </p>
 
 
@@ -16,10 +16,10 @@
 
 
 # GROWI
 # GROWI
 
 
-[![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
-[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
-[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
-[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+[![docker pulls](https://img.shields.io/docker/pulls/growilabs/growi.svg)](https://hub.docker.com/r/growilabs/growi/)
+[![CodeQL](https://github.com/growilabs/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/growilabs/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/growilabs/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/ci-app-prod.yml)
 
 
 ## Demonstration
 ## Demonstration
 <video src="https://private-user-images.githubusercontent.com/34241526/333079483-fee540d7-2fa6-46d7-833e-74014c5340e3.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk2OTEsIm5iZiI6MTcxNjQ0OTM5MSwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzk0ODMtZmVlNTQwZDctMmZhNi00NmQ3LTgzM2UtNzQwMTRjNTM0MGUzLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3Mjk1MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTBkYWFkMmYyYmIwMTI2YWE3ZmQzZTFiNWU3ZThkMDc5NDA5N2Q3YWE5ZGM1NDgwNjk0OGNjYjZmOTJkM2IzZGQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.FAvLseWBzE62yFA7wt26uERamvEVQdIGRVdBwk0uLhE"></video>
 <video src="https://private-user-images.githubusercontent.com/34241526/333079483-fee540d7-2fa6-46d7-833e-74014c5340e3.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk2OTEsIm5iZiI6MTcxNjQ0OTM5MSwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzk0ODMtZmVlNTQwZDctMmZhNi00NmQ3LTgzM2UtNzQwMTRjNTM0MGUzLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3Mjk1MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTBkYWFkMmYyYmIwMTI2YWE3ZmQzZTFiNWU3ZThkMDc5NDA5N2Q3YWE5ZGM1NDgwNjk0OGNjYjZmOTJkM2IzZGQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.FAvLseWBzE62yFA7wt26uERamvEVQdIGRVdBwk0uLhE"></video>
@@ -81,9 +81,9 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 
 ## Dependencies
 ## Dependencies
 
 
-- Node.js v20.x or v22.x
-- npm 10.x
-- pnpm 10.x
+- Node.js v18.x or v20.x
+- npm 6.x
+- pnpm 9.x
 - [Turborepo](https://turbo.build/repo)
 - [Turborepo](https://turbo.build/repo)
 - MongoDB v6.x or v8.x
 - MongoDB v6.x or v8.x
 
 
@@ -138,11 +138,11 @@ If you have questions or suggestions, you can [join our Slack team](https://comm
 # License
 # License
 
 
 - The MIT License (MIT)
 - The MIT License (MIT)
-- See [LICENSE](https://github.com/weseek/growi/blob/master/LICENSE) and [THIRD-PARTY-NOTICES.md](https://github.com/weseek/growi/blob/master/THIRD-PARTY-NOTICES.md).
+- See [LICENSE](https://github.com/growilabs/growi/blob/master/LICENSE) and [THIRD-PARTY-NOTICES.md](https://github.com/growilabs/growi/blob/master/THIRD-PARTY-NOTICES.md).
 
 
 [crowi]: https://github.com/crowi/crowi
 [crowi]: https://github.com/crowi/crowi
-[growi]: https://github.com/weseek/growi
-[issues]: https://github.com/weseek/growi/issues
-[pulls]: https://github.com/weseek/growi/pulls
-[dockerhub]: https://hub.docker.com/r/weseek/growi
-[docker-compose]: https://github.com/weseek/growi-docker-compose
+[growi]: https://github.com/growilabs/growi
+[issues]: https://github.com/growilabs/growi/issues
+[pulls]: https://github.com/growilabs/growi/pulls
+[dockerhub]: https://hub.docker.com/r/growilabs/growi
+[docker-compose]: https://github.com/growilabs/growi-docker-compose

+ 14 - 14
README_JP.md

@@ -6,7 +6,7 @@
   </a>
   </a>
 </p>
 </p>
 <p align="center">
 <p align="center">
-  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://github.com/growilabs/growi/releases/latest"><img src="https://img.shields.io/github/release/growilabs/growi.svg" alt="Latest version"></a>
   <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
   <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
 </p>
 </p>
 
 
@@ -16,10 +16,10 @@
 
 
 # GROWI
 # GROWI
 
 
-[![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
-[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
-[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
-[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+[![docker pulls](https://img.shields.io/docker/pulls/growilabs/growi.svg)](https://hub.docker.com/r/growilabs/growi/)
+[![CodeQL](https://github.com/growilabs/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/growilabs/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/growilabs/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/growilabs/growi/actions/workflows/ci-app-prod.yml)
 
 
 ## デモ
 ## デモ
 <video src="https://private-user-images.githubusercontent.com/34241526/333079216-cec7f7d8-c3cc-4ee7-bc94-167b056d4ce2.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk0MDQsIm5iZiI6MTcxNjQ0OTEwNCwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzkyMTYtY2VjN2Y3ZDgtYzNjYy00ZWU3LWJjOTQtMTY3YjA1NmQ0Y2UyLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3MjUwNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWQ2M2IwZjc0ZGNhOWQxNWE4MGIyZTZlMzQ0ZDQ4MGZlNjEzMWE3MTQ1YmMwYzg3MmY1NWMyZWI2NzQ3NGIwMTkmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.qLfu5120JrkdfpghXlLG8wCn0p4JNZ7W8AD3zUJTIYY"></video>
 <video src="https://private-user-images.githubusercontent.com/34241526/333079216-cec7f7d8-c3cc-4ee7-bc94-167b056d4ce2.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk0MDQsIm5iZiI6MTcxNjQ0OTEwNCwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzkyMTYtY2VjN2Y3ZDgtYzNjYy00ZWU3LWJjOTQtMTY3YjA1NmQ0Y2UyLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3MjUwNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWQ2M2IwZjc0ZGNhOWQxNWE4MGIyZTZlMzQ0ZDQ4MGZlNjEzMWE3MTQ1YmMwYzg3MmY1NWMyZWI2NzQ3NGIwMTkmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.qLfu5120JrkdfpghXlLG8wCn0p4JNZ7W8AD3zUJTIYY"></video>
@@ -81,9 +81,9 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 
 ## 依存関係
 ## 依存関係
 
 
-- Node.js v20.x or v22.x
-- npm 10.x
-- pnpm 10.x
+- Node.js v18.x or v20.x
+- npm 6.x
+- pnpm 9.x
 - [Turborepo](https://turbo.build/repo)
 - [Turborepo](https://turbo.build/repo)
 - MongoDB v6.x or v8.x
 - MongoDB v6.x or v8.x
 
 
@@ -137,11 +137,11 @@ Issue と Pull requests の作成は英語・日本語どちらでも受け付
 # ライセンス
 # ライセンス
 
 
 - The MIT License (MIT)
 - The MIT License (MIT)
-- [ライセンス](https://github.com/weseek/growi/blob/master/LICENSE) と [THIRD-PARTY-NOTICES.md](https://github.com/weseek/growi/blob/master/THIRD-PARTY-NOTICES.md) をご覧ください。
+- [ライセンス](https://github.com/growilabs/growi/blob/master/LICENSE) と [THIRD-PARTY-NOTICES.md](https://github.com/growilabs/growi/blob/master/THIRD-PARTY-NOTICES.md) をご覧ください。
 
 
   [crowi]: https://github.com/crowi/crowi
   [crowi]: https://github.com/crowi/crowi
-  [growi]: https://github.com/weseek/growi
-  [issues]: https://github.com/weseek/growi/issues
-  [pulls]: https://github.com/weseek/growi/pulls
-  [dockerhub]: https://hub.docker.com/r/weseek/growi
-  [docker-compose]: https://github.com/weseek/growi-docker-compose
+  [growi]: https://github.com/growilabs/growi
+  [issues]: https://github.com/growilabs/growi/issues
+  [pulls]: https://github.com/growilabs/growi/pulls
+  [dockerhub]: https://hub.docker.com/r/growilabs/growi
+  [docker-compose]: https://github.com/growilabs/growi-docker-compose

+ 1 - 1
THIRD-PARTY-NOTICES.md

@@ -9,7 +9,7 @@ please bring it to our attention through any of the ways detailed here :
 The attached notices are provided for information only.
 The attached notices are provided for information only.
 
 
 For any licenses that require disclosure of source, sources are available at  
 For any licenses that require disclosure of source, sources are available at  
-https://github.com/weseek/growi.
+https://github.com/growilabs/growi.
 
 
 
 
 1. Apache License, Version 2.0 Derivative Works
 1. Apache License, Version 2.0 Derivative Works

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

@@ -2,12 +2,8 @@
  * @type {import('eslint').Linter.Config}
  * @type {import('eslint').Linter.Config}
  */
  */
 module.exports = {
 module.exports = {
-  extends: [
-    'next/core-web-vitals',
-    'weseek/react',
-  ],
-  plugins: [
-  ],
+  extends: ['next/core-web-vitals', 'weseek/react'],
+  plugins: [],
   ignorePatterns: [
   ignorePatterns: [
     'dist/**',
     'dist/**',
     '**/dist/**',
     '**/dist/**',
@@ -25,11 +21,17 @@ module.exports = {
     'test/integration/middlewares/**',
     'test/integration/middlewares/**',
     'test/integration/migrations/**',
     'test/integration/migrations/**',
     'test/integration/models/**',
     'test/integration/models/**',
+    'test/integration/service/**',
     'test/integration/setup.js',
     'test/integration/setup.js',
+    'playwright/**',
+    'test-with-vite/**',
+    'public/**',
     'bin/**',
     'bin/**',
     'config/**',
     'config/**',
+    'src/styles/**',
     'src/linter-checker/**',
     'src/linter-checker/**',
     'src/migrations/**',
     'src/migrations/**',
+    'src/models/**',
     'src/features/callout/**',
     'src/features/callout/**',
     'src/features/comment/**',
     'src/features/comment/**',
     'src/features/templates/**',
     'src/features/templates/**',
@@ -38,10 +40,27 @@ module.exports = {
     'src/features/plantuml/**',
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
     'src/features/external-user-group/**',
     'src/features/page-bulk-export/**',
     'src/features/page-bulk-export/**',
+    'src/features/growi-plugin/**',
     'src/features/opentelemetry/**',
     'src/features/opentelemetry/**',
+    'src/features/openai/**',
+    'src/features/rate-limiter/**',
     'src/stores-universal/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/interfaces/**',
     'src/utils/**',
     'src/utils/**',
+    'src/components/**',
+    'src/services/**',
+    'src/stores/**',
+    'src/pages/**',
+    'src/server/crowi/**',
+    'src/server/events/**',
+    'src/server/interfaces/**',
+    'src/server/models/**',
+    'src/server/util/**',
+    'src/server/app.ts',
+    'src/server/repl.ts',
+    'src/server/routes/*.js',
+    'src/server/routes/*.ts',
+    'src/server/routes/attachment/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript
@@ -50,10 +69,6 @@ module.exports = {
     },
     },
   },
   },
   rules: {
   rules: {
-    'no-restricted-imports': ['error', {
-      name: 'axios',
-      message: 'Please use src/utils/axios instead.',
-    }],
     '@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 - 2
apps/app/bin/openapi/generate-operation-ids/cli.spec.ts

@@ -1,5 +1,4 @@
-import { writeFileSync } from 'fs';
-
+import { writeFileSync } from 'node:fs';
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 
 import { generateOperationIds } from './generate-operation-ids';
 import { generateOperationIds } from './generate-operation-ids';

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/cli.ts

@@ -1,5 +1,5 @@
+import { writeFileSync } from 'node:fs';
 import { Command } from 'commander';
 import { Command } from 'commander';
-import { writeFileSync } from 'fs';
 
 
 import { generateOperationIds } from './generate-operation-ids';
 import { generateOperationIds } from './generate-operation-ids';
 
 

+ 3 - 3
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts

@@ -1,7 +1,7 @@
-import fs from 'fs/promises';
+import fs from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
 import type { OpenAPI3 } from 'openapi-typescript';
 import type { OpenAPI3 } from 'openapi-typescript';
-import { tmpdir } from 'os';
-import path from 'path';
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
 import { generateOperationIds } from './generate-operation-ids';
 import { generateOperationIds } from './generate-operation-ids';

+ 416 - 0
apps/app/bin/print-memory-consumption.ts

@@ -0,0 +1,416 @@
+#!/usr/bin/env node
+/**
+ * Node.js Memory Consumption checker
+ *
+ * Retrieves heap memory information from a running Node.js server
+ * started with --inspect flag via Chrome DevTools Protocol
+ *
+ * Usage:
+ *   node --experimental-strip-types --experimental-transform-types \
+ *        --experimental-detect-module --no-warnings=ExperimentalWarning \
+ *        print-memory-consumption.ts [--port=9229] [--host=localhost] [--json]
+ */
+
+import { get } from 'node:http';
+import WebSocket from 'ws';
+
+interface MemoryInfo {
+  heapUsed: number;
+  heapTotal: number;
+  rss: number;
+  external: number;
+  arrayBuffers: number;
+  heapLimit?: number;
+  heapLimitSource: 'explicit' | 'estimated';
+  architecture: string;
+  platform: string;
+  nodeVersion: string;
+  pid: number;
+  uptime: number;
+  memoryFlags: string[];
+  timestamp: number;
+}
+
+interface DebugTarget {
+  webSocketDebuggerUrl: string;
+  title: string;
+  id: string;
+}
+
+class NodeMemoryConsumptionChecker {
+  private host: string;
+  private port: number;
+  private outputJson: boolean;
+
+  constructor(host = 'localhost', port = 9229, outputJson = false) {
+    this.host = host;
+    this.port = port;
+    this.outputJson = outputJson;
+  }
+
+  // Helper method to convert bytes to MB
+  private toMB(bytes: number): number {
+    return bytes / 1024 / 1024;
+  }
+
+  // Helper method to get pressure status and icon
+  private getPressureInfo(percentage: number): {
+    status: string;
+    icon: string;
+  } {
+    if (percentage > 90) return { status: 'HIGH PRESSURE', icon: '🔴' };
+    if (percentage > 70) return { status: 'MODERATE PRESSURE', icon: '🟡' };
+    return { status: 'LOW PRESSURE', icon: '🟢' };
+  }
+
+  // Helper method to create standard error
+  private createError(message: string): Error {
+    return new Error(message);
+  }
+
+  // Helper method to handle promise-based HTTP request
+  private httpGet(url: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+      get(url, (res) => {
+        let data = '';
+        res.on('data', (chunk) => {
+          data += chunk;
+        });
+        res.on('end', () => resolve(data));
+      }).on('error', (err) =>
+        reject(this.createError(`Cannot connect to ${url}: ${err.message}`)),
+      );
+    });
+  }
+
+  // Generate JavaScript expression for memory collection
+  private getMemoryCollectionScript(): string {
+    return `JSON.stringify((() => {
+      const mem = process.memoryUsage();
+      const result = { ...mem, architecture: process.arch, platform: process.platform,
+        nodeVersion: process.version, pid: process.pid, uptime: process.uptime(),
+        timestamp: Date.now(), execArgv: process.execArgv };
+
+      const memFlags = process.execArgv.filter(arg =>
+        arg.includes('max-old-space-size') || arg.includes('max-heap-size'));
+      result.memoryFlags = memFlags;
+
+      const maxOldSpaceArg = memFlags.find(flag => flag.includes('max-old-space-size'));
+      if (maxOldSpaceArg) {
+        const match = maxOldSpaceArg.match(/max-old-space-size=(\\\\d+)/);
+        if (match) result.explicitHeapLimit = parseInt(match[1]) * 1024 * 1024;
+      }
+
+      if (!result.explicitHeapLimit) {
+        const is64bit = result.architecture === 'x64' || result.architecture === 'arm64';
+        const nodeVersion = parseInt(result.nodeVersion.split('.')[0].slice(1));
+        result.estimatedHeapLimit = is64bit
+          ? (nodeVersion >= 14 ? 4 * 1024 * 1024 * 1024 : 1.7 * 1024 * 1024 * 1024)
+          : 512 * 1024 * 1024;
+      }
+
+      return result;
+    })())`;
+  }
+
+  async checkMemory(): Promise<MemoryInfo | null> {
+    try {
+      // Get debug targets
+      const targets = await this.getDebugTargets();
+      if (targets.length === 0) {
+        throw new Error(
+          'No debug targets found. Is the Node.js server running with --inspect?',
+        );
+      }
+
+      // Get memory information via WebSocket
+      const memoryInfo = await this.getMemoryInfoViaWebSocket(targets[0]);
+      return memoryInfo;
+    } catch (error: unknown) {
+      const errorMessage =
+        error instanceof Error ? error.message : String(error);
+      if (!this.outputJson) {
+        console.error('❌ Error:', errorMessage);
+      }
+      return null;
+    }
+  }
+
+  private async getDebugTargets(): Promise<DebugTarget[]> {
+    const url = `http://${this.host}:${this.port}/json/list`;
+    try {
+      const data = await this.httpGet(url);
+      return JSON.parse(data);
+    } catch (e) {
+      throw this.createError(`Failed to parse debug targets: ${e}`);
+    }
+  }
+
+  private async getMemoryInfoViaWebSocket(
+    target: DebugTarget,
+  ): Promise<MemoryInfo> {
+    return new Promise((resolve, reject) => {
+      const ws = new WebSocket(target.webSocketDebuggerUrl);
+
+      const timeout = setTimeout(() => {
+        ws.close();
+        reject(new Error('WebSocket connection timeout'));
+      }, 10000);
+
+      ws.on('open', () => {
+        // Send Chrome DevTools Protocol message
+        const message = JSON.stringify({
+          id: 1,
+          method: 'Runtime.evaluate',
+          params: { expression: this.getMemoryCollectionScript() },
+        });
+        ws.send(message);
+      });
+
+      ws.on('message', (data: Buffer | string) => {
+        clearTimeout(timeout);
+
+        try {
+          const response = JSON.parse(data.toString());
+
+          if (response.result?.result?.value) {
+            const rawData = JSON.parse(response.result.result.value);
+
+            const memoryInfo: MemoryInfo = {
+              heapUsed: rawData.heapUsed,
+              heapTotal: rawData.heapTotal,
+              rss: rawData.rss,
+              external: rawData.external,
+              arrayBuffers: rawData.arrayBuffers,
+              heapLimit:
+                rawData.explicitHeapLimit || rawData.estimatedHeapLimit,
+              heapLimitSource: rawData.explicitHeapLimit
+                ? 'explicit'
+                : 'estimated',
+              architecture: rawData.architecture,
+              platform: rawData.platform,
+              nodeVersion: rawData.nodeVersion,
+              pid: rawData.pid,
+              uptime: rawData.uptime,
+              memoryFlags: rawData.memoryFlags || [],
+              timestamp: rawData.timestamp,
+            };
+
+            resolve(memoryInfo);
+          } else {
+            reject(
+              new Error(
+                'Invalid response format from Chrome DevTools Protocol',
+              ),
+            );
+          }
+        } catch (error) {
+          reject(new Error(`Failed to parse WebSocket response: ${error}`));
+        } finally {
+          ws.close();
+        }
+      });
+
+      ws.on('error', (error: Error) => {
+        clearTimeout(timeout);
+        reject(new Error(`WebSocket error: ${error.message}`));
+      });
+    });
+  }
+
+  displayResults(info: MemoryInfo): void {
+    if (this.outputJson) {
+      console.log(JSON.stringify(info, null, 2));
+      return;
+    }
+
+    const [
+      heapUsedMB,
+      heapTotalMB,
+      heapLimitMB,
+      rssMB,
+      externalMB,
+      arrayBuffersMB,
+    ] = [
+      this.toMB(info.heapUsed),
+      this.toMB(info.heapTotal),
+      this.toMB(info.heapLimit || 0),
+      this.toMB(info.rss),
+      this.toMB(info.external),
+      this.toMB(info.arrayBuffers),
+    ];
+
+    console.log('\n📊 Node.js Memory Information');
+    console.log(''.padEnd(50, '='));
+
+    // Current Memory Usage
+    console.log('\n🔸 Current Memory Usage:');
+    console.log(`  Heap Used:      ${heapUsedMB.toFixed(2)} MB`);
+    console.log(`  Heap Total:     ${heapTotalMB.toFixed(2)} MB`);
+    console.log(`  RSS:            ${rssMB.toFixed(2)} MB`);
+    console.log(`  External:       ${externalMB.toFixed(2)} MB`);
+    console.log(`  Array Buffers:  ${arrayBuffersMB.toFixed(2)} MB`);
+
+    // Heap Limits
+    console.log('\n🔸 Heap Limits:');
+    if (info.heapLimit) {
+      const limitType =
+        info.heapLimitSource === 'explicit'
+          ? 'Explicit Limit'
+          : 'Default Limit';
+      const limitSource =
+        info.heapLimitSource === 'explicit'
+          ? '(from --max-old-space-size)'
+          : '(system default)';
+      console.log(
+        `  ${limitType}: ${heapLimitMB.toFixed(2)} MB ${limitSource}`,
+      );
+      console.log(
+        `  Global Usage:   ${((heapUsedMB / heapLimitMB) * 100).toFixed(2)}% of maximum`,
+      );
+    }
+
+    // Heap Pressure Analysis
+    const heapPressure = (info.heapUsed / info.heapTotal) * 100;
+    const { status: pressureStatus, icon: pressureIcon } =
+      this.getPressureInfo(heapPressure);
+    console.log('\n� Memory Pressure Analysis:');
+    console.log(
+      `  Current Pool:   ${pressureIcon} ${pressureStatus} (${heapPressure.toFixed(1)}% of allocated heap)`,
+    );
+
+    if (heapPressure > 90) {
+      console.log(
+        '  📝 Note: High pressure is normal - Node.js will allocate more heap as needed',
+      );
+    }
+
+    // System Information
+    console.log('\n🔸 System Information:');
+    console.log(`  Architecture:   ${info.architecture}`);
+    console.log(`  Platform:       ${info.platform}`);
+    console.log(`  Node.js:        ${info.nodeVersion}`);
+    console.log(`  Process ID:     ${info.pid}`);
+    console.log(`  Uptime:         ${(info.uptime / 60).toFixed(1)} minutes`);
+
+    // Memory Flags
+    if (info.memoryFlags.length > 0) {
+      console.log('\n🔸 Memory Flags:');
+      info.memoryFlags.forEach((flag) => {
+        console.log(`  ${flag}`);
+      });
+    }
+
+    // Summary
+    console.log('\n📋 Summary:');
+    if (info.heapLimit) {
+      const heapUsagePercent = (heapUsedMB / heapLimitMB) * 100;
+      console.log(
+        `Heap Memory: ${heapUsedMB.toFixed(2)} MB / ${heapLimitMB.toFixed(2)} MB (${heapUsagePercent.toFixed(2)}%)`,
+      );
+      console.log(
+        heapUsagePercent > 80
+          ? '⚠️  Consider increasing heap limit with --max-old-space-size if needed'
+          : '✅ Memory usage is within healthy limits',
+      );
+    }
+
+    console.log(''.padEnd(50, '='));
+    console.log(`Retrieved at: ${new Date(info.timestamp).toLocaleString()}`);
+  }
+}
+
+// Command line interface
+function parseArgs(): {
+  host: string;
+  port: number;
+  json: boolean;
+  help: boolean;
+} {
+  const args = process.argv.slice(2);
+  let host = 'localhost';
+  let port = 9229;
+  let json = false;
+  let help = false;
+
+  for (const arg of args) {
+    if (arg.startsWith('--host=')) {
+      host = arg.split('=')[1];
+    } else if (arg.startsWith('--port=')) {
+      port = parseInt(arg.split('=')[1]);
+    } else if (arg === '--json') {
+      json = true;
+    } else if (arg === '--help' || arg === '-h') {
+      help = true;
+    }
+  }
+
+  return {
+    host,
+    port,
+    json,
+    help,
+  };
+}
+
+function showHelp(): void {
+  console.log(`
+Node.js Memory Checker
+
+Retrieves heap memory information from a running Node.js server via Chrome DevTools Protocol.
+
+Usage:
+  node --experimental-strip-types --experimental-transform-types \\
+       --experimental-detect-module --no-warnings=ExperimentalWarning \\
+       print-memory-consumption.ts [OPTIONS]
+
+Options:
+  --host=HOST    Debug host (default: localhost)
+  --port=PORT    Debug port (default: 9229)
+  --json         Output in JSON format
+  --help, -h     Show this help message
+
+Prerequisites:
+  - Target Node.js server must be started with --inspect flag
+  - WebSocket package: npm install ws @types/ws
+
+Example:
+  # Check memory of server running on default debug port
+  node --experimental-strip-types --experimental-transform-types \\
+       --experimental-detect-module --no-warnings=ExperimentalWarning \\
+       print-memory-consumption.ts
+
+  # Check with custom port and JSON output
+  node --experimental-strip-types --experimental-transform-types \\
+       --experimental-detect-module --no-warnings=ExperimentalWarning \\
+       print-memory-consumption.ts --port=9230 --json
+`);
+}
+
+// Main execution
+async function main(): Promise<void> {
+  const { host, port, json, help } = parseArgs();
+
+  if (help) {
+    showHelp();
+    process.exit(0);
+  }
+
+  const checker = new NodeMemoryConsumptionChecker(host, port, json);
+  const memoryInfo = await checker.checkMemory();
+
+  if (memoryInfo) {
+    checker.displayResults(memoryInfo);
+    process.exit(0);
+  } else {
+    process.exit(1);
+  }
+}
+
+// Execute if called directly
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().catch((error) => {
+    console.error('Fatal error:', error);
+    process.exit(1);
+  });
+}

+ 0 - 14
apps/app/config/cdn.js

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

+ 1 - 1
apps/app/config/migrate-mongo-config.js

@@ -6,7 +6,7 @@
  */
  */
 const isProduction = process.env.NODE_ENV === 'production';
 const isProduction = process.env.NODE_ENV === 'production';
 
 
-const { URL } = require('url');
+const { URL } = require('node:url');
 
 
 const { getMongoUri, mongoOptions } = isProduction
 const { getMongoUri, mongoOptions } = isProduction
   ? // eslint-disable-next-line import/extensions, import/no-unresolved
   ? // eslint-disable-next-line import/extensions, import/no-unresolved

+ 1 - 0
apps/app/config/next-i18next.config.js

@@ -1,5 +1,6 @@
 const isDev = process.env.NODE_ENV === 'development';
 const isDev = process.env.NODE_ENV === 'development';
 
 
+// biome-ignore lint/style/useNodejsImportProtocol: ignore
 const path = require('path');
 const path = require('path');
 
 
 const { AllLang } = require('@growi/core');
 const { AllLang } = require('@growi/core');

+ 2 - 2
apps/app/docker/Dockerfile

@@ -6,7 +6,7 @@ ARG PNPM_HOME="/root/.local/share/pnpm"
 ##
 ##
 ## base
 ## base
 ##
 ##
-FROM node:22-slim AS base
+FROM node:20-slim AS base
 
 
 ARG OPT_DIR
 ARG OPT_DIR
 ARG PNPM_HOME
 ARG PNPM_HOME
@@ -72,7 +72,7 @@ RUN tar -zcf /tmp/packages.tar.gz \
 ##
 ##
 ## release
 ## release
 ##
 ##
-FROM node:22-slim
+FROM node:20-slim
 LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 
 
 ARG OPT_DIR
 ARG OPT_DIR

+ 10 - 10
apps/app/docker/README.md

@@ -2,7 +2,7 @@
 GROWI Official docker image
 GROWI Official docker image
 ========================
 ========================
 
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Release/badge.svg)](https://github.com/weseek/growi/actions) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
+[![Actions Status](https://github.com/growilabs/growi/workflows/Release/badge.svg)](https://github.com/growilabs/growi/actions) [![docker-pulls](https://img.shields.io/docker/pulls/growilabs/growi.svg)](https://hub.docker.com/r/growilabs/growi/) 
 
 
 ![GROWI-x-docker](https://github.com/user-attachments/assets/1a82236d-5a85-4a2e-842a-971b4c1625e6)
 ![GROWI-x-docker](https://github.com/user-attachments/assets/1a82236d-5a85-4a2e-842a-971b4c1625e6)
 
 
@@ -10,17 +10,17 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`7.1.0`, `7.1`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.1.0/apps/app/docker/Dockerfile)
-* [`7.0.23`, `7.0` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.23/apps/app/docker/Dockerfile)
-* [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
+* [`7.1.0`, `7.1`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.1.0/apps/app/docker/Dockerfile)
+* [`7.0.23`, `7.0` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.0.23/apps/app/docker/Dockerfile)
+* [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/growilabs/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 
 
 
 
 What is GROWI?
 What is GROWI?
 -------------
 -------------
 
 
-GROWI is a team collaboration software and it forked from [crowi](https://github.com/weseek/crowi/crowi)
+GROWI is a team collaboration software and it forked from [crowi](https://github.com/crowi/crowi)
 
 
-see: [weseek/growi](https://github.com/weseek/growi)
+see: [growilabs/growi](https://github.com/growilabs/growi)
 
 
 
 
 Requirements
 Requirements
@@ -41,7 +41,7 @@ Usage
 ```bash
 ```bash
 docker run -d \
 docker run -d \
     -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_PORT/growi \
     -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_PORT/growi \
-    weseek/growi
+    growilabs/growi
 ```
 ```
 
 
 and go to `http://localhost:3000/` .
 and go to `http://localhost:3000/` .
@@ -52,7 +52,7 @@ If you use ElasticSearch, type this:
 docker run -d \
 docker run -d \
     -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_PORT/growi \
     -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_PORT/growi \
     -e ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi \
     -e ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/growi \
-    weseek/growi
+    growilabs/growi
 ```
 ```
 
 
 
 
@@ -60,7 +60,7 @@ docker run -d \
 
 
 Using docker-compose is the fastest and the most convenient way to boot GROWI.
 Using docker-compose is the fastest and the most convenient way to boot GROWI.
 
 
-see: [weseek/growi-docker-compose](https://github.com/weseek/growi-docker-compose)
+see: [growilabs/growi-docker-compose](https://github.com/growilabs/growi-docker-compose)
 
 
 
 
 Configuration
 Configuration
@@ -76,5 +76,5 @@ See [GROWI Docs: Admin Guide](https://docs.growi.org/en/admin-guide/) ([en](http
 Issues
 Issues
 ------
 ------
 
 
-If you have any issues or questions about this image, please contact us through  [GitHub issue](https://github.com/weseek/growi-docker/issues).
+If you have any issues or questions about this image, please contact us through  [GitHub issue](https://github.com/growilabs/growi-docker/issues).
 
 

+ 44 - 44
apps/app/docker/codebuild/.terraform.lock.hcl

@@ -2,64 +2,64 @@
 # Manual edits may be lost in future updates.
 # Manual edits may be lost in future updates.
 
 
 provider "registry.terraform.io/hashicorp/aws" {
 provider "registry.terraform.io/hashicorp/aws" {
-  version     = "4.49.0"
-  constraints = "~> 4.16"
+  version     = "6.12.0"
+  constraints = ">= 5.0.0, >= 6.0.0, ~> 6.0"
   hashes = [
   hashes = [
-    "h1:oOwWQpvQWd1uVP1axBz/TO6xzzLWoL982AY/MQfeF7I=",
-    "zh:09803937f00fdf2873eccf685eec7854408925cbf183c9b683df7c5825be463f",
-    "zh:2af1575e538fb0b669266f8d1385b17bfdaf17c521b6b6329baa1f2971fc4a4d",
-    "zh:3f71882b438cde3108fe68cfe2637839d3eed08157a9721bd97babf3912247a8",
-    "zh:577af1b38f5da8a9f29eebe5eebec9279d26e757cd03b0c8c59311f9ce8a859b",
-    "zh:60160d39094973beefb9b10cfd6aaa5b63a2e68c32445ecffcd1b101356e6f9b",
-    "zh:762656454722548baeccf35cbaa23b887976337e1ed321682df7390419fdf22d",
-    "zh:7f6d7887821659bf3bef815949077dc91ffcdb0d911644a887b6683b264a5ca6",
-    "zh:8f16a352cc903f8951fa4619c36233b3e66e27d724817b131f2035dd8896f524",
-    "zh:8f768f65e370366c8b91c00d01c9a6264fe26ea9ae1819f14bdcd12c066272bc",
-    "zh:95ad78c689a83c08ef7c3e544c3c9aca93ed528054aa77cc968ddd9efa3a1023",
+    "h1:QiSzB4pjONZ4hek1L8Rcd6S9vtP+yMr5iOfczJg5/JI=",
+    "zh:054bcbf13c6ac9ddd2247876f82f9b56493e2f71d8c88baeec142386a395165d",
+    "zh:195489f16ad5621db2cec80be997d33060462a3b8d442c890bef3eceba34fa4d",
+    "zh:3461ef14904ab7de246296e44d24c042f3190e6bead3d7ce1d9fda63dcb0f047",
+    "zh:44517a0035996431e4127f45db5a84f53ce80730eae35629eda3101709df1e5c",
+    "zh:4b0374abaa6b9a9debed563380cc944873e4f30771dd1da7b9e812a49bf485e3",
+    "zh:531468b99465bd98a89a4ce2f1a30168dfadf6edb57f7836df8a977a2c4f9804",
+    "zh:6a95ed7b4852174aa748d3412bff3d45e4d7420d12659f981c3d9f4a1a59a35f",
+    "zh:88c2d21af1e64eed4a13dbb85590c66a519f3ecc54b72875d4bb6326f3ef84e7",
     "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
     "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
-    "zh:a47097ab6a4ca8302da82964303ffdd2310ed65e8f8524bfe4058816cf1addb7",
-    "zh:b66d820c70cd5fd628ffe882d2b97e76b969dca4e6827ac2ba0f8d3bc5d6e9c6",
-    "zh:b80f713a4f3e1355c3dd1600e9d08b9f15ed2370054ec792ad2c01f2541abe02",
-    "zh:ce065bc3962cb71fa7652562226b9d486f3d7fcb88285c1020ebe2f550e28641",
+    "zh:a8b648470bb5df098e56b1ec5c6a39e0bbb7b496b23a19ea9f494bf48d4a122a",
+    "zh:b23fb13efdb527677db546bc92aeb2bdf64ff3f480188841f2bfdfa7d3d907c1",
+    "zh:be5858a1951ae5f5a9c388949c3e3c66a3375f684fb79b06b1d1db7a9703b18e",
+    "zh:c368e03a7c922493daf4c7348faafc45f455225815ef218b5491c46cea5f76b7",
+    "zh:e31e75d5d19b8ac08aa01be7e78207966e1faa3b82ed9fe3acfdc2d806be924c",
+    "zh:ea84182343b5fd9252a6fae41e844eed4fdc3311473a753b09f06e49ec0e7853",
   ]
   ]
 }
 }
 
 
 provider "registry.terraform.io/hashicorp/random" {
 provider "registry.terraform.io/hashicorp/random" {
-  version     = "3.4.3"
+  version     = "3.7.2"
   constraints = ">= 2.1.0"
   constraints = ">= 2.1.0"
   hashes = [
   hashes = [
-    "h1:xZGZf18JjMS06pFa4NErzANI98qi59SEcBsOcS2P2yQ=",
-    "zh:41c53ba47085d8261590990f8633c8906696fa0a3c4b384ff6a7ecbf84339752",
-    "zh:59d98081c4475f2ad77d881c4412c5129c56214892f490adf11c7e7a5a47de9b",
-    "zh:686ad1ee40b812b9e016317e7f34c0d63ef837e084dea4a1f578f64a6314ad53",
+    "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
+    "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
+    "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
+    "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
+    "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
+    "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
+    "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
     "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
     "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
-    "zh:84103eae7251384c0d995f5a257c72b0096605048f757b749b7b62107a5dccb3",
-    "zh:8ee974b110adb78c7cd18aae82b2729e5124d8f115d484215fd5199451053de5",
-    "zh:9dd4561e3c847e45de603f17fa0c01ae14cae8c4b7b4e6423c9ef3904b308dda",
-    "zh:bb07bb3c2c0296beba0beec629ebc6474c70732387477a65966483b5efabdbc6",
-    "zh:e891339e96c9e5a888727b45b2e1bb3fcbdfe0fd7c5b4396e4695459b38c8cb1",
-    "zh:ea4739860c24dfeaac6c100b2a2e357106a89d18751f7693f3c31ecf6a996f8d",
-    "zh:f0c76ac303fd0ab59146c39bc121c5d7d86f878e9a69294e29444d4c653786f8",
-    "zh:f143a9a5af42b38fed328a161279906759ff39ac428ebcfe55606e05e1518b93",
+    "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
+    "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
+    "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
+    "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
+    "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
   ]
   ]
 }
 }
 
 
 provider "registry.terraform.io/hashicorp/tls" {
 provider "registry.terraform.io/hashicorp/tls" {
-  version     = "4.0.4"
-  constraints = ">= 3.0.0"
+  version     = "4.1.0"
+  constraints = ">= 4.0.0"
   hashes = [
   hashes = [
-    "h1:pe9vq86dZZKCm+8k1RhzARwENslF3SXb9ErHbQfgjXU=",
-    "zh:23671ed83e1fcf79745534841e10291bbf34046b27d6e68a5d0aab77206f4a55",
-    "zh:45292421211ffd9e8e3eb3655677700e3c5047f71d8f7650d2ce30242335f848",
-    "zh:59fedb519f4433c0fdb1d58b27c210b27415fddd0cd73c5312530b4309c088be",
-    "zh:5a8eec2409a9ff7cd0758a9d818c74bcba92a240e6c5e54b99df68fff312bbd5",
-    "zh:5e6a4b39f3171f53292ab88058a59e64825f2b842760a4869e64dc1dc093d1fe",
-    "zh:810547d0bf9311d21c81cc306126d3547e7bd3f194fc295836acf164b9f8424e",
-    "zh:824a5f3617624243bed0259d7dd37d76017097dc3193dac669be342b90b2ab48",
-    "zh:9361ccc7048be5dcbc2fafe2d8216939765b3160bd52734f7a9fd917a39ecbd8",
-    "zh:aa02ea625aaf672e649296bce7580f62d724268189fe9ad7c1b36bb0fa12fa60",
-    "zh:c71b4cd40d6ec7815dfeefd57d88bc592c0c42f5e5858dcc88245d371b4b8b1e",
-    "zh:dabcd52f36b43d250a3d71ad7abfa07b5622c69068d989e60b79b2bb4f220316",
+    "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
+    "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
+    "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
+    "zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
+    "zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
+    "zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
+    "zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
+    "zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
+    "zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
+    "zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
+    "zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
     "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
     "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+    "zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
   ]
   ]
 }
 }

+ 1 - 1
apps/app/docker/codebuild/buildspec.yml

@@ -11,7 +11,7 @@ phases:
   pre_build:
   pre_build:
     commands:
     commands:
       # login to docker.io
       # login to docker.io
-      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username wsmoogle --password-stdin
+      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username growimoogle --password-stdin
   build:
   build:
     commands:
     commands:
       - docker build -t ${IMAGE_TAG} -f ./apps/app/docker/Dockerfile .
       - docker build -t ${IMAGE_TAG} -f ./apps/app/docker/Dockerfile .

+ 1 - 1
apps/app/docker/codebuild/codebuild.tf

@@ -7,7 +7,7 @@ module "codebuild" {
   artifact_type       = "NO_ARTIFACTS"
   artifact_type       = "NO_ARTIFACTS"
 
 
   source_type         = "GITHUB"
   source_type         = "GITHUB"
-  source_location     = "https://github.com/weseek/growi.git"
+  source_location     = "https://github.com/growilabs/growi.git"
   source_version      = "refs/heads/master"
   source_version      = "refs/heads/master"
   git_clone_depth     = 1
   git_clone_depth     = 1
 
 

+ 1 - 1
apps/app/docker/codebuild/main.tf

@@ -10,7 +10,7 @@ terraform {
   required_providers {
   required_providers {
     aws = {
     aws = {
       source  = "hashicorp/aws"
       source  = "hashicorp/aws"
-      version = "~> 4.16"
+      version = "~> 6.0"
     }
     }
   }
   }
 
 

+ 1 - 1
apps/app/docker/codebuild/oidc.tf

@@ -7,7 +7,7 @@ module "oidc_github" {
   }
   }
 
 
   github_repositories = [
   github_repositories = [
-    "weseek/growi",
+    "growilabs/growi",
   ]
   ]
 }
 }
 
 

+ 2 - 2
apps/app/next.config.js

@@ -5,7 +5,7 @@
  * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
  * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
  */
  */
 
 
-const path = require('path');
+const path = require('node:path');
 
 
 const { withSuperjson } = require('next-superjson');
 const { withSuperjson } = require('next-superjson');
 const {
 const {
@@ -93,7 +93,7 @@ const optimizePackageImports = [
   '@growi/ui',
   '@growi/ui',
 ];
 ];
 
 
-module.exports = async (phase, { defaultConfig }) => {
+module.exports = async (phase) => {
   const { i18n, localePath } = require('./config/next-i18next.config');
   const { i18n, localePath } = require('./config/next-i18next.config');
 
 
   /** @type {import('next').NextConfig} */
   /** @type {import('next').NextConfig} */

+ 8 - 7
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.3.0-RC.0",
+  "version": "7.3.6-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -28,7 +28,7 @@
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
     "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
-    "lint:biome": "biome check",
+    "lint:biome": "biome check --diagnostic-level=error",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
@@ -104,7 +104,7 @@
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "axios-retry": "^3.2.4",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
     "body-parser": "^1.20.3",
@@ -166,7 +166,7 @@
     "mkdirp": "^1.0.3",
     "mkdirp": "^1.0.3",
     "mongodb": "^4.17.2",
     "mongodb": "^4.17.2",
     "mongoose": "^6.13.6",
     "mongoose": "^6.13.6",
-    "mongoose-gridfs": "^1.2.42",
+    "mongoose-gridfs": "^1.3.0",
     "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",
@@ -246,7 +246,7 @@
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "usehooks-ts": "^2.6.0",
     "uuid": "^11.0.3",
     "uuid": "^11.0.3",
-    "validator": "^13.7.0",
+    "validator": "^13.15.20",
     "ws": "^8.17.1",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-mongodb-provider": "^0.2.0",
@@ -273,9 +273,7 @@
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
     "@swc/jest": "^0.2.36",
-    "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/jest-dom": "^6.5.0",
-    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",
     "@types/bunyan": "^1.8.11",
@@ -290,12 +288,14 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/react-stickynode": "^4.0.3",
+    "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unist": "^3.0.3",
     "@types/unist": "^3.0.3",
     "@types/unzip-stream": "^0.3.4",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
     "@types/url-join": "^4.0.2",
     "@types/uuid": "^10.0.0",
     "@types/uuid": "^10.0.0",
+    "@types/ws": "^8.18.1",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
     "bootstrap": "=5.3.2",
     "commander": "^14.0.0",
     "commander": "^14.0.0",
@@ -338,6 +338,7 @@
     "simplebar-react": "^2.3.6",
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
     "source-map-loader": "^4.0.1",
+    "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "swagger2openapi": "^7.0.8",
     "unist-util-is": "^6.0.0",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"
     "unist-util-visit-parents": "^6.0.0"

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

@@ -1,6 +1,5 @@
 import fs from 'node:fs';
 import fs from 'node:fs';
 import path from 'node:path';
 import path from 'node:path';
-
 import { defineConfig, devices, type Project } from '@playwright/test';
 import { defineConfig, devices, type Project } from '@playwright/test';
 
 
 const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');
 const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');

+ 13 - 5
apps/app/playwright/10-installer/install.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Installer', async({ page }) => {
+test('Installer', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   await page.waitForURL('/installer');
   await page.waitForURL('/installer');
 
 
@@ -11,18 +11,26 @@ test('Installer', async({ page }) => {
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
   await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
   await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toBeVisible();
   await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toHaveAttribute('placeholder', 'ユーザーID');
+  await expect(
+    page.getByRole('textbox', { name: 'ユーザーID' }),
+  ).toHaveAttribute('placeholder', 'ユーザーID');
 
 
   // choose Chinese
   // choose Chinese
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
   await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
   await expect(page.getByRole('textbox', { name: '用户ID' })).toBeVisible();
   await expect(page.getByRole('textbox', { name: '用户ID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute('placeholder', '用户ID');
+  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute(
+    'placeholder',
+    '用户ID',
+  );
   // // choose English
   // // choose English
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-en_US').click();
   await page.getByTestId('dropdownLanguageMenu-en_US').click();
   await expect(page.getByRole('textbox', { name: 'User ID' })).toBeVisible();
   await expect(page.getByRole('textbox', { name: 'User ID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute('placeholder', 'User ID');
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute(
+    'placeholder',
+    'User ID',
+  );
 
 
   await page.getByRole('textbox', { name: 'User ID' }).focus();
   await page.getByRole('textbox', { name: 'User ID' }).focus();
 
 

+ 101 - 73
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -1,32 +1,36 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
 
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 };
 
 
-test('has title', async({ page }) => {
+test('has title', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   // Expect a title "to contain" a substring.
   // Expect a title "to contain" a substring.
   await expect(page).toHaveTitle(/Sandbox/);
   await expect(page).toHaveTitle(/Sandbox/);
 });
 });
 
 
-test('get h1', async({ page }) => {
+test('get h1', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   // Expects page to have a heading with the name of Installation.
   // Expects page to have a heading with the name of Installation.
-  await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
+  await expect(
+    page.getByRole('heading').filter({ hasText: /\/Sandbox/ }),
+  ).toBeVisible();
 });
 });
 
 
-test('/Sandbox/Math is successfully loaded', async({ page }) => {
+test('/Sandbox/Math is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox/Math');
   await page.goto('/Sandbox/Math');
 
 
   // Expect the Math-specific elements to be present
   // Expect the Math-specific elements to be present
   await expect(page.locator('.katex').first()).toBeVisible();
   await expect(page.locator('.katex').first()).toBeVisible();
 });
 });
 
 
-test('Sandbox with edit is successfully loaded', async({ page }) => {
+test('Sandbox with edit is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox#edit');
   await page.goto('/Sandbox#edit');
 
 
   // Expect the Editor-specific elements to be present
   // Expect the Editor-specific elements to be present
@@ -35,116 +39,140 @@ test('Sandbox with edit is successfully loaded', async({ page }) => {
   await expect(page.getByTestId('grw-grant-selector')).toBeVisible();
   await expect(page.getByTestId('grw-grant-selector')).toBeVisible();
 });
 });
 
 
-test.describe.serial('PageEditor', () => {
-  const body1 = 'hello';
-  const body2 = ' world!';
-  const targetPath = '/Sandbox/testForUseEditingMarkdown';
+test.describe
+  .serial('PageEditor', () => {
+    const body1 = 'hello';
+    const body2 = ' world!';
+    const targetPath = '/Sandbox/testForUseEditingMarkdown';
 
 
-  test('Edit and save with save-page-btn', async({ page }) => {
-    await page.goto(targetPath);
+    test('Edit and save with save-page-btn', async ({ page }) => {
+      await page.goto(targetPath);
 
 
-    await page.getByTestId('editor-button').click();
-    await appendTextToEditorUntilContains(page, body1);
-    await page.getByTestId('save-page-btn').click();
+      await page.getByTestId('editor-button').click();
+      await appendTextToEditorUntilContains(page, body1);
+      await page.getByTestId('save-page-btn').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(body1);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(body1);
+    });
 
 
-  test('Edit and save with shortcut key', async({ page }) => {
-    const savePageShortcutKey = 'Control+s';
+    test('Edit and save with shortcut key', async ({ page }) => {
+      const savePageShortcutKey = 'Control+s';
 
 
-    await page.goto(targetPath);
+      await page.goto(targetPath);
 
 
-    await page.getByTestId('editor-button').click();
+      await page.getByTestId('editor-button').click();
 
 
-    await expect(page.locator('.cm-content')).toContainText(body1);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(body1);
+      await expect(page.locator('.cm-content')).toContainText(body1);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        body1,
+      );
 
 
-    await appendTextToEditorUntilContains(page, body1 + body2);
-    await page.keyboard.press(savePageShortcutKey);
-    await page.getByTestId('view-button').click();
+      await appendTextToEditorUntilContains(page, body1 + body2);
+      await page.keyboard.press(savePageShortcutKey);
+      await page.getByTestId('view-button').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+      await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+    });
   });
   });
-});
 
 
-test('Access to /me page', async({ page }) => {
+test('Access to /me page', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 });
 
 
-test('All In-App Notification list is successfully loaded', async({ page }) => {
+test('All In-App Notification list is successfully loaded', async ({
+  page,
+}) => {
   await page.goto('/me/all-in-app-notifications');
   await page.goto('/me/all-in-app-notifications');
 
 
   // Expect the In-App Notification-specific elements to be present when accessing /me/all-in-app-notifications
   // Expect the In-App Notification-specific elements to be present when accessing /me/all-in-app-notifications
   await expect(page.getByTestId('grw-in-app-notification-page')).toBeVisible();
   await expect(page.getByTestId('grw-in-app-notification-page')).toBeVisible();
 });
 });
 
 
-test('/trash is successfully loaded', async({ page }) => {
+test('/trash is successfully loaded', async ({ page }) => {
   await page.goto('/trash');
   await page.goto('/trash');
 
 
-  await expect(page.getByTestId('trash-page-list')).toContainText('There are no pages under this page.');
+  await expect(page.getByTestId('trash-page-list')).toContainText(
+    'There are no pages under this page.',
+  );
 });
 });
 
 
-test('/tags is successfully loaded', async({ page }) => {
+test('/tags is successfully loaded', async ({ page }) => {
   await page.goto('/tags');
   await page.goto('/tags');
 
 
-  await expect(page.getByTestId('grw-tags-list')).toContainText('You have no tag, You can set tags on pages');
+  await expect(page.getByTestId('grw-tags-list')).toContainText(
+    'You have no tag, You can set tags on pages',
+  );
 });
 });
 
 
-test.describe.serial('Access to Template Editing Mode', () => {
-  const templateBody1 = 'Template for children';
-  const templateBody2 = 'Template for descendants';
+test.describe
+  .serial('Access to Template Editing Mode', () => {
+    const templateBody1 = 'Template for children';
+    const templateBody2 = 'Template for descendants';
 
 
-  test('Successfully created template for children', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Successfully created template for children', async ({ page }) => {
+      await page.goto('/Sandbox');
 
 
-    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
-    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-template-modal-btn').click();
-    expect(page.getByTestId('page-template-modal')).toBeVisible();
+      await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+      await page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('open-page-item-control-btn')
+        .click();
+      await page.getByTestId('open-page-template-modal-btn').click();
+      expect(page.getByTestId('page-template-modal')).toBeVisible();
 
 
-    await page.getByTestId('template-button-children').click();
+      await page.getByTestId('template-button-children').click();
 
 
-    await appendTextToEditorUntilContains(page, templateBody1);
-    await page.getByTestId('save-page-btn').click();
+      await appendTextToEditorUntilContains(page, templateBody1);
+      await page.getByTestId('save-page-btn').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(templateBody1);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(templateBody1);
+    });
 
 
-  test('Template is applied to pages created (template for children)', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Template is applied to pages created (template for children)', async ({
+      page,
+    }) => {
+      await page.goto('/Sandbox');
 
 
-    await page.getByTestId('grw-page-create-button').click();
+      await page.getByTestId('grw-page-create-button').click();
 
 
-    await expect(page.locator('.cm-content')).toContainText(templateBody1);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody1);
-  });
+      await expect(page.locator('.cm-content')).toContainText(templateBody1);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        templateBody1,
+      );
+    });
 
 
-  test('Successfully created template for descendants', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Successfully created template for descendants', async ({ page }) => {
+      await page.goto('/Sandbox');
 
 
-    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
-    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-template-modal-btn').click();
-    expect(page.getByTestId('page-template-modal')).toBeVisible();
+      await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+      await page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('open-page-item-control-btn')
+        .click();
+      await page.getByTestId('open-page-template-modal-btn').click();
+      expect(page.getByTestId('page-template-modal')).toBeVisible();
 
 
-    await page.getByTestId('template-button-descendants').click();
+      await page.getByTestId('template-button-descendants').click();
 
 
-    await appendTextToEditorUntilContains(page, templateBody2);
-    await page.getByTestId('save-page-btn').click();
+      await appendTextToEditorUntilContains(page, templateBody2);
+      await page.getByTestId('save-page-btn').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(templateBody2);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(templateBody2);
+    });
 
 
-  test('Template is applied to pages created (template for descendants)', async({ page }) => {
-    await page.goto('/Sandbox/Bootstrap5');
+    test('Template is applied to pages created (template for descendants)', async ({
+      page,
+    }) => {
+      await page.goto('/Sandbox/Bootstrap5');
 
 
-    await page.getByTestId('grw-page-create-button').click();
+      await page.getByTestId('grw-page-create-button').click();
 
 
-    await expect(page.locator('.cm-content')).toContainText(templateBody2);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody2);
+      await expect(page.locator('.cm-content')).toContainText(templateBody2);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        templateBody2,
+      );
+    });
   });
   });
-});

+ 17 - 9
apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts

@@ -1,29 +1,37 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
 
-const openPageAccessoriesModal = async(page: Page): Promise<void> => {
+const openPageAccessoriesModal = async (page: Page): Promise<void> => {
   await page.goto('/');
   await page.goto('/');
   await page.getByTestId('pageListButton').click();
   await page.getByTestId('pageListButton').click();
   await expect(page.getByTestId('descendants-page-list-modal')).toBeVisible();
   await expect(page.getByTestId('descendants-page-list-modal')).toBeVisible();
 };
 };
 
 
-test('Page list modal is successfully opened', async({ page }) => {
+test('Page list modal is successfully opened', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
-  await expect(page.getByTestId('page-list-item-L').first()).not.toContainText('You cannot see this page');
+  await expect(page.getByTestId('page-list-item-L').first()).not.toContainText(
+    'You cannot see this page',
+  );
 });
 });
 
 
-test('Successfully open PageItemControl', async({ page }) => {
+test('Successfully open PageItemControl', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
-  await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+  await page
+    .getByTestId('page-list-item-L')
+    .first()
+    .getByTestId('open-page-item-control-btn')
+    .click();
   await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   await expect(page.locator('.dropdown-menu.show')).toBeVisible();
 });
 });
 
 
-test('Successfully close modal', async({ page }) => {
+test('Successfully close modal', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
   await page.locator('.btn-close').click();
   await page.locator('.btn-close').click();
-  await expect(page.getByTestId('descendants-page-list-modal')).not.toBeVisible();
+  await expect(
+    page.getByTestId('descendants-page-list-modal'),
+  ).not.toBeVisible();
 });
 });
 
 
-test('Timeline list successfully openend', async({ page }) => {
+test('Timeline list successfully openend', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
   await page.getByTestId('timeline-tab-button').click();
   await page.getByTestId('timeline-tab-button').click();
   await expect(page.locator('.card-timeline').first()).toBeVisible();
   await expect(page.locator('.card-timeline').first()).toBeVisible();

+ 10 - 7
apps/app/playwright/20-basic-features/click-page-icons.spec.ts

@@ -1,11 +1,11 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Click page icons', () => {
 test.describe('Click page icons', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/Sandbox');
     await page.goto('/Sandbox');
   });
   });
 
 
-  test('Successfully Subscribe/Unsubscribe a page', async({ page }) => {
+  test('Successfully Subscribe/Unsubscribe a page', async ({ page }) => {
     const subscribeButton = page.locator('.btn-subscribe');
     const subscribeButton = page.locator('.btn-subscribe');
 
 
     // Subscribe
     // Subscribe
@@ -17,7 +17,7 @@ test.describe('Click page icons', () => {
     await expect(subscribeButton).not.toHaveClass(/active/);
     await expect(subscribeButton).not.toHaveClass(/active/);
   });
   });
 
 
-  test('Successfully Like/Unlike a page', async({ page }) => {
+  test('Successfully Like/Unlike a page', async ({ page }) => {
     const likeButton = page.locator('.btn-like').first();
     const likeButton = page.locator('.btn-like').first();
 
 
     // Like
     // Like
@@ -29,7 +29,7 @@ test.describe('Click page icons', () => {
     await expect(likeButton).not.toHaveClass(/active/);
     await expect(likeButton).not.toHaveClass(/active/);
   });
   });
 
 
-  test('Successfully Bookmark / Unbookmark a page', async({ page }) => {
+  test('Successfully Bookmark / Unbookmark a page', async ({ page }) => {
     const bookmarkButton = page.locator('.btn-bookmark').first();
     const bookmarkButton = page.locator('.btn-bookmark').first();
 
 
     // Bookmark
     // Bookmark
@@ -41,10 +41,13 @@ test.describe('Click page icons', () => {
     await expect(bookmarkButton).not.toHaveClass(/active/);
     await expect(bookmarkButton).not.toHaveClass(/active/);
   });
   });
 
 
-  test('Successfully display list of "seen by user"', async({ page }) => {
+  test('Successfully display list of "seen by user"', async ({ page }) => {
     await page.locator('.btn-seen-user').click();
     await page.locator('.btn-seen-user').click();
 
 
-    const imgCount = await page.locator('.user-list-content').locator('img').count();
+    const imgCount = await page
+      .locator('.user-list-content')
+      .locator('img')
+      .count();
     expect(imgCount).toBe(1);
     expect(imgCount).toBe(1);
   });
   });
 });
 });

+ 13 - 9
apps/app/playwright/20-basic-features/comments.spec.ts

@@ -1,18 +1,17 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Comment', () => {
 test.describe('Comment', () => {
-
   // make tests run in serial
   // make tests run in serial
   test.describe.configure({ mode: 'serial' });
   test.describe.configure({ mode: 'serial' });
 
 
-  test('Create comment page', async({ page }) => {
+  test('Create comment page', async ({ page }) => {
     await page.goto('/comment');
     await page.goto('/comment');
     await page.getByTestId('editor-button').click();
     await page.getByTestId('editor-button').click();
     await page.getByTestId('save-page-btn').click();
     await page.getByTestId('save-page-btn').click();
     await expect(page.locator('.page-meta')).toBeVisible();
     await expect(page.locator('.page-meta')).toBeVisible();
   });
   });
 
 
-  test('Successfully add comments', async({ page }) => {
+  test('Successfully add comments', async ({ page }) => {
     const commentText = 'add comment';
     const commentText = 'add comment';
     await page.goto('/comment');
     await page.goto('/comment');
 
 
@@ -23,10 +22,12 @@ test.describe('Comment', () => {
     await page.getByTestId('comment-submit-button').first().click();
     await page.getByTestId('comment-submit-button').first().click();
 
 
     await expect(page.locator('.page-comment-body')).toHaveText(commentText);
     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.getByTestId('page-comment-button').locator('.grw-count-badge'),
+    ).toHaveText('1');
   });
   });
 
 
-  test('Successfully reply comments', async({ page }) => {
+  test('Successfully reply comments', async ({ page }) => {
     const commentText = 'reply comment';
     const commentText = 'reply comment';
     await page.goto('/comment');
     await page.goto('/comment');
 
 
@@ -35,8 +36,12 @@ test.describe('Comment', () => {
     await page.locator('.cm-content').fill(commentText);
     await page.locator('.cm-content').fill(commentText);
     await page.getByTestId('comment-submit-button').first().click();
     await page.getByTestId('comment-submit-button').first().click();
 
 
-    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 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 delete comments', async({ page }) => {
   // test('Successfully delete comments', async({ page }) => {
@@ -51,5 +56,4 @@ test.describe('Comment', () => {
   // });
   // });
 
 
   // TODO: https://redmine.weseek.co.jp/issues/139520
   // TODO: https://redmine.weseek.co.jp/issues/139520
-
 });
 });

+ 16 - 6
apps/app/playwright/20-basic-features/create-page-button.spec.ts

@@ -1,10 +1,13 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Create page button', () => {
 test.describe('Create page button', () => {
-  test('click and autofocus to title text input', async({ page }) => {
+  test('click and autofocus to title text input', async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
 
 
-    await page.getByTestId('grw-page-create-button').getByRole('button', { name: 'Create' }).click();
+    await page
+      .getByTestId('grw-page-create-button')
+      .getByRole('button', { name: 'Create' })
+      .click();
 
 
     // should be focused
     // should be focused
     await expect(page.getByPlaceholder('Input page name')).toBeFocused();
     await expect(page.getByPlaceholder('Input page name')).toBeFocused();
@@ -12,13 +15,20 @@ test.describe('Create page button', () => {
 });
 });
 
 
 test.describe('Create page button dropdown menu', () => {
 test.describe('Create page button dropdown menu', () => {
-  test('open and create today page', async({ page }) => {
+  test('open and create today page', async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
 
 
     // open dropdown menu
     // open dropdown menu
     await page.getByTestId('grw-page-create-button').hover();
     await page.getByTestId('grw-page-create-button').hover();
-    await expect(page.getByTestId('grw-page-create-button').getByLabel('Open create page menu')).toBeVisible();
-    await page.getByTestId('grw-page-create-button').getByLabel('Open create page menu').dispatchEvent('click'); // simulate the click
+    await expect(
+      page
+        .getByTestId('grw-page-create-button')
+        .getByLabel('Open create page menu'),
+    ).toBeVisible();
+    await page
+      .getByTestId('grw-page-create-button')
+      .getByLabel('Open create page menu')
+      .dispatchEvent('click'); // simulate the click
     await page.getByRole('menuitem', { name: 'Create today page' }).click();
     await page.getByRole('menuitem', { name: 'Create today page' }).click();
 
 
     // should not be visible
     // should not be visible

+ 9 - 5
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -1,13 +1,17 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Presentation', async({ page }) => {
+test('Presentation', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
 
 
   // show presentation modal
   // show presentation modal
-  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page
+    .getByTestId('grw-contextual-sub-nav')
+    .getByTestId('open-page-item-control-btn')
+    .click();
   await page.getByTestId('open-presentation-modal-btn').click();
   await page.getByTestId('open-presentation-modal-btn').click();
 
 
   // check the content of the h1
   // check the content of the h1
-  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
-    .toHaveText(/Welcome to GROWI/);
+  await expect(
+    page.getByRole('application').getByRole('heading', { level: 1 }),
+  ).toHaveText(/Welcome to GROWI/);
 });
 });

+ 35 - 13
apps/app/playwright/20-basic-features/sticky-features.spec.ts

@@ -1,47 +1,69 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Sticky features', () => {
 test.describe('Sticky features', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
   });
   });
 
 
-  test('Subnavigation displays changes on scroll down and up', async({ page }) => {
+  test('Subnavigation displays changes on scroll down and up', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Scroll back to top
     // Scroll back to top
     await page.evaluate(() => window.scrollTo(0, 0));
     await page.evaluate(() => window.scrollTo(0, 0));
-    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+      /active/,
+    );
   });
   });
 
 
-  test('Subnavigation is not displayed when move to other pages', async({ page }) => {
+  test('Subnavigation is not displayed when move to other pages', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Move to /Sandbox page
     // Move to /Sandbox page
     await page.goto('/Sandbox');
     await page.goto('/Sandbox');
-    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+      /active/,
+    );
   });
   });
 
 
-  test('Able to click buttons on subnavigation switcher when sticky', async({ page }) => {
+  test('Able to click buttons on subnavigation switcher when sticky', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Click editor button
     // Click editor button
     await page.getByTestId('editor-button').click();
     await page.getByTestId('editor-button').click();
     await expect(page.locator('.layout-root')).toHaveClass(/editing/);
     await expect(page.locator('.layout-root')).toHaveClass(/editing/);
   });
   });
 
 
-  test('Subnavigation is sticky when on small window', async({ page }) => {
+  test('Subnavigation is sticky when on small window', async ({ page }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 500));
     await page.evaluate(() => window.scrollTo(0, 500));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Set viewport to small size
     // Set viewport to small size
     await page.setViewportSize({ width: 600, height: 1024 });
     await page.setViewportSize({ width: 600, height: 1024 });
-    await expect(page.getByTestId('grw-contextual-sub-nav').getByTestId('grw-page-editor-mode-manager')).toBeVisible();
+    await expect(
+      page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('grw-page-editor-mode-manager'),
+    ).toBeVisible();
   });
   });
 });
 });

+ 25 - 15
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
 
-const openPageItemControl = async(page: Page): Promise<void> => {
+const openPageItemControl = async (page: Page): Promise<void> => {
   const nav = page.getByTestId('grw-contextual-sub-nav');
   const nav = page.getByTestId('grw-contextual-sub-nav');
   const button = nav.getByTestId('open-page-item-control-btn');
   const button = nav.getByTestId('open-page-item-control-btn');
 
 
@@ -19,7 +19,7 @@ const openPageItemControl = async(page: Page): Promise<void> => {
   await button.click();
   await button.click();
 };
 };
 
 
-test('PageDeleteModal is shown successfully', async({ page }) => {
+test('PageDeleteModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   await openPageItemControl(page);
   await openPageItemControl(page);
@@ -28,7 +28,7 @@ test('PageDeleteModal is shown successfully', async({ page }) => {
   await expect(page.getByTestId('page-delete-modal')).toBeVisible();
   await expect(page.getByTestId('page-delete-modal')).toBeVisible();
 });
 });
 
 
-test('PageDuplicateModal is shown successfully', async({ page }) => {
+test('PageDuplicateModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   await openPageItemControl(page);
   await openPageItemControl(page);
@@ -37,7 +37,7 @@ test('PageDuplicateModal is shown successfully', async({ page }) => {
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
 });
 });
 
 
-test('PageMoveRenameModal is shown successfully', async({ page }) => {
+test('PageMoveRenameModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   await openPageItemControl(page);
   await openPageItemControl(page);
@@ -57,35 +57,45 @@ test('PageMoveRenameModal is shown successfully', async({ page }) => {
 // });
 // });
 
 
 test.describe('Page Accessories Modal', () => {
 test.describe('Page Accessories Modal', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
     await openPageItemControl(page);
     await openPageItemControl(page);
   });
   });
 
 
-  test('Page History is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-history-tab').click();
-    await expect(page.getByTestId(('page-history'))).toBeVisible();
+  test('Page History is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId('open-page-accessories-modal-btn-with-history-tab')
+      .click();
+    await expect(page.getByTestId('page-history')).toBeVisible();
   });
   });
 
 
-  test('Page Attachment Data is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab').click();
+  test('Page Attachment Data is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab')
+      .click();
     await expect(page.getByTestId('page-attachment')).toBeVisible();
     await expect(page.getByTestId('page-attachment')).toBeVisible();
   });
   });
 
 
-  test('Share Link Management is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
+  test('Share Link Management is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId(
+        'open-page-accessories-modal-btn-with-share-link-management-data-tab',
+      )
+      .click();
     await expect(page.getByTestId('share-link-management')).toBeVisible();
     await expect(page.getByTestId('share-link-management')).toBeVisible();
   });
   });
 });
 });
 
 
-test('Successfully add new tag', async({ page }) => {
+test('Successfully add new tag', async ({ page }) => {
   const tag = 'we';
   const tag = 'we';
   await page.goto('/Sandbox/Bootstrap5');
   await page.goto('/Sandbox/Bootstrap5');
 
 
   await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
   await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
   await expect(page.locator('#edit-tag-modal')).toBeVisible();
   await expect(page.locator('#edit-tag-modal')).toBeVisible();
   await page.locator('.rbt-input-main').fill(tag);
   await page.locator('.rbt-input-main').fill(tag);
-  await expect(page.locator('#tag-typeahead-asynctypeahead-item-0')).toBeVisible();
+  await expect(
+    page.locator('#tag-typeahead-asynctypeahead-item-0'),
+  ).toBeVisible();
   await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
   await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
   await page.getByTestId('tag-edit-done-btn').click();
   await page.getByTestId('tag-edit-done-btn').click();
   await expect(page.getByTestId('grw-tag-labels')).toContainText(tag);
   await expect(page.getByTestId('grw-tag-labels')).toContainText(tag);

+ 9 - 9
apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts

@@ -1,45 +1,45 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 import { collapseSidebar } from '../utils';
 import { collapseSidebar } from '../utils';
 
 
-test('/Sandbox is successfully loaded', async({ page }) => {
-
+test('/Sandbox is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   // Expect a title "to contain" a substring.
   // Expect a title "to contain" a substring.
   await expect(page).toHaveTitle(/Sandbox/);
   await expect(page).toHaveTitle(/Sandbox/);
 });
 });
 
 
-test('/Sandbox/math is successfully loaded', async({ page }) => {
-
+test('/Sandbox/math is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox/Math');
   await page.goto('/Sandbox/Math');
 
 
   // Check if the math elements are visible
   // Check if the math elements are visible
   await expect(page.locator('.katex').first()).toBeVisible();
   await expect(page.locator('.katex').first()).toBeVisible();
 });
 });
 
 
-test('Access to /me page', async({ page }) => {
+test('Access to /me page', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Expect to be redirected to /login when accessing /me
   // Expect to be redirected to /login when accessing /me
   await expect(page.getByTestId('login-form')).toBeVisible();
   await expect(page.getByTestId('login-form')).toBeVisible();
 });
 });
 
 
-test('Access to /trash page', async({ page }) => {
+test('Access to /trash page', async ({ page }) => {
   await page.goto('/trash');
   await page.goto('/trash');
 
 
   // Expect the trash page specific elements to be present when accessing /trash
   // Expect the trash page specific elements to be present when accessing /trash
   await expect(page.getByTestId('trash-page-list')).toBeVisible();
   await expect(page.getByTestId('trash-page-list')).toBeVisible();
 });
 });
 
 
-test('Access to /tags page', async({ page }) => {
+test('Access to /tags page', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
 
 
   await collapseSidebar(page, false);
   await collapseSidebar(page, false);
   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
   await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
   await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
   await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
   await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
-  await expect(page.getByTestId('grw-tags-list').first()).toContainText('You have no tag, You can set tags on pages');
+  await expect(page.getByTestId('grw-tags-list').first()).toContainText(
+    'You have no tag, You can set tags on pages',
+  );
 
 
   await page.getByTestId('check-all-tags-button').click();
   await page.getByTestId('check-all-tags-button').click();
   await expect(page.getByTestId('tags-page')).toBeVisible();
   await expect(page.getByTestId('tags-page')).toBeVisible();

+ 10 - 5
apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts

@@ -1,14 +1,19 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-
-test('Sub navigation sticky changes when scrolling down and up', async({ page }) => {
+test('Sub navigation sticky changes when scrolling down and up', async ({
+  page,
+}) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   // Sticky
   // Sticky
   await page.evaluate(() => window.scrollTo(0, 250));
   await page.evaluate(() => window.scrollTo(0, 250));
-  await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+  await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+    /active/,
+  );
 
 
   // Not sticky
   // Not sticky
   await page.evaluate(() => window.scrollTo(0, 0));
   await page.evaluate(() => window.scrollTo(0, 0));
-  await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+  await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+    /active/,
+  );
 });
 });

+ 38 - 33
apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts

@@ -1,37 +1,42 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 import { login } from '../utils/Login';
 import { login } from '../utils/Login';
 
 
-test.describe.serial('Access to sharelink by guest', () => {
-  let createdSharelink: string | null;
-
-  test('Prepare sharelink', async({ page }) => {
-    await page.goto('/Sandbox/Bootstrap5');
-
-    // Create Sharelink
-    await page.getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
-    await page.getByTestId('btn-sharelink-toggleform').click();
-    await page.getByTestId('btn-sharelink-issue').click();
-
-    // Get ShareLink
-    createdSharelink = await page.getByTestId('share-link').textContent();
-    expect(createdSharelink).toHaveLength(24);
-  });
-
-  test('The sharelink page is successfully loaded', async({ page }) => {
-    await page.goto('/');
-
-    // Logout
-    await page.getByTestId('personal-dropdown-button').click();
-    await expect(page.getByTestId('logout-button')).toBeVisible();
-    await page.getByTestId('logout-button').click();
-    await page.waitForURL('http://localhost:3000/login');
-
-    // Access sharelink
-    await page.goto(`/share/${createdSharelink}`);
-    await expect(page.locator('.page-meta')).toBeVisible();
-
-    await login(page);
+test.describe
+  .serial('Access to sharelink by guest', () => {
+    let createdSharelink: string | null;
+
+    test('Prepare sharelink', async ({ page }) => {
+      await page.goto('/Sandbox/Bootstrap5');
+
+      // Create Sharelink
+      await page.getByTestId('open-page-item-control-btn').click();
+      await page
+        .getByTestId(
+          'open-page-accessories-modal-btn-with-share-link-management-data-tab',
+        )
+        .click();
+      await page.getByTestId('btn-sharelink-toggleform').click();
+      await page.getByTestId('btn-sharelink-issue').click();
+
+      // Get ShareLink
+      createdSharelink = await page.getByTestId('share-link').textContent();
+      expect(createdSharelink).toHaveLength(24);
+    });
+
+    test('The sharelink page is successfully loaded', async ({ page }) => {
+      await page.goto('/');
+
+      // Logout
+      await page.getByTestId('personal-dropdown-button').click();
+      await expect(page.getByTestId('logout-button')).toBeVisible();
+      await page.getByTestId('logout-button').click();
+      await page.waitForURL('http://localhost:3000/login');
+
+      // Access sharelink
+      await page.goto(`/share/${createdSharelink}`);
+      await expect(page.locator('.page-meta')).toBeVisible();
+
+      await login(page);
+    });
   });
   });
-});

+ 13 - 9
apps/app/playwright/23-editor/saving.spec.ts

@@ -1,14 +1,14 @@
+import { expect, type Page, test } from '@playwright/test';
 import path from 'path';
 import path from 'path';
 
 
-import { test, expect, type Page } from '@playwright/test';
-
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 };
 
 
-
-test('Successfully create page under specific path', async({ page }) => {
+test('Successfully create page under specific path', async ({ page }) => {
   const newPagePath = '/child';
   const newPagePath = '/child';
   const openPageCreateModalShortcutKey = 'c';
   const openPageCreateModalShortcutKey = 'c';
 
 
@@ -16,7 +16,10 @@ test('Successfully create page under specific path', async({ page }) => {
 
 
   await page.keyboard.press(openPageCreateModalShortcutKey);
   await page.keyboard.press(openPageCreateModalShortcutKey);
   await expect(page.getByTestId('page-create-modal')).toBeVisible();
   await expect(page.getByTestId('page-create-modal')).toBeVisible();
-  page.getByTestId('page-create-modal').locator('.rbt-input-main').fill(newPagePath);
+  page
+    .getByTestId('page-create-modal')
+    .locator('.rbt-input-main')
+    .fill(newPagePath);
   page.getByTestId('btn-create-page-under-below').click();
   page.getByTestId('btn-create-page-under-below').click();
   await page.getByTestId('view-button').click();
   await page.getByTestId('view-button').click();
 
 
@@ -24,8 +27,9 @@ test('Successfully create page under specific path', async({ page }) => {
   expect(createdPageId.length).toBe(24);
   expect(createdPageId.length).toBe(24);
 });
 });
 
 
-
-test('Successfully updating a page using a shortcut on a previously created page', async({ page }) => {
+test('Successfully updating a page using a shortcut on a previously created page', async ({
+  page,
+}) => {
   const body1 = 'hello';
   const body1 = 'hello';
   const body2 = ' world!';
   const body2 = ' world!';
   const savePageShortcutKey = 'Control+s';
   const savePageShortcutKey = 'Control+s';

+ 8 - 4
apps/app/playwright/23-editor/template-modal.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Successfully select template and template locale', async({ page }) => {
+test('Successfully select template and template locale', async ({ page }) => {
   const jaText = '今日の目標';
   const jaText = '今日の目標';
   const enText = "TODAY'S GOALS";
   const enText = "TODAY'S GOALS";
   await page.goto('/Sandbox/TemplateModal');
   await page.goto('/Sandbox/TemplateModal');
@@ -16,10 +16,14 @@ test('Successfully select template and template locale', async({ page }) => {
 
 
   // select template and template locale
   // select template and template locale
   await templateModal.locator('.list-group-item').nth(0).click();
   await templateModal.locator('.list-group-item').nth(0).click();
-  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(enText);
+  await expect(
+    templateModal.locator('.card-body').locator('.has-data-line').nth(1),
+  ).toHaveText(enText);
   await templateModal.getByTestId('select-locale-dropdown-toggle').click();
   await templateModal.getByTestId('select-locale-dropdown-toggle').click();
   await templateModal.getByTestId('select-locale-dropdown-item').nth(1).click();
   await templateModal.getByTestId('select-locale-dropdown-item').nth(1).click();
-  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(jaText);
+  await expect(
+    templateModal.locator('.card-body').locator('.has-data-line').nth(1),
+  ).toHaveText(jaText);
 
 
   // insert
   // insert
   await templateModal.locator('.btn-primary').click();
   await templateModal.locator('.btn-primary').click();

+ 31 - 17
apps/app/playwright/23-editor/with-navigation.spec.ts

@@ -1,14 +1,15 @@
+import { expect, type Page, test } from '@playwright/test';
 import { readFileSync } from 'fs';
 import { readFileSync } from 'fs';
 import path from 'path';
 import path from 'path';
 
 
-import { test, expect, type Page } from '@playwright/test';
-
 /**
 /**
  * for the issues:
  * for the issues:
  * @see https://redmine.weseek.co.jp/issues/122040
  * @see https://redmine.weseek.co.jp/issues/122040
  * @see https://redmine.weseek.co.jp/issues/124281
  * @see https://redmine.weseek.co.jp/issues/124281
  */
  */
-test('should not be cleared and should prevent GrantSelector from modified', async({ page }) => {
+test('should not be cleared and should prevent GrantSelector from modified', async ({
+  page,
+}) => {
   await page.goto('/Sandbox/for-122040');
   await page.goto('/Sandbox/for-122040');
 
 
   // Open Editor
   // Open Editor
@@ -26,10 +27,10 @@ test('should not be cleared and should prevent GrantSelector from modified', asy
   const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
   const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
   const buffer = readFileSync(filePath).toString('base64');
   const buffer = readFileSync(filePath).toString('base64');
   const dataTransfer = await page.evaluateHandle(
   const dataTransfer = await page.evaluateHandle(
-    async({ bufferData, localFileName, localFileType }) => {
+    async ({ bufferData, localFileName, localFileType }) => {
       const dt = new DataTransfer();
       const dt = new DataTransfer();
 
 
-      const blobData = await fetch(bufferData).then(res => res.blob());
+      const blobData = await fetch(bufferData).then((res) => res.blob());
 
 
       const file = new File([blobData], localFileName, {
       const file = new File([blobData], localFileName, {
         type: localFileType,
         type: localFileType,
@@ -43,33 +44,41 @@ test('should not be cleared and should prevent GrantSelector from modified', asy
       localFileType: 'application/octet-stream',
       localFileType: 'application/octet-stream',
     },
     },
   );
   );
-  await page.locator('.dropzone').first().dispatchEvent('drop', { dataTransfer });
-  await expect(page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment')).toBeVisible();
+  await page
+    .locator('.dropzone')
+    .first()
+    .dispatchEvent('drop', { dataTransfer });
+  await expect(
+    page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment'),
+  ).toBeVisible();
 
 
   // Save page
   // Save page
   await page.getByTestId('save-page-btn').click();
   await page.getByTestId('save-page-btn').click();
 
 
   // Expect grant not to be reset after uploading an attachment
   // Expect grant not to be reset after uploading an attachment
-  await expect(page.getByTestId('page-grant-alert')).toContainText('Browsing of this page is restricted');
+  await expect(page.getByTestId('page-grant-alert')).toContainText(
+    'Browsing of this page is restricted',
+  );
 });
 });
 
 
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 };
 
 
 /**
 /**
  * for the issue:
  * for the issue:
  * @see https://redmine.weseek.co.jp/issues/115285
  * @see https://redmine.weseek.co.jp/issues/115285
  */
  */
-test('Successfully updating the page body', async({ page }) => {
+test('Successfully updating the page body', async ({ page }) => {
   const page1Path = '/Sandbox/for-115285/page1';
   const page1Path = '/Sandbox/for-115285/page1';
   const page2Path = '/Sandbox/for-115285/page2';
   const page2Path = '/Sandbox/for-115285/page2';
 
 
   const page1Body = 'Hello';
   const page1Body = 'Hello';
   const page2Body = 'World';
   const page2Body = 'World';
 
 
-
   await page.goto(page1Path);
   await page.goto(page1Path);
 
 
   // Open Editor (page1)
   // Open Editor (page1)
@@ -85,7 +94,10 @@ test('Successfully updating the page body', async({ page }) => {
   await expect(page.locator('.main')).toContainText(page1Body);
   await expect(page.locator('.main')).toContainText(page1Body);
 
 
   // Duplicate page1
   // Duplicate page1
-  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page
+    .getByTestId('grw-contextual-sub-nav')
+    .getByTestId('open-page-item-control-btn')
+    .click();
   await page.getByTestId('open-page-duplicate-modal-btn').click();
   await page.getByTestId('open-page-duplicate-modal-btn').click();
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   await page.locator('.form-control').fill(page2Path);
   await page.locator('.form-control').fill(page2Path);
@@ -96,18 +108,20 @@ test('Successfully updating the page body', async({ page }) => {
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
 
 
   // Expect to see the text from which you are duplicating
   // Expect to see the text from which you are duplicating
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    page1Body,
+  );
 
 
   // Append text
   // Append text
   await appendTextToEditorUntilContains(page, page1Body + page2Body);
   await appendTextToEditorUntilContains(page, page1Body + page2Body);
 
 
-
   await page.goto(page1Path);
   await page.goto(page1Path);
 
 
   // Open Editor (page1)
   // Open Editor (page1)
   await page.getByTestId('editor-button').click();
   await page.getByTestId('editor-button').click();
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
 
 
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
-
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    page1Body,
+  );
 });
 });

+ 82 - 68
apps/app/playwright/30-search/search.spect.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Search page with "q" param is successfully loaded', async({ page }) => {
+test('Search page with "q" param is successfully loaded', async ({ page }) => {
   // Navigate to the search page with query parameters
   // Navigate to the search page with query parameters
   await page.goto('/_search?q=alerts');
   await page.goto('/_search?q=alerts');
 
 
@@ -11,7 +11,7 @@ test('Search page with "q" param is successfully loaded', async({ page }) => {
   await expect(page.locator('.wiki')).toBeVisible();
   await expect(page.locator('.wiki')).toBeVisible();
 });
 });
 
 
-test('checkboxes behaviors', async({ page }) => {
+test('checkboxes behaviors', async ({ page }) => {
   // Navigate to the search page with query parameters
   // Navigate to the search page with query parameters
   await page.goto('/_search?q=alerts');
   await page.goto('/_search?q=alerts');
 
 
@@ -28,7 +28,10 @@ test('checkboxes behaviors', async({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
   await page.getByTestId('cb-select').first().click({ force: true });
 
 
   // Click the select all checkbox
   // Click the select all checkbox
-  await page.getByTestId('delete-control-button').first().click({ force: true });
+  await page
+    .getByTestId('delete-control-button')
+    .first()
+    .click({ force: true });
   await page.getByTestId('cb-select-all').click({ force: true });
   await page.getByTestId('cb-select-all').click({ force: true });
 
 
   // Unclick the first checkbox after selecting all
   // Unclick the first checkbox after selecting all
@@ -41,16 +44,19 @@ test('checkboxes behaviors', async({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
   await page.getByTestId('cb-select').first().click({ force: true });
 });
 });
 
 
-
-test('successfully loads /_private-legacy-pages', async({ page }) => {
+test('successfully loads /_private-legacy-pages', async ({ page }) => {
   await page.goto('/_private-legacy-pages');
   await page.goto('/_private-legacy-pages');
 
 
   // Confirm search result elements are visible
   // Confirm search result elements are visible
-  await expect(page.locator('[data-testid="search-result-base"]')).toBeVisible();
-  await expect(page.locator('[data-testid="search-result-private-legacy-pages"]')).toBeVisible();
+  await expect(
+    page.locator('[data-testid="search-result-base"]'),
+  ).toBeVisible();
+  await expect(
+    page.locator('[data-testid="search-result-private-legacy-pages"]'),
+  ).toBeVisible();
 });
 });
 
 
-test('Search all pages by word', async({ page }) => {
+test('Search all pages by word', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   await page.getByTestId('open-search-modal-button').click();
   await page.getByTestId('open-search-modal-button').click();
   await expect(page.getByTestId('search-modal')).toBeVisible();
   await expect(page.getByTestId('search-modal')).toBeVisible();
@@ -58,51 +64,51 @@ test('Search all pages by word', async({ page }) => {
   await expect(page.locator('.search-menu-item').first()).toBeVisible();
   await expect(page.locator('.search-menu-item').first()).toBeVisible();
 });
 });
 
 
-test.describe.serial('Search all pages', () => {
-  const tag = 'help';
-  const searchText = `tag:${tag}`;
-
-  test('Successfully created tags', async({ page }) => {
-    await page.goto('/');
-
-    // open Edit Tags Modal to add tag
-    await page.locator('.grw-side-contents-sticky-container').isVisible();
-    await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
-    await expect(page.locator('#edit-tag-modal')).toBeVisible();
-    await page.locator('.rbt-input-main').fill(tag);
-    await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
-    await page.getByTestId('tag-edit-done-btn').click();
-
-  });
-
-  test('Search all pages by tag is successfully loaded', async({ page }) => {
-    await page.goto('/');
-
-    // Search
-    await page.getByTestId('open-search-modal-button').click();
-    await expect(page.getByTestId('search-modal')).toBeVisible();
-    await page.locator('.form-control').fill(searchText);
-    await page.getByTestId('search-all-menu-item').click();
-
-    // Confirm search result elements are visible
-    const searchResultList = page.getByTestId('search-result-list');
-    await expect(searchResultList).toBeVisible();
-    await expect(searchResultList.locator('li')).toHaveCount(1);
+test.describe
+  .serial('Search all pages', () => {
+    const tag = 'help';
+    const searchText = `tag:${tag}`;
+
+    test('Successfully created tags', async ({ page }) => {
+      await page.goto('/');
+
+      // open Edit Tags Modal to add tag
+      await page.locator('.grw-side-contents-sticky-container').isVisible();
+      await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
+      await expect(page.locator('#edit-tag-modal')).toBeVisible();
+      await page.locator('.rbt-input-main').fill(tag);
+      await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+      await page.getByTestId('tag-edit-done-btn').click();
+    });
+
+    test('Search all pages by tag is successfully loaded', async ({ page }) => {
+      await page.goto('/');
+
+      // Search
+      await page.getByTestId('open-search-modal-button').click();
+      await expect(page.getByTestId('search-modal')).toBeVisible();
+      await page.locator('.form-control').fill(searchText);
+      await page.getByTestId('search-all-menu-item').click();
+
+      // Confirm search result elements are visible
+      const searchResultList = page.getByTestId('search-result-list');
+      await expect(searchResultList).toBeVisible();
+      await expect(searchResultList.locator('li')).toHaveCount(1);
+    });
+
+    test('Successfully order page search results by tag', async ({ page }) => {
+      await page.goto('/');
+
+      await page.locator('.grw-tag-simple-bar').locator('a').click();
+
+      expect(page.getByTestId('search-result-base')).toBeVisible();
+      expect(page.getByTestId('search-result-list')).toBeVisible();
+      expect(page.getByTestId('search-result-content')).toBeVisible();
+    });
   });
   });
 
 
-  test('Successfully order page search results by tag', async({ page }) => {
-    await page.goto('/');
-
-    await page.locator('.grw-tag-simple-bar').locator('a').click();
-
-    expect(page.getByTestId('search-result-base')).toBeVisible();
-    expect(page.getByTestId('search-result-list')).toBeVisible();
-    expect(page.getByTestId('search-result-content')).toBeVisible();
-  });
-});
-
 test.describe('Sort with dropdown', () => {
 test.describe('Sort with dropdown', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=sand');
     await page.goto('/_search?q=sand');
 
 
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
@@ -113,41 +119,40 @@ test.describe('Sort with dropdown', () => {
     await page.locator('.search-control').locator('button').first().click();
     await page.locator('.search-control').locator('button').first().click();
   });
   });
 
 
-  test('Open sort dropdown', async({ page }) => {
-    await expect(page.locator('.search-control .dropdown-menu.show')).toBeVisible();
+  test('Open sort dropdown', async ({ page }) => {
+    await expect(
+      page.locator('.search-control .dropdown-menu.show'),
+    ).toBeVisible();
   });
   });
 
 
-  test('Sort by relevance', async({ page }) => {
+  test('Sort by relevance', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
 
     await expect(dropdownMenu).toBeVisible();
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(0).click();
     await dropdownMenu.locator('.dropdown-item').nth(0).click();
 
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
   });
   });
 
 
-  test('Sort by creation date', async({ page }) => {
+  test('Sort by creation date', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
 
     await expect(dropdownMenu).toBeVisible();
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(1).click();
     await dropdownMenu.locator('.dropdown-item').nth(1).click();
 
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
   });
   });
 
 
-  test('Sort by last update date', async({ page }) => {
+  test('Sort by last update date', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
 
     await expect(dropdownMenu).toBeVisible();
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(2).click();
     await dropdownMenu.locator('.dropdown-item').nth(2).click();
 
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
@@ -155,22 +160,26 @@ test.describe('Sort with dropdown', () => {
 });
 });
 
 
 test.describe('Search and use', () => {
 test.describe('Search and use', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=alerts');
     await page.goto('/_search?q=alerts');
 
 
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
 
 
-    await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+    await page
+      .getByTestId('page-list-item-L')
+      .first()
+      .getByTestId('open-page-item-control-btn')
+      .click();
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   });
   });
 
 
-  test('Successfully the dropdown is opened', async({ page }) => {
+  test('Successfully the dropdown is opened', async ({ page }) => {
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   });
   });
 
 
-  test('Successfully add bookmark', async({ page }) => {
+  test('Successfully add bookmark', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -178,10 +187,15 @@ test.describe('Search and use', () => {
     // Add bookmark
     // Add bookmark
     await dropdonwMenu.getByTestId('add-bookmark-btn').click();
     await dropdonwMenu.getByTestId('add-bookmark-btn').click();
 
 
-    await expect(page.getByTestId('search-result-content').locator('.btn-bookmark.active').first()).toBeVisible();
+    await expect(
+      page
+        .getByTestId('search-result-content')
+        .locator('.btn-bookmark.active')
+        .first(),
+    ).toBeVisible();
   });
   });
 
 
-  test('Successfully open duplicate modal', async({ page }) => {
+  test('Successfully open duplicate modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -191,7 +205,7 @@ test.describe('Search and use', () => {
     await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
     await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   });
   });
 
 
-  test('Successfully open move/rename modal', async({ page }) => {
+  test('Successfully open move/rename modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -201,7 +215,7 @@ test.describe('Search and use', () => {
     await expect(page.getByTestId('page-rename-modal')).toBeVisible();
     await expect(page.getByTestId('page-rename-modal')).toBeVisible();
   });
   });
 
 
-  test('Successfully open delete modal', async({ page }) => {
+  test('Successfully open delete modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -212,7 +226,7 @@ test.describe('Search and use', () => {
   });
   });
 });
 });
 
 
-test('Search current tree by word is successfully loaded', async({ page }) => {
+test('Search current tree by word is successfully loaded', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   const searchText = 'GROWI';
   const searchText = 'GROWI';
 
 

+ 29 - 19
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -1,13 +1,15 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('admin is successfully loaded', async({ page }) => {
+test('admin is successfully loaded', async ({ page }) => {
   await page.goto('/admin');
   await page.goto('/admin');
 
 
   await expect(page.getByTestId('admin-home')).toBeVisible();
   await expect(page.getByTestId('admin-home')).toBeVisible();
-  await expect(page.getByTestId('admin-system-information-table')).toBeVisible();
+  await expect(
+    page.getByTestId('admin-system-information-table'),
+  ).toBeVisible();
 });
 });
 
 
-test('admin/app is successfully loaded', async({ page }) => {
+test('admin/app is successfully loaded', async ({ page }) => {
   await page.goto('/admin/app');
   await page.goto('/admin/app');
 
 
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
@@ -15,46 +17,50 @@ test('admin/app is successfully loaded', async({ page }) => {
   await expect(page.locator('#cbFileUpload')).toBeChecked();
   await expect(page.locator('#cbFileUpload')).toBeChecked();
 });
 });
 
 
-test('admin/security is successfully loaded', async({ page }) => {
+test('admin/security is successfully loaded', async ({ page }) => {
   await page.goto('/admin/security');
   await page.goto('/admin/security');
 
 
   await expect(page.getByTestId('admin-security')).toBeVisible();
   await expect(page.getByTestId('admin-security')).toBeVisible();
-  await expect(page.locator('#isShowRestrictedByOwner')).toHaveText('Always displayed');
-  await expect(page.locator('#isShowRestrictedByGroup')).toHaveText('Always displayed');
+  await expect(page.locator('#isShowRestrictedByOwner')).toHaveText(
+    'Always displayed',
+  );
+  await expect(page.locator('#isShowRestrictedByGroup')).toHaveText(
+    'Always displayed',
+  );
 });
 });
 
 
-test('admin/markdown is successfully loaded', async({ page }) => {
+test('admin/markdown is successfully loaded', async ({ page }) => {
   await page.goto('/admin/markdown');
   await page.goto('/admin/markdown');
 
 
   await expect(page.getByTestId('admin-markdown')).toBeVisible();
   await expect(page.getByTestId('admin-markdown')).toBeVisible();
   await expect(page.locator('#isEnabledLinebreaksInComments')).toBeChecked();
   await expect(page.locator('#isEnabledLinebreaksInComments')).toBeChecked();
 });
 });
 
 
-test('admin/customize is successfully loaded', async({ page }) => {
+test('admin/customize is successfully loaded', async ({ page }) => {
   await page.goto('/admin/customize');
   await page.goto('/admin/customize');
 
 
   await expect(page.getByTestId('admin-customize')).toBeVisible();
   await expect(page.getByTestId('admin-customize')).toBeVisible();
 });
 });
 
 
-test('admin/importer is successfully loaded', async({ page }) => {
+test('admin/importer is successfully loaded', async ({ page }) => {
   await page.goto('/admin/importer');
   await page.goto('/admin/importer');
 
 
   await expect(page.getByTestId('admin-import-data')).toBeVisible();
   await expect(page.getByTestId('admin-import-data')).toBeVisible();
 });
 });
 
 
-test('admin/export is successfully loaded', async({ page }) => {
+test('admin/export is successfully loaded', async ({ page }) => {
   await page.goto('/admin/export');
   await page.goto('/admin/export');
 
 
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
 });
 });
 
 
-test('admin/data-transfer is successfully loaded', async({ page }) => {
+test('admin/data-transfer is successfully loaded', async ({ page }) => {
   await page.goto('/admin/data-transfer');
   await page.goto('/admin/data-transfer');
 
 
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
 });
 });
 
 
-test('admin/notification is successfully loaded', async({ page }) => {
+test('admin/notification is successfully loaded', async ({ page }) => {
   await page.goto('/admin/notification');
   await page.goto('/admin/notification');
 
 
   await expect(page.getByTestId('admin-notification')).toBeVisible();
   await expect(page.getByTestId('admin-notification')).toBeVisible();
@@ -62,7 +68,7 @@ test('admin/notification is successfully loaded', async({ page }) => {
   await expect(page.getByTestId('slack-integration-list-item')).toBeVisible();
   await expect(page.getByTestId('slack-integration-list-item')).toBeVisible();
 });
 });
 
 
-test('admin/slack-integration is successfully loaded', async({ page }) => {
+test('admin/slack-integration is successfully loaded', async ({ page }) => {
   await page.goto('/admin/slack-integration');
   await page.goto('/admin/slack-integration');
 
 
   await expect(page.getByTestId('admin-slack-integration')).toBeVisible();
   await expect(page.getByTestId('admin-slack-integration')).toBeVisible();
@@ -70,27 +76,31 @@ test('admin/slack-integration is successfully loaded', async({ page }) => {
   await expect(page.locator('img.bot-difficulty-icon').first()).toBeVisible();
   await expect(page.locator('img.bot-difficulty-icon').first()).toBeVisible();
 });
 });
 
 
-test('admin/slack-integration-legacy is successfully loaded', async({ page }) => {
+test('admin/slack-integration-legacy is successfully loaded', async ({
+  page,
+}) => {
   await page.goto('/admin/slack-integration-legacy');
   await page.goto('/admin/slack-integration-legacy');
 
 
-  await expect(page.getByTestId('admin-slack-integration-legacy')).toBeVisible();
+  await expect(
+    page.getByTestId('admin-slack-integration-legacy'),
+  ).toBeVisible();
 });
 });
 
 
-test('admin/users is successfully loaded', async({ page }) => {
+test('admin/users is successfully loaded', async ({ page }) => {
   await page.goto('/admin/users');
   await page.goto('/admin/users');
 
 
   await expect(page.getByTestId('admin-users')).toBeVisible();
   await expect(page.getByTestId('admin-users')).toBeVisible();
   await expect(page.getByTestId('user-table-tr').first()).toBeVisible();
   await expect(page.getByTestId('user-table-tr').first()).toBeVisible();
 });
 });
 
 
-test('admin/user-groups is successfully loaded', async({ page }) => {
+test('admin/user-groups is successfully loaded', async ({ page }) => {
   await page.goto('/admin/user-groups');
   await page.goto('/admin/user-groups');
 
 
   await expect(page.getByTestId('admin-user-groups')).toBeVisible();
   await expect(page.getByTestId('admin-user-groups')).toBeVisible();
   await expect(page.getByTestId('grw-user-group-table').first()).toBeVisible();
   await expect(page.getByTestId('grw-user-group-table').first()).toBeVisible();
 });
 });
 
 
-test('admin/search is successfully loaded', async({ page }) => {
+test('admin/search is successfully loaded', async ({ page }) => {
   await page.goto('/admin/search');
   await page.goto('/admin/search');
 
 
   await expect(page.getByTestId('admin-full-text-search')).toBeVisible();
   await expect(page.getByTestId('admin-full-text-search')).toBeVisible();

+ 20 - 16
apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts

@@ -1,39 +1,43 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 import { collapseSidebar } from '../utils';
 import { collapseSidebar } from '../utils';
 
 
-
 test.describe('Access to sidebar', () => {
 test.describe('Access to sidebar', () => {
-
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
     await collapseSidebar(page, false);
     await collapseSidebar(page, false);
   });
   });
 
 
-  test('Successfully show sidebar', async({ page }) => {
+  test('Successfully show sidebar', async ({ page }) => {
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
   });
   });
 
 
-  test('Successfully access to page tree', async({ page }) => {
+  test('Successfully access to page tree', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-page-tree').click();
     await page.getByTestId('grw-sidebar-nav-primary-page-tree').click();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
-    await expect(page.getByTestId('grw-pagetree-item-container').first()).toBeVisible();
+    await expect(
+      page.getByTestId('grw-pagetree-item-container').first(),
+    ).toBeVisible();
   });
   });
 
 
-  test('Successfully access to recent changes', async({ page }) => {
+  test('Successfully access to recent changes', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-recent-changes').click();
     await page.getByTestId('grw-sidebar-nav-primary-recent-changes').click();
     await expect(page.getByTestId('grw-recent-changes')).toBeVisible();
     await expect(page.getByTestId('grw-recent-changes')).toBeVisible();
     await expect(page.locator('.list-group-item').first()).toBeVisible();
     await expect(page.locator('.list-group-item').first()).toBeVisible();
   });
   });
 
 
-  test('Successfully access to custom sidebar', async({ page }) => {
+  test('Successfully access to custom sidebar', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-custom-sidebar').click();
     await page.getByTestId('grw-sidebar-nav-primary-custom-sidebar').click();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
-    await expect(page.locator('.grw-sidebar-content-header > h3').locator('a')).toBeVisible();
+    await expect(
+      page.locator('.grw-sidebar-content-header > h3').locator('a'),
+    ).toBeVisible();
   });
   });
 
 
-  test('Successfully access to GROWI Docs page', async({ page }) => {
-    const linkElement = page.locator('.grw-sidebar-nav-secondary-container a[href*="https://docs.growi.org"]');
+  test('Successfully access to GROWI Docs page', async ({ page }) => {
+    const linkElement = page.locator(
+      '.grw-sidebar-nav-secondary-container a[href*="https://docs.growi.org"]',
+    );
     const docsUrl = await linkElement.getAttribute('href');
     const docsUrl = await linkElement.getAttribute('href');
     if (docsUrl == null) {
     if (docsUrl == null) {
       throw new Error('url is null');
       throw new Error('url is null');
@@ -43,12 +47,13 @@ test.describe('Access to sidebar', () => {
     expect(body).toContain('</html>');
     expect(body).toContain('</html>');
   });
   });
 
 
-  test('Successfully access to trash page', async({ page }) => {
-    await page.locator('.grw-sidebar-nav-secondary-container a[href*="/trash"]').click();
+  test('Successfully access to trash page', async ({ page }) => {
+    await page
+      .locator('.grw-sidebar-nav-secondary-container a[href*="/trash"]')
+      .click();
     await expect(page.getByTestId('trash-page-list')).toBeVisible();
     await expect(page.getByTestId('trash-page-list')).toBeVisible();
   });
   });
 
 
-
   //
   //
   // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
   // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
   //
   //
@@ -166,5 +171,4 @@ test.describe('Access to sidebar', () => {
   //     cy.get('.modal-header > button').click();
   //     cy.get('.modal-header > button').click();
   //   });
   //   });
   // });
   // });
-
 });
 });

+ 1 - 2
apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts

@@ -2,8 +2,7 @@ import { test } from '@playwright/test';
 
 
 import { collapseSidebar } from '../utils';
 import { collapseSidebar } from '../utils';
 
 
-
-test('Switch sidebar mode', async({ page }) => {
+test('Switch sidebar mode', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   await collapseSidebar(page, false);
   await collapseSidebar(page, false);
   await collapseSidebar(page, true);
   await collapseSidebar(page, true);

+ 26 - 18
apps/app/playwright/60-home/home.spec.ts

@@ -1,31 +1,34 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-
-test('Visit User home', async({ page }) => {
+test('Visit User home', async ({ page }) => {
   await page.goto('dummy');
   await page.goto('dummy');
 
 
   // Open PersonalDropdown
   // Open PersonalDropdown
   await page.getByTestId('personal-dropdown-button').click();
   await page.getByTestId('personal-dropdown-button').click();
-  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-personal-dropdown-menu-user-home'),
+  ).toBeVisible();
 
 
   // Click UserHomeMenu
   // Click UserHomeMenu
   await page.getByTestId('grw-personal-dropdown-menu-user-home').click();
   await page.getByTestId('grw-personal-dropdown-menu-user-home').click();
   await expect(page.getByTestId('grw-users-info')).toBeVisible();
   await expect(page.getByTestId('grw-users-info')).toBeVisible();
 });
 });
 
 
-test('Vist User settings', async({ page }) => {
+test('Vist User settings', async ({ page }) => {
   await page.goto('dummy');
   await page.goto('dummy');
 
 
   // Open PersonalDropdown
   // Open PersonalDropdown
   await page.getByTestId('personal-dropdown-button').click();
   await page.getByTestId('personal-dropdown-button').click();
-  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-personal-dropdown-menu-user-home'),
+  ).toBeVisible();
 
 
   // Click UserSettingsMenu
   // Click UserSettingsMenu
   page.getByTestId('grw-personal-dropdown-menu-user-settings').click();
   page.getByTestId('grw-personal-dropdown-menu-user-settings').click();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 });
 
 
-test('Access User information', async({ page }) => {
+test('Access User information', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click BasicInfoSettingUpdateButton
   // Click BasicInfoSettingUpdateButton
@@ -36,7 +39,7 @@ test('Access User information', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
-test('Access External account', async({ page }) => {
+test('Access External account', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click ExternalAccountsTabButton
   // Click ExternalAccountsTabButton
@@ -52,7 +55,7 @@ test('Access External account', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
 });
 });
 
 
-test('Access Password setting', async({ page }) => {
+test('Access Password setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click PasswordSettingTabButton
   // Click PasswordSettingTabButton
@@ -72,8 +75,7 @@ test('Access Password setting', async({ page }) => {
   await expect(page.getByTestId('.Toastify__toast')).not.toBeVisible();
   await expect(page.getByTestId('.Toastify__toast')).not.toBeVisible();
 });
 });
 
 
-
-test('Access API setting', async({ page }) => {
+test('Access API setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click ApiSettingTabButton
   // Click ApiSettingTabButton
@@ -85,7 +87,7 @@ test('Access API setting', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
-test('Access Access Token setting', async({ page }) => {
+test('Access Access Token setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click ApiSettingTabButton
   // Click ApiSettingTabButton
@@ -98,7 +100,9 @@ test('Access Access Token setting', async({ page }) => {
   await page.getByTestId('grw-accesstoken-checkbox-read:*').check();
   await page.getByTestId('grw-accesstoken-checkbox-read:*').check();
   await page.getByTestId('grw-accesstoken-create-button').click();
   await page.getByTestId('grw-accesstoken-create-button').click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
-  await expect(page.getByTestId('grw-accesstoken-new-token-display')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-accesstoken-new-token-display'),
+  ).toBeVisible();
 
 
   // Expect a success toaster to be displayed when the Access Token is deleted
   // Expect a success toaster to be displayed when the Access Token is deleted
   await page.getByTestId('grw-accesstoken-delete-button').click();
   await page.getByTestId('grw-accesstoken-delete-button').click();
@@ -106,22 +110,26 @@ test('Access Access Token setting', async({ page }) => {
   await page.getByTestId('grw-accesstoken-delete-button').click();
   await page.getByTestId('grw-accesstoken-delete-button').click();
   await page.getByTestId('grw-accesstoken-delete-button-in-modal').click();
   await page.getByTestId('grw-accesstoken-delete-button-in-modal').click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
-
 });
 });
 
 
-test('Access In-App Notification setting', async({ page }) => {
+test('Access In-App Notification setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click InAppNotificationSettingTabButton
   // Click InAppNotificationSettingTabButton
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
-  await page.getByTestId('in-app-notification-settings-tab-button').first().click();
+  await page
+    .getByTestId('in-app-notification-settings-tab-button')
+    .first()
+    .click();
 
 
   // Expect a success toaster to be displayed when the InAppNotificationSettingsUpdateButton is clicked
   // Expect a success toaster to be displayed when the InAppNotificationSettingsUpdateButton is clicked
-  await page.getByTestId('grw-in-app-notification-settings-update-button').click();
+  await page
+    .getByTestId('grw-in-app-notification-settings-update-button')
+    .click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
-test('Acccess Other setting', async({ page }) => {
+test('Acccess Other setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click OtherSettingTabButton
   // Click OtherSettingTabButton

+ 1 - 1
apps/app/playwright/auth.setup.ts

@@ -4,6 +4,6 @@ import { login } from './utils/Login';
 
 
 // Commonised login process for use elsewhere
 // Commonised login process for use elsewhere
 // see: https://github.com/microsoft/playwright/issues/22114
 // see: https://github.com/microsoft/playwright/issues/22114
-setup('Authenticate as the "admin" user', async({ page }) => {
+setup('Authenticate as the "admin" user', async ({ page }) => {
   await login(page);
   await login(page);
 });
 });

+ 8 - 4
apps/app/playwright/utils/CollapseSidebar.ts

@@ -1,7 +1,12 @@
 import { expect, type Page } from '@playwright/test';
 import { expect, type Page } from '@playwright/test';
 
 
-export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<void> => {
-  const isSidebarContentsHidden = !(await page.getByTestId('grw-sidebar-contents').isVisible());
+export const collapseSidebar = async (
+  page: Page,
+  isCollapsed: boolean,
+): Promise<void> => {
+  const isSidebarContentsHidden = !(await page
+    .getByTestId('grw-sidebar-contents')
+    .isVisible());
   if (isSidebarContentsHidden === isCollapsed) {
   if (isSidebarContentsHidden === isCollapsed) {
     return;
     return;
   }
   }
@@ -12,8 +17,7 @@ export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<
 
 
   if (isCollapsed) {
   if (isCollapsed) {
     await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
     await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
-  }
-  else {
+  } else {
     await expect(page.locator('.grw-sidebar-dock')).toBeVisible();
     await expect(page.locator('.grw-sidebar-dock')).toBeVisible();
   }
   }
 };
 };

+ 1 - 2
apps/app/playwright/utils/Login.ts

@@ -1,10 +1,9 @@
 import path from 'node:path';
 import path from 'node:path';
-
 import { expect, type Page } from '@playwright/test';
 import { expect, type Page } from '@playwright/test';
 
 
 const authFile = path.resolve(__dirname, '../.auth/admin.json');
 const authFile = path.resolve(__dirname, '../.auth/admin.json');
 
 
-export const login = async(page: Page): Promise<void> => {
+export const login = async (page: Page): Promise<void> => {
   // Perform authentication steps. Replace these actions with your own.
   // Perform authentication steps. Replace these actions with your own.
   await page.goto('/admin');
   await page.goto('/admin');
 
 

+ 40 - 40
apps/app/public/images/icons/favicon/manifest.json

@@ -1,41 +1,41 @@
 {
 {
- "name": "App",
- "icons": [
-  {
-   "src": "\/android-icon-36x36.png",
-   "sizes": "36x36",
-   "type": "image\/png",
-   "density": "0.75"
-  },
-  {
-   "src": "\/android-icon-48x48.png",
-   "sizes": "48x48",
-   "type": "image\/png",
-   "density": "1.0"
-  },
-  {
-   "src": "\/android-icon-72x72.png",
-   "sizes": "72x72",
-   "type": "image\/png",
-   "density": "1.5"
-  },
-  {
-   "src": "\/android-icon-96x96.png",
-   "sizes": "96x96",
-   "type": "image\/png",
-   "density": "2.0"
-  },
-  {
-   "src": "\/android-icon-144x144.png",
-   "sizes": "144x144",
-   "type": "image\/png",
-   "density": "3.0"
-  },
-  {
-   "src": "\/android-icon-192x192.png",
-   "sizes": "192x192",
-   "type": "image\/png",
-   "density": "4.0"
-  }
- ]
-}
+  "name": "App",
+  "icons": [
+    {
+      "src": "\/android-icon-36x36.png",
+      "sizes": "36x36",
+      "type": "image\/png",
+      "density": "0.75"
+    },
+    {
+      "src": "\/android-icon-48x48.png",
+      "sizes": "48x48",
+      "type": "image\/png",
+      "density": "1.0"
+    },
+    {
+      "src": "\/android-icon-72x72.png",
+      "sizes": "72x72",
+      "type": "image\/png",
+      "density": "1.5"
+    },
+    {
+      "src": "\/android-icon-96x96.png",
+      "sizes": "96x96",
+      "type": "image\/png",
+      "density": "2.0"
+    },
+    {
+      "src": "\/android-icon-144x144.png",
+      "sizes": "144x144",
+      "type": "image\/png",
+      "density": "3.0"
+    },
+    {
+      "src": "\/android-icon-192x192.png",
+      "sizes": "192x192",
+      "type": "image\/png",
+      "density": "4.0"
+    }
+  ]
+}

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

@@ -313,7 +313,7 @@
       "done": "Copied to clipboard!"
       "done": "Copied to clipboard!"
     },
     },
     "bug_report": "Submitting a bug report",
     "bug_report": "Submitting a bug report",
-    "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'>then submit your issue to GitHub.</a>"
+    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
     "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",

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

@@ -897,7 +897,7 @@
     "Password field is required": "Password field is required.",
     "Password field is required": "Password field is required.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "user_not_found": "User not found.",
     "user_not_found": "User not found.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Create Bootstrap 4 Grid",
     "create_bootstrap_4_grid": "Create Bootstrap 4 Grid",
@@ -999,7 +999,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Bookmarks",
     "bookmarks": "Bookmarks",
-    "recently_created": "Recently Created"
+    "recently_created": "Recently Created",
+    "recent_activity": "Recent Activity",
+    "unknown_action": "made an unspecified change",
+    "page_create": "created a page",
+    "page_update": "updated a page",
+    "page_delete": "deleted a page",
+    "page_delete_completely": "deleted a page",
+    "page_rename": "renamed a page",
+    "page_revert": "reverted a page",
+    "page_like": "liked a page",
+    "page_duplicate": "duplicated a page",
+    "comment_create": "posted a comment"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "bookmark folder",
     "bookmark_folder": "bookmark folder",

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

@@ -313,7 +313,7 @@
       "done": "Copié dans le presse-papier!"
       "done": "Copié dans le presse-papier!"
     },
     },
     "bug_report": "Informations de diagnostic",
     "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/growilabs/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": {
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",

+ 13 - 2
apps/app/public/static/locales/fr_FR/translation.json

@@ -891,7 +891,7 @@
     "Password field is required": "Mot de passe requis.",
     "Password field is required": "Mot de passe requis.",
     "Username or E-mail has invalid characters": "Le nom d'utilisateur ou l'adresse courriel contient des caractères invalides",
     "Username or E-mail has invalid characters": "Le nom d'utilisateur ou l'adresse courriel contient des caractères invalides",
     "user_not_found": "Utilisateur introuvable.",
     "user_not_found": "Utilisateur introuvable.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Créer grille Bootstrap 4",
     "create_bootstrap_4_grid": "Créer grille Bootstrap 4",
@@ -993,7 +993,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Favoris",
     "bookmarks": "Favoris",
-    "recently_created": "Page récentes"
+    "recently_created": "Page récentes",
+    "recent_activity": "Activité récente",
+    "unknown_action": "a effectué une modification non spécifiée",
+    "page_create": "a créé une page",
+    "page_update": "a mis à jour une page",
+    "page_delete": "a supprimé une page",
+    "page_delete_completely": "a supprimé complètement une page",
+    "page_rename": "a renommé une page",
+    "page_revert": "a restauré une page",
+    "page_duplicate": "a dupliqué une page",
+    "page_like": "a aimé une page",
+    "comment_create": "a publié un commentaire"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "dossier de favoris",
     "bookmark_folder": "dossier de favoris",

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

@@ -322,7 +322,7 @@
       "done": "クリップボードにコピーしました!"
       "done": "クリップボードにコピーしました!"
     },
     },
     "bug_report": "バグを報告する",
     "bug_report": "バグを報告する",
-    "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'>次に GitHub で Issue を投稿してください。</a>"
+    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
     "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",

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

@@ -244,7 +244,7 @@
     "scope_read": "Read",
     "scope_read": "Read",
     "action": "アクション",
     "action": "アクション",
     "create_token": "トークンを作成",
     "create_token": "トークンを作成",
-    "no_tokens_found":"アクセストークンが見つかりません",
+    "no_tokens_found": "アクセストークンが見つかりません",
     "new_token": {
     "new_token": {
       "title": "新しいアクセストークン",
       "title": "新しいアクセストークン",
       "copy_to_clipboard": "クリップボードにコピーしました",
       "copy_to_clipboard": "クリップボードにコピーしました",
@@ -708,7 +708,7 @@
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
-      },
+    },
     "delete_modal": {
     "delete_modal": {
       "title": "アシスタントを削除する",
       "title": "アシスタントを削除する",
       "confirm_message": "本当にアシスタントを削除しますか?"
       "confirm_message": "本当にアシスタントを削除しますか?"
@@ -930,7 +930,7 @@
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "user_not_found": "ユーザーが見つかりません",
     "user_not_found": "ユーザーが見つかりません",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/growilabs/growi/issues/193'>こちら: #193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Bootstrap 4 グリッドを作成",
     "create_bootstrap_4_grid": "Bootstrap 4 グリッドを作成",
@@ -1032,7 +1032,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "ブックマーク",
     "bookmarks": "ブックマーク",
-    "recently_created": "最近作成したページ"
+    "recently_created": "最近作成したページ",
+    "recent_activity": "最近のアクティビティ",
+    "unknown_action": "未指定の変更を加えました",
+    "page_create": "ページを作成しました",
+    "page_update": "ページを更新しました",
+    "page_delete": "ページを削除しました",
+    "page_delete_completely": "ページを完全に削除しました",
+    "page_rename": "ページの名前を変更しました",
+    "page_revert": "ページを元に戻しました",
+    "page_duplicate": "ページを複製しました",
+    "page_like": "ページをいいねしました",
+    "comment_create": "コメントを投稿しました"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "ブックマークフォルダ",
     "bookmark_folder": "ブックマークフォルダ",

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

@@ -313,7 +313,7 @@
       "done": "클립보드에 복사되었습니다!"
       "done": "클립보드에 복사되었습니다!"
     },
     },
     "bug_report": "버그 보고서 제출",
     "bug_report": "버그 보고서 제출",
-    "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'>그런 다음 GitHub에 문제를 제출하십시오.</a>"
+    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>그런 다음 GitHub에 문제를 제출하십시오.</a>"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "일부 페이지는 이전 v4 호환성을 가지고 있습니다. 페이지 트리 및 쉬운 이름 변경과 같은 새로운 기능을 활용하려면 모든 페이지를 v5 호환성으로 변환하십시오.",
     "migration_desc": "일부 페이지는 이전 v4 호환성을 가지고 있습니다. 페이지 트리 및 쉬운 이름 변경과 같은 새로운 기능을 활용하려면 모든 페이지를 v5 호환성으로 변환하십시오.",

+ 13 - 2
apps/app/public/static/locales/ko_KR/translation.json

@@ -857,7 +857,7 @@
     "Password field is required": "비밀번호 필드는 필수입니다.",
     "Password field is required": "비밀번호 필드는 필수입니다.",
     "Username or E-mail has invalid characters": "사용자 이름 또는 이메일에 유효하지 않은 문자가 있습니다.",
     "Username or E-mail has invalid characters": "사용자 이름 또는 이메일에 유효하지 않은 문자가 있습니다.",
     "user_not_found": "사용자를 찾을 수 없습니다.",
     "user_not_found": "사용자를 찾을 수 없습니다.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException 발생</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 인증은 성공했지만 새 사용자를 생성할 수 없습니다. <a href='https://github.com/weseek/growi/issues/193'>#193</a> 문제를 참조하십시오.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException 발생</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 인증은 성공했지만 새 사용자를 생성할 수 없습니다. <a href='https://github.com/growilabs/growi/issues/193'>#193</a> 문제를 참조하십시오.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Bootstrap 4 그리드 생성",
     "create_bootstrap_4_grid": "Bootstrap 4 그리드 생성",
@@ -959,7 +959,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "북마크",
     "bookmarks": "북마크",
-    "recently_created": "최근 생성됨"
+    "recently_created": "최근 생성됨",
+    "recent_activity": "최근 활동",
+    "unknown_action": "지정되지 않은 변경 사항을 적용했습니다",
+    "page_create": "페이지를 생성했습니다",
+    "page_update": "페이지를 업데이트했습니다",
+    "page_delete": "페이지를 삭제했습니다",
+    "page_delete_completely": "페이지를 완전히 삭제했습니다",
+    "page_rename": "페이지 이름을 변경했습니다",
+    "page_revert": "페이지를 되돌렸습니다",
+    "page_duplicate": "페이지를 복제했습니다",
+    "page_like": "페이지에 좋아요를 눌렀습니다",
+    "comment_create": "댓글을 게시했습니다"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "북마크 폴더",
     "bookmark_folder": "북마크 폴더",

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

@@ -322,7 +322,7 @@
       "done": "复制到剪贴板!"
       "done": "复制到剪贴板!"
     },
     },
     "bug_report": "提交一个错误报告",
     "bug_report": "提交一个错误报告",
-    "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'>然后提交你的问题到GitHub。</a>"
+    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
     "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",

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

@@ -902,7 +902,7 @@
     "Password field is required": "密码字段是必需的",
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "user_not_found": "未找到用户",
     "user_not_found": "未找到用户",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "创建Bootstrap 4网格",
     "create_bootstrap_4_grid": "创建Bootstrap 4网格",
@@ -1004,7 +1004,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "书签",
     "bookmarks": "书签",
-    "recently_created": "最近创建页面"
+    "recently_created": "最近创建页面",
+    "recent_activity": "最近动态",
+    "unknown_action": "进行了未指明的更改",
+    "page_create": "创建了页面",
+    "page_update": "更新了页面",
+    "page_delete": "删除了页面",
+    "page_delete_completely": "彻底删除了页面",
+    "page_rename": "重命名了页面",
+    "page_revert": "还原了页面",
+    "page_duplicate": "复制了页面",
+    "page_like": "赞了页面",
+    "comment_create": "发布了评论"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "书签文件夹",
     "bookmark_folder": "书签文件夹",

+ 50 - 29
apps/app/src/client/components/Admin/App/AppSetting.jsx

@@ -1,7 +1,8 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useTranslation, i18n } from 'next-i18next';
 import { useTranslation, i18n } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 
@@ -20,8 +21,44 @@ const AppSetting = (props) => {
   const { adminAppContainer } = props;
   const { adminAppContainer } = props;
   const { t } = useTranslation(['admin', 'commons']);
   const { t } = useTranslation(['admin', 'commons']);
 
 
-  const submitHandler = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Reset form when adminAppContainer state changes (e.g., after reload)
+  useEffect(() => {
+    reset({
+      title: adminAppContainer.state.title || '',
+      confidential: adminAppContainer.state.confidential || '',
+      globalLang: adminAppContainer.state.globalLang || 'en-US',
+      // Convert boolean to string for radio button value
+      isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
+      fileUpload: adminAppContainer.state.fileUpload ?? false,
+    });
+  }, [
+    adminAppContainer.state.title,
+    adminAppContainer.state.confidential,
+    adminAppContainer.state.globalLang,
+    adminAppContainer.state.isEmailPublishedForNewUser,
+    adminAppContainer.state.fileUpload,
+    reset,
+  ]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
     try {
+      // Await all setState completions before API call
+      await Promise.all([
+        adminAppContainer.changeTitle(data.title),
+        adminAppContainer.changeConfidential(data.confidential),
+        adminAppContainer.changeGlobalLang(data.globalLang),
+      ]);
+      // Convert string 'true'/'false' to boolean
+      const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true;
+      await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished);
+      await adminAppContainer.changeFileUpload(data.fileUpload);
+
       await adminAppContainer.updateAppSettingHandler();
       await adminAppContainer.updateAppSettingHandler();
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
     }
     }
@@ -33,18 +70,15 @@ const AppSetting = (props) => {
 
 
 
 
   return (
   return (
-    <React.Fragment>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <div className="row">
       <div className="row">
         <label className="text-start text-md-end col-md-3 col-form-label">{t('admin:app_setting.site_name')}</label>
         <label className="text-start text-md-end col-md-3 col-form-label">{t('admin:app_setting.site_name')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
-            value={adminAppContainer.state.title || ''}
-            onChange={(e) => {
-              adminAppContainer.changeTitle(e.target.value);
-            }}
             placeholder="GROWI"
             placeholder="GROWI"
+            {...register('title')}
           />
           />
           <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
           <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
         </div>
         </div>
@@ -60,11 +94,8 @@ const AppSetting = (props) => {
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
-            value={adminAppContainer.state.confidential || ''}
-            onChange={(e) => {
-              adminAppContainer.changeConfidential(e.target.value);
-            }}
             placeholder={t('admin:app_setting.confidential_example')}
             placeholder={t('admin:app_setting.confidential_example')}
+            {...register('confidential')}
           />
           />
           <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
           <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
         </div>
         </div>
@@ -88,12 +119,8 @@ const AppSetting = (props) => {
                     type="radio"
                     type="radio"
                     id={`radioLang${locale}`}
                     id={`radioLang${locale}`}
                     className="form-check-input"
                     className="form-check-input"
-                    name="globalLang"
                     value={locale}
                     value={locale}
-                    checked={adminAppContainer.state.globalLang === locale}
-                    onChange={(e) => {
-                      adminAppContainer.changeGlobalLang(e.target.value);
-                    }}
+                    {...register('globalLang')}
                   />
                   />
                   <label className="form-label form-check-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name')}</label>
                   <label className="form-label form-check-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name')}</label>
                 </div>
                 </div>
@@ -116,9 +143,8 @@ const AppSetting = (props) => {
               type="radio"
               type="radio"
               id="radio-email-show"
               id="radio-email-show"
               className="form-check-input"
               className="form-check-input"
-              name="mailVisibility"
-              checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
-              onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
+              value="true"
+              {...register('isEmailPublishedForNewUser')}
             />
             />
             <label className="form-label form-check-label" htmlFor="radio-email-show">{t('commons:Show')}</label>
             <label className="form-label form-check-label" htmlFor="radio-email-show">{t('commons:Show')}</label>
           </div>
           </div>
@@ -128,9 +154,8 @@ const AppSetting = (props) => {
               type="radio"
               type="radio"
               id="radio-email-hide"
               id="radio-email-hide"
               className="form-check-input"
               className="form-check-input"
-              name="mailVisibility"
-              checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
-              onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
+              value="false"
+              {...register('isEmailPublishedForNewUser')}
             />
             />
             <label className="form-label form-check-label" htmlFor="radio-email-hide">{t('commons:Hide')}</label>
             <label className="form-label form-check-label" htmlFor="radio-email-hide">{t('commons:Hide')}</label>
           </div>
           </div>
@@ -150,11 +175,7 @@ const AppSetting = (props) => {
               type="checkbox"
               type="checkbox"
               id="cbFileUpload"
               id="cbFileUpload"
               className="form-check-input"
               className="form-check-input"
-              name="fileUpload"
-              checked={adminAppContainer.state.fileUpload}
-              onChange={(e) => {
-                adminAppContainer.changeFileUpload(e.target.checked);
-              }}
+              {...register('fileUpload')}
             />
             />
             <label
             <label
               className="form-label form-check-label"
               className="form-label form-check-label"
@@ -170,8 +191,8 @@ const AppSetting = (props) => {
         </div>
         </div>
       </div>
       </div>
 
 
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-    </React.Fragment>
+      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+    </form>
   );
   );
 
 
 };
 };

+ 12 - 36
apps/app/src/client/components/Admin/App/AwsSetting.tsx

@@ -1,21 +1,14 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 
 export type AwsSettingMoleculeProps = {
 export type AwsSettingMoleculeProps = {
-  s3ReferenceFileWithRelayMode
-  s3Region
-  s3CustomEndpoint
-  s3Bucket
-  s3AccessKeyId
-  s3SecretAccessKey
+  register: UseFormRegister<FileUploadFormValues>
+  s3ReferenceFileWithRelayMode: boolean
   onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
   onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeS3Region: (val: string) => void
-  onChangeS3CustomEndpoint: (val: string) => void
-  onChangeS3Bucket: (val: string) => void
-  onChangeS3AccessKeyId: (val: string) => void
-  onChangeS3SecretAccessKey: (val: string) => void
 };
 };
 
 
 export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
 export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
@@ -23,7 +16,6 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
 
 
   return (
   return (
     <>
     <>
-
       <div className="row my-3">
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
@@ -46,16 +38,16 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(true) }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(false) }}
               >
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
             </div>
             </div>
 
 
@@ -76,10 +68,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
           <input
             className="form-control"
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
             placeholder={`${t('eg')} ap-northeast-1`}
-            value={props.s3Region || ''}
-            onChange={(e) => {
-              props?.onChangeS3Region(e.target.value);
-            }}
+            {...props.register('s3Region')}
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -93,10 +82,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
             placeholder={`${t('eg')} http://localhost:9000`}
-            value={props.s3CustomEndpoint || ''}
-            onChange={(e) => {
-              props?.onChangeS3CustomEndpoint(e.target.value);
-            }}
+            {...props.register('s3CustomEndpoint')}
           />
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
         </div>
         </div>
@@ -111,10 +97,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder={`${t('eg')} crowi`}
             placeholder={`${t('eg')} crowi`}
-            value={props.s3Bucket || ''}
-            onChange={(e) => {
-              props.onChangeS3Bucket(e.target.value);
-            }}
+            {...props.register('s3Bucket')}
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -127,10 +110,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
-            value={props.s3AccessKeyId || ''}
-            onChange={(e) => {
-              props?.onChangeS3AccessKeyId(e.target.value);
-            }}
+            {...props.register('s3AccessKeyId')}
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -143,15 +123,11 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
-            onChange={(e) => {
-              props?.onChangeS3SecretAccessKey(e.target.value);
-            }}
+            {...props.register('s3SecretAccessKey')}
           />
           />
           <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
           <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
         </div>
         </div>
       </div>
       </div>
-
-
     </>
     </>
   );
   );
 };
 };

+ 21 - 43
apps/app/src/client/components/Admin/App/AzureSetting.tsx

@@ -1,29 +1,21 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import MaskedInput from './MaskedInput';
 import MaskedInput from './MaskedInput';
 
 
-
 export type AzureSettingMoleculeProps = {
 export type AzureSettingMoleculeProps = {
-  azureReferenceFileWithRelayMode
-  azureUseOnlyEnvVars
-  azureTenantId
-  azureClientId
-  azureClientSecret
-  azureStorageAccountName
-  azureStorageContainerName
-  envAzureTenantId?
-  envAzureClientId?
-  envAzureClientSecret?
-  envAzureStorageAccountName?
-  envAzureStorageContainerName?
+  register: UseFormRegister<FileUploadFormValues>
+  azureReferenceFileWithRelayMode: boolean
+  azureUseOnlyEnvVars: boolean
+  envAzureTenantId?: string
+  envAzureClientId?: string
+  envAzureClientSecret?: string
+  envAzureStorageAccountName?: string
+  envAzureStorageContainerName?: string
   onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
   onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeAzureTenantId: (val: string) => void
-  onChangeAzureClientId: (val: string) => void
-  onChangeAzureClientSecret: (val: string) => void
-  onChangeAzureStorageAccountName: (val: string) => void
-  onChangeAzureStorageContainerName: (val: string) => void
 };
 };
 
 
 export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
 export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
@@ -32,21 +24,15 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
   const {
   const {
     azureReferenceFileWithRelayMode,
     azureReferenceFileWithRelayMode,
     azureUseOnlyEnvVars,
     azureUseOnlyEnvVars,
-    azureTenantId,
-    azureClientId,
-    azureClientSecret,
-    azureStorageAccountName,
     envAzureTenantId,
     envAzureTenantId,
     envAzureClientId,
     envAzureClientId,
     envAzureClientSecret,
     envAzureClientSecret,
     envAzureStorageAccountName,
     envAzureStorageAccountName,
-    azureStorageContainerName,
     envAzureStorageContainerName,
     envAzureStorageContainerName,
   } = props;
   } = props;
 
 
   return (
   return (
     <>
     <>
-
       <div className="row form-group my-3">
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
@@ -69,16 +55,16 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(true) }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(false) }}
               >
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
             </div>
             </div>
 
 
@@ -116,10 +102,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_tenant_id')}</th>
             <th>{t('admin:app_setting.azure_tenant_id')}</th>
             <td>
             <td>
               <MaskedInput
               <MaskedInput
-                name="azureTenantId"
+                register={props.register}
+                fieldName="azureTenantId"
                 readOnly={azureUseOnlyEnvVars}
                 readOnly={azureUseOnlyEnvVars}
-                value={azureTenantId}
-                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -134,10 +119,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_id')}</th>
             <th>{t('admin:app_setting.azure_client_id')}</th>
             <td>
             <td>
               <MaskedInput
               <MaskedInput
-                name="azureClientId"
+                register={props.register}
+                fieldName="azureClientId"
                 readOnly={azureUseOnlyEnvVars}
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientId}
-                onChange={e => props?.onChangeAzureClientId(e.target.value)}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -152,10 +136,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_secret')}</th>
             <th>{t('admin:app_setting.azure_client_secret')}</th>
             <td>
             <td>
               <MaskedInput
               <MaskedInput
-                name="azureClientSecret"
+                register={props.register}
+                fieldName="azureClientSecret"
                 readOnly={azureUseOnlyEnvVars}
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientSecret}
-                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -172,10 +155,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="text"
                 type="text"
-                name="azureStorageAccountName"
                 readOnly={azureUseOnlyEnvVars}
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageAccountName}
-                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+                {...props.register('azureStorageAccountName')}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -192,10 +173,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="text"
                 type="text"
-                name="azureStorageContainerName"
                 readOnly={azureUseOnlyEnvVars}
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageContainerName}
-                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+                {...props.register('azureStorageContainerName')}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -208,7 +187,6 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
           </tr>
           </tr>
         </tbody>
         </tbody>
       </table>
       </table>
-
     </>
     </>
   );
   );
 };
 };

+ 113 - 236
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

@@ -1,34 +1,94 @@
-import type { ChangeEvent, JSX } from 'react';
-import React, { useCallback } from 'react';
+import type { JSX } from 'react';
+import { useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useForm, useController } from 'react-hook-form';
 
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { FileUploadType } from '~/interfaces/file-uploader';
 import { FileUploadType } from '~/interfaces/file-uploader';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 import { AwsSettingMolecule } from './AwsSetting';
 import { AwsSettingMolecule } from './AwsSetting';
-import type { AwsSettingMoleculeProps } from './AwsSetting';
 import { AzureSettingMolecule } from './AzureSetting';
 import { AzureSettingMolecule } from './AzureSetting';
-import type { AzureSettingMoleculeProps } from './AzureSetting';
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import { GcsSettingMolecule } from './GcsSetting';
 import { GcsSettingMolecule } from './GcsSetting';
-import type { GcsSettingMoleculeProps } from './GcsSetting';
+import { useFileUploadSettings } from './useFileUploadSettings';
 
 
-type FileUploadSettingMoleculeProps = {
-  fileUploadType: string
-  isFixedFileUploadByEnvVar: boolean
-  envFileUploadType?: string
-  onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
-
-export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
+const FileUploadSetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
   const { t } = useTranslation(['admin', 'commons']);
+  const {
+    data, isLoading, error, updateSettings,
+  } = useFileUploadSettings();
+
+  const {
+    register, handleSubmit, control, watch, formState,
+  } = useForm<FileUploadFormValues>({
+    values: data ? {
+      fileUploadType: data.fileUploadType,
+      s3Region: data.s3Region,
+      s3CustomEndpoint: data.s3CustomEndpoint,
+      s3Bucket: data.s3Bucket,
+      s3AccessKeyId: data.s3AccessKeyId,
+      s3SecretAccessKey: data.s3SecretAccessKey,
+      s3ReferenceFileWithRelayMode: data.s3ReferenceFileWithRelayMode,
+      gcsApiKeyJsonPath: data.gcsApiKeyJsonPath,
+      gcsBucket: data.gcsBucket,
+      gcsUploadNamespace: data.gcsUploadNamespace,
+      gcsReferenceFileWithRelayMode: data.gcsReferenceFileWithRelayMode,
+      azureTenantId: data.azureTenantId,
+      azureClientId: data.azureClientId,
+      azureClientSecret: data.azureClientSecret,
+      azureStorageAccountName: data.azureStorageAccountName,
+      azureStorageContainerName: data.azureStorageContainerName,
+      azureReferenceFileWithRelayMode: data.azureReferenceFileWithRelayMode,
+    } : undefined,
+  });
+
+  // Use controller for fileUploadType radio buttons
+  const { field: fileUploadTypeField } = useController({
+    name: 'fileUploadType',
+    control,
+  });
+
+  // Use controller for relay mode fields
+  const { field: s3RelayModeField } = useController({
+    name: 's3ReferenceFileWithRelayMode',
+    control,
+  });
+
+  const { field: gcsRelayModeField } = useController({
+    name: 'gcsReferenceFileWithRelayMode',
+    control,
+  });
+
+  const { field: azureRelayModeField } = useController({
+    name: 'azureReferenceFileWithRelayMode',
+    control,
+  });
+
+  const fileUploadType = watch('fileUploadType');
+
+  const onSubmit = useCallback(async(formData: FileUploadFormValues) => {
+    try {
+      await updateSettings(formData, formState.dirtyFields);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [updateSettings, formState.dirtyFields, t]);
+
+  if (isLoading) {
+    return <div>Loading...</div>;
+  }
+
+  if (error || !data) {
+    return <div>Error loading settings</div>;
+  }
 
 
   return (
   return (
-    <>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <p className="card custom-card bg-warning-subtle my-3">
       <p className="card custom-card bg-warning-subtle my-3">
         {t('admin:app_setting.file_upload')}
         {t('admin:app_setting.file_upload')}
         <span className="text-danger mt-1">
         <span className="text-danger mt-1">
@@ -51,24 +111,27 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
                   className="form-check-input"
                   className="form-check-input"
                   name="file-upload-type"
                   name="file-upload-type"
                   id={`file-upload-type-radio-${type}`}
                   id={`file-upload-type-radio-${type}`}
-                  checked={props.fileUploadType === type}
-                  disabled={props.isFixedFileUploadByEnvVar}
-                  onChange={(e) => { props?.onChangeFileUploadType(e, type) }}
+                  checked={fileUploadTypeField.value === type}
+                  disabled={data.isFixedFileUploadByEnvVar}
+                  onChange={() => fileUploadTypeField.onChange(type)}
                 />
                 />
-                <label className="form-label form-check-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
+                <label className="form-label form-check-label" htmlFor={`file-upload-type-radio-${type}`}>
+                  {t(`admin:app_setting.${type}_label`)}
+                </label>
               </div>
               </div>
             );
             );
           })}
           })}
         </div>
         </div>
-        {props.isFixedFileUploadByEnvVar && (
+        {data.isFixedFileUploadByEnvVar && (
           <p className="alert alert-warning mt-2 text-start offset-3 col-6">
           <p className="alert alert-warning mt-2 text-start offset-3 col-6">
             <span className="material-symbols-outlined">help</span>
             <span className="material-symbols-outlined">help</span>
-            <b>FIXED</b><br />
+            <b>FIXED</b>
+            <br />
             {/* eslint-disable-next-line react/no-danger */}
             {/* eslint-disable-next-line react/no-danger */}
             <b dangerouslySetInnerHTML={{
             <b dangerouslySetInnerHTML={{
               __html: t('admin:app_setting.fixed_by_env_var', {
               __html: t('admin:app_setting.fixed_by_env_var', {
                 envKey: 'FILE_UPLOAD',
                 envKey: 'FILE_UPLOAD',
-                envVar: props.envFileUploadType,
+                envVar: data.envFileUploadType,
               }),
               }),
             }}
             }}
             />
             />
@@ -76,229 +139,43 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
         )}
         )}
       </div>
       </div>
 
 
-      {props.fileUploadType === 'aws' && (
+      {fileUploadType === 'aws' && (
         <AwsSettingMolecule
         <AwsSettingMolecule
-          s3ReferenceFileWithRelayMode={props.s3ReferenceFileWithRelayMode}
-          s3Region={props.s3Region}
-          s3CustomEndpoint={props.s3CustomEndpoint}
-          s3Bucket={props.s3Bucket}
-          s3AccessKeyId={props.s3AccessKeyId}
-          s3SecretAccessKey={props.s3SecretAccessKey}
-          onChangeS3ReferenceFileWithRelayMode={props.onChangeS3ReferenceFileWithRelayMode}
-          onChangeS3Region={props.onChangeS3Region}
-          onChangeS3CustomEndpoint={props.onChangeS3CustomEndpoint}
-          onChangeS3Bucket={props.onChangeS3Bucket}
-          onChangeS3AccessKeyId={props.onChangeS3AccessKeyId}
-          onChangeS3SecretAccessKey={props.onChangeS3SecretAccessKey}
+          register={register}
+          s3ReferenceFileWithRelayMode={s3RelayModeField.value}
+          onChangeS3ReferenceFileWithRelayMode={s3RelayModeField.onChange}
         />
         />
       )}
       )}
-      {props.fileUploadType === 'gcs' && (
+
+      {fileUploadType === 'gcs' && (
         <GcsSettingMolecule
         <GcsSettingMolecule
-          gcsReferenceFileWithRelayMode={props.gcsReferenceFileWithRelayMode}
-          gcsUseOnlyEnvVars={props.gcsUseOnlyEnvVars}
-          gcsApiKeyJsonPath={props.gcsApiKeyJsonPath}
-          gcsBucket={props.gcsBucket}
-          gcsUploadNamespace={props.gcsUploadNamespace}
-          envGcsApiKeyJsonPath={props.envGcsApiKeyJsonPath}
-          envGcsBucket={props.envGcsBucket}
-          envGcsUploadNamespace={props.envGcsUploadNamespace}
-          onChangeGcsReferenceFileWithRelayMode={props.onChangeGcsReferenceFileWithRelayMode}
-          onChangeGcsApiKeyJsonPath={props.onChangeGcsApiKeyJsonPath}
-          onChangeGcsBucket={props.onChangeGcsBucket}
-          onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
+          register={register}
+          gcsReferenceFileWithRelayMode={gcsRelayModeField.value}
+          gcsUseOnlyEnvVars={data.gcsUseOnlyEnvVars}
+          envGcsApiKeyJsonPath={data.envGcsApiKeyJsonPath}
+          envGcsBucket={data.envGcsBucket}
+          envGcsUploadNamespace={data.envGcsUploadNamespace}
+          onChangeGcsReferenceFileWithRelayMode={gcsRelayModeField.onChange}
         />
         />
       )}
       )}
-      {props.fileUploadType === 'azure' && (
+
+      {fileUploadType === 'azure' && (
         <AzureSettingMolecule
         <AzureSettingMolecule
-          azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
-          azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
-          azureTenantId={props.azureTenantId}
-          azureClientId={props.azureClientId}
-          azureClientSecret={props.azureClientSecret}
-          azureStorageAccountName={props.azureStorageAccountName}
-          azureStorageContainerName={props.azureStorageContainerName}
-          envAzureStorageAccountName={props.envAzureStorageAccountName}
-          envAzureStorageContainerName={props.envAzureStorageContainerName}
-          envAzureTenantId={props.envAzureTenantId}
-          envAzureClientId={props.envAzureClientId}
-          envAzureClientSecret={props.envAzureClientSecret}
-          onChangeAzureReferenceFileWithRelayMode={props.onChangeAzureReferenceFileWithRelayMode}
-          onChangeAzureTenantId={props.onChangeAzureTenantId}
-          onChangeAzureClientId={props.onChangeAzureClientId}
-          onChangeAzureClientSecret={props.onChangeAzureClientSecret}
-          onChangeAzureStorageAccountName={props.onChangeAzureStorageAccountName}
-          onChangeAzureStorageContainerName={props.onChangeAzureStorageContainerName}
+          register={register}
+          azureReferenceFileWithRelayMode={azureRelayModeField.value}
+          azureUseOnlyEnvVars={data.azureUseOnlyEnvVars}
+          envAzureTenantId={data.envAzureTenantId}
+          envAzureClientId={data.envAzureClientId}
+          envAzureClientSecret={data.envAzureClientSecret}
+          envAzureStorageAccountName={data.envAzureStorageAccountName}
+          envAzureStorageContainerName={data.envAzureStorageContainerName}
+          onChangeAzureReferenceFileWithRelayMode={azureRelayModeField.onChange}
         />
         />
       )}
       )}
-    </>
-  );
-});
-FileUploadSettingMolecule.displayName = 'FileUploadSettingMolecule';
-
-
-type FileUploadSettingProps = {
-  adminAppContainer: AdminAppContainer
-}
-
-const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
-  const { t } = useTranslation(['admin', 'commons']);
-  const { adminAppContainer } = props;
-
-  const {
-    fileUploadType, isFixedFileUploadByEnvVar, envFileUploadType, retrieveError,
-    s3ReferenceFileWithRelayMode,
-    s3Region, s3CustomEndpoint, s3Bucket,
-    s3AccessKeyId, s3SecretAccessKey,
-    gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
-    gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
-    envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
-    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
-    azureTenantId, azureClientId, azureClientSecret,
-    azureStorageAccountName, azureStorageContainerName,
-    envAzureTenantId, envAzureClientId, envAzureClientSecret,
-    envAzureStorageAccountName, envAzureStorageContainerName,
-  } = adminAppContainer.state;
-
-  const submitHandler = useCallback(async() => {
-    try {
-      await adminAppContainer.updateFileUploadSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminAppContainer, t]);
-
-  const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
-    adminAppContainer.changeFileUploadType(type);
-  }, [adminAppContainer]);
-
-  // S3
-  const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeS3ReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3RegionHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3Region(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3CustomEndpoint(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3BucketHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3Bucket(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3AccessKeyId(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3SecretAccessKey(val);
-  }, [adminAppContainer]);
-
-  // GCS
-  const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeGcsReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
 
 
-  const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsApiKeyJsonPath(val);
-  }, [adminAppContainer]);
-
-  const onChangeGcsBucketHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsBucket(val);
-  }, [adminAppContainer]);
-
-  const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsUploadNamespace(val);
-  }, [adminAppContainer]);
-
-  // Azure
-  const onChangeAzureReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeAzureReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureTenantIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureTenantId(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureClientIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureClientId(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureClientSecretHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureClientSecret(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureStorageAccountNameHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureStorageAccountName(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureStorageContainerNameHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureStorageContainerName(val);
-  }, [adminAppContainer]);
-
-  return (
-    <>
-      <FileUploadSettingMolecule
-        fileUploadType={fileUploadType}
-        isFixedFileUploadByEnvVar={isFixedFileUploadByEnvVar}
-        envFileUploadType={envFileUploadType}
-        onChangeFileUploadType={onChangeFileUploadTypeHandler}
-        s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
-        s3Region={s3Region}
-        s3CustomEndpoint={s3CustomEndpoint}
-        s3Bucket={s3Bucket}
-        s3AccessKeyId={s3AccessKeyId}
-        s3SecretAccessKey={s3SecretAccessKey}
-        onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
-        onChangeS3Region={onChangeS3RegionHandler}
-        onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
-        onChangeS3Bucket={onChangeS3BucketHandler}
-        onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
-        onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
-        gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
-        gcsUseOnlyEnvVars={gcsUseOnlyEnvVars}
-        gcsApiKeyJsonPath={gcsApiKeyJsonPath}
-        gcsBucket={gcsBucket}
-        gcsUploadNamespace={gcsUploadNamespace}
-        envGcsApiKeyJsonPath={envGcsApiKeyJsonPath}
-        envGcsBucket={envGcsBucket}
-        envGcsUploadNamespace={envGcsUploadNamespace}
-        onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
-        onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
-        onChangeGcsBucket={onChangeGcsBucketHandler}
-        onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
-        azureReferenceFileWithRelayMode={azureReferenceFileWithRelayMode}
-        azureUseOnlyEnvVars={azureUseOnlyEnvVars}
-        azureTenantId={azureTenantId}
-        azureClientId={azureClientId}
-        azureClientSecret={azureClientSecret}
-        azureStorageAccountName={azureStorageAccountName}
-        azureStorageContainerName={azureStorageContainerName}
-        envAzureTenantId={envAzureTenantId}
-        envAzureClientId={envAzureClientId}
-        envAzureClientSecret={envAzureClientSecret}
-        envAzureStorageAccountName={envAzureStorageAccountName}
-        envAzureStorageContainerName={envAzureStorageContainerName}
-        onChangeAzureReferenceFileWithRelayMode={onChangeAzureReferenceFileWithRelayModeHandler}
-        onChangeAzureTenantId={onChangeAzureTenantIdHandler}
-        onChangeAzureClientId={onChangeAzureClientIdHandler}
-        onChangeAzureClientSecret={onChangeAzureClientSecretHandler}
-        onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
-        onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
-      />
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
-    </>
+      <AdminUpdateButtonRow type="submit" disabled={isLoading} />
+    </form>
   );
   );
 };
 };
 
 
-
-/**
- * Wrapper component for using unstated
- */
-const FileUploadSettingWrapper = withUnstatedContainers(FileUploadSetting, [AdminAppContainer]);
-
-export default FileUploadSettingWrapper;
+export default FileUploadSetting;

+ 41 - 0
apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts

@@ -0,0 +1,41 @@
+export type FileUploadType = 'aws' | 'gcs' | 'azure' | 'local' | 'mongodb' | 'none';
+
+export type FileUploadFormValues = {
+  fileUploadType: FileUploadType
+  // AWS S3
+  s3Region: string
+  s3CustomEndpoint: string
+  s3Bucket: string
+  s3AccessKeyId: string
+  s3SecretAccessKey: string
+  s3ReferenceFileWithRelayMode: boolean
+  // GCS
+  gcsApiKeyJsonPath: string
+  gcsBucket: string
+  gcsUploadNamespace: string
+  gcsReferenceFileWithRelayMode: boolean
+  // Azure
+  azureTenantId: string
+  azureClientId: string
+  azureClientSecret: string
+  azureStorageAccountName: string
+  azureStorageContainerName: string
+  azureReferenceFileWithRelayMode: boolean
+};
+
+export type FileUploadSettingsData = FileUploadFormValues & {
+  isFixedFileUploadByEnvVar: boolean
+  envFileUploadType?: string
+  // GCS env vars
+  gcsUseOnlyEnvVars: boolean
+  envGcsApiKeyJsonPath?: string
+  envGcsBucket?: string
+  envGcsUploadNamespace?: string
+  // Azure env vars
+  azureUseOnlyEnvVars: boolean
+  envAzureTenantId?: string
+  envAzureClientId?: string
+  envAzureClientSecret?: string
+  envAzureStorageAccountName?: string
+  envAzureStorageContainerName?: string
+};

+ 14 - 28
apps/app/src/client/components/Admin/App/GcsSetting.tsx

@@ -1,21 +1,18 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 
 export type GcsSettingMoleculeProps = {
 export type GcsSettingMoleculeProps = {
-  gcsReferenceFileWithRelayMode
-  gcsUseOnlyEnvVars
-  gcsApiKeyJsonPath
-  gcsBucket
-  gcsUploadNamespace
-  envGcsApiKeyJsonPath?
-  envGcsBucket?
-  envGcsUploadNamespace?
+  register: UseFormRegister<FileUploadFormValues>
+  gcsReferenceFileWithRelayMode: boolean
+  gcsUseOnlyEnvVars: boolean
+  envGcsApiKeyJsonPath?: string
+  envGcsBucket?: string
+  envGcsUploadNamespace?: string
   onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
   onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeGcsApiKeyJsonPath: (val: string) => void
-  onChangeGcsBucket: (val: string) => void
-  onChangeGcsUploadNamespace: (val: string) => void
 };
 };
 
 
 export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
 export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
@@ -24,17 +21,13 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
   const {
   const {
     gcsReferenceFileWithRelayMode,
     gcsReferenceFileWithRelayMode,
     gcsUseOnlyEnvVars,
     gcsUseOnlyEnvVars,
-    gcsApiKeyJsonPath,
     envGcsApiKeyJsonPath,
     envGcsApiKeyJsonPath,
-    gcsBucket,
     envGcsBucket,
     envGcsBucket,
-    gcsUploadNamespace,
     envGcsUploadNamespace,
     envGcsUploadNamespace,
   } = props;
   } = props;
 
 
   return (
   return (
     <>
     <>
-
       <div className="row my-3">
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
@@ -57,16 +50,16 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(true) }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(false) }}
               >
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
             </div>
             </div>
 
 
@@ -106,10 +99,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="text"
                 type="text"
-                name="gcsApiKeyJsonPath"
                 readOnly={gcsUseOnlyEnvVars}
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsApiKeyJsonPath}
-                onChange={e => props?.onChangeGcsApiKeyJsonPath(e.target.value)}
+                {...props.register('gcsApiKeyJsonPath')}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -126,10 +117,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="text"
                 type="text"
-                name="gcsBucket"
                 readOnly={gcsUseOnlyEnvVars}
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsBucket}
-                onChange={e => props?.onChangeGcsBucket(e.target.value)}
+                {...props.register('gcsBucket')}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -146,10 +135,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
               <input
                 className="form-control"
                 className="form-control"
                 type="text"
                 type="text"
-                name="gcsUploadNamespace"
                 readOnly={gcsUseOnlyEnvVars}
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsUploadNamespace}
-                onChange={e => props?.onChangeGcsUploadNamespace(e.target.value)}
+                {...props.register('gcsUploadNamespace')}
               />
               />
             </td>
             </td>
             <td>
             <td>
@@ -162,7 +149,6 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
           </tr>
           </tr>
         </tbody>
         </tbody>
       </table>
       </table>
-
     </>
     </>
   );
   );
 };
 };

+ 63 - 19
apps/app/src/client/components/Admin/App/MailSetting.tsx

@@ -1,14 +1,15 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import SesSetting from './SesSetting';
-import SmtpSetting from './SmtpSetting';
+import { SesSetting } from './SesSetting';
+import { SmtpSetting } from './SmtpSetting';
 
 
 
 
 type Props = {
 type Props = {
@@ -22,15 +23,61 @@ const MailSetting = (props: Props) => {
 
 
   const transmissionMethods = ['smtp', 'ses'];
   const transmissionMethods = ['smtp', 'ses'];
 
 
-  async function submitHandler() {
+  const {
+    register,
+    handleSubmit,
+    reset,
+    watch,
+  } = useForm();
+
+  // Watch the transmission method to dynamically switch between SMTP and SES settings
+  const currentTransmissionMethod = watch('transmissionMethod', adminAppContainer.state.transmissionMethod || 'smtp');
+
+  // Reset form when adminAppContainer state changes
+  useEffect(() => {
+    reset({
+      fromAddress: adminAppContainer.state.fromAddress || '',
+      transmissionMethod: adminAppContainer.state.transmissionMethod || 'smtp',
+      smtpHost: adminAppContainer.state.smtpHost || '',
+      smtpPort: adminAppContainer.state.smtpPort || '',
+      smtpUser: adminAppContainer.state.smtpUser || '',
+      smtpPassword: adminAppContainer.state.smtpPassword || '',
+      sesAccessKeyId: adminAppContainer.state.sesAccessKeyId || '',
+      sesSecretAccessKey: adminAppContainer.state.sesSecretAccessKey || '',
+    });
+  }, [
+    adminAppContainer.state.fromAddress,
+    adminAppContainer.state.transmissionMethod,
+    adminAppContainer.state.smtpHost,
+    adminAppContainer.state.smtpPort,
+    adminAppContainer.state.smtpUser,
+    adminAppContainer.state.smtpPassword,
+    adminAppContainer.state.sesAccessKeyId,
+    adminAppContainer.state.sesSecretAccessKey,
+    reset,
+  ]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
     try {
+      // Await all setState completions before API call
+      await Promise.all([
+        adminAppContainer.changeFromAddress(data.fromAddress),
+        adminAppContainer.changeTransmissionMethod(data.transmissionMethod),
+        adminAppContainer.changeSmtpHost(data.smtpHost),
+        adminAppContainer.changeSmtpPort(data.smtpPort),
+        adminAppContainer.changeSmtpUser(data.smtpUser),
+        adminAppContainer.changeSmtpPassword(data.smtpPassword),
+        adminAppContainer.changeSesAccessKeyId(data.sesAccessKeyId),
+        adminAppContainer.changeSesSecretAccessKey(data.sesSecretAccessKey),
+      ]);
+
       await adminAppContainer.updateMailSettingHandler();
       await adminAppContainer.updateMailSettingHandler();
       toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings'), ns: 'commons' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }
+  }, [adminAppContainer, t]);
 
 
   async function sendTestEmailHandler() {
   async function sendTestEmailHandler() {
     const { adminAppContainer } = props;
     const { adminAppContainer } = props;
@@ -45,19 +92,18 @@ const MailSetting = (props: Props) => {
 
 
 
 
   return (
   return (
-    <React.Fragment>
+    <form onSubmit={handleSubmit(onSubmit)}>
       {!adminAppContainer.state.isMailerSetup && (
       {!adminAppContainer.state.isMailerSetup && (
         <div className="alert alert-danger"><span className="material-symbols-outlined">error</span> {t('admin:app_setting.mailer_is_not_set_up')}</div>
         <div className="alert alert-danger"><span className="material-symbols-outlined">error</span> {t('admin:app_setting.mailer_is_not_set_up')}</div>
       )}
       )}
-      <div className="row mb-5">
+      <div className="row mb-4">
         <label className="col-md-3 col-form-label text-end">{t('admin:app_setting.from_e-mail_address')}</label>
         <label className="col-md-3 col-form-label text-end">{t('admin:app_setting.from_e-mail_address')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
             type="text"
             type="text"
             placeholder={`${t('eg')} mail@growi.org`}
             placeholder={`${t('eg')} mail@growi.org`}
-            value={adminAppContainer.state.fromAddress || ''}
-            onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
+            {...register('fromAddress')}
           />
           />
         </div>
         </div>
       </div>
       </div>
@@ -73,12 +119,9 @@ const MailSetting = (props: Props) => {
                 <input
                 <input
                   type="radio"
                   type="radio"
                   className="form-check-input"
                   className="form-check-input"
-                  name="transmission-method"
                   id={`transmission-method-radio-${method}`}
                   id={`transmission-method-radio-${method}`}
-                  checked={adminAppContainer.state.transmissionMethod === method}
-                  onChange={(e) => {
-                    adminAppContainer.changeTransmissionMethod(method);
-                  }}
+                  value={method}
+                  {...register('transmissionMethod')}
                 />
                 />
                 <label className="form-label form-check-label" htmlFor={`transmission-method-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
                 <label className="form-label form-check-label" htmlFor={`transmission-method-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
               </div>
               </div>
@@ -87,12 +130,13 @@ const MailSetting = (props: Props) => {
         </div>
         </div>
       </div>
       </div>
 
 
-      {adminAppContainer.state.transmissionMethod === 'smtp' && <SmtpSetting />}
-      {adminAppContainer.state.transmissionMethod === 'ses' && <SesSetting />}
+      {currentTransmissionMethod === 'smtp' && <SmtpSetting register={register} />}
+      {currentTransmissionMethod === 'ses' && <SesSetting register={register} />}
 
 
       <div className="row my-3">
       <div className="row my-3">
-        <div className="mx-auto">
-          <button type="button" className="btn btn-primary" onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+        <div className="col-md-3"></div>
+        <div className="col-md-9">
+          <button type="submit" className="btn btn-primary" disabled={adminAppContainer.state.retrieveError != null}>
             { t('Update') }
             { t('Update') }
           </button>
           </button>
           {adminAppContainer.state.transmissionMethod === 'smtp' && (
           {adminAppContainer.state.transmissionMethod === 'smtp' && (
@@ -102,7 +146,7 @@ const MailSetting = (props: Props) => {
           )}
           )}
         </div>
         </div>
       </div>
       </div>
-    </React.Fragment>
+    </form>
   );
   );
 
 
 };
 };

+ 20 - 7
apps/app/src/client/components/Admin/App/MaskedInput.tsx

@@ -1,13 +1,19 @@
+import type { ChangeEvent } from 'react';
 import { useState, type JSX } from 'react';
 import { useState, type JSX } from 'react';
 
 
+import type { UseFormRegister } from 'react-hook-form';
+
 import styles from './MaskedInput.module.scss';
 import styles from './MaskedInput.module.scss';
 
 
 type Props = {
 type Props = {
-  name: string
+  name?: string
   readOnly: boolean
   readOnly: boolean
-  value: string
-  onChange?: (e: any) => void
+  value?: string
+  onChange?: (e: ChangeEvent<HTMLInputElement>) => void
   tabIndex?: number | undefined
   tabIndex?: number | undefined
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register?: UseFormRegister<any>
+  fieldName?: string
 };
 };
 
 
 export default function MaskedInput(props: Props): JSX.Element {
 export default function MaskedInput(props: Props): JSX.Element {
@@ -17,19 +23,26 @@ export default function MaskedInput(props: Props): JSX.Element {
   };
   };
 
 
   const {
   const {
-    name, readOnly, value, onChange, tabIndex,
+    name, readOnly, value, onChange, tabIndex, register, fieldName,
   } = props;
   } = props;
 
 
+  // Use register if provided, otherwise use value/onChange
+  const inputProps = register && fieldName
+    ? register(fieldName)
+    : {
+      name,
+      value,
+      onChange,
+    };
+
   return (
   return (
     <div className={styles.MaskedInput}>
     <div className={styles.MaskedInput}>
       <input
       <input
         className="form-control"
         className="form-control"
         type={passwordShown ? 'text' : 'password'}
         type={passwordShown ? 'text' : 'password'}
-        name={name}
         readOnly={readOnly}
         readOnly={readOnly}
-        value={value}
-        onChange={onChange}
         tabIndex={tabIndex}
         tabIndex={tabIndex}
+        {...inputProps}
       />
       />
       <span onClick={togglePassword} className={styles.PasswordReveal}>
       <span onClick={togglePassword} className={styles.PasswordReveal}>
         {passwordShown ? (
         {passwordShown ? (

+ 14 - 14
apps/app/src/client/components/Admin/App/SesSetting.tsx

@@ -1,20 +1,24 @@
 
 
 import React from 'react';
 import React from 'react';
 
 
+import type { UseFormRegister } from 'react-hook-form';
+
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 type Props = {
 type Props = {
-  adminAppContainer: AdminAppContainer,
+  adminAppContainer?: AdminAppContainer,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register: UseFormRegister<any>,
 }
 }
 
 
-const SmtpSetting = (props: Props) => {
-  const { adminAppContainer } = props;
+const SesSetting = (props: Props): JSX.Element => {
+  const { register } = props;
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
-      <div id="mail-smtp" className="tab-pane active mt-5">
+      <div id="mail-ses" className="tab-pane active">
 
 
         <div className="row">
         <div className="row">
           <label className="text-start text-md-end col-md-3 col-form-label">
           <label className="text-start text-md-end col-md-3 col-form-label">
@@ -24,10 +28,7 @@ const SmtpSetting = (props: Props) => {
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
-              value={adminAppContainer.state.sesAccessKeyId || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSesAccessKeyId(e.target.value);
-              }}
+              {...register('sesAccessKeyId')}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -40,10 +41,7 @@ const SmtpSetting = (props: Props) => {
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
-              value={adminAppContainer.state.sesSecretAccessKey || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSesSecretAccessKey(e.target.value);
-              }}
+              {...register('sesSecretAccessKey')}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -53,9 +51,11 @@ const SmtpSetting = (props: Props) => {
   );
   );
 };
 };
 
 
+export { SesSetting };
+
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SmtpSettingWrapper = withUnstatedContainers(SmtpSetting, [AdminAppContainer]);
+const SesSettingWrapper = withUnstatedContainers(SesSetting, [AdminAppContainer]);
 
 
-export default SmtpSettingWrapper;
+export default SesSettingWrapper;

+ 22 - 9
apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -21,9 +22,23 @@ const SiteUrlSetting = (props: Props) => {
   const { t: tCommon } = useTranslation('commons');
   const { t: tCommon } = useTranslation('commons');
   const { adminAppContainer } = props;
   const { adminAppContainer } = props;
 
 
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
 
 
-  const submitHandler = useCallback(async() => {
+  // Reset form when adminAppContainer state changes
+  useEffect(() => {
+    reset({
+      siteUrl: adminAppContainer.state.siteUrl || '',
+    });
+  }, [adminAppContainer.state.siteUrl, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
     try {
+      // Await setState completion before API call
+      await adminAppContainer.changeSiteUrl(data.siteUrl);
       await adminAppContainer.updateSiteUrlSettingHandler();
       await adminAppContainer.updateSiteUrlSettingHandler();
       toastSuccess(tCommon('toaster.update_successed', { target: t('site_url.title') }));
       toastSuccess(tCommon('toaster.update_successed', { target: t('site_url.title') }));
     }
     }
@@ -34,7 +49,7 @@ const SiteUrlSetting = (props: Props) => {
   }, [adminAppContainer, t, tCommon]);
   }, [adminAppContainer, t, tCommon]);
 
 
   return (
   return (
-    <React.Fragment>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <p className="card custom-card bg-body-tertiary">{t('site_url.desc')}</p>
       <p className="card custom-card bg-body-tertiary">{t('site_url.desc')}</p>
       {!adminAppContainer.state.isSetSiteUrl
       {!adminAppContainer.state.isSetSiteUrl
           && (<p className="alert alert-danger"><span className="material-symbols-outlined">error</span> {t('site_url.warn')}</p>)}
           && (<p className="alert alert-danger"><span className="material-symbols-outlined">error</span> {t('site_url.warn')}</p>)}
@@ -69,11 +84,9 @@ const SiteUrlSetting = (props: Props) => {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
-                  name="settingForm[app:siteUrl]"
-                  value={adminAppContainer.state.siteUrl || ''}
-                  disabled={adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true}
-                  onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
+                  readOnly={adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true}
                   placeholder="e.g. https://my.growi.org"
                   placeholder="e.g. https://my.growi.org"
+                  {...register('siteUrl')}
                 />
                 />
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   {/* eslint-disable-next-line react/no-danger */}
                   {/* eslint-disable-next-line react/no-danger */}
@@ -92,8 +105,8 @@ const SiteUrlSetting = (props: Props) => {
         </table>
         </table>
       </div>
       </div>
 
 
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-    </React.Fragment>
+      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+    </form>
   );
   );
 };
 };
 
 

+ 13 - 12
apps/app/src/client/components/Admin/App/SmtpSetting.tsx

@@ -2,6 +2,7 @@
 import React from 'react';
 import React from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
@@ -9,16 +10,18 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 
 
 type Props = {
 type Props = {
-  adminAppContainer: AdminAppContainer,
+  adminAppContainer?: AdminAppContainer,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register: UseFormRegister<any>,
 }
 }
 
 
-const SmtpSetting = (props: Props) => {
+const SmtpSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
+  const { register } = props;
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
-      <div id="mail-smtp" className="tab-pane active mt-5">
+      <div id="mail-smtp" className="tab-pane active">
         <div className="row">
         <div className="row">
           <label className="text-start text-md-end col-md-3 col-form-label">
           <label className="text-start text-md-end col-md-3 col-form-label">
             {t('admin:app_setting.host')}
             {t('admin:app_setting.host')}
@@ -27,8 +30,7 @@ const SmtpSetting = (props: Props) => {
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
-              value={adminAppContainer.state.smtpHost || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+              {...register('smtpHost')}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -40,8 +42,7 @@ const SmtpSetting = (props: Props) => {
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
-              value={adminAppContainer.state.smtpPort || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+              {...register('smtpPort')}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -54,8 +55,7 @@ const SmtpSetting = (props: Props) => {
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
-              value={adminAppContainer.state.smtpUser || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+              {...register('smtpUser')}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -68,8 +68,7 @@ const SmtpSetting = (props: Props) => {
             <input
             <input
               className="form-control"
               className="form-control"
               type="password"
               type="password"
-              value={adminAppContainer.state.smtpPassword || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+              {...register('smtpPassword')}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -78,6 +77,8 @@ const SmtpSetting = (props: Props) => {
   );
   );
 };
 };
 
 
+export { SmtpSetting };
+
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */

+ 395 - 0
apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts

@@ -0,0 +1,395 @@
+import { describe, it, expect } from 'vitest';
+
+import type { FileUploadFormValues, FileUploadSettingsData } from './FileUploadSetting.types';
+
+/**
+ * Helper function to build settings data (mimics useFileUploadSettings fetchData logic)
+ */
+function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSettingsData {
+  return {
+    // File upload type
+    fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
+      ? appSettingsParams.envFileUploadType
+      : appSettingsParams.fileUploadType,
+    isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+    envFileUploadType: appSettingsParams.envFileUploadType,
+
+    // AWS S3
+    s3Region: appSettingsParams.s3Region || '',
+    s3CustomEndpoint: appSettingsParams.s3CustomEndpoint || '',
+    s3Bucket: appSettingsParams.s3Bucket || '',
+    s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
+    s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
+    s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+
+    // GCS
+    gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
+    gcsBucket: appSettingsParams.gcsBucket || '',
+    gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
+    gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+    gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
+    envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+    envGcsBucket: appSettingsParams.envGcsBucket,
+    envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+    // Azure
+    azureTenantId: appSettingsParams.azureTenantId || '',
+    azureClientId: appSettingsParams.azureClientId || '',
+    azureClientSecret: appSettingsParams.azureClientSecret || '',
+    azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
+    azureStorageContainerName: appSettingsParams.azureStorageContainerName || '',
+    azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode || false,
+    azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
+    envAzureTenantId: appSettingsParams.envAzureTenantId,
+    envAzureClientId: appSettingsParams.envAzureClientId,
+    envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+    envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+    envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+  };
+}
+
+/**
+ * Helper function to build request params (mimics useFileUploadSettings updateSettings logic)
+ */
+function buildRequestParams(
+    formData: FileUploadFormValues,
+    dirtyFields: Partial<Record<keyof FileUploadFormValues, boolean>>,
+): Record<string, any> {
+  const { fileUploadType } = formData;
+
+  const requestParams: Record<string, any> = {
+    fileUploadType,
+  };
+
+  if (fileUploadType === 'aws') {
+    requestParams.s3Region = formData.s3Region;
+    requestParams.s3CustomEndpoint = formData.s3CustomEndpoint;
+    requestParams.s3Bucket = formData.s3Bucket;
+    requestParams.s3AccessKeyId = formData.s3AccessKeyId;
+    // Only include secret access key if it was changed
+    if (dirtyFields.s3SecretAccessKey) {
+      requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
+    }
+    requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+  }
+
+  if (fileUploadType === 'gcs') {
+    requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
+    requestParams.gcsBucket = formData.gcsBucket;
+    requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
+    requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+  }
+
+  if (fileUploadType === 'azure') {
+    // Only include secret fields if they were changed
+    if (dirtyFields.azureTenantId) {
+      requestParams.azureTenantId = formData.azureTenantId;
+    }
+    if (dirtyFields.azureClientId) {
+      requestParams.azureClientId = formData.azureClientId;
+    }
+    if (dirtyFields.azureClientSecret) {
+      requestParams.azureClientSecret = formData.azureClientSecret;
+    }
+    requestParams.azureStorageAccountName = formData.azureStorageAccountName;
+    requestParams.azureStorageContainerName = formData.azureStorageContainerName;
+    requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+  }
+
+  return requestParams;
+}
+
+describe('useFileUploadSettings - fileUploadType selection with useOnlyEnvVarForFileUploadType', () => {
+  it('should use envFileUploadType when useOnlyEnvVarForFileUploadType is true', () => {
+    const appSettingsParams = {
+      fileUploadType: 'local',
+      envFileUploadType: 'aws',
+      useOnlyEnvVarForFileUploadType: true,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('aws');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(true);
+    expect(settingsData.envFileUploadType).toBe('aws');
+  });
+
+  it('should use fileUploadType when useOnlyEnvVarForFileUploadType is false', () => {
+    const appSettingsParams = {
+      fileUploadType: 'gcs',
+      envFileUploadType: 'aws',
+      useOnlyEnvVarForFileUploadType: false,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('gcs');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(false);
+    expect(settingsData.envFileUploadType).toBe('aws');
+  });
+
+  it('should use fileUploadType when useOnlyEnvVarForFileUploadType is undefined', () => {
+    const appSettingsParams = {
+      fileUploadType: 'azure',
+      envFileUploadType: 'aws',
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('azure');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(false);
+  });
+
+  it('should prioritize envFileUploadType over fileUploadType when env var is enforced', () => {
+    const appSettingsParams = {
+      fileUploadType: 'local',
+      envFileUploadType: 'gcs',
+      useOnlyEnvVarForFileUploadType: true,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    // Even though DB has 'local', env var 'gcs' should be used
+    expect(settingsData.fileUploadType).toBe('gcs');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(true);
+  });
+});
+
+describe('useFileUploadSettings - secret field dirty tracking', () => {
+  it('should NOT include s3SecretAccessKey in request when it is not dirty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: '***existing-secret***', // Not changed
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3ReferenceFileWithRelayMode: true,
+      // s3SecretAccessKey is NOT marked as dirty
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3ReferenceFileWithRelayMode: true,
+    });
+
+    // Verify s3SecretAccessKey is NOT in the request
+    expect(requestParams).not.toHaveProperty('s3SecretAccessKey');
+  });
+
+  it('should include s3SecretAccessKey in request when it is dirty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: 'new-secret-key', // Changed
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3SecretAccessKey: true, // Marked as dirty
+      s3ReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: 'new-secret-key',
+      s3ReferenceFileWithRelayMode: true,
+    });
+  });
+
+  it('should include empty string for s3SecretAccessKey when explicitly set to empty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: '', // Explicitly cleared
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3SecretAccessKey: true, // Marked as dirty
+      s3ReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toHaveProperty('s3SecretAccessKey', '');
+  });
+
+  it('should NOT include Azure secret fields in request when they are not dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '***existing-tenant***', // Not changed
+      azureClientId: '***existing-client***', // Not changed
+      azureClientSecret: '***existing-secret***', // Not changed
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+      // Azure secret fields are NOT marked as dirty
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).not.toHaveProperty('azureTenantId');
+    expect(requestParams).not.toHaveProperty('azureClientId');
+    expect(requestParams).not.toHaveProperty('azureClientSecret');
+    expect(requestParams).toHaveProperty('azureStorageAccountName', 'new-account');
+    expect(requestParams).toHaveProperty('azureStorageContainerName', 'new-container');
+  });
+
+  it('should include Azure secret fields in request when they are dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: 'new-tenant-id',
+      azureClientId: 'new-client-id',
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureTenantId: true,
+      azureClientId: true,
+      azureClientSecret: true,
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'azure',
+      azureTenantId: 'new-tenant-id',
+      azureClientId: 'new-client-id',
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    });
+  });
+
+  it('should include only some Azure secret fields when only some are dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: 'new-tenant-id',
+      azureClientId: '***existing-client***', // Not changed
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureTenantId: true, // Marked as dirty
+      // azureClientId is NOT marked as dirty
+      azureClientSecret: true, // Marked as dirty
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toHaveProperty('azureTenantId', 'new-tenant-id');
+    expect(requestParams).not.toHaveProperty('azureClientId');
+    expect(requestParams).toHaveProperty('azureClientSecret', 'new-client-secret');
+  });
+});

+ 141 - 0
apps/app/src/client/components/Admin/App/useFileUploadSettings.ts

@@ -0,0 +1,141 @@
+import { useState, useEffect } from 'react';
+
+import type { FieldNamesMarkedBoolean } from 'react-hook-form';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+
+import type { FileUploadSettingsData, FileUploadFormValues } from './FileUploadSetting.types';
+
+type UseFileUploadSettingsReturn = {
+  data: FileUploadSettingsData | null
+  isLoading: boolean
+  error: Error | null
+  updateSettings: (formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>) => Promise<void>
+};
+
+export function useFileUploadSettings(): UseFileUploadSettingsReturn {
+  const [data, setData] = useState<FileUploadSettingsData | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    const fetchData = async() => {
+      try {
+        setIsLoading(true);
+        const response = await apiv3Get('/app-settings/');
+        const { appSettingsParams } = response.data;
+
+        const settingsData: FileUploadSettingsData = {
+          // File upload type
+          fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
+            ? appSettingsParams.envFileUploadType
+            : appSettingsParams.fileUploadType,
+          isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+          envFileUploadType: appSettingsParams.envFileUploadType,
+
+          // AWS S3
+          s3Region: appSettingsParams.s3Region || '',
+          s3CustomEndpoint: appSettingsParams.s3CustomEndpoint || '',
+          s3Bucket: appSettingsParams.s3Bucket || '',
+          s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
+          s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
+          s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+
+          // GCS
+          gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
+          gcsBucket: appSettingsParams.gcsBucket || '',
+          gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
+          gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+          gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
+          envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+          envGcsBucket: appSettingsParams.envGcsBucket,
+          envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+          // Azure
+          azureTenantId: appSettingsParams.azureTenantId || '',
+          azureClientId: appSettingsParams.azureClientId || '',
+          azureClientSecret: appSettingsParams.azureClientSecret || '',
+          azureStorageAccountName: appSettingsParams.azureStorageAccountName || '',
+          azureStorageContainerName: appSettingsParams.azureStorageContainerName || '',
+          azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode || false,
+          azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars || false,
+          envAzureTenantId: appSettingsParams.envAzureTenantId,
+          envAzureClientId: appSettingsParams.envAzureClientId,
+          envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+          envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+          envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+        };
+
+        setData(settingsData);
+        setError(null);
+      }
+      catch (err) {
+        setError(err instanceof Error ? err : new Error('Failed to fetch settings'));
+      }
+      finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchData();
+  }, []);
+
+  const updateSettings = async(formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>): Promise<void> => {
+    const { fileUploadType } = formData;
+
+    const requestParams: Record<string, any> = {
+      fileUploadType,
+    };
+
+    // Add fields based on upload type
+    if (fileUploadType === 'aws') {
+      requestParams.s3Region = formData.s3Region;
+      requestParams.s3CustomEndpoint = formData.s3CustomEndpoint;
+      requestParams.s3Bucket = formData.s3Bucket;
+      requestParams.s3AccessKeyId = formData.s3AccessKeyId;
+      // Only include secret access key if it was changed
+      if (dirtyFields.s3SecretAccessKey) {
+        requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
+      }
+      requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+    }
+
+    if (fileUploadType === 'gcs') {
+      requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
+      requestParams.gcsBucket = formData.gcsBucket;
+      requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
+      requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+    }
+
+    if (fileUploadType === 'azure') {
+      // Only include secret fields if they were changed
+      if (dirtyFields.azureTenantId) {
+        requestParams.azureTenantId = formData.azureTenantId;
+      }
+      if (dirtyFields.azureClientId) {
+        requestParams.azureClientId = formData.azureClientId;
+      }
+      if (dirtyFields.azureClientSecret) {
+        requestParams.azureClientSecret = formData.azureClientSecret;
+      }
+      requestParams.azureStorageAccountName = formData.azureStorageAccountName;
+      requestParams.azureStorageContainerName = formData.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+    }
+
+    const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
+    const { responseParams } = response.data;
+
+    // Update local state with response
+    if (data) {
+      setData({
+        ...data,
+        ...responseParams,
+      });
+    }
+  };
+
+  return {
+    data, isLoading, error, updateSettings,
+  };
+}

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