ソースを参照

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

Futa Arai 4 ヶ月 前
コミット
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",
-  "changelog": ["@changesets/changelog-github", { "repo": "weseek/growi" }],
+  "changelog": ["@changesets/changelog-github", { "repo": "growilabs/growi" }],
   "commit": false,
   "fixed": [],
   "linked": [],

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

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

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

@@ -3,7 +3,7 @@
 services:
   pdf-converter:
     # 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:
       - ..:/workspace/growi:delegated
       - pnpm-store:/workspace/.pnpm-store

+ 1 - 1
.devcontainer/compose.yml

@@ -21,7 +21,7 @@ services:
       - /data/db
 
   # 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:
     build:
       context: ../../growi-docker-compose/elasticsearch/v9

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

@@ -18,7 +18,7 @@ Environment
 |Using Docker|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)*
 

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

@@ -1,7 +1,7 @@
 blank_issues_enabled: false
 contact_links:
   - 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.
   - name: Questions
     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-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-
-      - -check-failure ~= test-prod-node22 /
+      - -check-failure ~= test-prod-node20 /
     merge_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - 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-slackbot-
-      - -check-failure ~= test-prod-node22 /
+      - -check-failure ~= test-prod-node20 /
 
 pull_request_rules:
   - name: Automatic queue to merge

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

@@ -39,8 +39,8 @@ concurrency:
 
 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: |
       ( github.event_name == 'push'
         || github.base_ref == 'master'
@@ -48,14 +48,14 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
-      node-version: 20.x
+      node-version: 18.x
       skip-e2e-test: true
     secrets:
       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: |
       ( github.event_name == 'push'
         || github.base_ref == 'master'
@@ -63,7 +63,7 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
-      node-version: 22.x
+      node-version: 20.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -71,7 +71,7 @@ jobs:
   # run-reg-suit-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()
 

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

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

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

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

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

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

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

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

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

@@ -28,7 +28,9 @@ jobs:
         images: growilabs/pdf-converter
         tags: |
           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
       run: |
@@ -72,7 +74,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
     steps:
     - uses: actions/checkout@v4

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

@@ -46,9 +46,9 @@ jobs:
 
 
   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:
-      image-name: weseek/growi
+      image-name: growilabs/growi
       tag-temporary: latest-rc
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
@@ -57,11 +57,12 @@ jobs:
   publish-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:
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io
       image-name: weseek/growi
+      docker-registry-username: wsmoogle
       tag-temporary: latest-rc
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

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

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

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

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

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

@@ -1,3 +1,4 @@
+# TODO: https://redmine.weseek.co.jp/issues/171293
 name: Release
 
 on:
@@ -26,7 +27,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '22'
+        node-version: '20'
         cache: 'pnpm'
 
     - name: Install dependencies
@@ -80,7 +81,8 @@ jobs:
     runs-on: ubuntu-latest
 
     outputs:
-      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_WESEEK: ${{ steps.meta-weseek.outputs.tags }}
+      TAGS_GROWILABS: ${{ steps.meta-growilabs.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v4
@@ -89,9 +91,9 @@ jobs:
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
 
-    - name: Docker meta for docker.io
+    - name: Docker meta for weseek/growi
       uses: docker/metadata-action@v5
-      id: meta
+      id: meta-weseek
       with:
         images: docker.io/weseek/growi
         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}}.{{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:
     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:
       source-version: refs/tags/v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
-      image-name: weseek/growi
+      image-name: growilabs/growi
       tag-temporary: latest
     secrets:
       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]
 
-    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
-      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      tags: ${{ needs.determine-tags.outputs.TAGS_WESEEK }}
       registry: docker.io
-      image-name: weseek/growi
+      image-name: 'growilabs/growi'
+      docker-registry-username: 'wsmoogle'
       tag-temporary: latest
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
 
-
   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
 
+    strategy:
+      matrix:
+        include:
+          - repository: weseek/growi
+            username: wsmoogle
+          - repository: growilabs/growi
+            username: growimoogle
+
     steps:
     - uses: actions/checkout@v4
       with:
@@ -139,9 +172,9 @@ jobs:
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v4
       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
 
     - name: Slack Notification
@@ -153,7 +186,7 @@ jobs:
 
 
   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
 
     steps:
@@ -165,7 +198,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '22'
+        node-version: '20'
         cache: 'pnpm'
 
     - name: Install dependencies

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

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

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

@@ -11,7 +11,10 @@ on:
         default: 'docker.io'
       image-name:
         type: string
-        default: weseek/growi
+        default: growilabs/growi
+      docker-registry-username:
+        type: string
+        default: growimoogle
       tag-temporary:
         type: string
         default: latest
@@ -41,7 +44,7 @@ jobs:
       uses: docker/login-action@v3
       with:
         registry: ${{ inputs.registry }}
-        username: wsmoogle
+        username: ${{ inputs.docker-registry-username }}
         password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
 
     - 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:
   workflow_call:
@@ -16,7 +16,7 @@ on:
       node-version:
         required: true
         type: string
-        default: 20.x
+        default: 22.x
       skip-e2e-test:
         type: boolean
         default: false

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

@@ -32,7 +32,7 @@ jobs:
 
   run-reg-suit:
     # 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
 
     if: ${{ !inputs.skip-reg-suit }}

+ 1 - 1
.mcp.json

@@ -14,7 +14,7 @@
         "--context",
         "ide-assistant",
         "--project",
-        "/workspace/growi"
+        "."
       ],
       "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
 - **ライセンス**: MIT
 - **作者**: Yuki Takei <yuki@weseek.co.jp>
-- **リポジトリ**: https://github.com/weseek/growi.git
+- **リポジトリ**: https://github.com/growilabs/growi.git
 - **公式サイト**: https://growi.org
 
 ## 主な特徴

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

@@ -11,7 +11,7 @@ pnpm install
 ## 開発サーバー
 ```bash
 # メインアプリケーション開発モード
-cd apps/app && pnpm run dev
+cd /workspace/growi/apps/app && pnpm run dev
 
 # ルートから起動(本番用ビルド後)
 pnpm start
@@ -31,20 +31,26 @@ turbo run build
 
 ## Lint・フォーマット
 ```bash
+# 全てのLint実行
+pnpm run lint
+```
+
+## apps/app の Lint・フォーマット
+```bash
 # 【推奨】Biome実行(lint + format)
-pnpm run lint:biome
+cd /workspace/growi/apps/app pnpm run lint:biome
 
 # 【過渡期】ESLint実行(廃止予定)
-pnpm run lint:eslint
+cd /workspace/growi/apps/app pnpm run lint:eslint
 
 # 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型チェック
-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"
   },
 
+  "[json]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],
@@ -96,6 +100,7 @@
     {
       "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
 
-Copyright (c) 2018 WESEEK, Inc.
+Copyright (c) 2018 GROWI, Inc.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal

+ 14 - 14
README.md

@@ -6,7 +6,7 @@
   </a>
 </p>
 <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>
 </p>
 
@@ -16,10 +16,10 @@
 
 # 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
 <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
 
-- 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)
 - 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
 
 - 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
-[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>
 </p>
 <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>
 </p>
 
@@ -16,10 +16,10 @@
 
 # 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>
@@ -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)
 - MongoDB v6.x or v8.x
 
@@ -137,11 +137,11 @@ Issue と Pull requests の作成は英語・日本語どちらでも受け付
 # ライセンス
 
 - 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
-  [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.
 
 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

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

@@ -2,12 +2,8 @@
  * @type {import('eslint').Linter.Config}
  */
 module.exports = {
-  extends: [
-    'next/core-web-vitals',
-    'weseek/react',
-  ],
-  plugins: [
-  ],
+  extends: ['next/core-web-vitals', 'weseek/react'],
+  plugins: [],
   ignorePatterns: [
     'dist/**',
     '**/dist/**',
@@ -25,11 +21,17 @@ module.exports = {
     'test/integration/middlewares/**',
     'test/integration/migrations/**',
     'test/integration/models/**',
+    'test/integration/service/**',
     'test/integration/setup.js',
+    'playwright/**',
+    'test-with-vite/**',
+    'public/**',
     'bin/**',
     'config/**',
+    'src/styles/**',
     'src/linter-checker/**',
     'src/migrations/**',
+    'src/models/**',
     'src/features/callout/**',
     'src/features/comment/**',
     'src/features/templates/**',
@@ -38,10 +40,27 @@ module.exports = {
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
     'src/features/page-bulk-export/**',
+    'src/features/growi-plugin/**',
     'src/features/opentelemetry/**',
+    'src/features/openai/**',
+    'src/features/rate-limiter/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     '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: {
     // resolve path aliases by eslint-import-resolver-typescript
@@ -50,10 +69,6 @@ module.exports = {
     },
   },
   rules: {
-    'no-restricted-imports': ['error', {
-      name: 'axios',
-      message: 'Please use src/utils/axios instead.',
-    }],
     '@typescript-eslint/no-var-requires': 'off',
 
     // 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 { 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 { writeFileSync } from 'fs';
 
 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 { tmpdir } from 'os';
-import path from 'path';
 import { describe, expect, it } from 'vitest';
 
 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 { URL } = require('url');
+const { URL } = require('node:url');
 
 const { getMongoUri, mongoOptions } = isProduction
   ? // 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';
 
+// biome-ignore lint/style/useNodejsImportProtocol: ignore
 const path = require('path');
 
 const { AllLang } = require('@growi/core');

+ 2 - 2
apps/app/docker/Dockerfile

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

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

@@ -2,7 +2,7 @@
 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)
 
@@ -10,17 +10,17 @@ GROWI Official docker image
 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?
 -------------
 
-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
@@ -41,7 +41,7 @@ Usage
 ```bash
 docker run -d \
     -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_PORT/growi \
-    weseek/growi
+    growilabs/growi
 ```
 
 and go to `http://localhost:3000/` .
@@ -52,7 +52,7 @@ If you use ElasticSearch, type this:
 docker run -d \
     -e MONGO_URI=mongodb://MONGODB_HOST:MONGODB_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.
 
-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
@@ -76,5 +76,5 @@ See [GROWI Docs: Admin Guide](https://docs.growi.org/en/admin-guide/) ([en](http
 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.
 
 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 = [
-    "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: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" {
-  version     = "3.4.3"
+  version     = "3.7.2"
   constraints = ">= 2.1.0"
   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: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" {
-  version     = "4.0.4"
-  constraints = ">= 3.0.0"
+  version     = "4.1.0"
+  constraints = ">= 4.0.0"
   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:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
   ]
 }

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

@@ -11,7 +11,7 @@ phases:
   pre_build:
     commands:
       # 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:
     commands:
       - 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"
 
   source_type         = "GITHUB"
-  source_location     = "https://github.com/weseek/growi.git"
+  source_location     = "https://github.com/growilabs/growi.git"
   source_version      = "refs/heads/master"
   git_clone_depth     = 1
 

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

@@ -10,7 +10,7 @@ terraform {
   required_providers {
     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 = [
-    "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
  */
 
-const path = require('path');
+const path = require('node:path');
 
 const { withSuperjson } = require('next-superjson');
 const {
@@ -93,7 +93,7 @@ const optimizePackageImports = [
   '@growi/ui',
 ];
 
-module.exports = async (phase, { defaultConfig }) => {
+module.exports = async (phase) => {
   const { i18n, localePath } = require('./config/next-i18next.config');
 
   /** @type {import('next').NextConfig} */

+ 8 - 7
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.0-RC.0",
+  "version": "7.3.6-RC.0",
   "license": "MIT",
   "private": "true",
   "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",
     "lint:typecheck": "vue-tsc --noEmit",
     "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: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",
@@ -104,7 +104,7 @@
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
@@ -166,7 +166,7 @@
     "mkdirp": "^1.0.3",
     "mongodb": "^4.17.2",
     "mongoose": "^6.13.6",
-    "mongoose-gridfs": "^1.2.42",
+    "mongoose-gridfs": "^1.3.0",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
@@ -246,7 +246,7 @@
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "uuid": "^11.0.3",
-    "validator": "^13.7.0",
+    "validator": "^13.15.20",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
@@ -273,9 +273,7 @@
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
-    "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.5.0",
-    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",
@@ -290,12 +288,14 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
+    "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unist": "^3.0.3",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
     "@types/uuid": "^10.0.0",
+    "@types/ws": "^8.18.1",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
     "commander": "^14.0.0",
@@ -338,6 +338,7 @@
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
+    "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "unist-util-is": "^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 path from 'node:path';
-
 import { defineConfig, devices, type Project } from '@playwright/test';
 
 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.waitForURL('/installer');
 
@@ -11,18 +11,26 @@ test('Installer', async({ page }) => {
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
   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
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
   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
   await page.getByTestId('dropdownLanguage').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' })).toHaveAttribute('placeholder', 'User ID');
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute(
+    'placeholder',
+    'User ID',
+  );
 
   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 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');
 
   // Expect a title "to contain" a substring.
   await expect(page).toHaveTitle(/Sandbox/);
 });
 
-test('get h1', async({ page }) => {
+test('get h1', async ({ page }) => {
   await page.goto('/Sandbox');
 
   // 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');
 
   // Expect the Math-specific elements to be present
   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');
 
   // 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();
 });
 
-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');
 
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   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');
 
   // 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();
 });
 
-test('/trash is successfully loaded', async({ page }) => {
+test('/trash is successfully loaded', async ({ page }) => {
   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 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.getByTestId('pageListButton').click();
   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 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 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();
 });
 
-test('Successfully close modal', async({ page }) => {
+test('Successfully close modal', async ({ page }) => {
   await openPageAccessoriesModal(page);
   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 page.getByTestId('timeline-tab-button').click();
   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.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     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');
 
     // Subscribe
@@ -17,7 +17,7 @@ test.describe('Click page icons', () => {
     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();
 
     // Like
@@ -29,7 +29,7 @@ test.describe('Click page icons', () => {
     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();
 
     // Bookmark
@@ -41,10 +41,13 @@ test.describe('Click page icons', () => {
     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();
 
-    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);
   });
 });

+ 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', () => {
-
   // make tests run in serial
   test.describe.configure({ mode: 'serial' });
 
-  test('Create comment page', async({ page }) => {
+  test('Create comment page', async ({ page }) => {
     await page.goto('/comment');
     await page.getByTestId('editor-button').click();
     await page.getByTestId('save-page-btn').click();
     await expect(page.locator('.page-meta')).toBeVisible();
   });
 
-  test('Successfully add comments', async({ page }) => {
+  test('Successfully add comments', async ({ page }) => {
     const commentText = 'add comment';
     await page.goto('/comment');
 
@@ -23,10 +22,12 @@ test.describe('Comment', () => {
     await page.getByTestId('comment-submit-button').first().click();
 
     await expect(page.locator('.page-comment-body')).toHaveText(commentText);
-    await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('1');
+    await expect(
+      page.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';
     await page.goto('/comment');
 
@@ -35,8 +36,12 @@ test.describe('Comment', () => {
     await page.locator('.cm-content').fill(commentText);
     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 }) => {
@@ -51,5 +56,4 @@ test.describe('Comment', () => {
   // });
 
   // 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('click and autofocus to title text input', async({ page }) => {
+  test('click and autofocus to title text input', async ({ page }) => {
     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
     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('open and create today page', async({ page }) => {
+  test('open and create today page', async ({ page }) => {
     await page.goto('/');
 
     // open dropdown menu
     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();
 
     // 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('/');
 
   // 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();
 
   // 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.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     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
     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
     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
     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
     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
     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
     await page.getByTestId('editor-button').click();
     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
     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
     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 button = nav.getByTestId('open-page-item-control-btn');
 
@@ -19,7 +19,7 @@ const openPageItemControl = async(page: Page): Promise<void> => {
   await button.click();
 };
 
-test('PageDeleteModal is shown successfully', async({ page }) => {
+test('PageDeleteModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
 
   await openPageItemControl(page);
@@ -28,7 +28,7 @@ test('PageDeleteModal is shown successfully', async({ page }) => {
   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 openPageItemControl(page);
@@ -37,7 +37,7 @@ test('PageDuplicateModal is shown successfully', async({ page }) => {
   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 openPageItemControl(page);
@@ -57,35 +57,45 @@ test('PageMoveRenameModal is shown successfully', async({ page }) => {
 // });
 
 test.describe('Page Accessories Modal', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     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();
   });
 
-  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();
   });
 });
 
-test('Successfully add new tag', async({ page }) => {
+test('Successfully add new tag', async ({ page }) => {
   const tag = 'we';
   await page.goto('/Sandbox/Bootstrap5');
 
   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 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.getByTestId('tag-edit-done-btn').click();
   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';
 
-test('/Sandbox is successfully loaded', async({ page }) => {
-
+test('/Sandbox is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox');
 
   // Expect a title "to contain" a substring.
   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');
 
   // Check if the math elements are visible
   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');
 
   // Expect to be redirected to /login when accessing /me
   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');
 
   // Expect the trash page specific elements to be present when accessing /trash
   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 collapseSidebar(page, false);
   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
   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()).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 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');
 
   // Sticky
   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
   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';
 
-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 { 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 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 openPageCreateModalShortcutKey = 'c';
 
@@ -16,7 +16,10 @@ test('Successfully create page under specific path', async({ page }) => {
 
   await page.keyboard.press(openPageCreateModalShortcutKey);
   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();
   await page.getByTestId('view-button').click();
 
@@ -24,8 +27,9 @@ test('Successfully create page under specific path', async({ page }) => {
   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 body2 = ' world!';
   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 enText = "TODAY'S GOALS";
   await page.goto('/Sandbox/TemplateModal');
@@ -16,10 +16,14 @@ test('Successfully select template and template locale', async({ page }) => {
 
   // select template and template locale
   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-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
   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 path from 'path';
 
-import { test, expect, type Page } from '@playwright/test';
-
 /**
  * for the issues:
  * @see https://redmine.weseek.co.jp/issues/122040
  * @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');
 
   // 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 buffer = readFileSync(filePath).toString('base64');
   const dataTransfer = await page.evaluateHandle(
-    async({ bufferData, localFileName, localFileType }) => {
+    async ({ bufferData, localFileName, localFileType }) => {
       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, {
         type: localFileType,
@@ -43,33 +44,41 @@ test('should not be cleared and should prevent GrantSelector from modified', asy
       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
   await page.getByTestId('save-page-btn').click();
 
   // 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 expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 
 /**
  * for the issue:
  * @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 page2Path = '/Sandbox/for-115285/page2';
 
   const page1Body = 'Hello';
   const page2Body = 'World';
 
-
   await page.goto(page1Path);
 
   // Open Editor (page1)
@@ -85,7 +94,10 @@ test('Successfully updating the page body', async({ page }) => {
   await expect(page.locator('.main')).toContainText(page1Body);
 
   // 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 expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   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();
 
   // 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
   await appendTextToEditorUntilContains(page, page1Body + page2Body);
 
-
   await page.goto(page1Path);
 
   // Open Editor (page1)
   await page.getByTestId('editor-button').click();
   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
   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();
 });
 
-test('checkboxes behaviors', async({ page }) => {
+test('checkboxes behaviors', async ({ page }) => {
   // Navigate to the search page with query parameters
   await page.goto('/_search?q=alerts');
 
@@ -28,7 +28,10 @@ test('checkboxes behaviors', async({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
 
   // 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 });
 
   // 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 });
 });
 
-
-test('successfully loads /_private-legacy-pages', async({ page }) => {
+test('successfully loads /_private-legacy-pages', async ({ page }) => {
   await page.goto('/_private-legacy-pages');
 
   // 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.getByTestId('open-search-modal-button').click();
   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();
 });
 
-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.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=sand');
 
     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();
   });
 
-  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');
 
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(0).click();
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).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');
 
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(1).click();
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).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');
 
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(2).click();
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
@@ -155,22 +160,26 @@ test.describe('Sort with dropdown', () => {
 });
 
 test.describe('Search and use', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=alerts');
 
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).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();
   });
 
-  test('Successfully the dropdown is opened', async({ page }) => {
+  test('Successfully the dropdown is opened', async ({ page }) => {
     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');
 
     await expect(dropdonwMenu).toBeVisible();
@@ -178,10 +187,15 @@ test.describe('Search and use', () => {
     // Add bookmark
     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');
 
     await expect(dropdonwMenu).toBeVisible();
@@ -191,7 +205,7 @@ test.describe('Search and use', () => {
     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');
 
     await expect(dropdonwMenu).toBeVisible();
@@ -201,7 +215,7 @@ test.describe('Search and use', () => {
     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');
 
     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('/');
   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 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 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();
 });
 
-test('admin/security is successfully loaded', async({ page }) => {
+test('admin/security is successfully loaded', async ({ page }) => {
   await page.goto('/admin/security');
 
   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 expect(page.getByTestId('admin-markdown')).toBeVisible();
   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 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 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 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 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 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();
 });
 
-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 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();
 });
 
-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 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 expect(page.getByTestId('admin-users')).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 expect(page.getByTestId('admin-user-groups')).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 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';
 
-
 test.describe('Access to sidebar', () => {
-
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await collapseSidebar(page, false);
   });
 
-  test('Successfully show sidebar', async({ page }) => {
+  test('Successfully show sidebar', async ({ page }) => {
     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 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 expect(page.getByTestId('grw-recent-changes')).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 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');
     if (docsUrl == null) {
       throw new Error('url is null');
@@ -43,12 +47,13 @@ test.describe('Access to sidebar', () => {
     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();
   });
 
-
   //
   // 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();
   //   });
   // });
-
 });

+ 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';
 
-
-test('Switch sidebar mode', async({ page }) => {
+test('Switch sidebar mode', async ({ page }) => {
   await page.goto('/');
   await collapseSidebar(page, false);
   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');
 
   // Open PersonalDropdown
   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
   await page.getByTestId('grw-personal-dropdown-menu-user-home').click();
   await expect(page.getByTestId('grw-users-info')).toBeVisible();
 });
 
-test('Vist User settings', async({ page }) => {
+test('Vist User settings', async ({ page }) => {
   await page.goto('dummy');
 
   // Open PersonalDropdown
   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
   page.getByTestId('grw-personal-dropdown-menu-user-settings').click();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 
-test('Access User information', async({ page }) => {
+test('Access User information', async ({ page }) => {
   await page.goto('/me');
 
   // Click BasicInfoSettingUpdateButton
@@ -36,7 +39,7 @@ test('Access User information', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 
-test('Access External account', async({ page }) => {
+test('Access External account', async ({ page }) => {
   await page.goto('/me');
 
   // Click ExternalAccountsTabButton
@@ -52,7 +55,7 @@ test('Access External account', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
 });
 
-test('Access Password setting', async({ page }) => {
+test('Access Password setting', async ({ page }) => {
   await page.goto('/me');
 
   // Click PasswordSettingTabButton
@@ -72,8 +75,7 @@ test('Access Password setting', async({ page }) => {
   await expect(page.getByTestId('.Toastify__toast')).not.toBeVisible();
 });
 
-
-test('Access API setting', async({ page }) => {
+test('Access API setting', async ({ page }) => {
   await page.goto('/me');
 
   // Click ApiSettingTabButton
@@ -85,7 +87,7 @@ test('Access API setting', async({ page }) => {
   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');
 
   // 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-create-button').click();
   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
   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-in-modal').click();
   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');
 
   // Click InAppNotificationSettingTabButton
   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
-  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();
 });
 
-test('Acccess Other setting', async({ page }) => {
+test('Acccess Other setting', async ({ page }) => {
   await page.goto('/me');
 
   // 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
 // 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);
 });

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

@@ -1,7 +1,12 @@
 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) {
     return;
   }
@@ -12,8 +17,7 @@ export const collapseSidebar = async(page: Page, isCollapsed: boolean): Promise<
 
   if (isCollapsed) {
     await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
-  }
-  else {
+  } else {
     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 { expect, type Page } from '@playwright/test';
 
 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.
   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!"
     },
     "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": {
     "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.",
     "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 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": {
     "create_bootstrap_4_grid": "Create Bootstrap 4 Grid",
@@ -999,7 +999,18 @@
   },
   "user_home_page": {
     "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",

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

@@ -313,7 +313,7 @@
       "done": "Copié dans le presse-papier!"
     },
     "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": {
     "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.",
     "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.",
-    "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": {
     "create_bootstrap_4_grid": "Créer grille Bootstrap 4",
@@ -993,7 +993,18 @@
   },
   "user_home_page": {
     "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": "dossier de favoris",

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

@@ -322,7 +322,7 @@
       "done": "クリップボードにコピーしました!"
     },
     "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": {
     "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",

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

@@ -244,7 +244,7 @@
     "scope_read": "Read",
     "action": "アクション",
     "create_token": "トークンを作成",
-    "no_tokens_found":"アクセストークンが見つかりません",
+    "no_tokens_found": "アクセストークンが見つかりません",
     "new_token": {
       "title": "新しいアクセストークン",
       "copy_to_clipboard": "クリップボードにコピーしました",
@@ -708,7 +708,7 @@
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
-      },
+    },
     "delete_modal": {
       "title": "アシスタントを削除する",
       "confirm_message": "本当にアシスタントを削除しますか?"
@@ -930,7 +930,7 @@
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "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": {
     "create_bootstrap_4_grid": "Bootstrap 4 グリッドを作成",
@@ -1032,7 +1032,18 @@
   },
   "user_home_page": {
     "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": "ブックマークフォルダ",

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

@@ -313,7 +313,7 @@
       "done": "클립보드에 복사되었습니다!"
     },
     "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": {
     "migration_desc": "일부 페이지는 이전 v4 호환성을 가지고 있습니다. 페이지 트리 및 쉬운 이름 변경과 같은 새로운 기능을 활용하려면 모든 페이지를 v5 호환성으로 변환하십시오.",

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

@@ -857,7 +857,7 @@
     "Password field is required": "비밀번호 필드는 필수입니다.",
     "Username or E-mail has invalid characters": "사용자 이름 또는 이메일에 유효하지 않은 문자가 있습니다.",
     "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": {
     "create_bootstrap_4_grid": "Bootstrap 4 그리드 생성",
@@ -959,7 +959,18 @@
   },
   "user_home_page": {
     "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": "북마크 폴더",

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

@@ -322,7 +322,7 @@
       "done": "复制到剪贴板!"
     },
     "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": {
     "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",

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

@@ -902,7 +902,7 @@
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "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": {
     "create_bootstrap_4_grid": "创建Bootstrap 4网格",
@@ -1004,7 +1004,18 @@
   },
   "user_home_page": {
     "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": "书签文件夹",

+ 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 PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
@@ -20,8 +21,44 @@ const AppSetting = (props) => {
   const { adminAppContainer } = props;
   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 {
+      // 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();
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
     }
@@ -33,18 +70,15 @@ const AppSetting = (props) => {
 
 
   return (
-    <React.Fragment>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <div className="row">
         <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">
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.title || ''}
-            onChange={(e) => {
-              adminAppContainer.changeTitle(e.target.value);
-            }}
             placeholder="GROWI"
+            {...register('title')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
         </div>
@@ -60,11 +94,8 @@ const AppSetting = (props) => {
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.confidential || ''}
-            onChange={(e) => {
-              adminAppContainer.changeConfidential(e.target.value);
-            }}
             placeholder={t('admin:app_setting.confidential_example')}
+            {...register('confidential')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
         </div>
@@ -88,12 +119,8 @@ const AppSetting = (props) => {
                     type="radio"
                     id={`radioLang${locale}`}
                     className="form-check-input"
-                    name="globalLang"
                     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>
                 </div>
@@ -116,9 +143,8 @@ const AppSetting = (props) => {
               type="radio"
               id="radio-email-show"
               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>
           </div>
@@ -128,9 +154,8 @@ const AppSetting = (props) => {
               type="radio"
               id="radio-email-hide"
               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>
           </div>
@@ -150,11 +175,7 @@ const AppSetting = (props) => {
               type="checkbox"
               id="cbFileUpload"
               className="form-check-input"
-              name="fileUpload"
-              checked={adminAppContainer.state.fileUpload}
-              onChange={(e) => {
-                adminAppContainer.changeFileUpload(e.target.checked);
-              }}
+              {...register('fileUpload')}
             />
             <label
               className="form-label form-check-label"
@@ -170,8 +191,8 @@ const AppSetting = (props) => {
         </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 { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 export type AwsSettingMoleculeProps = {
-  s3ReferenceFileWithRelayMode
-  s3Region
-  s3CustomEndpoint
-  s3Bucket
-  s3AccessKeyId
-  s3SecretAccessKey
+  register: UseFormRegister<FileUploadFormValues>
+  s3ReferenceFileWithRelayMode: boolean
   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 => {
@@ -23,7 +16,6 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
 
   return (
     <>
-
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -46,16 +38,16 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 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>
             </div>
 
@@ -76,10 +68,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
-            value={props.s3Region || ''}
-            onChange={(e) => {
-              props?.onChangeS3Region(e.target.value);
-            }}
+            {...props.register('s3Region')}
           />
         </div>
       </div>
@@ -93,10 +82,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             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>
         </div>
@@ -111,10 +97,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             placeholder={`${t('eg')} crowi`}
-            value={props.s3Bucket || ''}
-            onChange={(e) => {
-              props.onChangeS3Bucket(e.target.value);
-            }}
+            {...props.register('s3Bucket')}
           />
         </div>
       </div>
@@ -127,10 +110,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             type="text"
-            value={props.s3AccessKeyId || ''}
-            onChange={(e) => {
-              props?.onChangeS3AccessKeyId(e.target.value);
-            }}
+            {...props.register('s3AccessKeyId')}
           />
         </div>
       </div>
@@ -143,15 +123,11 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             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>
         </div>
       </div>
-
-
     </>
   );
 };

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

@@ -1,29 +1,21 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import MaskedInput from './MaskedInput';
 
-
 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
-  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 => {
@@ -32,21 +24,15 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
   const {
     azureReferenceFileWithRelayMode,
     azureUseOnlyEnvVars,
-    azureTenantId,
-    azureClientId,
-    azureClientSecret,
-    azureStorageAccountName,
     envAzureTenantId,
     envAzureClientId,
     envAzureClientSecret,
     envAzureStorageAccountName,
-    azureStorageContainerName,
     envAzureStorageContainerName,
   } = props;
 
   return (
     <>
-
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -69,16 +55,16 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 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>
             </div>
 
@@ -116,10 +102,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_tenant_id')}</th>
             <td>
               <MaskedInput
-                name="azureTenantId"
+                register={props.register}
+                fieldName="azureTenantId"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureTenantId}
-                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
               />
             </td>
             <td>
@@ -134,10 +119,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_id')}</th>
             <td>
               <MaskedInput
-                name="azureClientId"
+                register={props.register}
+                fieldName="azureClientId"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientId}
-                onChange={e => props?.onChangeAzureClientId(e.target.value)}
               />
             </td>
             <td>
@@ -152,10 +136,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_secret')}</th>
             <td>
               <MaskedInput
-                name="azureClientSecret"
+                register={props.register}
+                fieldName="azureClientSecret"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientSecret}
-                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
               />
             </td>
             <td>
@@ -172,10 +155,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
                 className="form-control"
                 type="text"
-                name="azureStorageAccountName"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageAccountName}
-                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+                {...props.register('azureStorageAccountName')}
               />
             </td>
             <td>
@@ -192,10 +173,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
                 className="form-control"
                 type="text"
-                name="azureStorageContainerName"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageContainerName}
-                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+                {...props.register('azureStorageContainerName')}
               />
             </td>
             <td>
@@ -208,7 +187,6 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
           </tr>
         </tbody>
       </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 { useForm, useController } from 'react-hook-form';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { FileUploadType } from '~/interfaces/file-uploader';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import { AwsSettingMolecule } from './AwsSetting';
-import type { AwsSettingMoleculeProps } from './AwsSetting';
 import { AzureSettingMolecule } from './AzureSetting';
-import type { AzureSettingMoleculeProps } from './AzureSetting';
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 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 {
+    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 (
-    <>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <p className="card custom-card bg-warning-subtle my-3">
         {t('admin:app_setting.file_upload')}
         <span className="text-danger mt-1">
@@ -51,24 +111,27 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
                   className="form-check-input"
                   name="file-upload-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>
-        {props.isFixedFileUploadByEnvVar && (
+        {data.isFixedFileUploadByEnvVar && (
           <p className="alert alert-warning mt-2 text-start offset-3 col-6">
             <span className="material-symbols-outlined">help</span>
-            <b>FIXED</b><br />
+            <b>FIXED</b>
+            <br />
             {/* eslint-disable-next-line react/no-danger */}
             <b dangerouslySetInnerHTML={{
               __html: t('admin:app_setting.fixed_by_env_var', {
                 envKey: 'FILE_UPLOAD',
-                envVar: props.envFileUploadType,
+                envVar: data.envFileUploadType,
               }),
             }}
             />
@@ -76,229 +139,43 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
         )}
       </div>
 
-      {props.fileUploadType === 'aws' && (
+      {fileUploadType === 'aws' && (
         <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
-          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
-          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 { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 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
-  onChangeGcsApiKeyJsonPath: (val: string) => void
-  onChangeGcsBucket: (val: string) => void
-  onChangeGcsUploadNamespace: (val: string) => void
 };
 
 export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
@@ -24,17 +21,13 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
   const {
     gcsReferenceFileWithRelayMode,
     gcsUseOnlyEnvVars,
-    gcsApiKeyJsonPath,
     envGcsApiKeyJsonPath,
-    gcsBucket,
     envGcsBucket,
-    gcsUploadNamespace,
     envGcsUploadNamespace,
   } = props;
 
   return (
     <>
-
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -57,16 +50,16 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 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>
             </div>
 
@@ -106,10 +99,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsApiKeyJsonPath"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsApiKeyJsonPath}
-                onChange={e => props?.onChangeGcsApiKeyJsonPath(e.target.value)}
+                {...props.register('gcsApiKeyJsonPath')}
               />
             </td>
             <td>
@@ -126,10 +117,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsBucket"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsBucket}
-                onChange={e => props?.onChangeGcsBucket(e.target.value)}
+                {...props.register('gcsBucket')}
               />
             </td>
             <td>
@@ -146,10 +135,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsUploadNamespace"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsUploadNamespace}
-                onChange={e => props?.onChangeGcsUploadNamespace(e.target.value)}
+                {...props.register('gcsUploadNamespace')}
               />
             </td>
             <td>
@@ -162,7 +149,6 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
           </tr>
         </tbody>
       </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 { useForm } from 'react-hook-form';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import SesSetting from './SesSetting';
-import SmtpSetting from './SmtpSetting';
+import { SesSetting } from './SesSetting';
+import { SmtpSetting } from './SmtpSetting';
 
 
 type Props = {
@@ -22,15 +23,61 @@ const MailSetting = (props: Props) => {
 
   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 {
+      // 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();
       toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);
     }
-  }
+  }, [adminAppContainer, t]);
 
   async function sendTestEmailHandler() {
     const { adminAppContainer } = props;
@@ -45,19 +92,18 @@ const MailSetting = (props: Props) => {
 
 
   return (
-    <React.Fragment>
+    <form onSubmit={handleSubmit(onSubmit)}>
       {!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="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>
         <div className="col-md-6">
           <input
             className="form-control"
             type="text"
             placeholder={`${t('eg')} mail@growi.org`}
-            value={adminAppContainer.state.fromAddress || ''}
-            onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
+            {...register('fromAddress')}
           />
         </div>
       </div>
@@ -73,12 +119,9 @@ const MailSetting = (props: Props) => {
                 <input
                   type="radio"
                   className="form-check-input"
-                  name="transmission-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>
               </div>
@@ -87,12 +130,13 @@ const MailSetting = (props: Props) => {
         </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="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') }
           </button>
           {adminAppContainer.state.transmissionMethod === 'smtp' && (
@@ -102,7 +146,7 @@ const MailSetting = (props: Props) => {
           )}
         </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 type { UseFormRegister } from 'react-hook-form';
+
 import styles from './MaskedInput.module.scss';
 
 type Props = {
-  name: string
+  name?: string
   readOnly: boolean
-  value: string
-  onChange?: (e: any) => void
+  value?: string
+  onChange?: (e: ChangeEvent<HTMLInputElement>) => void
   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 {
@@ -17,19 +23,26 @@ export default function MaskedInput(props: Props): JSX.Element {
   };
 
   const {
-    name, readOnly, value, onChange, tabIndex,
+    name, readOnly, value, onChange, tabIndex, register, fieldName,
   } = props;
 
+  // Use register if provided, otherwise use value/onChange
+  const inputProps = register && fieldName
+    ? register(fieldName)
+    : {
+      name,
+      value,
+      onChange,
+    };
+
   return (
     <div className={styles.MaskedInput}>
       <input
         className="form-control"
         type={passwordShown ? 'text' : 'password'}
-        name={name}
         readOnly={readOnly}
-        value={value}
-        onChange={onChange}
         tabIndex={tabIndex}
+        {...inputProps}
       />
       <span onClick={togglePassword} className={styles.PasswordReveal}>
         {passwordShown ? (

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

@@ -1,20 +1,24 @@
 
 import React from 'react';
 
+import type { UseFormRegister } from 'react-hook-form';
+
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 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 (
     <React.Fragment>
-      <div id="mail-smtp" className="tab-pane active mt-5">
+      <div id="mail-ses" className="tab-pane active">
 
         <div className="row">
           <label className="text-start text-md-end col-md-3 col-form-label">
@@ -24,10 +28,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.sesAccessKeyId || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSesAccessKeyId(e.target.value);
-              }}
+              {...register('sesAccessKeyId')}
             />
           </div>
         </div>
@@ -40,10 +41,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.sesSecretAccessKey || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSesSecretAccessKey(e.target.value);
-              }}
+              {...register('sesSecretAccessKey')}
             />
           </div>
         </div>
@@ -53,9 +51,11 @@ const SmtpSetting = (props: Props) => {
   );
 };
 
+export { SesSetting };
+
 /**
  * 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 { useForm } from 'react-hook-form';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -21,9 +22,23 @@ const SiteUrlSetting = (props: Props) => {
   const { t: tCommon } = useTranslation('commons');
   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 {
+      // Await setState completion before API call
+      await adminAppContainer.changeSiteUrl(data.siteUrl);
       await adminAppContainer.updateSiteUrlSettingHandler();
       toastSuccess(tCommon('toaster.update_successed', { target: t('site_url.title') }));
     }
@@ -34,7 +49,7 @@ const SiteUrlSetting = (props: Props) => {
   }, [adminAppContainer, t, tCommon]);
 
   return (
-    <React.Fragment>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <p className="card custom-card bg-body-tertiary">{t('site_url.desc')}</p>
       {!adminAppContainer.state.isSetSiteUrl
           && (<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
                   className="form-control"
                   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"
+                  {...register('siteUrl')}
                 />
                 <p className="form-text text-muted">
                   {/* eslint-disable-next-line react/no-danger */}
@@ -92,8 +105,8 @@ const SiteUrlSetting = (props: Props) => {
         </table>
       </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 { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
@@ -9,16 +10,18 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 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 { adminAppContainer } = props;
+  const { register } = props;
 
   return (
     <React.Fragment>
-      <div id="mail-smtp" className="tab-pane active mt-5">
+      <div id="mail-smtp" className="tab-pane active">
         <div className="row">
           <label className="text-start text-md-end col-md-3 col-form-label">
             {t('admin:app_setting.host')}
@@ -27,8 +30,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.smtpHost || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+              {...register('smtpHost')}
             />
           </div>
         </div>
@@ -40,8 +42,7 @@ const SmtpSetting = (props: Props) => {
           <div className="col-md-6">
             <input
               className="form-control"
-              value={adminAppContainer.state.smtpPort || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+              {...register('smtpPort')}
             />
           </div>
         </div>
@@ -54,8 +55,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.smtpUser || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+              {...register('smtpUser')}
             />
           </div>
         </div>
@@ -68,8 +68,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="password"
-              value={adminAppContainer.state.smtpPassword || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+              {...register('smtpPassword')}
             />
           </div>
         </div>
@@ -78,6 +77,8 @@ const SmtpSetting = (props: Props) => {
   );
 };
 
+export { SmtpSetting };
+
 /**
  * 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,
+  };
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません