Преглед изворни кода

Merge branch 'master' into support/156162-170357-playwright-tests-biome-migration

Futa Arai пре 5 месеци
родитељ
комит
4a2fe0bf60
100 измењених фајлова са 3390 додато и 1886 уклоњено
  1. 1 1
      .changeset/config.json
  2. 16 8
      .devcontainer/app/devcontainer.json
  3. 4 0
      .devcontainer/app/postCreateCommand.sh
  4. 2 3
      .devcontainer/compose.extend.template.yml
  5. 3 7
      .devcontainer/compose.yml
  6. 2 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  7. 1 1
      .github/ISSUE_TEMPLATE/bug-report.md
  8. 1 1
      .github/ISSUE_TEMPLATE/config.yml
  9. 5 5
      .github/mergify.yml
  10. 7 7
      .github/workflows/ci-app-prod.yml
  11. 6 4
      .github/workflows/ci-app.yml
  12. 2 2
      .github/workflows/ci-pdf-converter.yml
  13. 3 3
      .github/workflows/ci-slackbot-proxy.yml
  14. 1 1
      .github/workflows/list-unhealthy-branches.yml
  15. 1 1
      .github/workflows/release-pdf-converter.yml
  16. 4 3
      .github/workflows/release-rc-scheduled.yml
  17. 33 10
      .github/workflows/release-rc.yml
  18. 1 1
      .github/workflows/release-slackbot-proxy.yml
  19. 2 2
      .github/workflows/release-subpackages.yml
  20. 50 17
      .github/workflows/release.yml
  21. 1 1
      .github/workflows/reusable-app-build-image.yml
  22. 5 2
      .github/workflows/reusable-app-create-manifests.yml
  23. 13 8
      .github/workflows/reusable-app-prod.yml
  24. 1 1
      .github/workflows/reusable-app-reg-suit.yml
  25. 22 0
      .mcp.json
  26. 0 14
      .roo/mcp.json
  27. 1 0
      .serena/.gitignore
  28. 398 0
      .serena/memories/apps-app-admin-forms-react-hook-form-migration-guide.md
  29. 186 0
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  30. 1 1
      .serena/memories/project_overview.md
  31. 15 9
      .serena/memories/suggested_commands.md
  32. 1 1
      .serena/memories/task_completion_checklist.md
  33. 6 1
      .vscode/settings.json
  34. 218 106
      CHANGELOG.md
  35. 95 0
      CLAUDE.md
  36. 1 1
      LICENSE
  37. 15 15
      README.md
  38. 15 15
      README_JP.md
  39. 1 1
      THIRD-PARTY-NOTICES.md
  40. 36 10
      apps/app/.eslintrc.js
  41. 2 1
      apps/app/bin/openapi/definition-apiv1.js
  42. 3 11
      apps/app/bin/openapi/definition-apiv3.js
  43. 15 12
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  44. 5 4
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  45. 31 33
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  46. 42 16
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.ts
  47. 416 0
      apps/app/bin/print-memory-consumption.ts
  48. 0 8
      apps/app/config/cdn.js
  49. 3 3
      apps/app/config/migrate-mongo-config.js
  50. 4 8
      apps/app/config/migrate-mongo-config.spec.ts
  51. 7 7
      apps/app/config/next-i18next.config.js
  52. 2 2
      apps/app/docker/Dockerfile
  53. 10 10
      apps/app/docker/README.md
  54. 44 44
      apps/app/docker/codebuild/.terraform.lock.hcl
  55. 1 1
      apps/app/docker/codebuild/buildspec.yml
  56. 1 1
      apps/app/docker/codebuild/codebuild.tf
  57. 1 1
      apps/app/docker/codebuild/main.tf
  58. 1 1
      apps/app/docker/codebuild/oidc.tf
  59. 2 2
      apps/app/next.config.js
  60. 12 11
      apps/app/package.json
  61. 0 1
      apps/app/playwright.config.ts
  62. 40 40
      apps/app/public/images/icons/favicon/manifest.json
  63. 1 1
      apps/app/public/static/locales/en_US/admin.json
  64. 16 1
      apps/app/public/static/locales/en_US/translation.json
  65. 1 1
      apps/app/public/static/locales/fr_FR/admin.json
  66. 17 2
      apps/app/public/static/locales/fr_FR/translation.json
  67. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  68. 17 2
      apps/app/public/static/locales/ja_JP/translation.json
  69. 1 1
      apps/app/public/static/locales/ko_KR/admin.json
  70. 5 1
      apps/app/public/static/locales/ko_KR/translation.json
  71. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  72. 16 1
      apps/app/public/static/locales/zh_CN/translation.json
  73. 6 8
      apps/app/resource/Contributor.js
  74. 50 29
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  75. 12 36
      apps/app/src/client/components/Admin/App/AwsSetting.tsx
  76. 21 43
      apps/app/src/client/components/Admin/App/AzureSetting.tsx
  77. 113 236
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  78. 41 0
      apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts
  79. 14 28
      apps/app/src/client/components/Admin/App/GcsSetting.tsx
  80. 63 19
      apps/app/src/client/components/Admin/App/MailSetting.tsx
  81. 20 7
      apps/app/src/client/components/Admin/App/MaskedInput.tsx
  82. 14 14
      apps/app/src/client/components/Admin/App/SesSetting.tsx
  83. 22 9
      apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx
  84. 13 12
      apps/app/src/client/components/Admin/App/SmtpSetting.tsx
  85. 395 0
      apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts
  86. 141 0
      apps/app/src/client/components/Admin/App/useFileUploadSettings.ts
  87. 13 3
      apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx
  88. 29 13
      apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx
  89. 47 31
      apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  90. 47 31
      apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx
  91. 28 15
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  92. 18 8
      apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  93. 22 31
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  94. 210 205
      apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx
  95. 27 27
      apps/app/src/client/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx
  96. 17 26
      apps/app/src/client/components/Admin/MarkdownSetting/WhitelistInput.tsx
  97. 39 30
      apps/app/src/client/components/Admin/MarkdownSetting/XssForm.jsx
  98. 38 39
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx
  99. 36 44
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx
  100. 0 450
      apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.jsx

+ 1 - 1
.changeset/config.json

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

+ 16 - 8
.devcontainer/app/devcontainer.json

@@ -8,7 +8,7 @@
 
 
   "features": {
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
     "ghcr.io/devcontainers/features/node:1": {
-      "version": "22.17.0"
+      "version": "20.18.3"
     }
     }
   },
   },
 
 
@@ -23,21 +23,29 @@
   "customizations": {
   "customizations": {
     "vscode": {
     "vscode": {
       "extensions": [
       "extensions": [
+        // AI
+        "anthropic.claude-code",
+        // linter
         "dbaeumer.vscode-eslint",
         "dbaeumer.vscode-eslint",
         "biomejs.biome",
         "biomejs.biome",
+        "editorconfig.editorconfig",
+        "shinnn.stylelint",
+        "stylelint.vscode-stylelint",
+        // Test
+        "vitest.explorer",
+        "ms-playwright.playwright",
+        // git/github
+        "codeinklingon.git-worktree-menu",
+        "github.vscode-pull-request-github",
         "mhutchie.git-graph",
         "mhutchie.git-graph",
         "eamodio.gitlens",
         "eamodio.gitlens",
-        "github.vscode-pull-request-github",
         "cschleiden.vscode-github-actions",
         "cschleiden.vscode-github-actions",
+        // DB
         "cweijan.vscode-database-client2",
         "cweijan.vscode-database-client2",
         "mongodb.mongodb-vscode",
         "mongodb.mongodb-vscode",
+        // Debug
         "msjsdiag.debugger-for-chrome",
         "msjsdiag.debugger-for-chrome",
-        "firefox-devtools.vscode-firefox-debug",
-        "editorconfig.editorconfig",
-        "shinnn.stylelint",
-        "stylelint.vscode-stylelint",
-        "vitest.explorer",
-        "ms-playwright.playwright"
+        "firefox-devtools.vscode-firefox-debug"
       ],
       ],
       "settings": {
       "settings": {
         "terminal.integrated.defaultProfile.linux": "bash"
         "terminal.integrated.defaultProfile.linux": "bash"

+ 4 - 0
.devcontainer/app/postCreateCommand.sh

@@ -17,9 +17,13 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
 # Setup pnpm
 # Setup pnpm
 SHELL=bash pnpm setup
 SHELL=bash pnpm setup
 eval "$(cat /home/vscode/.bashrc)"
 eval "$(cat /home/vscode/.bashrc)"
+pnpm config set store-dir /workspace/.pnpm-store
 
 
 # Install turbo
 # Install turbo
 pnpm install turbo --global
 pnpm install turbo --global
 
 
+# Install Claude Code
+pnpm install @anthropic-ai/claude-code --global
+
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap

+ 2 - 3
.devcontainer/compose.extend.template.yml

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

+ 3 - 7
.devcontainer/compose.yml

@@ -3,9 +3,7 @@ services:
     image: mcr.microsoft.com/devcontainers/base:ubuntu
     image: mcr.microsoft.com/devcontainers/base:ubuntu
     volumes:
     volumes:
       - ..:/workspace/growi:delegated
       - ..:/workspace/growi:delegated
-      - pnpm-store:/workspace/growi/.pnpm-store
-      - node_modules:/workspace/growi/node_modules
-      - buildcache_app:/workspace/growi/apps/app/.next
+      - pnpm-store:/workspace/.pnpm-store
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
       - ../../share:/workspace/share:delegated
       - ../../share:/workspace/share:delegated
       - page_bulk_export_tmp:/tmp/page-bulk-export
       - page_bulk_export_tmp:/tmp/page-bulk-export
@@ -15,7 +13,7 @@ services:
     - opentelemetry-collector-dev-setup_default
     - opentelemetry-collector-dev-setup_default
 
 
   mongo:
   mongo:
-    image: mongo:6.0
+    image: mongo:8.0
     restart: unless-stopped
     restart: unless-stopped
     ports:
     ports:
       - 27017
       - 27017
@@ -23,7 +21,7 @@ services:
       - /data/db
       - /data/db
 
 
   # This container requires '../../growi-docker-compose' repository
   # This container requires '../../growi-docker-compose' repository
-  #   cloned from https://github.com/weseek/growi-docker-compose.git
+  #   cloned from https://github.com/growilabs/growi-docker-compose.git
   elasticsearch:
   elasticsearch:
     build:
     build:
       context: ../../growi-docker-compose/elasticsearch/v9
       context: ../../growi-docker-compose/elasticsearch/v9
@@ -47,8 +45,6 @@ services:
 
 
 volumes:
 volumes:
   pnpm-store:
   pnpm-store:
-  node_modules:
-  buildcache_app:
   page_bulk_export_tmp:
   page_bulk_export_tmp:
 
 
 networks:
 networks:

+ 2 - 0
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -12,6 +12,8 @@ sudo chmod 700 /tmp/page-bulk-export
 # Setup pnpm
 # Setup pnpm
 SHELL=bash pnpm setup
 SHELL=bash pnpm setup
 eval "$(cat /home/node/.bashrc)"
 eval "$(cat /home/node/.bashrc)"
+pnpm config set store-dir /workspace/.pnpm-store
+
 # Update pnpm
 # Update pnpm
 pnpm i -g pnpm
 pnpm i -g pnpm
 
 

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

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

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

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

+ 5 - 5
.github/mergify.yml

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

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

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

+ 6 - 4
.github/workflows/ci-app.yml

@@ -44,7 +44,7 @@ jobs:
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [22.x]
+        node-version: [20.x]
 
 
     steps:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
@@ -93,10 +93,11 @@ jobs:
     strategy:
     strategy:
       matrix:
       matrix:
         node-version: [20.x]
         node-version: [20.x]
+        mongodb-version: ['6.0', '8.0']
 
 
     services:
     services:
       mongodb:
       mongodb:
-        image: mongo:6.0
+        image: mongo:${{ matrix.mongodb-version }}
         ports:
         ports:
           - 27017/tcp
           - 27017/tcp
 
 
@@ -135,7 +136,7 @@ jobs:
       - name: Upload coverage report as artifact
       - name: Upload coverage report as artifact
         uses: actions/upload-artifact@v4
         uses: actions/upload-artifact@v4
         with:
         with:
-          name: Coverage Report
+          name: coverage-mongo${{ matrix.mongodb-version }}
           path: |
           path: |
             apps/app/coverage
             apps/app/coverage
             packages/remark-growi-directive/coverage
             packages/remark-growi-directive/coverage
@@ -157,10 +158,11 @@ jobs:
     strategy:
     strategy:
       matrix:
       matrix:
         node-version: [20.x]
         node-version: [20.x]
+        mongodb-version: ['6.0', '8.0']
 
 
     services:
     services:
       mongodb:
       mongodb:
-        image: mongo:6.0
+        image: mongo:${{ matrix.mongodb-version }}
         ports:
         ports:
           - 27017/tcp
           - 27017/tcp
 
 

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

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

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

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

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

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

+ 1 - 1
.github/workflows/release-pdf-converter.yml

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,4 +1,4 @@
-name: Reusable build app workflow for production
+name: Reusable build and test app for production
 
 
 on:
 on:
   workflow_call:
   workflow_call:
@@ -16,7 +16,7 @@ on:
       node-version:
       node-version:
         required: true
         required: true
         type: string
         type: string
-        default: 20.x
+        default: 22.x
       skip-e2e-test:
       skip-e2e-test:
         type: boolean
         type: boolean
         default: false
         default: false
@@ -107,13 +107,17 @@ jobs:
     needs: [build-prod]
     needs: [build-prod]
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
+    strategy:
+      matrix:
+        mongodb-version: ['6.0', '8.0']
+
     services:
     services:
       mongodb:
       mongodb:
-        image: mongo:6.0
+        image: mongo:${{ matrix.mongodb-version }}
         ports:
         ports:
         - 27017/tcp
         - 27017/tcp
       elasticsearch:
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
+        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
         ports:
         ports:
         - 9200/tcp
         - 9200/tcp
         env:
         env:
@@ -182,14 +186,15 @@ jobs:
       matrix:
       matrix:
         browser: [chromium, firefox, webkit]
         browser: [chromium, firefox, webkit]
         shard: [1/2, 2/2]
         shard: [1/2, 2/2]
+        mongodb-version: ['6.0', '8.0']
 
 
     services:
     services:
       mongodb:
       mongodb:
-        image: mongo:6.0
+        image: mongo:${{ matrix.mongodb-version }}
         ports:
         ports:
         - 27017/tcp
         - 27017/tcp
       elasticsearch:
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
+        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
         ports:
         ports:
         - 9200/tcp
         - 9200/tcp
         env:
         env:
@@ -279,7 +284,7 @@ jobs:
       uses: actions/upload-artifact@v4
       uses: actions/upload-artifact@v4
       if: always()
       if: always()
       with:
       with:
-        name: blob-report-${{ matrix.browser }}-${{ steps.shard-id.outputs.shard_id }}
+        name: blob-report-${{ matrix.browser }}-mongo${{ matrix.mongodb-version }}-${{ steps.shard-id.outputs.shard_id }}
         path: ./apps/app/blob-report
         path: ./apps/app/blob-report
         retention-days: 30
         retention-days: 30
 
 
@@ -288,7 +293,7 @@ jobs:
       if: failure()
       if: failure()
       with:
       with:
         type: ${{ job.status }}
         type: ${{ job.status }}
-        job_name: '*Node CI for growi - run-playwright*'
+        job_name: '*Node CI for growi - run-playwright (${{ matrix.browser }}, MongoDB ${{ matrix.mongodb-version }})*'
         channel: '#ci'
         channel: '#ci'
         isCompactMode: true
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

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

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

+ 22 - 0
.mcp.json

@@ -0,0 +1,22 @@
+{
+  "mcpServers": {
+    "context7": {
+      "type": "http",
+      "url": "https://mcp.context7.com/mcp"
+    },
+    "serena": {
+      "type": "stdio",
+      "command": "uvx",
+      "args": [
+        "--from",
+        "git+https://github.com/oraios/serena",
+        "serena-mcp-server",
+        "--context",
+        "ide-assistant",
+        "--project",
+        "."
+      ],
+      "env": {}
+    }
+  }
+}

+ 0 - 14
.roo/mcp.json

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

+ 1 - 0
.serena/.gitignore

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

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

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

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

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

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

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

+ 15 - 9
.serena/memories/suggested_commands.md

@@ -11,7 +11,7 @@ pnpm install
 ## 開発サーバー
 ## 開発サーバー
 ```bash
 ```bash
 # メインアプリケーション開発モード
 # メインアプリケーション開発モード
-cd apps/app && pnpm run dev
+cd /workspace/growi/apps/app && pnpm run dev
 
 
 # ルートから起動(本番用ビルド後)
 # ルートから起動(本番用ビルド後)
 pnpm start
 pnpm start
@@ -31,20 +31,26 @@ turbo run build
 
 
 ## Lint・フォーマット
 ## Lint・フォーマット
 ```bash
 ```bash
+# 全てのLint実行
+pnpm run lint
+```
+
+## apps/app の Lint・フォーマット
+```bash
 # 【推奨】Biome実行(lint + format)
 # 【推奨】Biome実行(lint + format)
-pnpm run lint:biome
+cd /workspace/growi/apps/app pnpm run lint:biome
 
 
 # 【過渡期】ESLint実行(廃止予定)
 # 【過渡期】ESLint実行(廃止予定)
-pnpm run lint:eslint
+cd /workspace/growi/apps/app pnpm run lint:eslint
 
 
 # Stylelint実行
 # Stylelint実行
-pnpm run lint:styles
+cd /workspace/growi/apps/app pnpm run lint:styles
 
 
-# 全てのLint実行(過渡期対応)
-pnpm run lint
+# 全てのLint実行
+cd /workspace/growi/apps/app pnpm run lint
 
 
 # TypeScript型チェック
 # TypeScript型チェック
-pnpm run lint:typecheck
+cd /workspace/growi/apps/app pnpm run lint:typecheck
 ```
 ```
 
 
 ## テスト
 ## テスト
@@ -58,8 +64,8 @@ pnpm run test:jest
 # 全てのテスト実行(過渡期対応)
 # 全てのテスト実行(過渡期対応)
 pnpm run test
 pnpm run test
 
 
-# Vitestをカバレッジ付きで実行
-vitest run --coverage
+# Vitestで特定のファイルに絞って実行
+pnpm run test:vitest {target-file-name}
 
 
 # E2Eテスト(Playwright)
 # E2Eテスト(Playwright)
 npx playwright test
 npx playwright test

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

@@ -26,7 +26,7 @@ pnpm run test
 
 
 # 個別実行
 # 個別実行
 pnpm run test:jest        # Jest(廃止予定)
 pnpm run test:jest        # Jest(廃止予定)
-vitest run --coverage     # Vitestカバレッジ付き
+pnpm run test:vitest {target-file-name}     # Vitest
 ```
 ```
 
 
 ### 3. E2Eテストの実行(重要な機能変更時)
 ### 3. E2Eテストの実行(重要な機能変更時)

+ 6 - 1
.vscode/settings.json

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

Разлика између датотеке није приказан због своје велике величине
+ 218 - 106
CHANGELOG.md


+ 95 - 0
CLAUDE.md

@@ -0,0 +1,95 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Language
+
+If it is detected at the start or during a session that the user's primary language is not English, always respond in that language from then on. However, technical terms may remain in English as needed.
+
+## Project Overview
+
+GROWI is a team collaboration software using markdown - a wiki platform with hierarchical page organization. It's built with Next.js, Express, MongoDB, and includes features like real-time collaborative editing, authentication integrations, and plugin support.
+
+## Development Commands
+
+### Core Development
+- `turbo run bootstrap` - Install dependencies for all workspace packages
+- `turbo run dev` - Start development server (automatically runs migrations and pre-builds styles)
+
+### Production Commands
+- `pnpm run app:build` - Build GROWI app client and server for production
+- `pnpm run app:server` - Launch GROWI app server in production mode
+- `pnpm start` - Build and start the application (runs both build and server commands)
+
+### Database Migrations
+- `pnpm run migrate` - Run MongoDB migrations (production)
+- `turbo run dev:migrate @apps/app` - Run migrations in development (or wait for automatic execution with dev)
+- `cd apps/app && pnpm run dev:migrate:status` - Check migration status
+- `cd apps/app && pnpm run dev:migrate:down` - Rollback last migration
+
+### Testing and Quality
+- `turbo run test @apps/app` - Run Jest and Vitest test suites with coverage
+- `turbo run lint @apps/app` - Run all linters (TypeScript, ESLint, Biome, Stylelint, OpenAPI)
+- `cd apps/app && pnpm run lint:typecheck` - TypeScript type checking only
+- `cd apps/app && pnpm run test:vitest` - Run Vitest unit tests
+- `cd apps/app && pnpm run test:jest` - Run Jest integration tests
+
+### Development Utilities  
+- `cd apps/app && pnpm run repl` - Start Node.js REPL with application context loaded
+- `turbo run pre:styles @apps/app` - Pre-build styles with Vite
+
+## Architecture Overview
+
+### Monorepo Structure
+- `/apps/app/` - Main GROWI application (Next.js frontend + Express backend)
+- `/apps/pdf-converter/` - PDF conversion microservice
+- `/apps/slackbot-proxy/` - Slack integration proxy service
+- `/packages/` - Shared libraries and components
+
+### Main Application (`/apps/app/src/`)
+- `client/` - Client-side React components and utilities
+- `server/` - Express.js backend (API routes, models, services)  
+- `components/` - Shared React components and layouts
+- `pages/` - Next.js page components using file-based routing
+- `stores/` - State management (SWR-based stores with React context)
+- `styles/` - SCSS stylesheets with modular architecture
+- `migrations/` - MongoDB database migration scripts
+- `interfaces/` - TypeScript type definitions
+
+### Key Technical Details
+- **Frontend**: Next.js 14 with React 18, TypeScript, SCSS modules
+- **Backend**: Express.js with TypeScript, MongoDB with Mongoose
+- **State Management**: SWR for server state, React Context for client state
+- **Authentication**: Passport.js with multiple strategies (local, LDAP, OAuth, SAML)
+- **Real-time Features**: Socket.io for collaborative editing and notifications
+- **Editor**: Custom markdown editor with collaborative editing using Yjs
+- **Database**: MongoDB 8.0+ with migration system using migrate-mongo
+- **Package Manager**: pnpm with workspace support
+- **Build System**: Turborepo for monorepo orchestration
+
+### Development Dependencies
+- Node.js v20.x or v22.x
+- pnpm 10.x  
+- MongoDB v6.x or v8.x
+- Optional: Redis 3.x, Elasticsearch 7.x/8.x/9.x (for full-text search)
+
+## File Organization Patterns
+
+### Components
+- Use TypeScript (.tsx) for React components
+- Co-locate styles as `.module.scss` files
+- Export components through `index.ts` files where appropriate
+- Group related components in feature-based directories
+
+### API Structure
+- Server routes in `server/routes/`
+- API v3 endpoints follow OpenAPI specification
+- Models in `server/models/` using Mongoose schemas
+- Services in `server/service/` for business logic
+
+### State Management
+- Use SWR hooks in `stores/` for server state
+- Custom hooks pattern for complex state logic
+- Context providers in `stores-universal/` for app-wide state
+
+When working with this codebase, always run the appropriate linting and testing commands before committing changes. The application uses strict TypeScript checking and comprehensive test coverage requirements.

+ 1 - 1
LICENSE

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

+ 15 - 15
README.md

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

+ 15 - 15
README_JP.md

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

+ 1 - 1
THIRD-PARTY-NOTICES.md

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

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

@@ -2,12 +2,8 @@
  * @type {import('eslint').Linter.Config}
  * @type {import('eslint').Linter.Config}
  */
  */
 module.exports = {
 module.exports = {
-  extends: [
-    'next/core-web-vitals',
-    'weseek/react',
-  ],
-  plugins: [
-  ],
+  extends: ['next/core-web-vitals', 'weseek/react'],
+  plugins: [],
   ignorePatterns: [
   ignorePatterns: [
     'dist/**',
     'dist/**',
     '**/dist/**',
     '**/dist/**',
@@ -25,8 +21,42 @@ module.exports = {
     'test/integration/middlewares/**',
     'test/integration/middlewares/**',
     'test/integration/migrations/**',
     'test/integration/migrations/**',
     'test/integration/models/**',
     'test/integration/models/**',
+    'test/integration/service/**',
     'test/integration/setup.js',
     'test/integration/setup.js',
     'playwright/**',
     '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/**',
+    'src/features/mermaid/**',
+    'src/features/search/**',
+    '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/util/**',
+    'src/server/app.ts',
+    'src/server/repl.ts',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript
@@ -35,10 +65,6 @@ module.exports = {
     },
     },
   },
   },
   rules: {
   rules: {
-    'no-restricted-imports': ['error', {
-      name: 'axios',
-      message: 'Please use src/utils/axios instead.',
-    }],
     '@typescript-eslint/no-var-requires': 'off',
     '@typescript-eslint/no-var-requires': 'off',
 
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 2 - 1
apps/app/bin/openapi/definition-apiv1.js

@@ -12,7 +12,8 @@ module.exports = {
       variables: {
       variables: {
         server: {
         server: {
           default: 'https://demo.growi.org',
           default: 'https://demo.growi.org',
-          description: 'The base URL for the GROWI API except for the version path (/_api). This can be set to your GROWI instance URL.',
+          description:
+            'The base URL for the GROWI API except for the version path (/_api). This can be set to your GROWI instance URL.',
         },
         },
       },
       },
     },
     },

+ 3 - 11
apps/app/bin/openapi/definition-apiv3.js

@@ -12,7 +12,8 @@ module.exports = {
       variables: {
       variables: {
         server: {
         server: {
           default: 'https://demo.growi.org',
           default: 'https://demo.growi.org',
-          description: 'The base URL for the GROWI API except for the version path (/_api/v3). This can be set to your GROWI instance URL.',
+          description:
+            'The base URL for the GROWI API except for the version path (/_api/v3). This can be set to your GROWI instance URL.',
         },
         },
       },
       },
     },
     },
@@ -115,16 +116,7 @@ module.exports = {
     },
     },
     {
     {
       name: 'Public API',
       name: 'Public API',
-      tags: [
-        'Healthcheck',
-        'Statistics',
-        '',
-        '',
-        '',
-        '',
-        '',
-        '',
-      ],
+      tags: ['Healthcheck', 'Statistics', '', '', '', '', '', ''],
     },
     },
   ],
   ],
 };
 };

+ 15 - 12
apps/app/bin/openapi/generate-operation-ids/cli.spec.ts

@@ -1,8 +1,5 @@
-import { writeFileSync } from 'fs';
-
-import {
-  beforeEach, describe, expect, it, vi,
-} from 'vitest';
+import { writeFileSync } from 'node:fs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 
 import { generateOperationIds } from './generate-operation-ids';
 import { generateOperationIds } from './generate-operation-ids';
 
 
@@ -23,7 +20,7 @@ describe('cli', () => {
     vi.spyOn(console, 'error').mockImplementation(() => {});
     vi.spyOn(console, 'error').mockImplementation(() => {});
   });
   });
 
 
-  it('processes input file and writes output to specified file', async() => {
+  it('processes input file and writes output to specified file', async () => {
     // Mock generateOperationIds to return success
     // Mock generateOperationIds to return success
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
 
 
@@ -35,13 +32,15 @@ describe('cli', () => {
     await cliModule.main();
     await cliModule.main();
 
 
     // Verify generateOperationIds was called with correct arguments
     // Verify generateOperationIds was called with correct arguments
-    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: undefined });
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', {
+      overwriteExisting: undefined,
+    });
 
 
     // Verify writeFileSync was called with correct arguments
     // Verify writeFileSync was called with correct arguments
     expect(writeFileSync).toHaveBeenCalledWith('output.json', mockJsonStrings);
     expect(writeFileSync).toHaveBeenCalledWith('output.json', mockJsonStrings);
   });
   });
 
 
-  it('uses input file as output when no output file is specified', async() => {
+  it('uses input file as output when no output file is specified', async () => {
     // Mock generateOperationIds to return success
     // Mock generateOperationIds to return success
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
 
 
@@ -53,13 +52,15 @@ describe('cli', () => {
     await cliModule.main();
     await cliModule.main();
 
 
     // Verify generateOperationIds was called with correct arguments
     // Verify generateOperationIds was called with correct arguments
-    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: undefined });
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', {
+      overwriteExisting: undefined,
+    });
 
 
     // Verify writeFileSync was called with input file as output
     // Verify writeFileSync was called with input file as output
     expect(writeFileSync).toHaveBeenCalledWith('input.json', mockJsonStrings);
     expect(writeFileSync).toHaveBeenCalledWith('input.json', mockJsonStrings);
   });
   });
 
 
-  it('handles overwrite-existing option correctly', async() => {
+  it('handles overwrite-existing option correctly', async () => {
     // Mock generateOperationIds to return success
     // Mock generateOperationIds to return success
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
     vi.mocked(generateOperationIds).mockResolvedValue(mockJsonStrings);
 
 
@@ -71,10 +72,12 @@ describe('cli', () => {
     await cliModule.main();
     await cliModule.main();
 
 
     // Verify generateOperationIds was called with overwriteExisting option
     // Verify generateOperationIds was called with overwriteExisting option
-    expect(generateOperationIds).toHaveBeenCalledWith('input.json', { overwriteExisting: true });
+    expect(generateOperationIds).toHaveBeenCalledWith('input.json', {
+      overwriteExisting: true,
+    });
   });
   });
 
 
-  it('handles generateOperationIds error correctly', async() => {
+  it('handles generateOperationIds error correctly', async () => {
     // Mock generateOperationIds to throw error
     // Mock generateOperationIds to throw error
     const error = new Error('Test error');
     const error = new Error('Test error');
     vi.mocked(generateOperationIds).mockRejectedValue(error);
     vi.mocked(generateOperationIds).mockRejectedValue(error);

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

@@ -1,10 +1,9 @@
-import { writeFileSync } from 'fs';
-
+import { writeFileSync } from 'node:fs';
 import { Command } from 'commander';
 import { Command } from 'commander';
 
 
 import { generateOperationIds } from './generate-operation-ids';
 import { generateOperationIds } from './generate-operation-ids';
 
 
-export const main = async(): Promise<void> => {
+export const main = async (): Promise<void> => {
   // parse command line arguments
   // parse command line arguments
   const program = new Command();
   const program = new Command();
   program
   program
@@ -18,7 +17,9 @@ export const main = async(): Promise<void> => {
   const [inputFile] = program.args;
   const [inputFile] = program.args;
 
 
   // eslint-disable-next-line no-console
   // eslint-disable-next-line no-console
-  const jsonStrings = await generateOperationIds(inputFile, { overwriteExisting }).catch(console.error);
+  const jsonStrings = await generateOperationIds(inputFile, {
+    overwriteExisting,
+  }).catch(console.error);
   if (jsonStrings != null) {
   if (jsonStrings != null) {
     writeFileSync(outputFile ?? inputFile, jsonStrings);
     writeFileSync(outputFile ?? inputFile, jsonStrings);
   }
   }

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

@@ -1,13 +1,11 @@
-import fs from 'fs/promises';
-import { tmpdir } from 'os';
-import path from 'path';
-
+import fs from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
 import type { OpenAPI3 } from 'openapi-typescript';
 import type { OpenAPI3 } from 'openapi-typescript';
 import { describe, expect, it } from 'vitest';
 import { describe, expect, it } from 'vitest';
 
 
 import { generateOperationIds } from './generate-operation-ids';
 import { generateOperationIds } from './generate-operation-ids';
 
 
-
 async function createTempOpenAPIFile(spec: OpenAPI3): Promise<string> {
 async function createTempOpenAPIFile(spec: OpenAPI3): Promise<string> {
   const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'openapi-test-'));
   const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'openapi-test-'));
   const filePath = path.join(tempDir, 'openapi.json');
   const filePath = path.join(tempDir, 'openapi.json');
@@ -19,15 +17,14 @@ async function cleanup(filePath: string): Promise<void> {
   try {
   try {
     await fs.unlink(filePath);
     await fs.unlink(filePath);
     await fs.rmdir(path.dirname(filePath));
     await fs.rmdir(path.dirname(filePath));
-  }
-  catch (err) {
+  } catch (err) {
     // eslint-disable-next-line no-console
     // eslint-disable-next-line no-console
     console.error('Cleanup failed:', err);
     console.error('Cleanup failed:', err);
   }
   }
 }
 }
 
 
 describe('generateOperationIds', () => {
 describe('generateOperationIds', () => {
-  it('should generate correct operationId for simple paths', async() => {
+  it('should generate correct operationId for simple paths', async () => {
     const spec: OpenAPI3 = {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
       info: { title: 'Test API', version: '1.0.0' },
@@ -46,13 +43,12 @@ describe('generateOperationIds', () => {
 
 
       expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
       expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
       expect(parsed.paths['/foo'].post.operationId).toBe('postFoo');
       expect(parsed.paths['/foo'].post.operationId).toBe('postFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
       await cleanup(filePath);
     }
     }
   });
   });
 
 
-  it('should generate correct operationId for paths with parameters', async() => {
+  it('should generate correct operationId for paths with parameters', async () => {
     const spec: OpenAPI3 = {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
       info: { title: 'Test API', version: '1.0.0' },
@@ -72,14 +68,15 @@ describe('generateOperationIds', () => {
       const parsed = JSON.parse(result);
       const parsed = JSON.parse(result);
 
 
       expect(parsed.paths['/foo/{id}'].get.operationId).toBe('getFooById');
       expect(parsed.paths['/foo/{id}'].get.operationId).toBe('getFooById');
-      expect(parsed.paths['/foo/{id}/bar/{page}'].get.operationId).toBe('getBarByPageByIdForFoo');
-    }
-    finally {
+      expect(parsed.paths['/foo/{id}/bar/{page}'].get.operationId).toBe(
+        'getBarByPageByIdForFoo',
+      );
+    } finally {
       await cleanup(filePath);
       await cleanup(filePath);
     }
     }
   });
   });
 
 
-  it('should generate correct operationId for nested resources', async() => {
+  it('should generate correct operationId for nested resources', async () => {
     const spec: OpenAPI3 = {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
       info: { title: 'Test API', version: '1.0.0' },
@@ -96,13 +93,12 @@ describe('generateOperationIds', () => {
       const parsed = JSON.parse(result);
       const parsed = JSON.parse(result);
 
 
       expect(parsed.paths['/foo/bar'].get.operationId).toBe('getBarForFoo');
       expect(parsed.paths['/foo/bar'].get.operationId).toBe('getBarForFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
       await cleanup(filePath);
     }
     }
   });
   });
 
 
-  it('should preserve existing operationId when overwriteExisting is false', async() => {
+  it('should preserve existing operationId when overwriteExisting is false', async () => {
     const existingOperationId = 'existingOperation';
     const existingOperationId = 'existingOperation';
     const spec: OpenAPI3 = {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       openapi: '3.0.0',
@@ -118,17 +114,18 @@ describe('generateOperationIds', () => {
 
 
     const filePath = await createTempOpenAPIFile(spec);
     const filePath = await createTempOpenAPIFile(spec);
     try {
     try {
-      const result = await generateOperationIds(filePath, { overwriteExisting: false });
+      const result = await generateOperationIds(filePath, {
+        overwriteExisting: false,
+      });
       const parsed = JSON.parse(result);
       const parsed = JSON.parse(result);
 
 
       expect(parsed.paths['/foo'].get.operationId).toBe(existingOperationId);
       expect(parsed.paths['/foo'].get.operationId).toBe(existingOperationId);
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
       await cleanup(filePath);
     }
     }
   });
   });
 
 
-  it('should overwrite existing operationId when overwriteExisting is true', async() => {
+  it('should overwrite existing operationId when overwriteExisting is true', async () => {
     const spec: OpenAPI3 = {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
       info: { title: 'Test API', version: '1.0.0' },
@@ -143,17 +140,18 @@ describe('generateOperationIds', () => {
 
 
     const filePath = await createTempOpenAPIFile(spec);
     const filePath = await createTempOpenAPIFile(spec);
     try {
     try {
-      const result = await generateOperationIds(filePath, { overwriteExisting: true });
+      const result = await generateOperationIds(filePath, {
+        overwriteExisting: true,
+      });
       const parsed = JSON.parse(result);
       const parsed = JSON.parse(result);
 
 
       expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
       expect(parsed.paths['/foo'].get.operationId).toBe('getFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
       await cleanup(filePath);
     }
     }
   });
   });
 
 
-  it('should generate correct operationId for root path', async() => {
+  it('should generate correct operationId for root path', async () => {
     const spec: OpenAPI3 = {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
       info: { title: 'Test API', version: '1.0.0' },
@@ -170,13 +168,12 @@ describe('generateOperationIds', () => {
       const parsed = JSON.parse(result);
       const parsed = JSON.parse(result);
 
 
       expect(parsed.paths['/'].get.operationId).toBe('getRoot');
       expect(parsed.paths['/'].get.operationId).toBe('getRoot');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
       await cleanup(filePath);
     }
     }
   });
   });
 
 
-  it('should generate operationId for all HTTP methods', async() => {
+  it('should generate operationId for all HTTP methods', async () => {
     const spec: OpenAPI3 = {
     const spec: OpenAPI3 = {
       openapi: '3.0.0',
       openapi: '3.0.0',
       info: { title: 'Test API', version: '1.0.0' },
       info: { title: 'Test API', version: '1.0.0' },
@@ -207,13 +204,14 @@ describe('generateOperationIds', () => {
       expect(parsed.paths['/foo'].options.operationId).toBe('optionsFoo');
       expect(parsed.paths['/foo'].options.operationId).toBe('optionsFoo');
       expect(parsed.paths['/foo'].head.operationId).toBe('headFoo');
       expect(parsed.paths['/foo'].head.operationId).toBe('headFoo');
       expect(parsed.paths['/foo'].trace.operationId).toBe('traceFoo');
       expect(parsed.paths['/foo'].trace.operationId).toBe('traceFoo');
-    }
-    finally {
+    } finally {
       await cleanup(filePath);
       await cleanup(filePath);
     }
     }
   });
   });
 
 
-  it('should throw error for non-existent file', async() => {
-    await expect(generateOperationIds('non-existent-file.json')).rejects.toThrow();
+  it('should throw error for non-existent file', async () => {
+    await expect(
+      generateOperationIds('non-existent-file.json'),
+    ).rejects.toThrow();
   });
   });
 });
 });

+ 42 - 16
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.ts

@@ -1,15 +1,25 @@
 import SwaggerParser from '@apidevtools/swagger-parser';
 import SwaggerParser from '@apidevtools/swagger-parser';
-import type { OpenAPI3, OperationObject, PathItemObject } from 'openapi-typescript';
+import type {
+  OpenAPI3,
+  OperationObject,
+  PathItemObject,
+} from 'openapi-typescript';
 
 
-const toPascal = (s: string): string => s.split('-').map(w => w[0]?.toUpperCase() + w.slice(1)).join('');
+const toPascal = (s: string): string =>
+  s
+    .split('-')
+    .map((w) => w[0]?.toUpperCase() + w.slice(1))
+    .join('');
 
 
 const createParamSuffix = (params: string[]): string => {
 const createParamSuffix = (params: string[]): string => {
   return params.length > 0
   return params.length > 0
-    ? params.reverse().map(param => `By${toPascal(param.slice(1, -1))}`).join('')
+    ? params
+        .reverse()
+        .map((param) => `By${toPascal(param.slice(1, -1))}`)
+        .join('')
     : '';
     : '';
 };
 };
 
 
-
 /**
 /**
  * Generates a PascalCase operation name based on the HTTP method and path.
  * Generates a PascalCase operation name based on the HTTP method and path.
  *
  *
@@ -24,8 +34,8 @@ const createParamSuffix = (params: string[]): string => {
  */
  */
 function createOperationId(method: string, path: string): string {
 function createOperationId(method: string, path: string): string {
   const segments = path.split('/').filter(Boolean);
   const segments = path.split('/').filter(Boolean);
-  const params = segments.filter(s => s.startsWith('{'));
-  const paths = segments.filter(s => !s.startsWith('{'));
+  const params = segments.filter((s) => s.startsWith('{'));
+  const paths = segments.filter((s) => !s.startsWith('{'));
 
 
   const paramSuffix = createParamSuffix(params);
   const paramSuffix = createParamSuffix(params);
 
 
@@ -37,19 +47,35 @@ function createOperationId(method: string, path: string): string {
   return `${method.toLowerCase()}${toPascal(resource)}${paramSuffix}For${context.reverse().map(toPascal).join('')}`;
   return `${method.toLowerCase()}${toPascal(resource)}${paramSuffix}For${context.reverse().map(toPascal).join('')}`;
 }
 }
 
 
-export async function generateOperationIds(inputFile: string, opts?: { overwriteExisting: boolean }): Promise<string> {
-  const api = await SwaggerParser.parse(inputFile) as OpenAPI3;
+export async function generateOperationIds(
+  inputFile: string,
+  opts?: { overwriteExisting: boolean },
+): Promise<string> {
+  const api = (await SwaggerParser.parse(inputFile)) as OpenAPI3;
 
 
   Object.entries(api.paths || {}).forEach(([path, pathItem]) => {
   Object.entries(api.paths || {}).forEach(([path, pathItem]) => {
     const item = pathItem as PathItemObject;
     const item = pathItem as PathItemObject;
-    (['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'] as const)
-      .forEach((method) => {
-        const operation = item[method] as OperationObject | undefined;
-        if (operation == null || (operation.operationId != null && !opts?.overwriteExisting)) {
-          return;
-        }
-        operation.operationId = createOperationId(method, path);
-      });
+    (
+      [
+        'get',
+        'post',
+        'put',
+        'delete',
+        'patch',
+        'options',
+        'head',
+        'trace',
+      ] as const
+    ).forEach((method) => {
+      const operation = item[method] as OperationObject | undefined;
+      if (
+        operation == null ||
+        (operation.operationId != null && !opts?.overwriteExisting)
+      ) {
+        return;
+      }
+      operation.operationId = createOperationId(method, path);
+    });
   });
   });
 
 
   const output = JSON.stringify(api, null, 2);
   const output = JSON.stringify(api, null, 2);

+ 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 - 8
apps/app/config/cdn.js

@@ -1,8 +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';

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

@@ -6,11 +6,11 @@
  */
  */
 const isProduction = process.env.NODE_ENV === 'production';
 const isProduction = process.env.NODE_ENV === 'production';
 
 
-const { URL } = require('url');
+const { URL } = require('node:url');
 
 
 const { getMongoUri, mongoOptions } = isProduction
 const { getMongoUri, mongoOptions } = isProduction
-  // eslint-disable-next-line import/extensions, import/no-unresolved
-  ? require('../dist/server/util/mongoose-utils')
+  ? // eslint-disable-next-line import/extensions, import/no-unresolved
+    require('../dist/server/util/mongoose-utils')
   : require('../src/server/util/mongoose-utils');
   : require('../src/server/util/mongoose-utils');
 
 
 // get migrationsDir from env var
 // get migrationsDir from env var

+ 4 - 8
apps/app/config/migrate-mongo-config.spec.ts

@@ -2,11 +2,8 @@ import mockRequire from 'mock-require';
 
 
 const { reRequire } = mockRequire;
 const { reRequire } = mockRequire;
 
 
-
 describe('config/migrate-mongo-config.js', () => {
 describe('config/migrate-mongo-config.js', () => {
-
   test.concurrent('throws an error when MIGRATIONS_DIR is not set', () => {
   test.concurrent('throws an error when MIGRATIONS_DIR is not set', () => {
-
     const getMongoUriMock = vi.fn();
     const getMongoUriMock = vi.fn();
     const mongoOptionsMock = vi.fn();
     const mongoOptionsMock = vi.fn();
 
 
@@ -32,13 +29,11 @@ describe('config/migrate-mongo-config.js', () => {
     ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
     ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
     ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
     ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
   `('returns', ({ MONGO_URI, expectedDbName }) => {
   `('returns', ({ MONGO_URI, expectedDbName }) => {
-
-    beforeEach(async() => {
+    beforeEach(async () => {
       process.env.MIGRATIONS_DIR = 'testdir/migrations';
       process.env.MIGRATIONS_DIR = 'testdir/migrations';
     });
     });
 
 
     test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
     test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
-
       const getMongoUriMock = vi.fn(() => MONGO_URI);
       const getMongoUriMock = vi.fn(() => MONGO_URI);
       const mongoOptionsMock = vi.fn();
       const mongoOptionsMock = vi.fn();
 
 
@@ -49,7 +44,9 @@ describe('config/migrate-mongo-config.js', () => {
       });
       });
 
 
       // use reRequire to avoid using module cache
       // use reRequire to avoid using module cache
-      const { mongodb, migrationsDir, changelogCollectionName } = reRequire('./migrate-mongo-config');
+      const { mongodb, migrationsDir, changelogCollectionName } = reRequire(
+        './migrate-mongo-config',
+      );
 
 
       mockRequire.stop('../src/server/util/mongoose-utils');
       mockRequire.stop('../src/server/util/mongoose-utils');
 
 
@@ -61,5 +58,4 @@ describe('config/migrate-mongo-config.js', () => {
       expect(changelogCollectionName).toBe('migrations');
       expect(changelogCollectionName).toBe('migrations');
     });
     });
   });
   });
-
 });
 });

+ 7 - 7
apps/app/config/next-i18next.config.js

@@ -1,5 +1,6 @@
 const isDev = process.env.NODE_ENV === 'development';
 const isDev = process.env.NODE_ENV === 'development';
 
 
+// biome-ignore lint/style/useNodejsImportProtocol: ignore
 const path = require('path');
 const path = require('path');
 
 
 const { AllLang } = require('@growi/core');
 const { AllLang } = require('@growi/core');
@@ -26,17 +27,17 @@ module.exports = {
     ? isServer()
     ? isServer()
       ? [new HMRPlugin({ webpack: { server: true } })]
       ? [new HMRPlugin({ webpack: { server: true } })]
       : [
       : [
-        require('i18next-chained-backend').default,
-        new HMRPlugin({ webpack: { client: true } }),
-      ]
+          require('i18next-chained-backend').default,
+          new HMRPlugin({ webpack: { client: true } }),
+        ]
     : [],
     : [],
   backend: {
   backend: {
     backends: isServer()
     backends: isServer()
       ? []
       ? []
       : [
       : [
-        require('i18next-localstorage-backend').default,
-        require('i18next-http-backend').default,
-      ],
+          require('i18next-localstorage-backend').default,
+          require('i18next-http-backend').default,
+        ],
     backendOptions: [
     backendOptions: [
       // options for i18next-localstorage-backend
       // options for i18next-localstorage-backend
       { expirationTime: isDev ? 0 : 24 * 60 * 60 * 1000 }, // 1 day in production
       { expirationTime: isDev ? 0 : 24 * 60 * 60 * 1000 }, // 1 day in production
@@ -44,5 +45,4 @@ module.exports = {
       { loadPath: '/static/locales/{{lng}}/{{ns}}.json' },
       { loadPath: '/static/locales/{{lng}}/{{ns}}.json' },
     ],
     ],
   },
   },
-
 };
 };

+ 2 - 2
apps/app/docker/Dockerfile

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 12 - 11
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.3.0-RC.0",
+  "version": "7.3.4-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -35,12 +35,12 @@
     "lint": "run-p lint:**",
     "lint": "run-p lint:**",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv3": "pnpm run openapi:generate-spec:apiv3",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
     "prelint:openapi:apiv1": "pnpm run openapi:generate-spec:apiv1",
-    "test": "run-p test:*",
+    "test": "run-p test:jest test:vitest:coverage",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
     "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
-    "test:vitest": "vitest run --coverage",
+    "test:vitest": "vitest run",
+    "test:vitest:coverage": "COLUMNS=200 vitest run --coverage",
     "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "jest:run": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "reg:run": "reg-suit run",
-    "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "//// misc": "",
     "console": "npm run repl",
     "console": "npm run repl",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
     "repl": "cross-env NODE_ENV=development npm run ts-node src/server/repl.ts",
@@ -104,7 +104,7 @@
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "axios-retry": "^3.2.4",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
     "body-parser": "^1.20.3",
@@ -158,7 +158,7 @@
     "mdast-util-from-markdown": "^2.0.1",
     "mdast-util-from-markdown": "^2.0.1",
     "mdast-util-gfm-table": "^2.0.0",
     "mdast-util-gfm-table": "^2.0.0",
     "mdast-util-wiki-link": "^0.1.2",
     "mdast-util-wiki-link": "^0.1.2",
-    "mermaid": "^11.9.0",
+    "mermaid": "^11.10.0",
     "method-override": "^3.0.0",
     "method-override": "^3.0.0",
     "micromark-extension-gfm-table": "^2.1.0",
     "micromark-extension-gfm-table": "^2.1.0",
     "micromark-extension-wiki-link": "^0.0.4",
     "micromark-extension-wiki-link": "^0.0.4",
@@ -166,13 +166,13 @@
     "mkdirp": "^1.0.3",
     "mkdirp": "^1.0.3",
     "mongodb": "^4.17.2",
     "mongodb": "^4.17.2",
     "mongoose": "^6.13.6",
     "mongoose": "^6.13.6",
-    "mongoose-gridfs": "^1.2.42",
+    "mongoose-gridfs": "^1.3.0",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
-    "next": "^14.2.30",
+    "next": "^14.2.32",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^0.0.4",
     "next-superjson": "^0.0.4",
@@ -246,7 +246,7 @@
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "usehooks-ts": "^2.6.0",
     "uuid": "^11.0.3",
     "uuid": "^11.0.3",
-    "validator": "^13.7.0",
+    "validator": "^13.15.20",
     "ws": "^8.17.1",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-mongodb-provider": "^0.2.0",
@@ -273,9 +273,7 @@
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
     "@swc/jest": "^0.2.36",
-    "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/jest-dom": "^6.5.0",
-    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",
     "@types/bunyan": "^1.8.11",
@@ -290,12 +288,14 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/react-stickynode": "^4.0.3",
+    "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unist": "^3.0.3",
     "@types/unist": "^3.0.3",
     "@types/unzip-stream": "^0.3.4",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
     "@types/url-join": "^4.0.2",
     "@types/uuid": "^10.0.0",
     "@types/uuid": "^10.0.0",
+    "@types/ws": "^8.18.1",
     "babel-loader": "^8.2.5",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
     "bootstrap": "=5.3.2",
     "commander": "^14.0.0",
     "commander": "^14.0.0",
@@ -338,6 +338,7 @@
     "simplebar-react": "^2.3.6",
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
     "source-map-loader": "^4.0.1",
+    "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "swagger2openapi": "^7.0.8",
     "unist-util-is": "^6.0.0",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"
     "unist-util-visit-parents": "^6.0.0"

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

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

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

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

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

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

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

@@ -600,6 +600,17 @@
       "create_failed": "Failed to create assistant",
       "create_failed": "Failed to create assistant",
       "update_failed": "Failed to update assistant"
       "update_failed": "Failed to update assistant"
     },
     },
+    "select_source_pages": "Select pages for the assistant to reference",
+    "search_reference_pages_by_keyword": "Search for pages the assistant will reference by keyword",
+    "search_by_keyword": "Search by keyword",
+    "enter_keywords": "Enter keywords",
+    "max_items_space_separated_hint": "Enter up to 5 items separated by spaces",
+    "select_assistant_reference_pages": "Select pages for the assistant to reference",
+    "reference_pages": "Reference pages",
+    "no_pages_selected": "No pages selected",
+    "can_add_later": "You can add more later",
+    "next": "Next",
+    "select_from_page_tree": "Select from page tree",
     "edit_page_description": "Edit pages that the assistant can reference.<br> The assistant can reference up to {{limitLearnablePageCountPerAssistant}} pages including child pages.",
     "edit_page_description": "Edit pages that the assistant can reference.<br> The assistant can reference up to {{limitLearnablePageCountPerAssistant}} pages including child pages.",
     "default_instruction": "You are the knowledge assistant for this Wiki.\n\n## Multilingual Support:\nRespond in the same language the user uses in their input.\n",
     "default_instruction": "You are the knowledge assistant for this Wiki.\n\n## Multilingual Support:\nRespond in the same language the user uses in their input.\n",
     "add_page_button": "Add page",
     "add_page_button": "Add page",
@@ -664,6 +675,10 @@
       "thread_deleted_failed": "Failed to delete thread",
       "thread_deleted_failed": "Failed to delete thread",
       "ai_assistant_set_default_success": "Default assistant set successfully",
       "ai_assistant_set_default_success": "Default assistant set successfully",
       "ai_assistant_set_default_failed": "Failed to set default assistant"
       "ai_assistant_set_default_failed": "Failed to set default assistant"
+    },
+    "delete_modal": {
+      "title": "Delete Assistant",
+      "confirm_message": "Are you sure you want to delete this assistant?"
     }
     }
   },
   },
   "link_edit": {
   "link_edit": {
@@ -882,7 +897,7 @@
     "Password field is required": "Password field is required.",
     "Password field is required": "Password field is required.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "user_not_found": "User not found.",
     "user_not_found": "User not found.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Create Bootstrap 4 Grid",
     "create_bootstrap_4_grid": "Create Bootstrap 4 Grid",

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

@@ -313,7 +313,7 @@
       "done": "Copié dans le presse-papier!"
       "done": "Copié dans le presse-papier!"
     },
     },
     "bug_report": "Informations de diagnostic",
     "bug_report": "Informations de diagnostic",
-    "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
+    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",

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

@@ -258,7 +258,7 @@
       "title": "Créer un nouveau jeton d'accès",
       "title": "Créer un nouveau jeton d'accès",
       "expiredAt_desc": "Sélectionnez la date d'expiration de ce jeton d'accès.",
       "expiredAt_desc": "Sélectionnez la date d'expiration de ce jeton d'accès.",
       "description_desc": "Fournissez une description pour vous aider à identifier ce jeton ultérieurement.",
       "description_desc": "Fournissez une description pour vous aider à identifier ce jeton ultérieurement.",
-      "description_max_length": "Veuillez saisir jusqu'à {{length}} caractères."
+      "description_max_length": "Veuillez saisir jusqu'à {{length}} caractères.",
       "scope_desc": "Sélectionnez la portée du jeton d'accès."
       "scope_desc": "Sélectionnez la portée du jeton d'accès."
     },
     },
     "copy_to_clipboard": "Copier dans le presse-papiers"
     "copy_to_clipboard": "Copier dans le presse-papiers"
@@ -594,6 +594,17 @@
       "create_failed": "Échec de la création de l'assistant",
       "create_failed": "Échec de la création de l'assistant",
       "update_failed": "Échec de la mise à jour de l'assistant"
       "update_failed": "Échec de la mise à jour de l'assistant"
     },
     },
+    "select_source_pages": "Sélectionnez les pages que l'assistant doit référencer",
+    "search_reference_pages_by_keyword": "Rechercher les pages de référence de l'assistant par mot-clé",
+    "search_by_keyword": "Rechercher par mot-clé",
+    "max_items_space_separated_hint": "Saisissez jusqu'à 5 éléments séparés par des espaces",
+    "select_assistant_reference_pages": "Sélectionnez les pages de référence pour l'assistant",
+    "enter_keywords": "Entrer des mots-clés",
+    "reference_pages": "Pages de référence",
+    "no_pages_selected": "Aucune page sélectionnée",
+    "can_add_later": "Vous pouvez en ajouter plus tard",
+    "next": "Suivant",
+    "select_from_page_tree": "Sélectionner depuis l'arborescence des pages",
     "edit_page_description": "Modifier les pages que l'assistant peut référencer.<br> L'assistant peut référencer jusqu'à {{limitLearnablePageCountPerAssistant}} pages, y compris les pages enfants.",
     "edit_page_description": "Modifier les pages que l'assistant peut référencer.<br> L'assistant peut référencer jusqu'à {{limitLearnablePageCountPerAssistant}} pages, y compris les pages enfants.",
     "default_instruction": "Vous êtes l'assistant de connaissances pour ce Wiki.\n\n## Support multilingue :\nRépondez dans la même langue que celle utilisée par l'utilisateur dans sa requête.\n",
     "default_instruction": "Vous êtes l'assistant de connaissances pour ce Wiki.\n\n## Support multilingue :\nRépondez dans la même langue que celle utilisée par l'utilisateur dans sa requête.\n",
     "add_page_button": "Ajouter une page",
     "add_page_button": "Ajouter une page",
@@ -658,6 +669,10 @@
       "thread_deleted_failed": "Échec de la suppression de la discussion",
       "thread_deleted_failed": "Échec de la suppression de la discussion",
       "ai_assistant_set_default_success": "Assistant par défaut défini avec succès",
       "ai_assistant_set_default_success": "Assistant par défaut défini avec succès",
       "ai_assistant_set_default_failed": "Échec de la définition de l'assistant par défaut"
       "ai_assistant_set_default_failed": "Échec de la définition de l'assistant par défaut"
+    },
+    "delete_modal": {
+      "title": "Supprimer l'assistant",
+      "confirm_message": "Êtes-vous sûr de vouloir supprimer cet assistant ?"
     }
     }
   },
   },
   "link_edit": {
   "link_edit": {
@@ -876,7 +891,7 @@
     "Password field is required": "Mot de passe requis.",
     "Password field is required": "Mot de passe requis.",
     "Username or E-mail has invalid characters": "Le nom d'utilisateur ou l'adresse courriel contient des caractères invalides",
     "Username or E-mail has invalid characters": "Le nom d'utilisateur ou l'adresse courriel contient des caractères invalides",
     "user_not_found": "Utilisateur introuvable.",
     "user_not_found": "Utilisateur introuvable.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Créer grille Bootstrap 4",
     "create_bootstrap_4_grid": "Créer grille Bootstrap 4",

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

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

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

@@ -244,7 +244,7 @@
     "scope_read": "Read",
     "scope_read": "Read",
     "action": "アクション",
     "action": "アクション",
     "create_token": "トークンを作成",
     "create_token": "トークンを作成",
-    "no_tokens_found":"アクセストークンが見つかりません",
+    "no_tokens_found": "アクセストークンが見つかりません",
     "new_token": {
     "new_token": {
       "title": "新しいアクセストークン",
       "title": "新しいアクセストークン",
       "copy_to_clipboard": "クリップボードにコピーしました",
       "copy_to_clipboard": "クリップボードにコピーしました",
@@ -633,6 +633,17 @@
       "create_failed": "アシスタントの作成に失敗しました",
       "create_failed": "アシスタントの作成に失敗しました",
       "update_failed": "アシスタントの更新に失敗しました"
       "update_failed": "アシスタントの更新に失敗しました"
     },
     },
+    "select_source_pages": "アシスタントが参照するページを選択します",
+    "search_reference_pages_by_keyword": "アシスタントが参照するページをキーワードで検索",
+    "search_by_keyword": "キーワードで検索",
+    "enter_keywords": "キーワードを入力",
+    "max_items_space_separated_hint": "スペース区切りで最大5つまで入力できます",
+    "select_assistant_reference_pages": "アシスタントが参照するページを選択してください",
+    "reference_pages": "参照するページ",
+    "no_pages_selected": "ページが選択されていません",
+    "can_add_later": "あとからでも追加できます",
+    "next": "次へ",
+    "select_from_page_tree": "ページツリーから選択",
     "edit_page_description": " アシスタントが参照するページを編集します。<br> 参照できるページは配下ページも含めて {{limitLearnablePageCountPerAssistant}} ページまでです。",
     "edit_page_description": " アシスタントが参照するページを編集します。<br> 参照できるページは配下ページも含めて {{limitLearnablePageCountPerAssistant}} ページまでです。",
     "default_instruction": "あなたはこのWikiの知識アシスタントです。\n\n## 多言語サポート:\nユーザーが入力で使用した言語と同じ言語で応答してください。\n",
     "default_instruction": "あなたはこのWikiの知識アシスタントです。\n\n## 多言語サポート:\nユーザーが入力で使用した言語と同じ言語で応答してください。\n",
     "add_page_button": "ページを追加する",
     "add_page_button": "ページを追加する",
@@ -697,6 +708,10 @@
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
+    },
+    "delete_modal": {
+      "title": "アシスタントを削除する",
+      "confirm_message": "本当にアシスタントを削除しますか?"
     }
     }
   },
   },
   "link_edit": {
   "link_edit": {
@@ -915,7 +930,7 @@
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "user_not_found": "ユーザーが見つかりません",
     "user_not_found": "ユーザーが見つかりません",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/growilabs/growi/issues/193'>こちら: #193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Bootstrap 4 グリッドを作成",
     "create_bootstrap_4_grid": "Bootstrap 4 グリッドを作成",

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

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

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

@@ -635,6 +635,10 @@
       "thread_deleted_failed": "스레드 삭제 실패",
       "thread_deleted_failed": "스레드 삭제 실패",
       "ai_assistant_set_default_success": "기본 어시스턴트 설정 성공",
       "ai_assistant_set_default_success": "기본 어시스턴트 설정 성공",
       "ai_assistant_set_default_failed": "기본 어시스턴트 설정 실패"
       "ai_assistant_set_default_failed": "기본 어시스턴트 설정 실패"
+    },
+    "delete_modal": {
+      "title": "어시스턴트 삭제",
+      "confirm_message": "정말로 이 어시스턴트를 삭제하시겠습니까?"
     }
     }
   },
   },
   "link_edit": {
   "link_edit": {
@@ -853,7 +857,7 @@
     "Password field is required": "비밀번호 필드는 필수입니다.",
     "Password field is required": "비밀번호 필드는 필수입니다.",
     "Username or E-mail has invalid characters": "사용자 이름 또는 이메일에 유효하지 않은 문자가 있습니다.",
     "Username or E-mail has invalid characters": "사용자 이름 또는 이메일에 유효하지 않은 문자가 있습니다.",
     "user_not_found": "사용자를 찾을 수 없습니다.",
     "user_not_found": "사용자를 찾을 수 없습니다.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException 발생</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 인증은 성공했지만 새 사용자를 생성할 수 없습니다. <a href='https://github.com/weseek/growi/issues/193'>#193</a> 문제를 참조하십시오.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException 발생</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 인증은 성공했지만 새 사용자를 생성할 수 없습니다. <a href='https://github.com/growilabs/growi/issues/193'>#193</a> 문제를 참조하십시오.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "Bootstrap 4 그리드 생성",
     "create_bootstrap_4_grid": "Bootstrap 4 그리드 생성",

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

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

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

@@ -591,6 +591,17 @@
       "create_failed": "创建助手失败",
       "create_failed": "创建助手失败",
       "update_failed": "更新助手失败"
       "update_failed": "更新助手失败"
     },
     },
+    "select_source_pages": "选择助手要参考的页面",
+    "search_reference_pages_by_keyword": "按关键词搜索助手参考的页面",
+    "search_by_keyword": "按关键词搜索",
+    "max_items_space_separated_hint": "请输入最多5个项目,用空格分隔",
+    "select_assistant_reference_pages": "请选择助手参考的页面",
+    "enter_keywords": "输入关键词",
+    "reference_pages": "参考页面",
+    "no_pages_selected": "未选择任何页面",
+    "can_add_later": "稍后也可以添加",
+    "next": "下一步",
+    "select_from_page_tree": "从页面树选择",
     "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
     "edit_page_description": "编辑助手可以参考的页面。<br> 助手可以参考最多 {{limitLearnablePageCountPerAssistant}} 个页面,包括子页面。",
     "default_instruction": "您是这个Wiki的知识助手。\n\n## 多语言支持:\n请使用用户输入中使用的相同语言进行回复。\n",
     "default_instruction": "您是这个Wiki的知识助手。\n\n## 多语言支持:\n请使用用户输入中使用的相同语言进行回复。\n",
     "add_page_button": "添加页面",
     "add_page_button": "添加页面",
@@ -655,6 +666,10 @@
       "thread_deleted_failed": "删除会话失败",
       "thread_deleted_failed": "删除会话失败",
       "ai_assistant_set_default_success": "已成功设置默认助手",
       "ai_assistant_set_default_success": "已成功设置默认助手",
       "ai_assistant_set_default_failed": "设置默认助手失败"
       "ai_assistant_set_default_failed": "设置默认助手失败"
+    },
+    "delete_modal": {
+      "title": "删除助手",
+      "confirm_message": "确定要删除此助手吗?"
     }
     }
   },
   },
   "link_edit": {
   "link_edit": {
@@ -887,7 +902,7 @@
     "Password field is required": "密码字段是必需的",
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "user_not_found": "未找到用户",
     "user_not_found": "未找到用户",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   },
   "grid_edit": {
   "grid_edit": {
     "create_bootstrap_4_grid": "创建Bootstrap 4网格",
     "create_bootstrap_4_grid": "创建Bootstrap 4网格",

+ 6 - 8
apps/app/resource/Contributor.js

@@ -104,10 +104,7 @@ const contributors = [
       },
       },
       {
       {
         additionalClass: 'col-md-6 my-4',
         additionalClass: 'col-md-6 my-4',
-        members: [
-          { name: 'shaminmeerankutty' },
-          { name: 'rabitarochan' },
-        ],
+        members: [{ name: 'shaminmeerankutty' }, { name: 'rabitarochan' }],
       },
       },
       {
       {
         additionalClass: 'col-md-4 my-4',
         additionalClass: 'col-md-4 my-4',
@@ -150,7 +147,10 @@ const contributors = [
           { position: 'Flatt Security', name: 'stypr' },
           { position: 'Flatt Security', name: 'stypr' },
           { position: 'Flatt Security', name: 'Azara/Norihide Saito' },
           { position: 'Flatt Security', name: 'Azara/Norihide Saito' },
           { position: 'CyberAgent, Inc.', name: 'Daisuke Takahashi' },
           { position: 'CyberAgent, Inc.', name: 'Daisuke Takahashi' },
-          { position: 'Mitsui Bussan Secure Directions, Inc.', name: 'Yuji Tounai' },
+          {
+            position: 'Mitsui Bussan Secure Directions, Inc.',
+            name: 'Yuji Tounai',
+          },
           { name: 'yy0931' },
           { name: 'yy0931' },
         ],
         ],
       },
       },
@@ -172,9 +172,7 @@ const contributors = [
       },
       },
       {
       {
         additionalClass: 'col-12 staff-credit-mt-10rem',
         additionalClass: 'col-12 staff-credit-mt-10rem',
-        members: [
-          { name: 'AND YOU' },
-        ],
+        members: [{ name: 'AND YOU' }],
       },
       },
     ],
     ],
   },
   },

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 13 - 3
apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx

@@ -3,8 +3,9 @@ import React, { type JSX } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 type Props = {
 type Props = {
-  onClick: () => void,
+  onClick?: () => void,
   disabled?: boolean,
   disabled?: boolean,
+  type?: 'button' | 'submit' | 'reset',
 }
 }
 
 
 const AdminUpdateButtonRow = (props: Props): JSX.Element => {
 const AdminUpdateButtonRow = (props: Props): JSX.Element => {
@@ -12,8 +13,17 @@ const AdminUpdateButtonRow = (props: Props): JSX.Element => {
 
 
   return (
   return (
     <div className="row my-3">
     <div className="row my-3">
-      <div className="mx-auto">
-        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled ?? false}>{ t('Update') }</button>
+      <div className="col-md-3"></div>
+      <div className="col-md-9">
+        <button
+          // eslint-disable-next-line react/button-has-type
+          type={props.type ?? 'button'}
+          className="btn btn-primary"
+          onClick={props.onClick}
+          disabled={props.disabled ?? false}
+        >
+          { t('Update') }
+        </button>
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 29 - 13
apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { Card, CardBody } from 'reactstrap';
 import { Card, CardBody } from 'reactstrap';
 
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -18,8 +19,23 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      customizeCss: adminCustomizeContainer.state.currentCustomizeCss || '',
+    });
+  }, [adminCustomizeContainer.state.currentCustomizeCss, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
     try {
+      // Update container state before API call
+      await adminCustomizeContainer.changeCustomizeCss(data.customizeCss);
       await adminCustomizeContainer.updateCustomizeCss();
       await adminCustomizeContainer.updateCustomizeCss();
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_css'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_css'), ns: 'commons' }));
     }
     }
@@ -41,17 +57,17 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
             </CardBody>
             </CardBody>
           </Card>
           </Card>
 
 
-          <div>
-            <textarea
-              className="form-control"
-              name="customizeCss"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeCss || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeCss(e.target.value) }}
-            />
-          </div>
-
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control"
+                rows={8}
+                {...register('customizeCss')}
+              />
+            </div>
+
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
         </div>
       </div>
       </div>
     </React.Fragment>
     </React.Fragment>

+ 47 - 31
apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
 import { Card, CardBody } from 'reactstrap';
@@ -20,8 +21,23 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      customizeNoscript: adminCustomizeContainer.state.currentCustomizeNoscript || '',
+    });
+  }, [adminCustomizeContainer.state.currentCustomizeNoscript, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
     try {
+      // Update container state before API call
+      await adminCustomizeContainer.changeCustomizeNoscript(data.customizeNoscript);
       await adminCustomizeContainer.updateCustomizeNoscript();
       await adminCustomizeContainer.updateCustomizeNoscript();
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_noscript'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_noscript'), ns: 'commons' }));
     }
     }
@@ -45,40 +61,40 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
             </CardBody>
             </CardBody>
           </Card>
           </Card>
 
 
-          <div>
-            <textarea
-              className="form-control mb-2"
-              name="customizeNoscript"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeNoscript || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeNoscript(e.target.value) }}
-            />
-          </div>
-
-          <a
-            className="text-muted"
-            data-bs-toggle="collapse"
-            href="#collapseExampleHtml"
-            role="button"
-            aria-expanded="false"
-            aria-controls="collapseExampleHtml"
-          >
-            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
-            Example for Google Tag Manager
-          </a>
-          <div className="collapse" id="collapseExampleHtml">
-            <PrismAsyncLight
-              style={oneDark}
-              language="javascript"
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control mb-2"
+                rows={8}
+                {...register('customizeNoscript')}
+              />
+            </div>
+
+            <a
+              className="text-muted"
+              data-bs-toggle="collapse"
+              href="#collapseExampleHtml"
+              role="button"
+              aria-expanded="false"
+              aria-controls="collapseExampleHtml"
             >
             >
-              {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
+              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
+              Example for Google Tag Manager
+            </a>
+            <div className="collapse" id="collapseExampleHtml">
+              <PrismAsyncLight
+                style={oneDark}
+                language="javascript"
+              >
+                {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
   height="0"
   height="0"
   width="0"
   width="0"
   style="display:none;visibility:hidden"></iframe>`}
   style="display:none;visibility:hidden"></iframe>`}
-            </PrismAsyncLight>
-          </div>
+              </PrismAsyncLight>
+            </div>
 
 
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
         </div>
       </div>
       </div>
     </React.Fragment>
     </React.Fragment>

+ 47 - 31
apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
 import { Card, CardBody } from 'reactstrap';
@@ -20,8 +21,23 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      customizeScript: adminCustomizeContainer.state.currentCustomizeScript || '',
+    });
+  }, [adminCustomizeContainer.state.currentCustomizeScript, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
     try {
+      // Update container state before API call
+      await adminCustomizeContainer.changeCustomizeScript(data.customizeScript);
       await adminCustomizeContainer.updateCustomizeScript();
       await adminCustomizeContainer.updateCustomizeScript();
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_script'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_script'), ns: 'commons' }));
     }
     }
@@ -42,33 +58,32 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
             </CardBody>
             </CardBody>
           </Card>
           </Card>
 
 
-          <div>
-            <textarea
-              className="form-control mb-2"
-              name="customizeScript"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeScript || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeScript(e.target.value) }}
-            />
-          </div>
-
-          <a
-            className="text-muted"
-            data-bs-toggle="collapse"
-            href="#collapseExampleScript"
-            role="button"
-            aria-expanded="false"
-            aria-controls="collapseExampleScript"
-          >
-            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
-            Example for Google Tag Manager
-          </a>
-          <div className="collapse" id="collapseExampleScript">
-            <PrismAsyncLight
-              style={oneDark}
-              language="javascript"
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control mb-2"
+                rows={8}
+                {...register('customizeScript')}
+              />
+            </div>
+
+            <a
+              className="text-muted"
+              data-bs-toggle="collapse"
+              href="#collapseExampleScript"
+              role="button"
+              aria-expanded="false"
+              aria-controls="collapseExampleScript"
             >
             >
-              {`(function(w,d,s,l,i){
+              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
+              Example for Google Tag Manager
+            </a>
+            <div className="collapse" id="collapseExampleScript">
+              <PrismAsyncLight
+                style={oneDark}
+                language="javascript"
+              >
+                {`(function(w,d,s,l,i){
 w[l]=w[l]||[];
 w[l]=w[l]||[];
 w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
 w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
 var f=d.getElementsByTagName(s)[0],
 var f=d.getElementsByTagName(s)[0],
@@ -77,10 +92,11 @@ var f=d.getElementsByTagName(s)[0],
 j.async=true;
 j.async=true;
 j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
 j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
 })(window,document,'script','dataLayer','GTM-XXXXXX');`}
 })(window,document,'script','dataLayer','GTM-XXXXXX');`}
-            </PrismAsyncLight>
-          </div>
+              </PrismAsyncLight>
+            </div>
 
 
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
         </div>
       </div>
       </div>
     </React.Fragment>
     </React.Fragment>

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

@@ -1,7 +1,8 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import React, { useState } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { Card, CardBody } from 'reactstrap';
 import { Card, CardBody } from 'reactstrap';
 
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
@@ -16,19 +17,30 @@ export const CustomizeTitle: FC = () => {
 
 
   const { data: customizeTitle } = useCustomizeTitle();
   const { data: customizeTitle } = useCustomizeTitle();
 
 
-  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle ?? '');
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
 
 
-  const onClickSubmit = async() => {
+  // Sync form with store data
+  useEffect(() => {
+    reset({
+      customizeTitle: customizeTitle ?? '',
+    });
+  }, [customizeTitle, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
     try {
       await apiv3Put('/customize-setting/customize-title', {
       await apiv3Put('/customize-setting/customize-title', {
-        customizeTitle: currentCustomizeTitle,
+        customizeTitle: data.customizeTitle,
       });
       });
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_title'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_title'), ns: 'commons' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  };
+  }, [t]);
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
@@ -64,16 +76,17 @@ export const CustomizeTitle: FC = () => {
           <br />
           <br />
           Default Output Example: <code className="xml">&lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;</code>
           Default Output Example: <code className="xml">&lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;</code>
         </div>
         </div>
-        <div className="col-12">
-          <input
-            className="form-control"
-            value={currentCustomizeTitle}
-            onChange={(e) => { setCrrentCustomizeTitle(e.target.value) }}
-          />
-        </div>
-        <div className="col-12">
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={false} />
-        </div>
+        <form onSubmit={handleSubmit(onSubmit)}>
+          <div className="col-12">
+            <input
+              className="form-control"
+              {...register('customizeTitle')}
+            />
+          </div>
+          <div className="col-12">
+            <AdminUpdateButtonRow type="submit" disabled={false} />
+          </div>
+        </form>
       </div>
       </div>
     </React.Fragment>
     </React.Fragment>
   );
   );

+ 18 - 8
apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -14,7 +14,7 @@ import RebuildIndexControls from './RebuildIndexControls';
 import ReconnectControls from './ReconnectControls';
 import ReconnectControls from './ReconnectControls';
 import StatusTable from './StatusTable';
 import StatusTable from './StatusTable';
 
 
-const ElasticsearchManagement = () => {
+const ElasticsearchManagement = (): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: socket } = useAdminSocket();
   const { data: socket } = useAdminSocket();
@@ -43,6 +43,8 @@ const ElasticsearchManagement = () => {
       setIndicesData(info.indices);
       setIndicesData(info.indices);
       setAliasesData(info.aliases);
       setAliasesData(info.aliases);
       setIsNormalized(info.isNormalized);
       setIsNormalized(info.isNormalized);
+
+      return info.isNormalized;
     }
     }
     catch (errors: unknown) {
     catch (errors: unknown) {
       setIsConnected(false);
       setIsConnected(false);
@@ -60,6 +62,7 @@ const ElasticsearchManagement = () => {
         toastError(errors as Error);
         toastError(errors as Error);
       }
       }
 
 
+      return false;
     }
     }
     finally {
     finally {
       setIsInitialized(true);
       setIsInitialized(true);
@@ -67,13 +70,9 @@ const ElasticsearchManagement = () => {
   }, []);
   }, []);
 
 
   useEffect(() => {
   useEffect(() => {
-    const fetchIndicesStatusData = async() => {
-      await retrieveIndicesStatus();
-    };
-    fetchIndicesStatusData();
+    retrieveIndicesStatus();
   }, [retrieveIndicesStatus]);
   }, [retrieveIndicesStatus]);
 
 
-
   useEffect(() => {
   useEffect(() => {
     if (socket == null) {
     if (socket == null) {
       return;
       return;
@@ -83,7 +82,19 @@ const ElasticsearchManagement = () => {
     });
     });
 
 
     socket.on(SocketEventName.FinishAddPage, async(data) => {
     socket.on(SocketEventName.FinishAddPage, async(data) => {
-      await retrieveIndicesStatus();
+      let retryCount = 0;
+      const maxRetries = 5;
+      const retryDelay = 500;
+
+      const retrieveIndicesStatusWithRetry = async() => {
+        const isNormalizedResult = await retrieveIndicesStatus();
+        if (!isNormalizedResult && retryCount < maxRetries) {
+          retryCount++;
+          setTimeout(retrieveIndicesStatusWithRetry, retryDelay);
+        }
+      };
+
+      await retrieveIndicesStatusWithRetry();
       setIsRebuildingProcessing(false);
       setIsRebuildingProcessing(false);
       setIsRebuildingCompleted(true);
       setIsRebuildingCompleted(true);
     });
     });
@@ -99,7 +110,6 @@ const ElasticsearchManagement = () => {
     };
     };
   }, [retrieveIndicesStatus, socket]);
   }, [retrieveIndicesStatus, socket]);
 
 
-
   const reconnect = async() => {
   const reconnect = async() => {
     setIsReconnectingProcessing(true);
     setIsReconnectingProcessing(true);
 
 

+ 22 - 31
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -1,7 +1,9 @@
 import React from 'react';
 import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import { Progress } from 'reactstrap';
+import {
+  Progress, UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
 
 
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 
 
@@ -49,6 +51,8 @@ export default class ImportCollectionItem extends React.Component {
     onOptionChange(collectionName, { mode });
     onOptionChange(collectionName, { mode });
   }
   }
 
 
+  // No toggle state needed when using UncontrolledDropdown
+
   configButtonClickedHandler() {
   configButtonClickedHandler() {
     const { collectionName, onConfigButtonClicked } = this.props;
     const { collectionName, onConfigButtonClicked } = this.props;
 
 
@@ -103,40 +107,28 @@ export default class ImportCollectionItem extends React.Component {
     const {
     const {
       collectionName, option, isImporting,
       collectionName, option, isImporting,
     } = this.props;
     } = this.props;
-
-    const attrMap = MODE_ATTR_MAP[option.mode];
-    const btnColor = `btn-${attrMap.color}`;
-
+    const currentMode = option?.mode || 'insert';
+    const attrMap = MODE_ATTR_MAP[currentMode];
     const modes = MODE_RESTRICTED_COLLECTION[collectionName] || Object.keys(MODE_ATTR_MAP);
     const modes = MODE_RESTRICTED_COLLECTION[collectionName] || Object.keys(MODE_ATTR_MAP);
 
 
     return (
     return (
       <span className="d-inline-flex align-items-center">
       <span className="d-inline-flex align-items-center">
         Mode:&nbsp;
         Mode:&nbsp;
-        <div className="dropdown d-inline-block">
-          <button
-            className={`btn ${btnColor} btn-sm dropdown-toggle`}
-            type="button"
-            id="ddmMode"
-            disabled={isImporting}
-            data-bs-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="true"
-          >
-            {this.renderModeLabel(option.mode)}
-            <span className="caret ms-2"></span>
-          </button>
-          <ul className="dropdown-menu" aria-labelledby="ddmMode">
-            { modes.map((mode) => {
-              return (
-                <li key={`buttonMode_${mode}`}>
-                  <button type="button" className="dropdown-item" role="button" onClick={() => this.modeSelectedHandler(mode)}>
-                    {this.renderModeLabel(mode, true)}
-                  </button>
-                </li>
-              );
-            }) }
-          </ul>
-        </div>
+        <UncontrolledDropdown size="sm" className="d-inline-block">
+          <DropdownToggle color={attrMap.color} caret disabled={isImporting} id={`ddmMode-${collectionName}`}>
+            {this.renderModeLabel(currentMode)}
+          </DropdownToggle>
+          <DropdownMenu>
+            {modes.map(mode => (
+              <DropdownItem
+                key={`buttonMode_${mode}`}
+                onClick={() => this.modeSelectedHandler(mode)}
+              >
+                {this.renderModeLabel(mode, true)}
+              </DropdownItem>
+            ))}
+          </DropdownMenu>
+        </UncontrolledDropdown>
       </span>
       </span>
     );
     );
   }
   }
@@ -190,7 +182,6 @@ export default class ImportCollectionItem extends React.Component {
         }
         }
       </div>
       </div>
     );
     );
-
   }
   }
 
 
   render() {
   render() {

+ 210 - 205
apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 
 import AdminImportContainer from '~/client/services/AdminImportContainer';
 import AdminImportContainer from '~/client/services/AdminImportContainer';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
@@ -14,233 +15,237 @@ import GrowiArchiveSection from './GrowiArchiveSection';
 
 
 const logger = loggerFactory('growi:importer');
 const logger = loggerFactory('growi:importer');
 
 
-class ImportDataPageContents extends React.Component {
-
-  render() {
-    const { t, adminImportContainer } = this.props;
-
-    return (
-      <div data-testid="admin-import-data">
-        <GrowiArchiveSection />
-
-        <form
-          className="mt-5"
-          id="importerSettingFormEsa"
-          role="form"
-        >
-          <fieldset>
-            <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'esa.io' })}</h2>
-            <table className="table table-bordered table-mapping">
-              <thead>
-                <tr>
-                  <th width="45%">esa.io</th>
-                  <th width="10%"></th>
-                  <th>GROWI</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('importer_management.article')}</th>
-                  <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                  <th>{t('importer_management.page')}</th>
-                </tr>
-                <tr>
-                  <th>{t('importer_management.category')}</th>
-                  <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                  <th>{t('importer_management.page_path')}</th>
-                </tr>
-                <tr>
-                  <th>{t('User')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-              </tbody>
-            </table>
-
-            <div className="card custom-card bg-body-tertiary mb-0 small">
-              <ul>
-                <li>{t('importer_management.page_skip')}</li>
-              </ul>
-            </div>
-
-            <div className="row mt-4">
-              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-            </div>
+const ImportDataPageContents = ({ t, adminImportContainer }) => {
+  const { register: registerEsa, reset: resetEsa, handleSubmit: handleSubmitEsa } = useForm();
+  const { register: registerQiita, reset: resetQiita, handleSubmit: handleSubmitQiita } = useForm();
 
 
-            <div className="row">
-              <label htmlFor="settingForm[importer:esa:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.esa_settings.team_name')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="esaTeamName"
-                  value={adminImportContainer.state.esaTeamName || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
+  useEffect(() => {
+    resetEsa({
+      esaTeamName: adminImportContainer.state.esaTeamName || '',
+      esaAccessToken: adminImportContainer.state.esaAccessToken || '',
+    });
+  }, [resetEsa, adminImportContainer.state.esaTeamName, adminImportContainer.state.esaAccessToken]);
 
 
+  useEffect(() => {
+    resetQiita({
+      qiitaTeamName: adminImportContainer.state.qiitaTeamName || '',
+      qiitaAccessToken: adminImportContainer.state.qiitaAccessToken || '',
+    });
+  }, [resetQiita, adminImportContainer.state.qiitaTeamName, adminImportContainer.state.qiitaAccessToken]);
+
+  return (
+    <div data-testid="admin-import-data">
+      <GrowiArchiveSection />
+
+      <form
+        className="mt-5"
+        id="importerSettingFormEsa"
+        role="form"
+        onSubmit={handleSubmitEsa(adminImportContainer.esaHandleSubmitUpdate)}
+      >
+        <fieldset>
+          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'esa.io' })}</h2>
+          <table className="table table-bordered table-mapping">
+            <thead>
+              <tr>
+                <th width="45%">esa.io</th>
+                <th width="10%"></th>
+                <th>GROWI</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th>{t('importer_management.article')}</th>
+                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
+                <th>{t('importer_management.page')}</th>
+              </tr>
+              <tr>
+                <th>{t('importer_management.category')}</th>
+                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
+                <th>{t('importer_management.page_path')}</th>
+              </tr>
+              <tr>
+                <th>{t('User')}</th>
+                <th></th>
+                <th>(TBD)</th>
+              </tr>
+            </tbody>
+          </table>
+
+          <div className="card custom-card bg-body-tertiary mb-0 small">
+            <ul>
+              <li>{t('importer_management.page_skip')}</li>
+            </ul>
+          </div>
+
+          <div className="row mt-4">
+            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+          </div>
+
+          <div className="row">
+            <label htmlFor="settingForm[importer:esa:team_name]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.esa_settings.team_name')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...registerEsa('esaTeamName')}
+              />
             </div>
             </div>
 
 
-            <div className="row mt-3">
-              <label htmlFor="settingForm[importer:esa:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.esa_settings.access_token')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="password"
-                  name="esaAccessToken"
-                  value={adminImportContainer.state.esaAccessToken || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
+          </div>
+
+          <div className="row mt-3">
+            <label htmlFor="settingForm[importer:esa:access_token]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.esa_settings.access_token')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="password"
+                {...registerEsa('esaAccessToken')}
+              />
             </div>
             </div>
-
-            <div className="row mt-3">
-              <div className="offset-md-3 col-md-6">
+          </div>
+
+          <div className="row mt-3">
+            <div className="offset-md-3 col-md-6">
+              <input
+                id="testConnectionToEsa"
+                type="button"
+                className="btn btn-primary btn-esa me-3"
+                name="Esa"
+                onClick={adminImportContainer.esaHandleSubmit}
+                value={t('importer_management.import')}
+              />
+              <input type="submit" className="btn btn-secondary" value={t('Update')} />
+              <span className="offset-0 offset-sm-1">
                 <input
                 <input
-                  id="testConnectionToEsa"
+                  id="importFromEsa"
                   type="button"
                   type="button"
-                  className="btn btn-primary btn-esa me-3"
                   name="Esa"
                   name="Esa"
-                  onClick={adminImportContainer.esaHandleSubmit}
-                  value={t('importer_management.import')}
+                  className="btn btn-secondary btn-esa"
+                  onClick={adminImportContainer.esaHandleSubmitTest}
+                  value={t('importer_management.esa_settings.test_connection')}
                 />
                 />
-                <input type="button" className="btn btn-secondary" onClick={adminImportContainer.esaHandleSubmitUpdate} value={t('Update')} />
-                <span className="offset-0 offset-sm-1">
-                  <input
-                    id="importFromEsa"
-                    type="button"
-                    name="Esa"
-                    className="btn btn-secondary btn-esa"
-                    onClick={adminImportContainer.esaHandleSubmitTest}
-                    value={t('importer_management.esa_settings.test_connection')}
-                  />
-                </span>
-
-              </div>
+              </span>
             </div>
             </div>
-          </fieldset>
-        </form>
-
-        <form
-          className="mt-5"
-          id="importerSettingFormQiita"
-          role="form"
-        >
-          <fieldset>
-            <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'Qiita:Team' })}</h2>
-            <table className="table table-bordered table-mapping">
-              <thead>
-                <tr>
-                  <th width="45%">Qiita:Team</th>
-                  <th width="10%"></th>
-                  <th>GROWI</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('importer_management.article')}</th>
-                  <th><span className="material-symbols-outlined">arrow_circle_right</span></th>
-                  <th>{t('importer_management.page')}</th>
-                </tr>
-                <tr>
-                  <th>{t('importer_management.tag')}</th>
-                  <th></th>
-                  <th>-</th>
-                </tr>
-                <tr>
-                  <th>{t('importer_management.Directory_hierarchy_tag')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-                <tr>
-                  <th>{t('User')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-              </tbody>
-            </table>
-            <div className="card custom-card bg-body-tertiary mb-3 small">
-              <ul>
-                <li>{t('importer_management.page_skip')}</li>
-              </ul>
+          </div>
+        </fieldset>
+      </form>
+
+      <form
+        className="mt-5"
+        id="importerSettingFormQiita"
+        role="form"
+        onSubmit={handleSubmitQiita(adminImportContainer.qiitaHandleSubmitUpdate)}
+      >
+        <fieldset>
+          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'Qiita:Team' })}</h2>
+          <table className="table table-bordered table-mapping">
+            <thead>
+              <tr>
+                <th width="45%">Qiita:Team</th>
+                <th width="10%"></th>
+                <th>GROWI</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th>{t('importer_management.article')}</th>
+                <th><span className="material-symbols-outlined">arrow_circle_right</span></th>
+                <th>{t('importer_management.page')}</th>
+              </tr>
+              <tr>
+                <th>{t('importer_management.tag')}</th>
+                <th></th>
+                <th>-</th>
+              </tr>
+              <tr>
+                <th>{t('importer_management.Directory_hierarchy_tag')}</th>
+                <th></th>
+                <th>(TBD)</th>
+              </tr>
+              <tr>
+                <th>{t('User')}</th>
+                <th></th>
+                <th>(TBD)</th>
+              </tr>
+            </tbody>
+          </table>
+          <div className="card custom-card bg-body-tertiary mb-3 small">
+            <ul>
+              <li>{t('importer_management.page_skip')}</li>
+            </ul>
+          </div>
+
+          <div className="row mt-3">
+            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+          </div>
+          <div className="row mt-3">
+            <label htmlFor="settingForm[importer:qiita:team_name]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.qiita_settings.team_name')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...registerQiita('qiitaTeamName')}
+              />
             </div>
             </div>
-
-            <div className="row mt-3">
-              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+          </div>
+
+          <div className="row mt-3">
+            <label htmlFor="settingForm[importer:qiita:access_token]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.qiita_settings.access_token')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="password"
+                {...registerQiita('qiitaAccessToken')}
+              />
             </div>
             </div>
-            <div className="row mt-3">
-              <label htmlFor="settingForm[importer:qiita:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.qiita_settings.team_name')}
-              </label>
-              <div className="col-md-6">
+          </div>
+
+
+          <div className="row mt-3">
+            <div className="offset-md-3 col-md-6">
+              <input
+                id="testConnectionToQiita"
+                type="button"
+                className="btn btn-primary btn-qiita me-3"
+                name="Qiita"
+                onClick={adminImportContainer.qiitaHandleSubmit}
+                value={t('importer_management.import')}
+              />
+              <input type="submit" className="btn btn-secondary" value={t('Update')} />
+              <span className="offset-0 offset-sm-1">
                 <input
                 <input
-                  className="form-control"
-                  type="text"
-                  name="qiitaTeamName"
-                  value={adminImportContainer.state.qiitaTeamName || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
-            </div>
-
-            <div className="row mt-3">
-              <label htmlFor="settingForm[importer:qiita:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.qiita_settings.access_token')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="password"
-                  name="qiitaAccessToken"
-                  value={adminImportContainer.state.qiitaAccessToken || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
-            </div>
-
-
-            <div className="row mt-3">
-              <div className="offset-md-3 col-md-6">
-                <input
-                  id="testConnectionToQiita"
-                  type="button"
-                  className="btn btn-primary btn-qiita me-3"
                   name="Qiita"
                   name="Qiita"
-                  onClick={adminImportContainer.qiitaHandleSubmit}
-                  value={t('importer_management.import')}
+                  type="button"
+                  id="importFromQiita"
+                  className="btn btn-secondary btn-qiita"
+                  onClick={adminImportContainer.qiitaHandleSubmitTest}
+                  value={t('importer_management.qiita_settings.test_connection')}
                 />
                 />
-                <input type="button" className="btn btn-secondary" onClick={adminImportContainer.qiitaHandleSubmitUpdate} value={t('Update')} />
-                <span className="offset-0 offset-sm-1">
-                  <input
-                    name="Qiita"
-                    type="button"
-                    id="importFromQiita"
-                    className="btn btn-secondary btn-qiita"
-                    onClick={adminImportContainer.qiitaHandleSubmitTest}
-                    value={t('importer_management.qiita_settings.test_connection')}
-                  />
-                </span>
-
-              </div>
-            </div>
+              </span>
 
 
+            </div>
+          </div>
 
 
-          </fieldset>
 
 
+        </fieldset>
 
 
-        </form>
-      </div>
-    );
-  }
 
 
-}
+      </form>
+    </div>
+  );
+};
 
 
 ImportDataPageContents.propTypes = {
 ImportDataPageContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
+  t: PropTypes.func.isRequired,
   adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired,
   adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired,
 };
 };
 
 

+ 27 - 27
apps/app/src/client/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx

@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -12,18 +13,24 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 
 const logger = loggerFactory('growi:slackAppConfiguration');
 const logger = loggerFactory('growi:slackAppConfiguration');
 
 
-class SlackConfiguration extends React.Component {
+const SlackConfiguration = (props) => {
+  const { t, adminSlackIntegrationLegacyContainer } = props;
+  const { webhookUrl, slackToken, retrieveError } = adminSlackIntegrationLegacyContainer.state;
 
 
-  constructor(props) {
-    super(props);
+  const { register, handleSubmit, reset } = useForm();
 
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminSlackIntegrationLegacyContainer } = this.props;
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      webhookUrl,
+      slackToken,
+    });
+  }, [reset, webhookUrl, slackToken]);
 
 
+  const onClickSubmit = useCallback(async(data) => {
     try {
     try {
+      await adminSlackIntegrationLegacyContainer.changeWebhookUrl(data.webhookUrl ?? '');
+      await adminSlackIntegrationLegacyContainer.changeSlackToken(data.slackToken ?? '');
       await adminSlackIntegrationLegacyContainer.updateSlackAppConfiguration();
       await adminSlackIntegrationLegacyContainer.updateSlackAppConfiguration();
       toastSuccess(t('notification_settings.updated_slackApp'));
       toastSuccess(t('notification_settings.updated_slackApp'));
     }
     }
@@ -31,12 +38,10 @@ class SlackConfiguration extends React.Component {
       toastError(err);
       toastError(err);
       logger.error(err);
       logger.error(err);
     }
     }
-  }
-
-  render() {
-    const { t, adminSlackIntegrationLegacyContainer } = this.props;
+  }, [adminSlackIntegrationLegacyContainer, t]);
 
 
-    return (
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
       <React.Fragment>
         <div className="row my-3">
         <div className="row my-3">
           <div className="col-6 text-start">
           <div className="col-6 text-start">
@@ -70,8 +75,7 @@ class SlackConfiguration extends React.Component {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
-                  value={adminSlackIntegrationLegacyContainer.state.webhookUrl || ''}
-                  onChange={e => adminSlackIntegrationLegacyContainer.changeWebhookUrl(e.target.value)}
+                  {...register('webhookUrl')}
                 />
                 />
               </div>
               </div>
             </div>
             </div>
@@ -122,8 +126,7 @@ class SlackConfiguration extends React.Component {
                   <input
                   <input
                     className="form-control"
                     className="form-control"
                     type="text"
                     type="text"
-                    value={adminSlackIntegrationLegacyContainer.state.slackToken || ''}
-                    onChange={e => adminSlackIntegrationLegacyContainer.changeSlackToken(e.target.value)}
+                    {...register('slackToken')}
                   />
                   />
                 </div>
                 </div>
               </div>
               </div>
@@ -133,8 +136,8 @@ class SlackConfiguration extends React.Component {
         }
         }
 
 
         <AdminUpdateButtonRow
         <AdminUpdateButtonRow
-          onClick={this.onClickSubmit}
-          disabled={adminSlackIntegrationLegacyContainer.state.retrieveError != null}
+          disabled={retrieveError != null}
+          onClick={handleSubmit(onClickSubmit)}
         />
         />
 
 
         <hr />
         <hr />
@@ -149,7 +152,7 @@ class SlackConfiguration extends React.Component {
             {t('notification_settings.how_to.workspace')}
             {t('notification_settings.how_to.workspace')}
             <ol>
             <ol>
               {/* eslint-disable-next-line react/no-danger */}
               {/* eslint-disable-next-line react/no-danger */}
-              <li dangerouslySetInnerHTML={{ __html:  t('notification_settings.how_to.workspace_desc1') }} />
+              <li dangerouslySetInnerHTML={{ __html: t('notification_settings.how_to.workspace_desc1') }} />
               <li>{t('notification_settings.how_to.workspace_desc2')}</li>
               <li>{t('notification_settings.how_to.workspace_desc2')}</li>
               <li>{t('notification_settings.how_to.workspace_desc3')}</li>
               <li>{t('notification_settings.how_to.workspace_desc3')}</li>
             </ol>
             </ol>
@@ -164,16 +167,13 @@ class SlackConfiguration extends React.Component {
         </ol>
         </ol>
 
 
       </React.Fragment>
       </React.Fragment>
-    );
-  }
-
-}
-
+    </form>
+  );
+};
 
 
 SlackConfiguration.propTypes = {
 SlackConfiguration.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
   adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
-
 };
 };
 
 
 const SlackConfigurationWrapperFc = (props) => {
 const SlackConfigurationWrapperFc = (props) => {

+ 17 - 26
apps/app/src/client/components/Admin/MarkdownSetting/WhitelistInput.tsx

@@ -1,41 +1,38 @@
-import { useCallback, useRef, type JSX } from 'react';
+import { useCallback, type JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister, UseFormSetValue } from 'react-hook-form';
 
 
 import type AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import type AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
 import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
 
 
+type FormValues = {
+  tagWhitelist: string,
+  attrWhitelist: string,
+}
+
 type Props ={
 type Props ={
-  adminMarkDownContainer: AdminMarkDownContainer
+  adminMarkDownContainer: AdminMarkDownContainer,
+  register: UseFormRegister<FormValues>,
+  setValue: UseFormSetValue<FormValues>,
 }
 }
 
 
 export const WhitelistInput = (props: Props): JSX.Element => {
 export const WhitelistInput = (props: Props): JSX.Element => {
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
-  const { adminMarkDownContainer } = props;
-
-  const tagNamesRef = useRef<HTMLTextAreaElement>(null);
-  const attrsRef = useRef<HTMLTextAreaElement>(null);
+  const { adminMarkDownContainer, register, setValue } = props;
 
 
   const clickRecommendTagButtonHandler = useCallback(() => {
   const clickRecommendTagButtonHandler = useCallback(() => {
-    if (tagNamesRef.current == null) {
-      return;
-    }
-
     const tagWhitelist = recommendedTagNames.join(',');
     const tagWhitelist = recommendedTagNames.join(',');
-    tagNamesRef.current.value = tagWhitelist;
+    setValue('tagWhitelist', tagWhitelist);
     adminMarkDownContainer.setState({ tagWhitelist });
     adminMarkDownContainer.setState({ tagWhitelist });
-  }, [adminMarkDownContainer]);
+  }, [adminMarkDownContainer, setValue]);
 
 
   const clickRecommendAttrButtonHandler = useCallback(() => {
   const clickRecommendAttrButtonHandler = useCallback(() => {
-    if (attrsRef.current == null) {
-      return;
-    }
-
     const attrWhitelist = JSON.stringify(recommendedAttributes);
     const attrWhitelist = JSON.stringify(recommendedAttributes);
-    attrsRef.current.value = attrWhitelist;
+    setValue('attrWhitelist', attrWhitelist);
     adminMarkDownContainer.setState({ attrWhitelist });
     adminMarkDownContainer.setState({ attrWhitelist });
-  }, [adminMarkDownContainer]);
+  }, [adminMarkDownContainer, setValue]);
 
 
   return (
   return (
     <>
     <>
@@ -47,13 +44,10 @@ export const WhitelistInput = (props: Props): JSX.Element => {
           </p>
           </p>
         </div>
         </div>
         <textarea
         <textarea
-          ref={tagNamesRef}
           className="form-control xss-list"
           className="form-control xss-list"
-          name="recommendedTags"
           rows={6}
           rows={6}
           cols={40}
           cols={40}
-          value={adminMarkDownContainer.state.tagWhitelist}
-          onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
+          {...register('tagWhitelist')}
         />
         />
       </div>
       </div>
       <div className="mt-4">
       <div className="mt-4">
@@ -64,13 +58,10 @@ export const WhitelistInput = (props: Props): JSX.Element => {
           </p>
           </p>
         </div>
         </div>
         <textarea
         <textarea
-          ref={attrsRef}
           className="form-control xss-list"
           className="form-control xss-list"
-          name="recommendedAttrs"
           rows={6}
           rows={6}
           cols={40}
           cols={40}
-          value={adminMarkDownContainer.state.attrWhitelist}
-          onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
+          {...register('attrWhitelist')}
         />
         />
       </div>
       </div>
     </>
     </>

+ 39 - 30
apps/app/src/client/components/Admin/MarkdownSetting/XssForm.jsx

@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -16,30 +17,38 @@ import { WhitelistInput } from './WhitelistInput';
 
 
 const logger = loggerFactory('growi:importer');
 const logger = loggerFactory('growi:importer');
 
 
-class XssForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t } = this.props;
-
+const XssForm = (props) => {
+  const { t, adminMarkDownContainer } = props;
+  const {
+    xssOption, tagWhitelist, attrWhitelist, retrieveError,
+  } = adminMarkDownContainer.state;
+
+  const {
+    register, handleSubmit, reset, setValue,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      tagWhitelist,
+      attrWhitelist,
+    });
+  }, [reset, tagWhitelist, attrWhitelist]);
+
+  const onClickSubmit = useCallback(async(data) => {
     try {
     try {
-      await this.props.adminMarkDownContainer.updateXssSetting();
+      await adminMarkDownContainer.setState({ tagWhitelist: data.tagWhitelist ?? '' });
+      await adminMarkDownContainer.setState({ attrWhitelist: data.attrWhitelist ?? '' });
+      await adminMarkDownContainer.updateXssSetting();
       toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.xss_header'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.xss_header'), ns: 'commons' }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
       logger.error(err);
       logger.error(err);
     }
     }
-  }
+  }, [adminMarkDownContainer, t]);
 
 
-  xssOptions() {
-    const { t, adminMarkDownContainer } = this.props;
-    const { xssOption } = adminMarkDownContainer.state;
+  const xssOptions = useCallback(() => {
 
 
     const rehypeRecommendedTags = recommendedTagNames.join(',');
     const rehypeRecommendedTags = recommendedTagNames.join(',');
     const rehypeRecommendedAttributes = JSON.stringify(recommendedAttributes);
     const rehypeRecommendedAttributes = JSON.stringify(recommendedAttributes);
@@ -102,20 +111,19 @@ class XssForm extends React.Component {
               />
               />
               <label className="form-label form-check-label w-100" htmlFor="xssOption2">
               <label className="form-label form-check-label w-100" htmlFor="xssOption2">
                 <p className="fw-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <p className="fw-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
-                <WhitelistInput adminMarkDownContainer={adminMarkDownContainer} />
+                <WhitelistInput adminMarkDownContainer={adminMarkDownContainer} register={register} setValue={setValue} />
               </label>
               </label>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
     );
     );
-  }
+  }, [t, adminMarkDownContainer, xssOption, register, setValue]);
 
 
-  render() {
-    const { t, adminMarkDownContainer } = this.props;
-    const { isEnabledXss } = adminMarkDownContainer.state;
+  const { isEnabledXss } = adminMarkDownContainer.state;
 
 
-    return (
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
       <React.Fragment>
         <fieldset className="col-12">
         <fieldset className="col-12">
           <div>
           <div>
@@ -137,16 +145,17 @@ class XssForm extends React.Component {
           </div>
           </div>
 
 
           <div className="col-12">
           <div className="col-12">
-            {isEnabledXss && this.xssOptions()}
+            {isEnabledXss && xssOptions()}
           </div>
           </div>
         </fieldset>
         </fieldset>
-        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null} />
+        <AdminUpdateButtonRow
+          disabled={retrieveError != null}
+          onClick={handleSubmit(onClickSubmit)}
+        />
       </React.Fragment>
       </React.Fragment>
-    );
-  }
-
-}
-
+    </form>
+  );
+};
 
 
 XssForm.propTypes = {
 XssForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next

+ 38 - 39
apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx

@@ -1,9 +1,10 @@
 /* eslint-disable react/no-danger */
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 
 
@@ -14,18 +15,29 @@ import { useSiteUrl } from '~/stores-universal/context';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-class GitHubSecurityManagementContents extends React.Component {
+const GitHubSecurityManagementContents = (props) => {
+  const {
+    t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
+  } = props;
 
 
-  constructor(props) {
-    super(props);
+  const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
+  const { githubClientId, githubClientSecret, retrieveError } = adminGitHubSecurityContainer.state;
+  const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
 
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
+  const { register, handleSubmit, reset } = useForm();
 
 
-  async onClickSubmit() {
-    const { t, adminGitHubSecurityContainer, adminGeneralSecurityContainer } = this.props;
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      githubClientId,
+      githubClientSecret,
+    });
+  }, [reset, githubClientId, githubClientSecret]);
 
 
+  const onClickSubmit = useCallback(async(data) => {
     try {
     try {
+      await adminGitHubSecurityContainer.changeGitHubClientId(data.githubClientId ?? '');
+      await adminGitHubSecurityContainer.changeGitHubClientSecret(data.githubClientSecret ?? '');
       await adminGitHubSecurityContainer.updateGitHubSetting();
       await adminGitHubSecurityContainer.updateGitHubSetting();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.GitHub.updated_github'));
       toastSuccess(t('security_settings.OAuth.GitHub.updated_github'));
@@ -33,26 +45,19 @@ class GitHubSecurityManagementContents extends React.Component {
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }
-
-  render() {
-    const {
-      t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
-    } = this.props;
-    const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
-    const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
-
-    return (
+  }, [adminGitHubSecurityContainer, adminGeneralSecurityContainer, t]);
 
 
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
       <React.Fragment>
 
 
         <h2 className="alert-anchor border-bottom">
         <h2 className="alert-anchor border-bottom">
           {t('security_settings.OAuth.GitHub.name')}
           {t('security_settings.OAuth.GitHub.name')}
         </h2>
         </h2>
 
 
-        {adminGitHubSecurityContainer.state.retrieveError != null && (
+        {retrieveError != null && (
           <div className="alert alert-danger">
           <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {adminGitHubSecurityContainer.state.retrieveError}</p>
+            <p>{t('Error occurred')} : {retrieveError}</p>
           </div>
           </div>
         )}
         )}
 
 
@@ -108,9 +113,7 @@ class GitHubSecurityManagementContents extends React.Component {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
-                  name="githubClientId"
-                  value={adminGitHubSecurityContainer.state.githubClientId || ''}
-                  onChange={e => adminGitHubSecurityContainer.changeGitHubClientId(e.target.value)}
+                  {...register('githubClientId')}
                 />
                 />
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_ID' }) }} />
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_ID' }) }} />
@@ -124,9 +127,7 @@ class GitHubSecurityManagementContents extends React.Component {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
-                  name="githubClientSecret"
-                  value={adminGitHubSecurityContainer.state.githubClientSecret || ''}
-                  onChange={e => adminGitHubSecurityContainer.changeGitHubClientSecret(e.target.value)}
+                  {...register('githubClientSecret')}
                 />
                 />
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_SECRET' }) }} />
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_SECRET' }) }} />
@@ -158,9 +159,9 @@ class GitHubSecurityManagementContents extends React.Component {
 
 
             <div className="row mb-4">
             <div className="row mb-4">
               <div className="offset-3 col-5">
               <div className="offset-3 col-5">
-                <div className="btn btn-primary" disabled={adminGitHubSecurityContainer.state.retrieveError != null} onClick={this.onClickSubmit}>
+                <button type="submit" className="btn btn-primary" disabled={retrieveError != null}>
                   {t('Update')}
                   {t('Update')}
-                </div>
+                </button>
               </div>
               </div>
             </div>
             </div>
 
 
@@ -185,12 +186,16 @@ class GitHubSecurityManagementContents extends React.Component {
         </div>
         </div>
 
 
       </React.Fragment>
       </React.Fragment>
+    </form>
+  );
+};
 
 
-
-    );
-  }
-
-}
+GitHubSecurityManagementContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
+};
 
 
 const GitHubSecurityManagementContentsFC = (props) => {
 const GitHubSecurityManagementContentsFC = (props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
@@ -206,10 +211,4 @@ const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSec
   AdminGitHubSecurityContainer,
   AdminGitHubSecurityContainer,
 ]);
 ]);
 
 
-GitHubSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
-};
-
 export default GitHubSecurityManagementContentsWrapper;
 export default GitHubSecurityManagementContentsWrapper;

+ 36 - 44
apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -1,8 +1,9 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
@@ -12,18 +13,29 @@ import { useSiteUrl } from '~/stores-universal/context';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-class GoogleSecurityManagementContents extends React.Component {
+const GoogleSecurityManagementContents = (props) => {
+  const {
+    t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
+  } = props;
 
 
-  constructor(props) {
-    super(props);
+  const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
+  const { googleClientId, googleClientSecret, retrieveError } = adminGoogleSecurityContainer.state;
+  const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
 
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
+  const { register, handleSubmit, reset } = useForm();
 
 
-  async onClickSubmit() {
-    const { t, adminGoogleSecurityContainer, adminGeneralSecurityContainer } = this.props;
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      googleClientId,
+      googleClientSecret,
+    });
+  }, [reset, googleClientId, googleClientSecret]);
 
 
+  const onClickSubmit = useCallback(async(data) => {
     try {
     try {
+      await adminGoogleSecurityContainer.changeGoogleClientId(data.googleClientId ?? '');
+      await adminGoogleSecurityContainer.changeGoogleClientSecret(data.googleClientSecret ?? '');
       await adminGoogleSecurityContainer.updateGoogleSetting();
       await adminGoogleSecurityContainer.updateGoogleSetting();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.Google.updated_google'));
       toastSuccess(t('security_settings.OAuth.Google.updated_google'));
@@ -31,26 +43,19 @@ class GoogleSecurityManagementContents extends React.Component {
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }
-
-  render() {
-    const {
-      t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
-    } = this.props;
-    const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
-    const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
-
-    return (
+  }, [adminGoogleSecurityContainer, adminGeneralSecurityContainer, t]);
 
 
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
       <React.Fragment>
 
 
         <h2 className="alert-anchor border-bottom">
         <h2 className="alert-anchor border-bottom">
           {t('security_settings.OAuth.Google.name')}
           {t('security_settings.OAuth.Google.name')}
         </h2>
         </h2>
 
 
-        {adminGoogleSecurityContainer.state.retrieveError != null && (
+        {retrieveError != null && (
           <div className="alert alert-danger">
           <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {adminGoogleSecurityContainer.state.retrieveError}</p>
+            <p>{t('Error occurred')} : {retrieveError}</p>
           </div>
           </div>
         )}
         )}
 
 
@@ -107,9 +112,7 @@ class GoogleSecurityManagementContents extends React.Component {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="text"
                   type="text"
-                  name="googleClientId"
-                  value={adminGoogleSecurityContainer.state.googleClientId || ''}
-                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientId(e.target.value)}
+                  {...register('googleClientId')}
                 />
                 />
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_ID' }) }} />
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_ID' }) }} />
@@ -123,9 +126,7 @@ class GoogleSecurityManagementContents extends React.Component {
                 <input
                 <input
                   className="form-control"
                   className="form-control"
                   type="password"
                   type="password"
-                  name="googleClientSecret"
-                  value={adminGoogleSecurityContainer.state.googleClientSecret || ''}
-                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientSecret(e.target.value)}
+                  {...register('googleClientSecret')}
                 />
                 />
                 <p className="form-text text-muted">
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_SECRET' }) }} />
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_SECRET' }) }} />
@@ -157,12 +158,7 @@ class GoogleSecurityManagementContents extends React.Component {
 
 
             <div className="row mb-4">
             <div className="row mb-4">
               <div className="offset-3 col-5">
               <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminGoogleSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
+                <button type="submit" className="btn btn-primary" disabled={retrieveError != null}>
                   {t('Update')}
                   {t('Update')}
                 </button>
                 </button>
               </div>
               </div>
@@ -191,20 +187,10 @@ class GoogleSecurityManagementContents extends React.Component {
         </div>
         </div>
 
 
       </React.Fragment>
       </React.Fragment>
-
-
-    );
-  }
-
-}
-
-const GoogleSecurityManagementContentsFc = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
+    </form>
+  );
 };
 };
 
 
-
 GoogleSecurityManagementContents.propTypes = {
 GoogleSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
@@ -212,6 +198,12 @@ GoogleSecurityManagementContents.propTypes = {
   siteUrl: PropTypes.string,
   siteUrl: PropTypes.string,
 };
 };
 
 
+const GoogleSecurityManagementContentsFc = (props) => {
+  const { t } = useTranslation('admin');
+  const { data: siteUrl } = useSiteUrl();
+  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
+};
+
 const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [
 const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [
   AdminGeneralSecurityContainer,
   AdminGeneralSecurityContainer,
   AdminGoogleSecurityContainer,
   AdminGoogleSecurityContainer,

+ 0 - 450
apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -1,450 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import LdapAuthTestModal from './LdapAuthTestModal';
-
-
-class LdapSecuritySettingContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isLdapAuthTestModalShown: false,
-    };
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-    this.openLdapAuthTestModal = this.openLdapAuthTestModal.bind(this);
-    this.closeLdapAuthTestModal = this.closeLdapAuthTestModal.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminLdapSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminLdapSecurityContainer.updateLdapSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.ldap.updated_ldap'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  openLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: true });
-  }
-
-  closeLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: false });
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminLdapSecurityContainer } = this.props;
-    const { isLdapEnabled } = adminGeneralSecurityContainer.state;
-
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom mb-4">
-          LDAP
-        </h2>
-
-        <div className="row my-4">
-          <div className="col-6 offset-3">
-            <div className="form-check form-switch form-check-success">
-              <input
-                id="isLdapEnabled"
-                className="form-check-input"
-                type="checkbox"
-                checked={isLdapEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
-              />
-              <label className="form-label form-check-label" htmlFor="isLdapEnabled">
-                {t('security_settings.ldap.enable_ldap')}
-              </label>
-            </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isLdapEnabled)
-              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-
-        {isLdapEnabled && (
-          <React.Fragment>
-
-            <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
-
-            <div className="row my-3">
-              <label htmlFor="serverUrl" className="text-start text-md-end col-md-3 col-form-label">
-                Server URL
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="serverUrl"
-                  value={adminLdapSecurityContainer.state.serverUrl || ''}
-                  onChange={e => adminLdapSecurityContainer.changeServerUrl(e.target.value)}
-                />
-                <small>
-                  <p
-                    className="form-text text-muted"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.server_url_detail') }}
-                  />
-                  {t('security_settings.example')}: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
-                </small>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong>{t('security_settings.ldap.bind_mode')}</strong>
-              </label>
-              <div className="col-md-9">
-                <div className="dropdown">
-                  <button
-                    className="btn btn-outline-secondary dropdown-toggle"
-                    type="button"
-                    id="dropdownMenuButton"
-                    data-bs-toggle="dropdown"
-                    aria-haspopup="true"
-                    aria-expanded="true"
-                  >
-                    {adminLdapSecurityContainer.state.isUserBind
-                      ? <span className="pull-left">{t('security_settings.ldap.bind_user')}</span>
-                      : <span className="pull-left">{t('security_settings.ldap.bind_manager')}</span>}
-                  </button>
-                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                    <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(true) }}>
-                      {t('security_settings.ldap.bind_user')}
-                    </button>
-                    <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(false) }}>
-                      {t('security_settings.ldap.bind_manager')}
-                    </button>
-                  </div>
-                </div>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong>Bind DN</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="bindDN"
-                  value={adminLdapSecurityContainer.state.ldapBindDN || ''}
-                  onChange={e => adminLdapSecurityContainer.changeBindDN(e.target.value)}
-                />
-                {(adminLdapSecurityContainer.state.isUserBind === true) ? (
-                  <p className="form-text text-muted passport-ldap-userbind">
-                    <small>
-                      {t('security_settings.ldap.bind_DN_user_detail1')}<br />
-                      {/* eslint-disable-next-line react/no-danger */}
-                      <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.bind_DN_user_detail2') }} /><br />
-                      {t('security_settings.example')}1: <code>uid={'{{ username }}'},dc=domain,dc=com</code><br />
-                      {t('security_settings.example')}2: <code>{'{{ username }}'}@domain.com</code>
-                    </small>
-                  </p>
-                )
-                  : (
-                    <p className="form-text text-muted passport-ldap-managerbind">
-                      <small>
-                        {t('security_settings.ldap.bind_DN_manager_detail')}<br />
-                        {t('security_settings.example')}1: <code>uid=admin,dc=domain,dc=com</code><br />
-                        {t('security_settings.example')}2: <code>admin@domain.com</code>
-                      </small>
-                    </p>
-                  )}
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div htmlFor="bindDNPassword" className="text-start text-md-end col-md-3 col-form-label">
-                <strong>{t('security_settings.ldap.bind_DN_password')}</strong>
-              </div>
-              <div className="col-md-9">
-                {(adminLdapSecurityContainer.state.isUserBind) ? (
-                  <p className="card custom-card passport-ldap-userbind">
-                    <small>
-                      {t('security_settings.ldap.bind_DN_password_user_detail')}
-                    </small>
-                  </p>
-                )
-                  : (
-                    <>
-                      <p className="card custom-card passport-ldap-managerbind">
-                        <small>
-                          {t('security_settings.ldap.bind_DN_password_manager_detail')}
-                        </small>
-                      </p>
-                      <input
-                        className="form-control passport-ldap-managerbind"
-                        type="password"
-                        name="bindDNPassword"
-                        value={adminLdapSecurityContainer.state.ldapBindDNPassword || ''}
-                        onChange={e => adminLdapSecurityContainer.changeBindDNPassword(e.target.value)}
-                      />
-                    </>
-                  )}
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong>{t('security_settings.ldap.search_filter')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="searchFilter"
-                  value={adminLdapSecurityContainer.state.ldapSearchFilter || ''}
-                  onChange={e => adminLdapSecurityContainer.changeSearchFilter(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.ldap.search_filter_detail1')}<br />
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.search_filter_detail2') }} /><br />
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.search_filter_detail3') }} />
-                  </small>
-                </p>
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.example')}1 - {t('security_settings.ldap.search_filter_example1')}:
-                    <code>(|(uid={'{{username}}'})(mail={'{{username}}'}))</code><br />
-                    {t('security_settings.example')}2 - {t('security_settings.ldap.search_filter_example2')}:
-                    <code>(sAMAccountName={'{{username}}'})</code>
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <h3 className="alert-anchor border-bottom mb-4">
-              Attribute Mapping ({t('optional')})
-            </h3>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="attrMapUsername">{t('username')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: uid"
-                  name="attrMapUsername"
-                  value={adminLdapSecurityContainer.state.ldapAttrMapUsername || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapUsername(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.username_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div className="offset-md-3 col-md-9">
-                <div className="form-check form-check-success">
-                  <input
-                    type="checkbox"
-                    className="form-check-input"
-                    id="isSameUsernameTreatedAsIdenticalUser"
-                    checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                    onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="form-check-label"
-                    htmlFor="isSameUsernameTreatedAsIdenticalUser"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="attrMapMail">{t('Email')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: mail"
-                  name="attrMapMail"
-                  value={adminLdapSecurityContainer.state.ldapAttrMapMail || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapMail(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.ldap.mail_detail')}
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="attrMapName">{t('Name')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="attrMapName"
-                  value={adminLdapSecurityContainer.state.ldapAttrMapName || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapName(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.ldap.name_detail')}
-                  </small>
-                </p>
-              </div>
-            </div>
-
-
-            <h3 className="alert-anchor border-bottom mb-4">
-              {t('security_settings.ldap.group_search_filter')} ({t('optional')})
-            </h3>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="groupSearchBase">{t('security_settings.ldap.group_search_base_DN')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="groupSearchBase"
-                  value={adminLdapSecurityContainer.state.ldapGroupSearchBase || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupSearchBase(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_base_DN_detail') }} /><br />
-                    {t('security_settings.example')}: <code>ou=groups,dc=domain,dc=com</code>
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="groupSearchFilter">{t('security_settings.ldap.group_search_filter')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="groupSearchFilter"
-                  value={adminLdapSecurityContainer.state.ldapGroupSearchFilter || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupSearchFilter(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {/* eslint-disable react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail1') }} /><br />
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail2') }} /><br />
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail3') }} />
-                    {/* eslint-enable react/no-danger */}
-                  </small>
-                </p>
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.example')}:
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail4') }} />
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="groupDnProperty">{t('security_settings.ldap.group_search_user_DN_property')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: uid"
-                  name="groupDnProperty"
-                  value={adminLdapSecurityContainer.state.ldapGroupDnProperty || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupDnProperty(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_user_DN_property_detail') }} />
-                </p>
-              </div>
-            </div>
-            <div className="row my-3">
-              <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminLdapSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-                <button
-                  type="button"
-                  className="btn btn-outline-secondary ms-2"
-                  onClick={this.openLdapAuthTestModal}
-                >{t('security_settings.ldap.test_config')}
-                </button>
-              </div>
-            </div>
-
-          </React.Fragment>
-        )}
-
-
-        <LdapAuthTestModal isOpen={this.state.isLdapAuthTestModalShown} onClose={this.closeLdapAuthTestModal} />
-
-      </React.Fragment>
-    );
-  }
-
-}
-
-LdapSecuritySettingContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
-};
-
-const LdapSecuritySettingContentsWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <LdapSecuritySettingContents t={t} {...props} />;
-};
-
-const LdapSecuritySettingContentsWrapper = withUnstatedContainers(LdapSecuritySettingContentsWrapperFC, [
-  AdminGeneralSecurityContainer,
-  AdminLdapSecurityContainer,
-]);
-
-export default LdapSecuritySettingContentsWrapper;

Неке датотеке нису приказане због велике количине промена