Browse Source

Merge branch 'master' of https://github.com/growilabs/growi into support/136168-update-axios

Futa Arai 6 months ago
parent
commit
deafe4fec5
100 changed files with 2269 additions and 901 deletions
  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. 1 1
      .serena/memories/project_overview.md
  29. 13 7
      .serena/memories/suggested_commands.md
  30. 2 1
      .vscode/settings.json
  31. 179 110
      CHANGELOG.md
  32. 95 0
      CLAUDE.md
  33. 1 1
      LICENSE
  34. 15 15
      README.md
  35. 15 15
      README_JP.md
  36. 1 1
      THIRD-PARTY-NOTICES.md
  37. 12 0
      apps/app/.eslintrc.js
  38. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  39. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  40. 3 3
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  41. 415 0
      apps/app/bin/print-memory-consumption.ts
  42. 0 14
      apps/app/config/cdn.js
  43. 1 1
      apps/app/config/migrate-mongo-config.js
  44. 1 0
      apps/app/config/next-i18next.config.js
  45. 2 2
      apps/app/docker/Dockerfile
  46. 10 10
      apps/app/docker/README.md
  47. 44 44
      apps/app/docker/codebuild/.terraform.lock.hcl
  48. 1 1
      apps/app/docker/codebuild/buildspec.yml
  49. 1 1
      apps/app/docker/codebuild/codebuild.tf
  50. 1 1
      apps/app/docker/codebuild/main.tf
  51. 1 1
      apps/app/docker/codebuild/oidc.tf
  52. 2 2
      apps/app/next.config.js
  53. 4 3
      apps/app/package.json
  54. 2 2
      apps/app/public/static/locales/en_US/admin.json
  55. 6 2
      apps/app/public/static/locales/en_US/translation.json
  56. 2 2
      apps/app/public/static/locales/fr_FR/admin.json
  57. 7 3
      apps/app/public/static/locales/fr_FR/translation.json
  58. 2 2
      apps/app/public/static/locales/ja_JP/admin.json
  59. 7 3
      apps/app/public/static/locales/ja_JP/translation.json
  60. 2 2
      apps/app/public/static/locales/ko_KR/admin.json
  61. 6 2
      apps/app/public/static/locales/ko_KR/translation.json
  62. 2 2
      apps/app/public/static/locales/zh_CN/admin.json
  63. 6 2
      apps/app/public/static/locales/zh_CN/translation.json
  64. 18 8
      apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  65. 22 31
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  66. 287 0
      apps/app/src/client/components/LoginForm/LoginForm.spec.tsx
  67. 25 20
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  68. 0 2
      apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx
  69. 2 2
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  70. 2 2
      apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx
  71. 1 1
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  72. 0 2
      apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx
  73. 2 2
      apps/app/src/client/components/PageSideContents/PageAccessoriesControl.tsx
  74. 3 3
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  75. 22 4
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  76. 9 9
      apps/app/src/client/components/SearchPage/SearchPageBase.tsx
  77. 2 2
      apps/app/src/client/components/TableOfContents.tsx
  78. 5 2
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  79. 1 1
      apps/app/src/client/services/AdminHomeContainer.js
  80. 2 2
      apps/app/src/client/services/page-operation.ts
  81. 3 2
      apps/app/src/components/PageView/PageContentFooter.module.scss
  82. 1 1
      apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx
  83. 32 31
      apps/app/src/features/callout/components/CalloutViewer.tsx
  84. 11 5
      apps/app/src/features/callout/services/callout.spec.ts
  85. 20 9
      apps/app/src/features/callout/services/callout.ts
  86. 10 2
      apps/app/src/features/callout/services/consts.ts
  87. 1 1
      apps/app/src/features/callout/services/index.ts
  88. 1 1
      apps/app/src/features/comment/server/events/consts.ts
  89. 1 1
      apps/app/src/features/comment/server/events/event-emitter.ts
  90. 48 43
      apps/app/src/features/comment/server/models/comment.ts
  91. 118 79
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  92. 5 3
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  93. 109 45
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  94. 28 18
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  95. 104 47
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  96. 46 26
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  97. 76 33
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  98. 50 38
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  99. 95 39
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  100. 55 23
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

+ 1 - 1
.changeset/config.json

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

+ 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

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

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

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

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

+ 2 - 1
.vscode/settings.json

@@ -96,6 +96,7 @@
     {
     {
       "text": "Always write commit messages in English."
       "text": "Always write commit messages in English."
     }
     }
-  ]
+  ],
+  "git-worktree-menu.worktreeDir": "/workspace"
 
 
 }
 }

File diff suppressed because it is too large
+ 179 - 110
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

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

@@ -30,6 +30,18 @@ module.exports = {
     'config/**',
     'config/**',
     'src/linter-checker/**',
     'src/linter-checker/**',
     'src/migrations/**',
     'src/migrations/**',
+    '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/opentelemetry/**',
+    'src/stores-universal/**',
+    'src/interfaces/**',
+    'src/utils/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

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

@@ -1,4 +1,4 @@
-import { writeFileSync } from 'fs';
+import { writeFileSync } from 'node:fs';
 
 
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 

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

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

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

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

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

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

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

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

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

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

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

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

+ 2 - 2
apps/app/docker/Dockerfile

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 4 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.3.0-RC.0",
+  "version": "7.3.2-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -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",
@@ -294,6 +294,7 @@
     "@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",

+ 2 - 2
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.",
@@ -1139,4 +1139,4 @@
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "ai_search_management": "AI search management"
     "ai_search_management": "AI search management"
   }
   }
-}
+}

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

@@ -675,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": {
@@ -893,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",
@@ -1058,4 +1062,4 @@
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "error-toaster": "Synchronization of the latest text failed"
     "error-toaster": "Synchronization of the latest text failed"
   }
   }
-}
+}

+ 2 - 2
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.",
@@ -1138,4 +1138,4 @@
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "ai_search_management": "Gestion de la recherche par l'IA"
     "ai_search_management": "Gestion de la recherche par l'IA"
   }
   }
-}
+}

+ 7 - 3
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"
@@ -669,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": {
@@ -887,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",
@@ -1049,4 +1053,4 @@
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
     "error-toaster": "Synchronisation échouée"
   }
   }
-}
+}

+ 2 - 2
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 互換形式に変換してください。",
@@ -1148,4 +1148,4 @@
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
     "ai_search_management": "AI 検索管理"
     "ai_search_management": "AI 検索管理"
   }
   }
-}
+}

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

@@ -244,7 +244,7 @@
     "scope_read": "Read",
     "scope_read": "Read",
     "action": "アクション",
     "action": "アクション",
     "create_token": "トークンを作成",
     "create_token": "トークンを作成",
-    "no_tokens_found":"アクセストークンが見つかりません",
+    "no_tokens_found": "アクセストークンが見つかりません",
     "new_token": {
     "new_token": {
       "title": "新しいアクセストークン",
       "title": "新しいアクセストークン",
       "copy_to_clipboard": "クリップボードにコピーしました",
       "copy_to_clipboard": "クリップボードにコピーしました",
@@ -708,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": {
@@ -926,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 グリッドを作成",
@@ -1091,4 +1095,4 @@
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "error-toaster": "最新の本文の同期に失敗しました"
     "error-toaster": "最新の本文の同期に失敗しました"
   }
   }
-}
+}

+ 2 - 2
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 호환성으로 변환하십시오.",
@@ -1139,4 +1139,4 @@
     "disable_mode_explanation": "현재 AI 통합이 비활성화되어 있습니다. 활성화하려면 <code>AI_ENABLED</code> 환경 변수와 필요한 추가 변수를 구성하십시오.<br><br>자세한 내용은 <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>문서</a>를 참조하십시오.",
     "disable_mode_explanation": "현재 AI 통합이 비활성화되어 있습니다. 활성화하려면 <code>AI_ENABLED</code> 환경 변수와 필요한 추가 변수를 구성하십시오.<br><br>자세한 내용은 <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>문서</a>를 참조하십시오.",
     "ai_search_management": "AI 검색 관리"
     "ai_search_management": "AI 검색 관리"
   }
   }
-}
+}

+ 6 - 2
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 그리드 생성",
@@ -1018,4 +1022,4 @@
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "error-toaster": "최신 텍스트 동기화 실패"
     "error-toaster": "최신 텍스트 동기화 실패"
   }
   }
-}
+}

+ 2 - 2
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兼容性。",
@@ -1148,4 +1148,4 @@
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
     "ai_search_management": "AI 搜索管理"
     "ai_search_management": "AI 搜索管理"
   }
   }
-}
+}

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

@@ -666,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": {
@@ -898,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网格",
@@ -1063,4 +1067,4 @@
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "error-toaster": "同步最新文本失败"
     "error-toaster": "同步最新文本失败"
   }
   }
-}
+}

+ 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() {

+ 287 - 0
apps/app/src/client/components/LoginForm/LoginForm.spec.tsx

@@ -0,0 +1,287 @@
+import React from 'react';
+
+import {
+  render, screen, fireEvent, waitFor,
+} from '@testing-library/react';
+import {
+  describe, it, expect, vi, beforeEach,
+} from 'vitest';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
+import { LoginForm } from './LoginForm';
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}));
+
+vi.mock('next/router', () => ({
+  useRouter: () => ({
+    push: vi.fn(),
+  }),
+}));
+
+vi.mock('~/client/util/t-with-opt', () => ({
+  useTWithOpt: () => (key: string) => key,
+}));
+
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Post: vi.fn(),
+}));
+
+vi.mock('./ExternalAuthButton', () => ({
+  ExternalAuthButton: ({ authType }: { authType: string }) => (
+    <button type="button" data-testid={`external-auth-${authType}`}>
+      External Auth {authType}
+    </button>
+  ),
+}));
+
+vi.mock('../CompleteUserRegistration', () => ({
+  CompleteUserRegistration: () => <div>Complete Registration</div>,
+}));
+
+const defaultProps = {
+  isEmailAuthenticationEnabled: false,
+  registrationMode: 'Open' as const,
+  registrationWhitelist: [],
+  isPasswordResetEnabled: true,
+  isLocalStrategySetup: true,
+  isLdapStrategySetup: false,
+  isLdapSetupFailed: false,
+  minPasswordLength: 8,
+  isMailerSetup: true,
+};
+
+const mockApiv3Post = vi.mocked(apiv3Post);
+
+describe('LoginForm - Error Display', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('when password login is enabled', () => {
+    it('should display login form', () => {
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByTestId('login-form')).toBeInTheDocument();
+    });
+
+    it('should display external account login errors', () => {
+      const externalAccountLoginError = {
+        message: 'jwks must be a JSON Web Key Set formatted object',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+    });
+  });
+
+  describe('when password login is disabled', () => {
+    it('should still display external account login errors', () => {
+      const externalAccountLoginError = {
+        message: 'jwks must be a JSON Web Key Set formatted object',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: false,
+        isLdapStrategySetup: false,
+        enabledExternalAuthType: ['oidc'] satisfies IExternalAuthProviderType[],
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+    });
+
+    it('should not render local/LDAP form but should still show errors', () => {
+      const externalAccountLoginError = {
+        message: 'OIDC authentication failed',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: false,
+        isLdapStrategySetup: false,
+        enabledExternalAuthType: ['oidc'] satisfies IExternalAuthProviderType[],
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.queryByTestId('tiUsernameForLogin')).not.toBeInTheDocument();
+      expect(screen.queryByTestId('tiPasswordForLogin')).not.toBeInTheDocument();
+      expect(screen.getByText('OIDC authentication failed')).toBeInTheDocument();
+    });
+  });
+
+  describe('error display priority and login error handling', () => {
+    it('should show external errors when no login errors exist', () => {
+      const externalAccountLoginError = {
+        message: 'External error message',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('External error message')).toBeInTheDocument();
+    });
+
+    it('should prioritize login errors over external account login errors after failed login', async() => {
+      const externalAccountLoginError = {
+        message: 'External error message',
+        name: 'ExternalAccountLoginError',
+      };
+
+      // Mock API call to return error
+      mockApiv3Post.mockRejectedValueOnce([
+        {
+          message: 'Invalid username or password',
+          code: 'LOGIN_FAILED',
+          args: {},
+        },
+      ]);
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      // Initially, external error should be visible
+      expect(screen.getByText('External error message')).toBeInTheDocument();
+
+      // Fill in login form and submit
+      const usernameInput = screen.getByTestId('tiUsernameForLogin');
+      const passwordInput = screen.getByTestId('tiPasswordForLogin');
+      const submitButton = screen.getByTestId('btnSubmitForLogin');
+
+      fireEvent.change(usernameInput, { target: { value: 'testuser' } });
+      fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
+      fireEvent.click(submitButton);
+
+      // Wait for login error to appear and external error to be replaced
+      await waitFor(() => {
+        expect(screen.getByText('Invalid username or password')).toBeInTheDocument();
+      });
+
+      // External error should no longer be visible when login error exists
+      expect(screen.queryByText('External error message')).not.toBeInTheDocument();
+    });
+
+    it('should display dangerouslySetInnerHTML errors for PROVIDER_DUPLICATED_USERNAME_EXCEPTION', async() => {
+      // Mock API call to return PROVIDER_DUPLICATED_USERNAME_EXCEPTION error
+      mockApiv3Post.mockRejectedValueOnce([
+        {
+          message: 'This username is already taken by <a href="/login">another provider</a>',
+          code: 'provider-duplicated-username-exception',
+          args: {},
+        },
+      ]);
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+      };
+
+      render(<LoginForm {...props} />);
+
+      // Fill in login form and submit
+      const usernameInput = screen.getByTestId('tiUsernameForLogin');
+      const passwordInput = screen.getByTestId('tiPasswordForLogin');
+      const submitButton = screen.getByTestId('btnSubmitForLogin');
+
+      fireEvent.change(usernameInput, { target: { value: 'testuser' } });
+      fireEvent.change(passwordInput, { target: { value: 'password' } });
+      fireEvent.click(submitButton);
+
+      // Wait for the dangerouslySetInnerHTML error to appear
+      await waitFor(() => {
+        // Check that the error with HTML content is rendered
+        expect(screen.getByText(/This username is already taken by/)).toBeInTheDocument();
+      });
+    });
+
+    it('should handle multiple login errors correctly', async() => {
+      // Mock API call to return multiple errors
+      mockApiv3Post.mockRejectedValueOnce([
+        {
+          message: 'Username is required',
+          code: 'VALIDATION_ERROR',
+          args: {},
+        },
+        {
+          message: 'Password is too short',
+          code: 'VALIDATION_ERROR',
+          args: {},
+        },
+      ]);
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+      };
+
+      render(<LoginForm {...props} />);
+
+      // Submit form without filling inputs
+      const submitButton = screen.getByTestId('btnSubmitForLogin');
+      fireEvent.click(submitButton);
+
+      // Wait for multiple errors to appear
+      await waitFor(() => {
+        expect(screen.getByText('Username is required')).toBeInTheDocument();
+        expect(screen.getByText('Password is too short')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('error display when both login methods are disabled', () => {
+    it('should still display external errors when no login methods are available', () => {
+      const externalAccountLoginError = {
+        message: 'Authentication service unavailable',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: false,
+        isLdapStrategySetup: false,
+        enabledExternalAuthType: undefined,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('Authentication service unavailable')).toBeInTheDocument();
+    });
+  });
+});

+ 25 - 20
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -154,9 +154,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return (
     return (
       <ul className="alert alert-danger">
       <ul className="alert alert-danger">
         {errors.map((err, index) => (
         {errors.map((err, index) => (
-          <li className={index > 0 ? 'mt-1' : ''}>
+          <small className={index > 0 ? 'mt-1' : ''}>
             {tWithOpt(err.message, err.args)}
             {tWithOpt(err.message, err.args)}
-          </li>
+          </small>
         ))}
         ))}
       </ul>
       </ul>
     );
     );
@@ -165,21 +165,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const renderLocalOrLdapLoginForm = useCallback(() => {
   const renderLocalOrLdapLoginForm = useCallback(() => {
     const { isLdapStrategySetup } = props;
     const { isLdapStrategySetup } = props;
 
 
-    // separate login errors into two arrays based on error code
-    const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
-    // Generate login error elements using dangerouslySetInnerHTML
-    const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
-    // Generate login error elements using <ul>, <li>
-
-    const loginErrorElement = (loginErrorList ?? []).length > 0
-    // prioritize loginErrorList because the list should contains new error
-      ? generateSafelySetErrors(loginErrorList)
-      : generateSafelySetErrors(props.externalAccountLoginError != null ? [props.externalAccountLoginError] : []);
-
     return (
     return (
       <>
       <>
         {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
         {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
-        {/* https://github.com/weseek/growi/pull/7873 */}
+        {/* https://github.com/growilabs/growi/pull/7873 */}
         <div className="visually-hidden">
         <div className="visually-hidden">
           <LoadingSpinner />
           <LoadingSpinner />
         </div>
         </div>
@@ -191,8 +180,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
             <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
           </div>
           </div>
         )}
         )}
-        {loginErrorElementWithDangerouslySetInnerHTML}
-        {loginErrorElement}
 
 
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
           <div className="input-group">
@@ -253,8 +240,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       </>
       </>
     );
     );
   }, [
   }, [
-    props, separateErrorsBasedOnErrorCode, loginErrors, generateDangerouslySetErrors, generateSafelySetErrors,
-    isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading,
+    props, isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading,
   ]);
   ]);
 
 
 
 
@@ -268,7 +254,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return (
     return (
       <>
       <>
         <div className="mt-2">
         <div className="mt-2">
-          { enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />) }
+          {enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />)}
         </div>
         </div>
       </>
       </>
     );
     );
@@ -342,7 +328,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             {t('page_register.notice.restricted_defail')}
             {t('page_register.notice.restricted_defail')}
           </p>
           </p>
         )}
         )}
-        { (!isMailerSetup && isEmailAuthenticationEnabled) && (
+        {(!isMailerSetup && isEmailAuthenticationEnabled) && (
           <p className="alert alert-danger">
           <p className="alert alert-danger">
             <span>{t('commons:alert.please_enable_mailer')}</span>
             <span>{t('commons:alert.please_enable_mailer')}</span>
           </p>
           </p>
@@ -510,6 +496,25 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           <div className="col-12 px-md-4 pb-5">
           <div className="col-12 px-md-4 pb-5">
             <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
             <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
               <div className="front">
               <div className="front">
+                {/* Error display section - always shown regardless of login method configuration */}
+                {(() => {
+                  // separate login errors into two arrays based on error code
+                  const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
+                  // Generate login error elements using dangerouslySetInnerHTML
+                  const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
+                  // Generate login error elements - prioritize loginErrorList, fallback to externalAccountLoginError
+                  const loginErrorElement = (loginErrorList ?? []).length > 0
+                    ? generateSafelySetErrors(loginErrorList)
+                    : generateSafelySetErrors(props.externalAccountLoginError != null ? [props.externalAccountLoginError] : []);
+
+                  return (
+                    <>
+                      {loginErrorElementWithDangerouslySetInnerHTML}
+                      {loginErrorElement}
+                    </>
+                  );
+                })()}
+
                 {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && isSomeExternalAuthEnabled && (
                 {isLocalOrLdapStrategiesEnabled && isSomeExternalAuthEnabled && (
                   <div className="text-center text-line d-flex align-items-center mb-3">
                   <div className="text-center text-line d-flex align-items-center mb-3">

+ 0 - 2
apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx

@@ -1,5 +1,3 @@
-import '@testing-library/jest-dom/vitest';
-
 import { render, screen } from '@testing-library/react';
 import { render, screen } from '@testing-library/react';
 import {
 import {
   describe, it, expect, vi,
   describe, it, expect, vi,

+ 2 - 2
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -116,7 +116,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     setShowPreview(showPreview);
     setShowPreview(showPreview);
   }, []);
   }, []);
 
 
-  // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
+  // DO NOT dependent on slackChannelsData directly: https://github.com/growilabs/growi/pull/7332
   const slackChannelsDataString = slackChannelsData?.toString();
   const slackChannelsDataString = slackChannelsData?.toString();
   const initializeSlackEnabled = useCallback(() => {
   const initializeSlackEnabled = useCallback(() => {
     setSlackChannels(slackChannelsDataString ?? '');
     setSlackChannels(slackChannelsDataString ?? '');
@@ -186,7 +186,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       const errorMessage = err.message || 'An unknown error occured when posting comment';
       const errorMessage = err.message || 'An unknown error occured when posting comment';
       setError(errorMessage);
       setError(errorMessage);
     }
     }
-  // eslint-disable-next-line max-len
+    // eslint-disable-next-line max-len
   }, [currentCommentId, initializeEditor, onCommented, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
   }, [currentCommentId, initializeEditor, onCommented, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
 
 
   // the upload event handler
   // the upload event handler

+ 2 - 2
apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx

@@ -38,7 +38,7 @@ declare global {
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
 
 
-const SavePageButton = (props: {slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean}) => {
+const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean }) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
@@ -159,7 +159,7 @@ export const SavePageControls = (): JSX.Element | null => {
   const [slackChannels, setSlackChannels] = useState<string>('');
   const [slackChannels, setSlackChannels] = useState<string>('');
   const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState<boolean>(false);
   const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState<boolean>(false);
 
 
-  // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
+  // DO NOT dependent on slackChannelsData directly: https://github.com/growilabs/growi/pull/7332
   const slackChannelsDataString = slackChannelsData?.toString();
   const slackChannelsDataString = slackChannelsData?.toString();
   useEffect(() => {
   useEffect(() => {
     if (editorMode === 'editor') {
     if (editorMode === 'editor') {

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

@@ -189,7 +189,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
         ...(opts ?? {}),
         ...(opts ?? {}),
       });
       });
 
 
-      // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
+      // to sync revision id with page tree: https://github.com/growilabs/growi/pull/7227
       mutatePageTree();
       mutatePageTree();
 
 
       mutateRecentlyUpdated();
       mutateRecentlyUpdated();

+ 0 - 2
apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx

@@ -1,5 +1,3 @@
-import '@testing-library/jest-dom/vitest';
-
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import {
 import {

+ 2 - 2
apps/app/src/client/components/PageSideContents/PageAccessoriesControl.tsx

@@ -33,8 +33,8 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
       <span className="grw-icon d-flex me-lg-2">{icon}</span>
       <span className="grw-icon d-flex me-lg-2">{icon}</span>
       <span className="grw-labels d-none d-lg-flex">
       <span className="grw-labels d-none d-lg-flex">
         {label}
         {label}
-        {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
-        { count != null
+        {/* Do not display CountBadge if '/trash/*': https://github.com/growilabs/growi/pull/7600 */}
+        {count != null
           ? <CountBadge count={count} offset={offset} />
           ? <CountBadge count={count} offset={offset} />
           : <div className="px-2"></div>}
           : <div className="px-2"></div>}
       </span>
       </span>

+ 3 - 3
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -109,13 +109,13 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
       )}
       )}
 
 
       {/* Tags */}
       {/* Tags */}
-      { page.revision != null && (
+      {page.revision != null && (
         <div ref={tagsRef}>
         <div ref={tagsRef}>
           <Suspense fallback={<PageTagsSkeleton />}>
           <Suspense fallback={<PageTagsSkeleton />}>
             <Tags pageId={page._id} revisionId={page.revision._id} />
             <Tags pageId={page._id} revisionId={page.revision._id} />
           </Suspense>
           </Suspense>
         </div>
         </div>
-      ) }
+      )}
 
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
         {/* Page list */}
         {/* Page list */}
@@ -124,7 +124,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             <PageAccessoriesControl
             <PageAccessoriesControl
               icon={<span className="material-symbols-outlined">subject</span>}
               icon={<span className="material-symbols-outlined">subject</span>}
               label={t('page_list')}
               label={t('page_list')}
-              // Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600
+              // Do not display CountBadge if '/trash/*': https://github.com/growilabs/growi/pull/7600
               count={!isTrash && pageInfo != null ? (pageInfo as IPageInfoForOperation).descendantCount : undefined}
               count={!isTrash && pageInfo != null ? (pageInfo as IPageInfoForOperation).descendantCount : undefined}
               offset={1}
               offset={1}
               onClick={() => openDescendantPageListModal(pagePath)}
               onClick={() => openDescendantPageListModal(pagePath)}

+ 22 - 4
apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -2,8 +2,10 @@ import React, { useCallback } from 'react';
 
 
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Image from 'next/image';
 import prettyBytes from 'pretty-bytes';
 import prettyBytes from 'pretty-bytes';
 
 
+import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores-universal/context';
 import { useSWRxAttachment } from '~/stores/attachment';
 import { useSWRxAttachment } from '~/stores/attachment';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 
 
@@ -21,6 +23,12 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   const { data: attachment, remove } = useSWRxAttachment(attachmentId);
   const { data: attachment, remove } = useSWRxAttachment(attachmentId);
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
 
 
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const showTrashButton = isGuestUser === false && isSharedUser === false && isReadOnlyUser === false;
+
   const onClickTrashButtonHandler = useCallback(() => {
   const onClickTrashButtonHandler = useCallback(() => {
     if (attachment == null) {
     if (attachment == null) {
       return;
       return;
@@ -57,7 +65,13 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
       <div className="my-2 p-2 card">
       <div className="my-2 p-2 card">
         <div className="p-1 card-body d-flex align-items-center">
         <div className="p-1 card-body d-flex align-items-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">
           <div className="me-2 px-0 d-flex align-items-center justify-content-center">
-            <img src="/images/icons/editor/attachment.svg" className="attachment-icon" alt="attachment icon" />
+            <Image
+              width={20}
+              height={20}
+              src="/images/icons/editor/attachment.svg"
+              className="attachment-icon"
+              alt="attachment icon"
+            />
           </div>
           </div>
           <div className="ps-0">
           <div className="ps-0">
             <div className="d-inline-block">
             <div className="d-inline-block">
@@ -69,9 +83,13 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
               <a className="ms-2 attachment-download" href={downloadPathProxied}>
               <a className="ms-2 attachment-download" href={downloadPathProxied}>
                 <span className="material-symbols-outlined">cloud_download</span>
                 <span className="material-symbols-outlined">cloud_download</span>
               </a>
               </a>
-              <a className="ml-2 text-danger attachment-delete d-share-link-none" type="button" onClick={onClickTrashButtonHandler}>
-                <span className="material-symbols-outlined">delete</span>
-              </a>
+
+              {showTrashButton && (
+                <a className="ml-2 text-danger attachment-delete d-share-link-none" type="button" onClick={onClickTrashButtonHandler}>
+                  <span className="material-symbols-outlined">delete</span>
+                </a>
+              )}
+
             </div>
             </div>
             <div className="d-flex align-items-center">
             <div className="d-flex align-items-center">
               <UserPicture user={creator} size="sm" />
               <UserPicture user={creator} size="sm" />

+ 9 - 9
apps/app/src/client/components/SearchPage/SearchPageBase.tsx

@@ -20,7 +20,7 @@ import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
 
 // Do not import with next/dynamic
 // Do not import with next/dynamic
-// see: https://github.com/weseek/growi/pull/7923
+// see: https://github.com/growilabs/growi/pull/7923
 import { SearchResultList } from './SearchResultList';
 import { SearchResultList } from './SearchResultList';
 
 
 import styles from './SearchPageBase.module.scss';
 import styles from './SearchPageBase.module.scss';
@@ -52,7 +52,7 @@ const SearchResultContent = dynamic(() => import('./SearchResultContent').then(m
   ssr: false,
   ssr: false,
   loading: () => <></>,
   loading: () => <></>,
 });
 });
-const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
+const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props: Props, ref) => {
 
 
   const {
   const {
     className,
     className,
@@ -63,7 +63,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     searchControl, searchResultListHead, searchPager,
     searchControl, searchResultListHead, searchPager,
   } = props;
   } = props;
 
 
-  const searchResultListRef = useRef<ISelectableAll|null>(null);
+  const searchResultListRef = useRef<ISelectableAll | null>(null);
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
@@ -182,20 +182,20 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
         <div className="overflow-y-scroll">
         <div className="overflow-y-scroll">
 
 
           {/* Loading */}
           {/* Loading */}
-          { pages == null && (
+          {pages == null && (
             <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
             <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
               <LoadingSpinner className="me-1 fs-3" />
               <LoadingSpinner className="me-1 fs-3" />
             </div>
             </div>
-          ) }
+          )}
 
 
           {/* Loaded */}
           {/* Loaded */}
-          { pages != null && (
+          {pages != null && (
             <>
             <>
               <div className="my-3 px-md-4 px-3">
               <div className="my-3 px-md-4 px-3">
                 {searchResultListHead}
                 {searchResultListHead}
               </div>
               </div>
 
 
-              { pages.length > 0 && (
+              {pages.length > 0 && (
                 <div className={`page-list ${styles['page-list']} px-md-4`}>
                 <div className={`page-list ${styles['page-list']} px-md-4`}>
                   <SearchResultList
                   <SearchResultList
                     ref={searchResultListRef}
                     ref={searchResultListRef}
@@ -206,12 +206,12 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                     onCheckboxChanged={checkboxChangedHandler}
                     onCheckboxChanged={checkboxChangedHandler}
                   />
                   />
                 </div>
                 </div>
-              ) }
+              )}
               <div className="my-4 d-flex justify-content-center">
               <div className="my-4 d-flex justify-content-center">
                 {searchPager}
                 {searchPager}
               </div>
               </div>
             </>
             </>
-          ) }
+          )}
 
 
         </div>
         </div>
 
 

+ 2 - 2
apps/app/src/client/components/TableOfContents.tsx

@@ -33,7 +33,7 @@ const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
     const containerElem = document.querySelector('#revision-toc');
     const containerElem = document.querySelector('#revision-toc');
 
 
     // rendererOptions for redo calcViewHeight()
     // rendererOptions for redo calcViewHeight()
-    // see: https://github.com/weseek/growi/pull/6791
+    // see: https://github.com/growilabs/growi/pull/6791
     if (parentElem == null || containerElem == null || rendererOptions == null || tagsElementHeight == null) {
     if (parentElem == null || containerElem == null || rendererOptions == null || tagsElementHeight == null) {
       return 0;
       return 0;
     }
     }
@@ -64,7 +64,7 @@ const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
           data-testid="revision-toc-content"
           data-testid="revision-toc-content"
           className="revision-toc-content mb-3"
           className="revision-toc-content mb-3"
         >
         >
-          {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}
+          {/* parse blank to show toc (https://github.com/growilabs/growi/pull/6277) */}
           <ReactMarkdown {...rendererOptions}>{' '}</ReactMarkdown>
           <ReactMarkdown {...rendererOptions}>{' '}</ReactMarkdown>
         </div>
         </div>
       </StickyStretchableScroller>
       </StickyStretchableScroller>

+ 5 - 2
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -9,6 +9,8 @@ import React, {
   type JSX,
   type JSX,
 } from 'react';
 } from 'react';
 
 
+import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
+
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
 
@@ -88,9 +90,10 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     setIsOpen(!isOpen);
     setIsOpen(!isOpen);
   }, [isOpen]);
   }, [isOpen]);
 
 
-  // didMount
   useEffect(() => {
   useEffect(() => {
-    const isPathToTarget = page.path != null && targetPath.startsWith(page.path) && targetPath !== page.path; // Target Page does not need to be opened
+    const isPathToTarget = page.path != null
+      && targetPath.startsWith(addTrailingSlash(page.path))
+      && targetPath !== page.path; // Target Page does not need to be opened
     if (isPathToTarget) setIsOpen(true);
     if (isPathToTarget) setIsOpen(true);
   }, [targetPath, page.path]);
   }, [targetPath, page.path]);
 
 

+ 1 - 1
apps/app/src/client/services/AdminHomeContainer.js

@@ -107,7 +107,7 @@ export default class AdminHomeContainer extends Container {
 |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)*`;
   }
   }

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

@@ -112,7 +112,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
   return useCallback(async() => {
   return useCallback(async() => {
     if (pageId == null) { return }
     if (pageId == null) { return }
 
 
-    // update tag before page: https://github.com/weseek/growi/pull/7158
+    // update tag before page: https://github.com/growilabs/growi/pull/7158
     // !! DO NOT CHANGE THE ORDERS OF THE MUTATIONS !! -- 12.26 yuken-t
     // !! DO NOT CHANGE THE ORDERS OF THE MUTATIONS !! -- 12.26 yuken-t
     await mutateTagsInfo(); // get from DB
     await mutateTagsInfo(); // get from DB
     syncTagsInfoForEditor(); // sync global state for client
     syncTagsInfoForEditor(); // sync global state for client
@@ -123,7 +123,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
     if (updatedPage == null || updatedPage.revision == null) { return }
     if (updatedPage == null || updatedPage.revision == null) { return }
 
 
     // supress to mutate only when updated from built-in editor
     // supress to mutate only when updated from built-in editor
-    // and see: https://github.com/weseek/growi/pull/7118
+    // and see: https://github.com/growilabs/growi/pull/7118
     const supressEditingMarkdownMutation = opts?.supressEditingMarkdownMutation ?? false;
     const supressEditingMarkdownMutation = opts?.supressEditingMarkdownMutation ?? false;
     if (!supressEditingMarkdownMutation) {
     if (!supressEditingMarkdownMutation) {
       mutateEditingMarkdown(updatedPage.revision.body);
       mutateEditingMarkdown(updatedPage.revision.body);

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

@@ -2,13 +2,14 @@
 
 
 .page-content-footer :global {
 .page-content-footer :global {
   border-top: solid 1px var(--bs-border-color);
   border-top: solid 1px var(--bs-border-color);
+
   .page-meta {
   .page-meta {
     font-size: 0.95em;
     font-size: 0.95em;
   }
   }
 }
 }
 
 
-// TODO: Should Soft Coding see: https://github.com/weseek/growi/pull/6404
+// TODO: Should Soft Coding see: https://github.com/growilabs/growi/pull/6404
 .page-content-footer-skeleton :global {
 .page-content-footer-skeleton :global {
   width: 300px;
   width: 300px;
   height: 20px;
   height: 20px;
-}
+}

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

@@ -52,7 +52,7 @@ function extractChildrenToIgnoreReactNode(children: ReactNode): ReactNode {
 function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactNode }): JSX.Element {
 function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactNode }): JSX.Element {
   // return alternative element
   // return alternative element
   //   in order to fix "CodeBlock string is be [object Object] if searched"
   //   in order to fix "CodeBlock string is be [object Object] if searched"
-  // see: https://github.com/weseek/growi/pull/7484
+  // see: https://github.com/growilabs/growi/pull/7484
   //
   //
   // Note: You can also remove this code if the user requests to see the code highlighted in Prism as-is.
   // Note: You can also remove this code if the user requests to see the code highlighted in Prism as-is.
 
 

+ 32 - 31
apps/app/src/features/callout/components/CalloutViewer.tsx

@@ -1,9 +1,9 @@
 // Ref: https://github.com/Microflash/remark-callout-directives/blob/fabe4d8adc7738469f253836f0da346591ea2a2b/README.md
 // Ref: https://github.com/Microflash/remark-callout-directives/blob/fabe4d8adc7738469f253836f0da346591ea2a2b/README.md
 
 
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React from 'react';
 import React from 'react';
 
 
-import { type Callout } from '../services/consts';
+import type { Callout } from '../services/consts';
 
 
 import styles from './CalloutViewer.module.scss';
 import styles from './CalloutViewer.module.scss';
 
 
@@ -11,7 +11,7 @@ const moduleClass = styles['callout-viewer'];
 
 
 type CALLOUT_TO = {
 type CALLOUT_TO = {
   [key in Callout]: string;
   [key in Callout]: string;
-}
+};
 
 
 const CALLOUT_TO_TYPE: CALLOUT_TO = {
 const CALLOUT_TO_TYPE: CALLOUT_TO = {
   note: 'Note',
   note: 'Note',
@@ -34,38 +34,39 @@ const CALLOUT_TO_ICON: CALLOUT_TO = {
 };
 };
 
 
 type CalloutViewerProps = {
 type CalloutViewerProps = {
-  children: ReactNode,
-  node: Element,
-  type: string,
-  label?: string,
-}
-
-export const CalloutViewer = React.memo((props: CalloutViewerProps): JSX.Element => {
+  children: ReactNode;
+  node: Element;
+  type: string;
+  label?: string;
+};
 
 
-  const {
-    node, type, label, children,
-  } = props;
+export const CalloutViewer = React.memo(
+  (props: CalloutViewerProps): JSX.Element => {
+    const { node, type, label, children } = props;
 
 
-  if (node == null) {
-    return <></>;
-  }
+    if (node == null) {
+      return <></>;
+    }
 
 
-  return (
-    <div className={`${moduleClass} callout-viewer`}>
-      <div className={`callout callout-${CALLOUT_TO_TYPE[type].toLowerCase()}`}>
-        <div className="callout-indicator">
-          <div className="callout-hint">
-            <span className="material-symbols-outlined">{CALLOUT_TO_ICON[type]}</span>
+    return (
+      <div className={`${moduleClass} callout-viewer`}>
+        <div
+          className={`callout callout-${CALLOUT_TO_TYPE[type].toLowerCase()}`}
+        >
+          <div className="callout-indicator">
+            <div className="callout-hint">
+              <span className="material-symbols-outlined">
+                {CALLOUT_TO_ICON[type]}
+              </span>
+            </div>
+            <div className="callout-title">
+              {label ?? CALLOUT_TO_TYPE[type]}
+            </div>
           </div>
           </div>
-          <div className="callout-title">
-            {label ?? CALLOUT_TO_TYPE[type]}
-          </div>
-        </div>
-        <div className="callout-content">
-          {children}
+          <div className="callout-content">{children}</div>
         </div>
         </div>
       </div>
       </div>
-    </div>
-  );
-});
+    );
+  },
+);
 CalloutViewer.displayName = 'CalloutViewer';
 CalloutViewer.displayName = 'CalloutViewer';

+ 11 - 5
apps/app/src/features/callout/services/callout.spec.ts

@@ -3,7 +3,7 @@ import remarkDirective from 'remark-directive';
 import remarkParse from 'remark-parse';
 import remarkParse from 'remark-parse';
 import { unified } from 'unified';
 import { unified } from 'unified';
 import { visit } from 'unist-util-visit';
 import { visit } from 'unist-util-visit';
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 
 import * as callout from './callout';
 import * as callout from './callout';
 
 
@@ -23,7 +23,7 @@ This is an info callout.
     const tree = processor.parse(markdown);
     const tree = processor.parse(markdown);
     processor.runSync(tree);
     processor.runSync(tree);
 
 
-    let calloutNode;
+    let calloutNode: ContainerDirective | undefined;
     visit(tree, 'containerDirective', (node) => {
     visit(tree, 'containerDirective', (node) => {
       calloutNode = node;
       calloutNode = node;
     });
     });
@@ -41,7 +41,9 @@ This is an info callout.
     assert('value' in calloutNode.children[0].children[0]);
     assert('value' in calloutNode.children[0].children[0]);
 
 
     expect(calloutNode.children.length).toBe(1);
     expect(calloutNode.children.length).toBe(1);
-    expect(calloutNode.children[0].children[0].value).toBe('This is an info callout.');
+    expect(calloutNode.children[0].children[0].value).toBe(
+      'This is an info callout.',
+    );
   });
   });
 
 
   it('should transform containerDirective to callout with custom label', () => {
   it('should transform containerDirective to callout with custom label', () => {
@@ -77,7 +79,9 @@ This is an info callout.
     assert('value' in calloutNode.children[0].children[0]);
     assert('value' in calloutNode.children[0].children[0]);
 
 
     expect(calloutNode.children.length).toBe(1);
     expect(calloutNode.children.length).toBe(1);
-    expect(calloutNode.children[0].children[0].value).toBe('This is an info callout.');
+    expect(calloutNode.children[0].children[0].value).toBe(
+      'This is an info callout.',
+    );
   });
   });
 
 
   it('should transform containerDirective to callout with empty label', () => {
   it('should transform containerDirective to callout with empty label', () => {
@@ -113,6 +117,8 @@ This is an info callout.
     assert('value' in calloutNode.children[0].children[0]);
     assert('value' in calloutNode.children[0].children[0]);
 
 
     expect(calloutNode.children.length).toBe(1);
     expect(calloutNode.children.length).toBe(1);
-    expect(calloutNode.children[0].children[0].value).toBe('This is an info callout.');
+    expect(calloutNode.children[0].children[0].value).toBe(
+      'This is an info callout.',
+    );
   });
   });
 });
 });

+ 20 - 9
apps/app/src/features/callout/services/callout.ts

@@ -8,19 +8,31 @@ import { AllCallout } from './consts';
 export const remarkPlugin: Plugin = () => {
 export const remarkPlugin: Plugin = () => {
   return (tree) => {
   return (tree) => {
     visit(tree, 'containerDirective', (node: ContainerDirective) => {
     visit(tree, 'containerDirective', (node: ContainerDirective) => {
-      if (AllCallout.some(name => name === node.name.toLowerCase())) {
+      if (AllCallout.some((name) => name === node.name.toLowerCase())) {
         const type = node.name.toLowerCase();
         const type = node.name.toLowerCase();
-        const data = node.data ?? (node.data = {});
+        if (node.data == null) {
+          node.data = {};
+        }
+        const data = node.data;
 
 
         // extract directive label
         // extract directive label
-        const paragraphs = (node.children ?? []).filter((child): child is Paragraph => child.type === 'paragraph');
-        const paragraphForDirectiveLabel = paragraphs.find(p => p.data?.directiveLabel);
-        const label = paragraphForDirectiveLabel != null && paragraphForDirectiveLabel.children.length > 0
-          ? (paragraphForDirectiveLabel.children[0] as Text).value
-          : undefined;
+        const paragraphs = (node.children ?? []).filter(
+          (child): child is Paragraph => child.type === 'paragraph',
+        );
+        const paragraphForDirectiveLabel = paragraphs.find(
+          (p) => p.data?.directiveLabel,
+        );
+        const label =
+          paragraphForDirectiveLabel != null &&
+          paragraphForDirectiveLabel.children.length > 0
+            ? (paragraphForDirectiveLabel.children[0] as Text).value
+            : undefined;
         // remove directive label from children
         // remove directive label from children
         if (paragraphForDirectiveLabel != null) {
         if (paragraphForDirectiveLabel != null) {
-          node.children.splice(node.children.indexOf(paragraphForDirectiveLabel), 1);
+          node.children.splice(
+            node.children.indexOf(paragraphForDirectiveLabel),
+            1,
+          );
         }
         }
 
 
         data.hName = 'callout';
         data.hName = 'callout';
@@ -28,7 +40,6 @@ export const remarkPlugin: Plugin = () => {
           type,
           type,
           label,
           label,
         };
         };
-
       }
       }
     });
     });
   };
   };

+ 10 - 2
apps/app/src/features/callout/services/consts.ts

@@ -1,5 +1,13 @@
 // Ref: https://github.com/Microflash/remark-callout-directives/blob/fabe4d8adc7738469f253836f0da346591ea2a2b/themes/github/index.js
 // Ref: https://github.com/Microflash/remark-callout-directives/blob/fabe4d8adc7738469f253836f0da346591ea2a2b/themes/github/index.js
 // Ref: https://github.com/orgs/community/discussions/16925
 // Ref: https://github.com/orgs/community/discussions/16925
 
 
-export const AllCallout = ['note', 'tip', 'important', 'info', 'warning', 'danger', 'caution'] as const;
-export type Callout = typeof AllCallout[number];
+export const AllCallout = [
+  'note',
+  'tip',
+  'important',
+  'info',
+  'warning',
+  'danger',
+  'caution',
+] as const;
+export type Callout = (typeof AllCallout)[number];

+ 1 - 1
apps/app/src/features/callout/services/index.ts

@@ -1 +1 @@
-export { sanitizeOption, remarkPlugin } from './callout';
+export { remarkPlugin, sanitizeOption } from './callout';

+ 1 - 1
apps/app/src/features/comment/server/events/consts.ts

@@ -3,4 +3,4 @@ export const CommentEvent = {
   UPDATE: 'update',
   UPDATE: 'update',
   DELETE: 'delete',
   DELETE: 'delete',
 } as const;
 } as const;
-export type CommentEvent = typeof CommentEvent[keyof typeof CommentEvent];
+export type CommentEvent = (typeof CommentEvent)[keyof typeof CommentEvent];

+ 1 - 1
apps/app/src/features/comment/server/events/event-emitter.ts

@@ -1,3 +1,3 @@
-import { EventEmitter } from 'events';
+import { EventEmitter } from 'node:events';
 
 
 export const commentEvent = new EventEmitter();
 export const commentEvent = new EventEmitter();

+ 48 - 43
apps/app/src/features/comment/server/models/comment.ts

@@ -1,7 +1,5 @@
 import type { IUser } from '@growi/core/dist/interfaces';
 import type { IUser } from '@growi/core/dist/interfaces';
-import type {
-  Types, Document, Model, Query,
-} from 'mongoose';
+import type { Document, Model, Query, Types } from 'mongoose';
 import { Schema } from 'mongoose';
 import { Schema } from 'mongoose';
 
 
 import type { IComment } from '~/interfaces/comment';
 import type { IComment } from '~/interfaces/comment';
@@ -11,11 +9,10 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:models:comment');
 const logger = loggerFactory('growi:models:comment');
 
 
 export interface CommentDocument extends IComment, Document {
 export interface CommentDocument extends IComment, Document {
-  removeWithReplies: () => Promise<void>
-  findCreatorsByPage: (pageId: Types.ObjectId) => Promise<CommentDocument[]>
+  removeWithReplies: () => Promise<void>;
+  findCreatorsByPage: (pageId: Types.ObjectId) => Promise<CommentDocument[]>;
 }
 }
 
 
-
 type Add = (
 type Add = (
   pageId: Types.ObjectId,
   pageId: Types.ObjectId,
   creatorId: Types.ObjectId,
   creatorId: Types.ObjectId,
@@ -24,38 +21,45 @@ type Add = (
   commentPosition: number,
   commentPosition: number,
   replyTo?: Types.ObjectId | null,
   replyTo?: Types.ObjectId | null,
 ) => Promise<CommentDocument>;
 ) => Promise<CommentDocument>;
-type FindCommentsByPageId = (pageId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
-type FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
-type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>
-type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>
+type FindCommentsByPageId = (
+  pageId: Types.ObjectId,
+) => Query<CommentDocument[], CommentDocument>;
+type FindCommentsByRevisionId = (
+  revisionId: Types.ObjectId,
+) => Query<CommentDocument[], CommentDocument>;
+type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>;
+type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>;
 
 
 export interface CommentModel extends Model<CommentDocument> {
 export interface CommentModel extends Model<CommentDocument> {
-  add: Add
-  findCommentsByPageId: FindCommentsByPageId
-  findCommentsByRevisionId: FindCommentsByRevisionId
-  findCreatorsByPage: FindCreatorsByPage
-  countCommentByPageId: CountCommentByPageId
+  add: Add;
+  findCommentsByPageId: FindCommentsByPageId;
+  findCommentsByRevisionId: FindCommentsByRevisionId;
+  findCreatorsByPage: FindCreatorsByPage;
+  countCommentByPageId: CountCommentByPageId;
 }
 }
 
 
-const commentSchema = new Schema<CommentDocument, CommentModel>({
-  page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
-  creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
-  revision: { type: Schema.Types.ObjectId, ref: 'Revision', index: true },
-  comment: { type: String, required: true },
-  commentPosition: { type: Number, default: -1 },
-  replyTo: { type: Schema.Types.ObjectId },
-}, {
-  timestamps: true,
-});
+const commentSchema = new Schema<CommentDocument, CommentModel>(
+  {
+    page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+    creator: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+    revision: { type: Schema.Types.ObjectId, ref: 'Revision', index: true },
+    comment: { type: String, required: true },
+    commentPosition: { type: Number, default: -1 },
+    replyTo: { type: Schema.Types.ObjectId },
+  },
+  {
+    timestamps: true,
+  },
+);
 
 
-const add: Add = async function(
-    this: CommentModel,
-    pageId,
-    creatorId,
-    revisionId,
-    comment,
-    commentPosition,
-    replyTo?,
+const add: Add = async function (
+  this: CommentModel,
+  pageId,
+  creatorId,
+  revisionId,
+  comment,
+  commentPosition,
+  replyTo?,
 ): Promise<CommentDocument> {
 ): Promise<CommentDocument> {
   try {
   try {
     const data = await this.create({
     const data = await this.create({
@@ -69,35 +73,36 @@ const add: Add = async function(
     logger.debug('Comment saved.', data);
     logger.debug('Comment saved.', data);
 
 
     return data;
     return data;
-  }
-  catch (err) {
+  } catch (err) {
     logger.debug('Error on saving comment.', err);
     logger.debug('Error on saving comment.', err);
     throw err;
     throw err;
   }
   }
 };
 };
 commentSchema.statics.add = add;
 commentSchema.statics.add = add;
 
 
-commentSchema.statics.findCommentsByPageId = function(id) {
+commentSchema.statics.findCommentsByPageId = function (id) {
   return this.find({ page: id }).sort({ createdAt: -1 });
   return this.find({ page: id }).sort({ createdAt: -1 });
 };
 };
 
 
-commentSchema.statics.findCommentsByRevisionId = function(id) {
+commentSchema.statics.findCommentsByRevisionId = function (id) {
   return this.find({ revision: id }).sort({ createdAt: -1 });
   return this.find({ revision: id }).sort({ createdAt: -1 });
 };
 };
 
 
-commentSchema.statics.findCreatorsByPage = async function(page) {
+commentSchema.statics.findCreatorsByPage = async function (page) {
   return this.distinct('creator', { page }).exec();
   return this.distinct('creator', { page }).exec();
 };
 };
 
 
-commentSchema.statics.countCommentByPageId = async function(page) {
+commentSchema.statics.countCommentByPageId = async function (page) {
   return this.count({ page });
   return this.count({ page });
 };
 };
 
 
-commentSchema.statics.removeWithReplies = async function(comment) {
+commentSchema.statics.removeWithReplies = async function (comment) {
   await this.deleteMany({
   await this.deleteMany({
-    $or:
-      [{ replyTo: comment._id }, { _id: comment._id }],
+    $or: [{ replyTo: comment._id }, { _id: comment._id }],
   });
   });
 };
 };
 
 
-export const Comment = getOrCreateModel<CommentDocument, CommentModel>('Comment', commentSchema);
+export const Comment = getOrCreateModel<CommentDocument, CommentModel>(
+  'Comment',
+  commentSchema,
+);

+ 118 - 79
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -1,8 +1,7 @@
-import type { FC } from 'react';
-import { useCallback, useMemo, useState } from 'react';
-
 import type { IGrantedGroup } from '@growi/core';
 import type { IGrantedGroup } from '@growi/core';
 import { GroupType, getIdForRef } from '@growi/core';
 import { GroupType, getIdForRef } from '@growi/core';
+import type { FC } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
 
 
@@ -14,37 +13,55 @@ import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
-import { useIsAclEnabled } from '~/stores-universal/context';
 import { useSWRxUserGroupList } from '~/stores/user-group';
 import { useSWRxUserGroupList } from '~/stores/user-group';
+import { useIsAclEnabled } from '~/stores-universal/context';
 
 
-import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '../../stores/external-user-group';
+import {
+  useSWRxChildExternalUserGroupList,
+  useSWRxExternalUserGroupList,
+  useSWRxExternalUserGroupRelationList,
+} from '../../stores/external-user-group';
 
 
 import { KeycloakGroupManagement } from './KeycloakGroupManagement';
 import { KeycloakGroupManagement } from './KeycloakGroupManagement';
 import { LdapGroupManagement } from './LdapGroupManagement';
 import { LdapGroupManagement } from './LdapGroupManagement';
 
 
 export const ExternalGroupManagement: FC = () => {
 export const ExternalGroupManagement: FC = () => {
-  const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const { data: externalUserGroupList, mutate: mutateExternalUserGroups } =
+    useSWRxExternalUserGroupList();
   const { data: userGroupList } = useSWRxUserGroupList();
   const { data: userGroupList } = useSWRxUserGroupList();
-  const externalUserGroups = externalUserGroupList != null ? externalUserGroupList : [];
-  const externalUserGroupsForDeleteModal: IGrantedGroup[] = externalUserGroups.map((group) => {
-    return { item: group, type: GroupType.externalUserGroup };
-  });
-  const userGroupsForDeleteModal: IGrantedGroup[] = userGroupList != null ? userGroupList.map((group) => {
-    return { item: group, type: GroupType.userGroup };
-  }) : [];
-  const externalUserGroupIds = externalUserGroups.map(group => group._id);
-
-  const { data: externalUserGroupRelationList } = useSWRxExternalUserGroupRelationList(externalUserGroupIds);
-  const externalUserGroupRelations = externalUserGroupRelationList != null ? externalUserGroupRelationList : [];
-
-  const { data: childExternalUserGroupsList } = useSWRxChildExternalUserGroupList(externalUserGroupIds);
-  const childExternalUserGroups = childExternalUserGroupsList?.childUserGroups != null ? childExternalUserGroupsList.childUserGroups : [];
+  const externalUserGroups =
+    externalUserGroupList != null ? externalUserGroupList : [];
+  const externalUserGroupsForDeleteModal: IGrantedGroup[] =
+    externalUserGroups.map((group) => {
+      return { item: group, type: GroupType.externalUserGroup };
+    });
+  const userGroupsForDeleteModal: IGrantedGroup[] =
+    userGroupList != null
+      ? userGroupList.map((group) => {
+          return { item: group, type: GroupType.userGroup };
+        })
+      : [];
+  const externalUserGroupIds = externalUserGroups.map((group) => group._id);
+
+  const { data: externalUserGroupRelationList } =
+    useSWRxExternalUserGroupRelationList(externalUserGroupIds);
+  const externalUserGroupRelations =
+    externalUserGroupRelationList != null ? externalUserGroupRelationList : [];
+
+  const { data: childExternalUserGroupsList } =
+    useSWRxChildExternalUserGroupList(externalUserGroupIds);
+  const childExternalUserGroups =
+    childExternalUserGroupsList?.childUserGroups != null
+      ? childExternalUserGroupsList.childUserGroups
+      : [];
 
 
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
 
 
   const [activeTab, setActiveTab] = useState('ldap');
   const [activeTab, setActiveTab] = useState('ldap');
   const [activeComponents, setActiveComponents] = useState(new Set(['ldap']));
   const [activeComponents, setActiveComponents] = useState(new Set(['ldap']));
-  const [selectedExternalUserGroup, setSelectedExternalUserGroup] = useState<IExternalUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+  const [selectedExternalUserGroup, setSelectedExternalUserGroup] = useState<
+    IExternalUserGroupHasId | undefined
+  >(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
 
@@ -53,79 +70,95 @@ export const ExternalGroupManagement: FC = () => {
   const showUpdateModal = useCallback((group: IExternalUserGroupHasId) => {
   const showUpdateModal = useCallback((group: IExternalUserGroupHasId) => {
     setUpdateModalShown(true);
     setUpdateModalShown(true);
     setSelectedExternalUserGroup(group);
     setSelectedExternalUserGroup(group);
-  }, [setUpdateModalShown]);
+  }, []);
 
 
   const hideUpdateModal = useCallback(() => {
   const hideUpdateModal = useCallback(() => {
     setUpdateModalShown(false);
     setUpdateModalShown(false);
     setSelectedExternalUserGroup(undefined);
     setSelectedExternalUserGroup(undefined);
-  }, [setUpdateModalShown]);
+  }, []);
 
 
-  const syncUserGroupAndRelations = useCallback(async() => {
+  const syncUserGroupAndRelations = useCallback(async () => {
     try {
     try {
       await mutateExternalUserGroups();
       await mutateExternalUserGroups();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [mutateExternalUserGroups]);
   }, [mutateExternalUserGroups]);
 
 
-  const showDeleteModal = useCallback(async(group: IExternalUserGroupHasId) => {
-    try {
-      await syncUserGroupAndRelations();
-
-      setSelectedExternalUserGroup(group);
-      setDeleteModalShown(true);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [syncUserGroupAndRelations]);
+  const showDeleteModal = useCallback(
+    async (group: IExternalUserGroupHasId) => {
+      try {
+        await syncUserGroupAndRelations();
+
+        setSelectedExternalUserGroup(group);
+        setDeleteModalShown(true);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [syncUserGroupAndRelations],
+  );
 
 
   const hideDeleteModal = useCallback(() => {
   const hideDeleteModal = useCallback(() => {
     setSelectedExternalUserGroup(undefined);
     setSelectedExternalUserGroup(undefined);
     setDeleteModalShown(false);
     setDeleteModalShown(false);
   }, []);
   }, []);
 
 
-  const updateExternalUserGroup = useCallback(async(userGroupData: IExternalUserGroupHasId) => {
-    try {
-      await apiv3Put(`/external-user-groups/${userGroupData._id}`, {
-        description: userGroupData.description,
-      });
-
-      toastSuccess(t('toaster.update_successed', { target: t('ExternalUserGroup'), ns: 'commons' }));
-
-      await mutateExternalUserGroups();
-
-      hideUpdateModal();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, mutateExternalUserGroups, hideUpdateModal]);
-
-  const deleteExternalUserGroupById = useCallback(async(
-      deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup | null,
-  ) => {
-    const transferToUserGroupId = transferToUserGroup != null ? getIdForRef(transferToUserGroup.item) : null;
-    const transferToUserGroupType = transferToUserGroup != null ? transferToUserGroup.type : null;
-    try {
-      await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
-        actionName,
-        transferToUserGroupId,
-        transferToUserGroupType,
-      });
-
-      // sync
-      await mutateExternalUserGroups();
-
-      hideDeleteModal();
+  const updateExternalUserGroup = useCallback(
+    async (userGroupData: IExternalUserGroupHasId) => {
+      try {
+        await apiv3Put(`/external-user-groups/${userGroupData._id}`, {
+          description: userGroupData.description,
+        });
+
+        toastSuccess(
+          t('toaster.update_successed', {
+            target: t('ExternalUserGroup'),
+            ns: 'commons',
+          }),
+        );
+
+        await mutateExternalUserGroups();
+
+        hideUpdateModal();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, mutateExternalUserGroups, hideUpdateModal],
+  );
 
 
-      toastSuccess(`Deleted ${selectedExternalUserGroup?.name} group.`);
-    }
-    catch (err) {
-      toastError(new Error('Unable to delete the groups'));
-    }
-  }, [mutateExternalUserGroups, selectedExternalUserGroup, hideDeleteModal]);
+  const deleteExternalUserGroupById = useCallback(
+    async (
+      deleteGroupId: string,
+      actionName: PageActionOnGroupDelete,
+      transferToUserGroup: IGrantedGroup | null,
+    ) => {
+      const transferToUserGroupId =
+        transferToUserGroup != null
+          ? getIdForRef(transferToUserGroup.item)
+          : null;
+      const transferToUserGroupType =
+        transferToUserGroup != null ? transferToUserGroup.type : null;
+      try {
+        await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
+          actionName,
+          transferToUserGroupId,
+          transferToUserGroupType,
+        });
+
+        // sync
+        await mutateExternalUserGroups();
+
+        hideDeleteModal();
+
+        toastSuccess(`Deleted ${selectedExternalUserGroup?.name} group.`);
+      } catch {
+        toastError(new Error('Unable to delete the groups'));
+      }
+    },
+    [mutateExternalUserGroups, selectedExternalUserGroup, hideDeleteModal],
+  );
 
 
   const switchActiveTab = (selectedTab) => {
   const switchActiveTab = (selectedTab) => {
     setActiveTab(selectedTab);
     setActiveTab(selectedTab);
@@ -135,7 +168,9 @@ export const ExternalGroupManagement: FC = () => {
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
       ldap: {
       ldap: {
-        Icon: () => <span className="material-symbols-outlined">network_node</span>,
+        Icon: () => (
+          <span className="material-symbols-outlined">network_node</span>
+        ),
         i18n: 'LDAP',
         i18n: 'LDAP',
       },
       },
       keycloak: {
       keycloak: {
@@ -147,7 +182,9 @@ export const ExternalGroupManagement: FC = () => {
 
 
   return (
   return (
     <>
     <>
-      <h2 className="border-bottom mb-4">{t('external_user_group.management')}</h2>
+      <h2 className="border-bottom mb-4">
+        {t('external_user_group.management')}
+      </h2>
       <UserGroupTable
       <UserGroupTable
         headerLabel={t('admin:user_group_management.group_list')}
         headerLabel={t('admin:user_group_management.group_list')}
         userGroups={externalUserGroups}
         userGroups={externalUserGroups}
@@ -169,7 +206,9 @@ export const ExternalGroupManagement: FC = () => {
       />
       />
 
 
       <UserGroupDeleteModal
       <UserGroupDeleteModal
-        userGroups={userGroupsForDeleteModal.concat(externalUserGroupsForDeleteModal)}
+        userGroups={userGroupsForDeleteModal.concat(
+          externalUserGroupsForDeleteModal,
+        )}
         deleteUserGroup={selectedExternalUserGroup}
         deleteUserGroup={selectedExternalUserGroup}
         onDelete={deleteExternalUserGroupById}
         onDelete={deleteExternalUserGroupById}
         isShow={isDeleteModalShown}
         isShow={isDeleteModalShown}

+ 5 - 3
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx

@@ -8,15 +8,17 @@ import { KeycloakGroupSyncSettingsForm } from './KeycloakGroupSyncSettingsForm';
 import { SyncExecution } from './SyncExecution';
 import { SyncExecution } from './SyncExecution';
 
 
 export const KeycloakGroupManagement: FC = () => {
 export const KeycloakGroupManagement: FC = () => {
-
-  const requestSyncAPI = useCallback(async() => {
+  const requestSyncAPI = useCallback(async () => {
     await apiv3Put('/external-user-groups/keycloak/sync');
     await apiv3Put('/external-user-groups/keycloak/sync');
   }, []);
   }, []);
 
 
   return (
   return (
     <>
     <>
       <KeycloakGroupSyncSettingsForm />
       <KeycloakGroupSyncSettingsForm />
-      <SyncExecution provider={ExternalGroupProviderType.keycloak} requestSyncAPI={requestSyncAPI} />
+      <SyncExecution
+        provider={ExternalGroupProviderType.keycloak}
+        requestSyncAPI={requestSyncAPI}
+      />
     </>
     </>
   );
   );
 };
 };

+ 109 - 45
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx

@@ -11,7 +11,8 @@ import type { KeycloakGroupSyncSettings } from '~/features/external-user-group/i
 export const KeycloakGroupSyncSettingsForm: FC = () => {
 export const KeycloakGroupSyncSettingsForm: FC = () => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const { data: keycloakGroupSyncSettings } = useSWRxKeycloakGroupSyncSettings();
+  const { data: keycloakGroupSyncSettings } =
+    useSWRxKeycloakGroupSyncSettings();
 
 
   const [formValues, setFormValues] = useState<KeycloakGroupSyncSettings>({
   const [formValues, setFormValues] = useState<KeycloakGroupSyncSettings>({
     keycloakHost: '',
     keycloakHost: '',
@@ -28,22 +29,31 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
     if (keycloakGroupSyncSettings != null) {
     if (keycloakGroupSyncSettings != null) {
       setFormValues(keycloakGroupSyncSettings);
       setFormValues(keycloakGroupSyncSettings);
     }
     }
-  }, [keycloakGroupSyncSettings, setFormValues]);
+  }, [keycloakGroupSyncSettings]);
 
 
-  const submitHandler = useCallback(async(e) => {
-    e.preventDefault();
-    try {
-      await apiv3Put('/external-user-groups/keycloak/sync-settings', formValues);
-      toastSuccess(t('external_user_group.keycloak.updated_group_sync_settings'));
-    }
-    catch (errs) {
-      toastError(t(errs[0]?.message));
-    }
-  }, [formValues, t]);
+  const submitHandler = useCallback(
+    async (e) => {
+      e.preventDefault();
+      try {
+        await apiv3Put(
+          '/external-user-groups/keycloak/sync-settings',
+          formValues,
+        );
+        toastSuccess(
+          t('external_user_group.keycloak.updated_group_sync_settings'),
+        );
+      } catch (errs) {
+        toastError(t(errs[0]?.message));
+      }
+    },
+    [formValues, t],
+  );
 
 
   return (
   return (
     <>
     <>
-      <h3 className="border-bottom mb-3">{t('external_user_group.keycloak.group_sync_settings')}</h3>
+      <h3 className="border-bottom mb-3">
+        {t('external_user_group.keycloak.group_sync_settings')}
+      </h3>
       <form onSubmit={submitHandler}>
       <form onSubmit={submitHandler}>
         <div className="row form-group">
         <div className="row form-group">
           <label
           <label
@@ -59,7 +69,9 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakHost"
               name="keycloakHost"
               id="keycloakHost"
               id="keycloakHost"
               value={formValues.keycloakHost}
               value={formValues.keycloakHost}
-              onChange={e => setFormValues({ ...formValues, keycloakHost: e.target.value })}
+              onChange={(e) =>
+                setFormValues({ ...formValues, keycloakHost: e.target.value })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>{t('external_user_group.keycloak.host_detail')}</small>
               <small>{t('external_user_group.keycloak.host_detail')}</small>
@@ -67,7 +79,10 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="keycloakGroupRealm" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="keycloakGroupRealm"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('external_user_group.keycloak.group_realm')}
             {t('external_user_group.keycloak.group_realm')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -78,7 +93,12 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupRealm"
               name="keycloakGroupRealm"
               id="keycloakGroupRealm"
               id="keycloakGroupRealm"
               value={formValues.keycloakGroupRealm}
               value={formValues.keycloakGroupRealm}
-              onChange={e => setFormValues({ ...formValues, keycloakGroupRealm: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  keycloakGroupRealm: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
@@ -88,7 +108,10 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="keycloakGroupSyncClientRealm" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="keycloakGroupSyncClientRealm"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('external_user_group.keycloak.group_sync_client_realm')}
             {t('external_user_group.keycloak.group_sync_client_realm')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -99,17 +122,28 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupSyncClientRealm"
               name="keycloakGroupSyncClientRealm"
               id="keycloakGroupSyncClientRealm"
               id="keycloakGroupSyncClientRealm"
               value={formValues.keycloakGroupSyncClientRealm}
               value={formValues.keycloakGroupSyncClientRealm}
-              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientRealm: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  keycloakGroupSyncClientRealm: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
-                {t('external_user_group.keycloak.group_sync_client_realm_detail')} <br />
+                {t(
+                  'external_user_group.keycloak.group_sync_client_realm_detail',
+                )}{' '}
+                <br />
               </small>
               </small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="keycloakGroupSyncClientID" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="keycloakGroupSyncClientID"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('external_user_group.keycloak.group_sync_client_id')}
             {t('external_user_group.keycloak.group_sync_client_id')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -120,17 +154,26 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupSyncClientID"
               name="keycloakGroupSyncClientID"
               id="keycloakGroupSyncClientID"
               id="keycloakGroupSyncClientID"
               value={formValues.keycloakGroupSyncClientID}
               value={formValues.keycloakGroupSyncClientID}
-              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientID: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  keycloakGroupSyncClientID: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
-                {t('external_user_group.keycloak.group_sync_client_id_detail')} <br />
+                {t('external_user_group.keycloak.group_sync_client_id_detail')}{' '}
+                <br />
               </small>
               </small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="keycloakGroupSyncClientSecret" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="keycloakGroupSyncClientSecret"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('external_user_group.keycloak.group_sync_client_secret')}
             {t('external_user_group.keycloak.group_sync_client_secret')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -141,21 +184,25 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupSyncClientSecret"
               name="keycloakGroupSyncClientSecret"
               id="keycloakGroupSyncClientSecret"
               id="keycloakGroupSyncClientSecret"
               value={formValues.keycloakGroupSyncClientSecret}
               value={formValues.keycloakGroupSyncClientSecret}
-              onChange={e => setFormValues({ ...formValues, keycloakGroupSyncClientSecret: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  keycloakGroupSyncClientSecret: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
-                {t('external_user_group.keycloak.group_sync_client_secret_detail')} <br />
+                {t(
+                  'external_user_group.keycloak.group_sync_client_secret_detail',
+                )}{' '}
+                <br />
               </small>
               </small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label
-            className="text-left text-md-end col-md-3 col-form-label"
-          >
-            {/* {t('external_user_group.auto_generate_user_on_sync')} */}
-          </label>
+          <div className="col-md-3"></div>
           <div className="col-md-9">
           <div className="col-md-9">
             <div className="custom-control custom-checkbox custom-checkbox-info">
             <div className="custom-control custom-checkbox custom-checkbox-info">
               <input
               <input
@@ -164,7 +211,13 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
                 name="autoGenerateUserOnKeycloakGroupSync"
                 name="autoGenerateUserOnKeycloakGroupSync"
                 id="autoGenerateUserOnKeycloakGroupSync"
                 id="autoGenerateUserOnKeycloakGroupSync"
                 checked={formValues.autoGenerateUserOnKeycloakGroupSync}
                 checked={formValues.autoGenerateUserOnKeycloakGroupSync}
-                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnKeycloakGroupSync: !formValues.autoGenerateUserOnKeycloakGroupSync })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    autoGenerateUserOnKeycloakGroupSync:
+                      !formValues.autoGenerateUserOnKeycloakGroupSync,
+                  })
+                }
               />
               />
               <label
               <label
                 className="custom-control-label"
                 className="custom-control-label"
@@ -176,11 +229,7 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label
-            className="text-left text-md-end col-md-3 col-form-label"
-          >
-            {/* {t('external_user_group.keycloak.preserve_deleted_keycloak_groups')} */}
-          </label>
+          <div className="col-md-3"></div>
           <div className="col-md-9">
           <div className="col-md-9">
             <div className="custom-control custom-checkbox custom-checkbox-info">
             <div className="custom-control custom-checkbox custom-checkbox-info">
               <input
               <input
@@ -189,22 +238,35 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
                 name="preserveDeletedKeycloakGroups"
                 name="preserveDeletedKeycloakGroups"
                 id="preserveDeletedKeycloakGroups"
                 id="preserveDeletedKeycloakGroups"
                 checked={formValues.preserveDeletedKeycloakGroups}
                 checked={formValues.preserveDeletedKeycloakGroups}
-                onChange={() => setFormValues({ ...formValues, preserveDeletedKeycloakGroups: !formValues.preserveDeletedKeycloakGroups })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    preserveDeletedKeycloakGroups:
+                      !formValues.preserveDeletedKeycloakGroups,
+                  })
+                }
               />
               />
               <label
               <label
                 className="custom-control-label"
                 className="custom-control-label"
                 htmlFor="preserveDeletedKeycloakGroups"
                 htmlFor="preserveDeletedKeycloakGroups"
               >
               >
-                {t('external_user_group.keycloak.preserve_deleted_keycloak_groups')}
+                {t(
+                  'external_user_group.keycloak.preserve_deleted_keycloak_groups',
+                )}
               </label>
               </label>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
         <div className="mt-5 mb-4">
         <div className="mt-5 mb-4">
-          <h4 className="border-bottom mb-3">Attribute Mapping ({t('optional')})</h4>
+          <h4 className="border-bottom mb-3">
+            Attribute Mapping ({t('optional')})
+          </h4>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="keycloakGroupDescriptionAttribute" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="keycloakGroupDescriptionAttribute"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('Description')}
             {t('Description')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -214,7 +276,12 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupDescriptionAttribute"
               name="keycloakGroupDescriptionAttribute"
               id="keycloakGroupDescriptionAttribute"
               id="keycloakGroupDescriptionAttribute"
               value={formValues.keycloakGroupDescriptionAttribute || ''}
               value={formValues.keycloakGroupDescriptionAttribute || ''}
-              onChange={e => setFormValues({ ...formValues, keycloakGroupDescriptionAttribute: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  keycloakGroupDescriptionAttribute: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
@@ -226,10 +293,7 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
 
 
         <div className="row my-3">
         <div className="row my-3">
           <div className="offset-3 col-5">
           <div className="offset-3 col-5">
-            <button
-              type="submit"
-              className="btn btn-primary"
-            >
+            <button type="submit" className="btn btn-primary">
               {t('Update')}
               {t('Update')}
             </button>
             </button>
           </div>
           </div>

+ 28 - 18
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx

@@ -1,7 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
-import {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
+import { type JSX, useCallback, useEffect, useState } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
@@ -17,33 +15,39 @@ export const LdapGroupManagement: FC = () => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   useEffect(() => {
   useEffect(() => {
-    const getIsUserBind = async() => {
+    const getIsUserBind = async () => {
       try {
       try {
         const response = await apiv3Get('/security-setting/');
         const response = await apiv3Get('/security-setting/');
         const { ldapAuth } = response.data.securityParams;
         const { ldapAuth } = response.data.securityParams;
         setIsUserBind(ldapAuth.isUserBind);
         setIsUserBind(ldapAuth.isUserBind);
-      }
-      catch (e) {
+      } catch (e) {
         toastError(e);
         toastError(e);
       }
       }
     };
     };
     getIsUserBind();
     getIsUserBind();
   }, []);
   }, []);
 
 
-  const requestSyncAPI = useCallback(async(e) => {
-    if (isUserBind) {
-      const password = e.target.password?.value;
-      await apiv3Put('/external-user-groups/ldap/sync', { password });
-    }
-    else {
-      await apiv3Put('/external-user-groups/ldap/sync');
-    }
-  }, [isUserBind]);
+  const requestSyncAPI = useCallback(
+    async (e) => {
+      if (isUserBind) {
+        const password = e.target.password?.value;
+        await apiv3Put('/external-user-groups/ldap/sync', { password });
+      } else {
+        await apiv3Put('/external-user-groups/ldap/sync');
+      }
+    },
+    [isUserBind],
+  );
 
 
   const AdditionalForm = (): JSX.Element => {
   const AdditionalForm = (): JSX.Element => {
     return isUserBind ? (
     return isUserBind ? (
       <div className="row form-group">
       <div className="row form-group">
-        <label htmlFor="ldapGroupSyncPassword" className="text-left text-md-right col-md-3 col-form-label">{t('external_user_group.ldap.password')}</label>
+        <label
+          htmlFor="ldapGroupSyncPassword"
+          className="text-left text-md-right col-md-3 col-form-label"
+        >
+          {t('external_user_group.ldap.password')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
@@ -56,13 +60,19 @@ export const LdapGroupManagement: FC = () => {
           </p>
           </p>
         </div>
         </div>
       </div>
       </div>
-    ) : <></>;
+    ) : (
+      <></>
+    );
   };
   };
 
 
   return (
   return (
     <>
     <>
       <LdapGroupSyncSettingsForm />
       <LdapGroupSyncSettingsForm />
-      <SyncExecution provider={ExternalGroupProviderType.ldap} requestSyncAPI={requestSyncAPI} AdditionalForm={AdditionalForm} />
+      <SyncExecution
+        provider={ExternalGroupProviderType.ldap}
+        requestSyncAPI={requestSyncAPI}
+        AdditionalForm={AdditionalForm}
+      />
     </>
     </>
   );
   );
 };
 };

+ 104 - 47
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx

@@ -29,22 +29,26 @@ export const LdapGroupSyncSettingsForm: FC = () => {
     if (ldapGroupSyncSettings != null) {
     if (ldapGroupSyncSettings != null) {
       setFormValues(ldapGroupSyncSettings);
       setFormValues(ldapGroupSyncSettings);
     }
     }
-  }, [ldapGroupSyncSettings, setFormValues]);
+  }, [ldapGroupSyncSettings]);
 
 
-  const submitHandler = useCallback(async(e) => {
-    e.preventDefault();
-    try {
-      await apiv3Put('/external-user-groups/ldap/sync-settings', formValues);
-      toastSuccess(t('external_user_group.ldap.updated_group_sync_settings'));
-    }
-    catch (errs) {
-      toastError(t(errs[0]?.code));
-    }
-  }, [formValues, t]);
+  const submitHandler = useCallback(
+    async (e) => {
+      e.preventDefault();
+      try {
+        await apiv3Put('/external-user-groups/ldap/sync-settings', formValues);
+        toastSuccess(t('external_user_group.ldap.updated_group_sync_settings'));
+      } catch (errs) {
+        toastError(t(errs[0]?.code));
+      }
+    },
+    [formValues, t],
+  );
 
 
   return (
   return (
     <>
     <>
-      <h3 className="border-bottom mb-3">{t('external_user_group.ldap.group_sync_settings')}</h3>
+      <h3 className="border-bottom mb-3">
+        {t('external_user_group.ldap.group_sync_settings')}
+      </h3>
       <form onSubmit={submitHandler}>
       <form onSubmit={submitHandler}>
         <div className="row form-group">
         <div className="row form-group">
           <label
           <label
@@ -60,15 +64,25 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupSearchBase"
               name="ldapGroupSearchBase"
               id="ldapGroupSearchBase"
               id="ldapGroupSearchBase"
               value={formValues.ldapGroupSearchBase}
               value={formValues.ldapGroupSearchBase}
-              onChange={e => setFormValues({ ...formValues, ldapGroupSearchBase: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  ldapGroupSearchBase: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
-              <small>{t('external_user_group.ldap.group_search_base_dn_detail')}</small>
+              <small>
+                {t('external_user_group.ldap.group_search_base_dn_detail')}
+              </small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="ldapGroupMembershipAttribute" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="ldapGroupMembershipAttribute"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('external_user_group.ldap.membership_attribute')}
             {t('external_user_group.ldap.membership_attribute')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -79,18 +93,27 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupMembershipAttribute"
               name="ldapGroupMembershipAttribute"
               id="ldapGroupMembershipAttribute"
               id="ldapGroupMembershipAttribute"
               value={formValues.ldapGroupMembershipAttribute}
               value={formValues.ldapGroupMembershipAttribute}
-              onChange={e => setFormValues({ ...formValues, ldapGroupMembershipAttribute: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  ldapGroupMembershipAttribute: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
-                {t('external_user_group.ldap.membership_attribute_detail')} <br />
+                {t('external_user_group.ldap.membership_attribute_detail')}{' '}
+                <br />
                 e.g.) <code>member</code>, <code>memberUid</code>
                 e.g.) <code>member</code>, <code>memberUid</code>
               </small>
               </small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="ldapGroupMembershipAttributeType" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="ldapGroupMembershipAttributeType"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('external_user_group.ldap.membership_attribute_type')}
             {t('external_user_group.ldap.membership_attribute_type')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -101,8 +124,14 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               id="ldapGroupMembershipAttributeType"
               id="ldapGroupMembershipAttributeType"
               value={formValues.ldapGroupMembershipAttributeType}
               value={formValues.ldapGroupMembershipAttributeType}
               onChange={(e) => {
               onChange={(e) => {
-                if (e.target.value === LdapGroupMembershipAttributeType.dn || e.target.value === LdapGroupMembershipAttributeType.uid) {
-                  setFormValues({ ...formValues, ldapGroupMembershipAttributeType: e.target.value });
+                if (
+                  e.target.value === LdapGroupMembershipAttributeType.dn ||
+                  e.target.value === LdapGroupMembershipAttributeType.uid
+                ) {
+                  setFormValues({
+                    ...formValues,
+                    ldapGroupMembershipAttributeType: e.target.value,
+                  });
                 }
                 }
               }}
               }}
             >
             >
@@ -117,7 +146,10 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="ldapGroupChildGroupAttribute" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="ldapGroupChildGroupAttribute"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('external_user_group.ldap.child_group_attribute')}
             {t('external_user_group.ldap.child_group_attribute')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -128,22 +160,24 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupChildGroupAttribute"
               name="ldapGroupChildGroupAttribute"
               id="ldapGroupChildGroupAttribute"
               id="ldapGroupChildGroupAttribute"
               value={formValues.ldapGroupChildGroupAttribute}
               value={formValues.ldapGroupChildGroupAttribute}
-              onChange={e => setFormValues({ ...formValues, ldapGroupChildGroupAttribute: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  ldapGroupChildGroupAttribute: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
-                {t('external_user_group.ldap.child_group_attribute_detail')}<br />
+                {t('external_user_group.ldap.child_group_attribute_detail')}
+                <br />
                 e.g.) <code>member</code>
                 e.g.) <code>member</code>
               </small>
               </small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label
-            className="text-left text-md-end col-md-3 col-form-label"
-          >
-            {/* {t('external_user_group.auto_generate_user_on_sync')} */}
-          </label>
+          <div className="col-md-3"></div>
           <div className="col-md-9">
           <div className="col-md-9">
             <div className="custom-control custom-checkbox custom-checkbox-info">
             <div className="custom-control custom-checkbox custom-checkbox-info">
               <input
               <input
@@ -152,7 +186,13 @@ export const LdapGroupSyncSettingsForm: FC = () => {
                 name="autoGenerateUserOnLdapGroupSync"
                 name="autoGenerateUserOnLdapGroupSync"
                 id="autoGenerateUserOnLdapGroupSync"
                 id="autoGenerateUserOnLdapGroupSync"
                 checked={formValues.autoGenerateUserOnLdapGroupSync}
                 checked={formValues.autoGenerateUserOnLdapGroupSync}
-                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnLdapGroupSync: !formValues.autoGenerateUserOnLdapGroupSync })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    autoGenerateUserOnLdapGroupSync:
+                      !formValues.autoGenerateUserOnLdapGroupSync,
+                  })
+                }
               />
               />
               <label
               <label
                 className="custom-control-label"
                 className="custom-control-label"
@@ -164,11 +204,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label
-            className="text-left text-md-end col-md-3 col-form-label"
-          >
-            {/* {t('external_user_group.ldap.preserve_deleted_ldap_groups')} */}
-          </label>
+          <div className="col-md-3"></div>
           <div className="col-md-9">
           <div className="col-md-9">
             <div className="custom-control custom-checkbox custom-checkbox-info">
             <div className="custom-control custom-checkbox custom-checkbox-info">
               <input
               <input
@@ -177,7 +213,13 @@ export const LdapGroupSyncSettingsForm: FC = () => {
                 name="preserveDeletedLdapGroups"
                 name="preserveDeletedLdapGroups"
                 id="preserveDeletedLdapGroups"
                 id="preserveDeletedLdapGroups"
                 checked={formValues.preserveDeletedLdapGroups}
                 checked={formValues.preserveDeletedLdapGroups}
-                onChange={() => setFormValues({ ...formValues, preserveDeletedLdapGroups: !formValues.preserveDeletedLdapGroups })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    preserveDeletedLdapGroups:
+                      !formValues.preserveDeletedLdapGroups,
+                  })
+                }
               />
               />
               <label
               <label
                 className="custom-control-label"
                 className="custom-control-label"
@@ -189,10 +231,17 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           </div>
           </div>
         </div>
         </div>
         <div className="mt-5 mb-4">
         <div className="mt-5 mb-4">
-          <h4 className="border-bottom mb-3">Attribute Mapping ({t('optional')})</h4>
+          <h4 className="border-bottom mb-3">
+            Attribute Mapping ({t('optional')})
+          </h4>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="ldapGroupNameAttribute" className="text-left text-md-end col-md-3 col-form-label">{t('Name')}</label>
+          <label
+            htmlFor="ldapGroupNameAttribute"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
+            {t('Name')}
+          </label>
           <div className="col-md-9">
           <div className="col-md-9">
             <input
             <input
               className="form-control"
               className="form-control"
@@ -200,18 +249,24 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupNameAttribute"
               name="ldapGroupNameAttribute"
               id="ldapGroupNameAttribute"
               id="ldapGroupNameAttribute"
               value={formValues.ldapGroupNameAttribute}
               value={formValues.ldapGroupNameAttribute}
-              onChange={e => setFormValues({ ...formValues, ldapGroupNameAttribute: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  ldapGroupNameAttribute: e.target.value,
+                })
+              }
               placeholder="Default: cn"
               placeholder="Default: cn"
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
-              <small>
-                {t('external_user_group.ldap.name_mapper_detail')}
-              </small>
+              <small>{t('external_user_group.ldap.name_mapper_detail')}</small>
             </p>
             </p>
           </div>
           </div>
         </div>
         </div>
         <div className="row form-group">
         <div className="row form-group">
-          <label htmlFor="ldapGroupDescriptionAttribute" className="text-left text-md-end col-md-3 col-form-label">
+          <label
+            htmlFor="ldapGroupDescriptionAttribute"
+            className="text-left text-md-end col-md-3 col-form-label"
+          >
             {t('Description')}
             {t('Description')}
           </label>
           </label>
           <div className="col-md-9">
           <div className="col-md-9">
@@ -221,7 +276,12 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupDescriptionAttribute"
               name="ldapGroupDescriptionAttribute"
               id="ldapGroupDescriptionAttribute"
               id="ldapGroupDescriptionAttribute"
               value={formValues.ldapGroupDescriptionAttribute || ''}
               value={formValues.ldapGroupDescriptionAttribute || ''}
-              onChange={e => setFormValues({ ...formValues, ldapGroupDescriptionAttribute: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  ldapGroupDescriptionAttribute: e.target.value,
+                })
+              }
             />
             />
             <p className="form-text text-muted">
             <p className="form-text text-muted">
               <small>
               <small>
@@ -233,10 +293,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
 
 
         <div className="row my-3">
         <div className="row my-3">
           <div className="offset-3 col-5">
           <div className="offset-3 col-5">
-            <button
-              type="submit"
-              className="btn btn-primary"
-            >
+            <button type="submit" className="btn btn-primary">
               {t('Update')}
               {t('Update')}
             </button>
             </button>
           </div>
           </div>

+ 46 - 26
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx

@@ -2,7 +2,7 @@ import type { FC, JSX } from 'react';
 import { useCallback, useEffect, useState } from 'react';
 import { useCallback, useEffect, useState } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 
 import LabeledProgressBar from '~/client/components/Admin/Common/LabeledProgressBar';
 import LabeledProgressBar from '~/client/components/Admin/Common/LabeledProgressBar';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
@@ -14,10 +14,10 @@ import { useAdminSocket } from '~/stores/socket-io';
 import { useSWRxExternalUserGroupList } from '../../stores/external-user-group';
 import { useSWRxExternalUserGroupList } from '../../stores/external-user-group';
 
 
 type SyncExecutionProps = {
 type SyncExecutionProps = {
-  provider: ExternalGroupProviderType
-  requestSyncAPI: (e?: React.FormEvent<HTMLFormElement>) => Promise<void>
-  AdditionalForm?: FC
-}
+  provider: ExternalGroupProviderType;
+  requestSyncAPI: (e?: React.FormEvent<HTMLFormElement>) => Promise<void>;
+  AdditionalForm?: FC;
+};
 
 
 enum SyncStatus {
 enum SyncStatus {
   beforeSync,
   beforeSync,
@@ -34,14 +34,17 @@ export const SyncExecution = ({
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { data: socket } = useAdminSocket();
   const { data: socket } = useAdminSocket();
   const { mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
   const { mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
-  const [syncStatus, setSyncStatus] = useState<SyncStatus>(SyncStatus.beforeSync);
+  const [syncStatus, setSyncStatus] = useState<SyncStatus>(
+    SyncStatus.beforeSync,
+  );
   const [progress, setProgress] = useState({
   const [progress, setProgress] = useState({
     total: 0,
     total: 0,
     current: 0,
     current: 0,
   });
   });
   const [isAlertModalOpen, setIsAlertModalOpen] = useState(false);
   const [isAlertModalOpen, setIsAlertModalOpen] = useState(false);
   // value to propagate the submit event of form to submit confirm modal
   // value to propagate the submit event of form to submit confirm modal
-  const [currentSubmitEvent, setCurrentSubmitEvent] = useState<React.FormEvent<HTMLFormElement>>();
+  const [currentSubmitEvent, setCurrentSubmitEvent] =
+    useState<React.FormEvent<HTMLFormElement>>();
 
 
   useEffect(() => {
   useEffect(() => {
     if (socket == null) return;
     if (socket == null) return;
@@ -77,8 +80,10 @@ export const SyncExecution = ({
 
 
   // get sync status on load, since next socket data may take a while
   // get sync status on load, since next socket data may take a while
   useEffect(() => {
   useEffect(() => {
-    const getSyncStatus = async() => {
-      const res = await apiv3Get(`/external-user-groups/${provider}/sync-status`);
+    const getSyncStatus = async () => {
+      const res = await apiv3Get(
+        `/external-user-groups/${provider}/sync-status`,
+      );
       if (res.data.isExecutingSync) {
       if (res.data.isExecutingSync) {
         setSyncStatus(SyncStatus.syncExecuting);
         setSyncStatus(SyncStatus.syncExecuting);
         setProgress({ total: res.data.totalCount, current: res.data.count });
         setProgress({ total: res.data.totalCount, current: res.data.count });
@@ -93,15 +98,14 @@ export const SyncExecution = ({
     setIsAlertModalOpen(true);
     setIsAlertModalOpen(true);
   };
   };
 
 
-  const onSyncExecConfirmBtnClick = useCallback(async() => {
+  const onSyncExecConfirmBtnClick = useCallback(async () => {
     setIsAlertModalOpen(false);
     setIsAlertModalOpen(false);
     try {
     try {
       // set sync status before requesting to API, so that setting to syncFailed does not get overwritten
       // set sync status before requesting to API, so that setting to syncFailed does not get overwritten
       setSyncStatus(SyncStatus.syncExecuting);
       setSyncStatus(SyncStatus.syncExecuting);
       setProgress({ total: 0, current: 0 });
       setProgress({ total: 0, current: 0 });
       await requestSyncAPI(currentSubmitEvent);
       await requestSyncAPI(currentSubmitEvent);
-    }
-    catch (errs) {
+    } catch (errs) {
       setSyncStatus(SyncStatus.syncFailed);
       setSyncStatus(SyncStatus.syncFailed);
       toastError(t(errs[0]?.code));
       toastError(t(errs[0]?.code));
     }
     }
@@ -110,14 +114,12 @@ export const SyncExecution = ({
   const renderProgressBar = () => {
   const renderProgressBar = () => {
     if (syncStatus === SyncStatus.beforeSync) return null;
     if (syncStatus === SyncStatus.beforeSync) return null;
 
 
-    let header;
+    let header: string;
     if (syncStatus === SyncStatus.syncExecuting) {
     if (syncStatus === SyncStatus.syncExecuting) {
       header = 'Processing..';
       header = 'Processing..';
-    }
-    else if (syncStatus === SyncStatus.syncCompleted) {
+    } else if (syncStatus === SyncStatus.syncCompleted) {
       header = 'Completed';
       header = 'Completed';
-    }
-    else {
+    } else {
       header = 'Failed';
       header = 'Failed';
     }
     }
 
 
@@ -132,18 +134,22 @@ export const SyncExecution = ({
 
 
   return (
   return (
     <>
     <>
-      <h3 className="border-bottom mb-3">{t('external_user_group.execute_sync')}</h3>
+      <h3 className="border-bottom mb-3">
+        {t('external_user_group.execute_sync')}
+      </h3>
       <div className="row">
       <div className="row">
         <div className="col-md-3"></div>
         <div className="col-md-3"></div>
-        <div className="col-md-9">
-          {renderProgressBar()}
-        </div>
+        <div className="col-md-9">{renderProgressBar()}</div>
       </div>
       </div>
       <form onSubmit={onSyncBtnClick}>
       <form onSubmit={onSyncBtnClick}>
         <AdditionalForm />
         <AdditionalForm />
         <div className="row">
         <div className="row">
           <div className="col-md-3"></div>
           <div className="col-md-3"></div>
-          <div className="col-md-6"><button className="btn btn-primary" type="submit">{t('external_user_group.sync')}</button></div>
+          <div className="col-md-6">
+            <button className="btn btn-primary" type="submit">
+              {t('external_user_group.sync')}
+            </button>
+          </div>
         </div>
         </div>
       </form>
       </form>
 
 
@@ -151,9 +157,17 @@ export const SyncExecution = ({
         isOpen={isAlertModalOpen}
         isOpen={isAlertModalOpen}
         toggle={() => setIsAlertModalOpen(false)}
         toggle={() => setIsAlertModalOpen(false)}
       >
       >
-        <ModalHeader tag="h4" toggle={() => setIsAlertModalOpen(false)} className="text-info">
-          <span className="material-symbols-outlined me-1 align-middle">error</span>
-          <span className="align-middle">{t('external_user_group.confirmation_before_sync')}</span>
+        <ModalHeader
+          tag="h4"
+          toggle={() => setIsAlertModalOpen(false)}
+          className="text-info"
+        >
+          <span className="material-symbols-outlined me-1 align-middle">
+            error
+          </span>
+          <span className="align-middle">
+            {t('external_user_group.confirmation_before_sync')}
+          </span>
         </ModalHeader>
         </ModalHeader>
         <ModalBody>
         <ModalBody>
           <ul>
           <ul>
@@ -161,7 +175,13 @@ export const SyncExecution = ({
             <li>{t('external_user_group.parallel_sync_forbidden')}</li>
             <li>{t('external_user_group.parallel_sync_forbidden')}</li>
           </ul>
           </ul>
           <div className="text-center">
           <div className="text-center">
-            <button className="btn btn-primary" type="button" onClick={onSyncExecConfirmBtnClick}>{t('Execute')}</button>
+            <button
+              className="btn btn-primary"
+              type="button"
+              onClick={onSyncExecConfirmBtnClick}
+            >
+              {t('Execute')}
+            </button>
           </div>
           </div>
         </ModalBody>
         </ModalBody>
       </Modal>
       </Modal>

+ 76 - 33
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -5,39 +5,57 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import type {
 import type {
-  IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
+  IExternalUserGroupHasId,
+  IExternalUserGroupRelationHasId,
+  KeycloakGroupSyncSettings,
+  LdapGroupSyncSettings,
 } from '~/features/external-user-group/interfaces/external-user-group';
 } from '~/features/external-user-group/interfaces/external-user-group';
-import type { ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupRelationListResult } from '~/interfaces/user-group-response';
+import type {
+  ChildUserGroupListResult,
+  IUserGroupRelationHasIdPopulatedUser,
+  UserGroupRelationListResult,
+} from '~/interfaces/user-group-response';
 
 
-export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
-  return useSWR(
-    '/external-user-groups/ldap/sync-settings',
-    endpoint => apiv3Get(endpoint).then((response) => {
+export const useSWRxLdapGroupSyncSettings = (): SWRResponse<
+  LdapGroupSyncSettings,
+  Error
+> => {
+  return useSWR('/external-user-groups/ldap/sync-settings', (endpoint) =>
+    apiv3Get(endpoint).then((response) => {
       return response.data;
       return response.data;
     }),
     }),
   );
   );
 };
 };
 
 
-export const useSWRxKeycloakGroupSyncSettings = (): SWRResponse<KeycloakGroupSyncSettings, Error> => {
-  return useSWR(
-    '/external-user-groups/keycloak/sync-settings',
-    endpoint => apiv3Get(endpoint).then((response) => {
+export const useSWRxKeycloakGroupSyncSettings = (): SWRResponse<
+  KeycloakGroupSyncSettings,
+  Error
+> => {
+  return useSWR('/external-user-groups/keycloak/sync-settings', (endpoint) =>
+    apiv3Get(endpoint).then((response) => {
       return response.data;
       return response.data;
     }),
     }),
   );
   );
 };
 };
 
 
-export const useSWRxExternalUserGroup = (groupId: string | null): SWRResponse<IExternalUserGroupHasId, Error> => {
+export const useSWRxExternalUserGroup = (
+  groupId: string | null,
+): SWRResponse<IExternalUserGroupHasId, Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? `/external-user-groups/${groupId}` : null,
     groupId != null ? `/external-user-groups/${groupId}` : null,
-    endpoint => apiv3Get(endpoint).then(result => result.data.userGroup),
+    (endpoint) => apiv3Get(endpoint).then((result) => result.data.userGroup),
   );
   );
 };
 };
 
 
-export const useSWRxExternalUserGroupList = (initialData?: IExternalUserGroupHasId[]): SWRResponse<IExternalUserGroupHasId[], Error> => {
+export const useSWRxExternalUserGroupList = (
+  initialData?: IExternalUserGroupHasId[],
+): SWRResponse<IExternalUserGroupHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     '/external-user-groups',
     '/external-user-groups',
-    endpoint => apiv3Get(endpoint, { pagination: false }).then(result => result.data.userGroups),
+    (endpoint) =>
+      apiv3Get(endpoint, { pagination: false }).then(
+        (result) => result.data.userGroups,
+      ),
     {
     {
       fallbackData: initialData,
       fallbackData: initialData,
     },
     },
@@ -45,21 +63,30 @@ export const useSWRxExternalUserGroupList = (initialData?: IExternalUserGroupHas
 };
 };
 
 
 type ChildExternalUserGroupListUtils = {
 type ChildExternalUserGroupListUtils = {
-  updateChild(childGroupData: IExternalUserGroupHasId): Promise<void>, // update one child and refresh list
-}
+  updateChild(childGroupData: IExternalUserGroupHasId): Promise<void>; // update one child and refresh list
+};
 export const useSWRxChildExternalUserGroupList = (
 export const useSWRxChildExternalUserGroupList = (
-    parentIds?: string[], includeGrandChildren?: boolean,
-): SWRResponseWithUtils<ChildExternalUserGroupListUtils, ChildUserGroupListResult<IExternalUserGroupHasId>, Error> => {
+  parentIds?: string[],
+  includeGrandChildren?: boolean,
+): SWRResponseWithUtils<
+  ChildExternalUserGroupListUtils,
+  ChildUserGroupListResult<IExternalUserGroupHasId>,
+  Error
+> => {
   const shouldFetch = parentIds != null && parentIds.length > 0;
   const shouldFetch = parentIds != null && parentIds.length > 0;
 
 
   const swrResponse = useSWRImmutable(
   const swrResponse = useSWRImmutable(
-    shouldFetch ? ['/external-user-groups/children', parentIds, includeGrandChildren] : null,
-    ([endpoint, parentIds, includeGrandChildren]) => apiv3Get<ChildUserGroupListResult<IExternalUserGroupHasId>>(
-      endpoint, { parentIds, includeGrandChildren },
-    ).then((result => result.data)),
+    shouldFetch
+      ? ['/external-user-groups/children', parentIds, includeGrandChildren]
+      : null,
+    ([endpoint, parentIds, includeGrandChildren]) =>
+      apiv3Get<ChildUserGroupListResult<IExternalUserGroupHasId>>(endpoint, {
+        parentIds,
+        includeGrandChildren,
+      }).then((result) => result.data),
   );
   );
 
 
-  const updateChild = async(childGroupData: IExternalUserGroupHasId) => {
+  const updateChild = async (childGroupData: IExternalUserGroupHasId) => {
     await apiv3Put(`/external-user-groups/${childGroupData._id}`, {
     await apiv3Put(`/external-user-groups/${childGroupData._id}`, {
       description: childGroupData.description,
       description: childGroupData.description,
     });
     });
@@ -69,30 +96,46 @@ export const useSWRxChildExternalUserGroupList = (
   return withUtils(swrResponse, { updateChild });
   return withUtils(swrResponse, { updateChild });
 };
 };
 
 
-export const useSWRxExternalUserGroupRelations = (groupId: string | null): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
+export const useSWRxExternalUserGroupRelations = (
+  groupId: string | null,
+): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
-    groupId != null ? `/external-user-groups/${groupId}/external-user-group-relations` : null,
-    endpoint => apiv3Get(endpoint).then(result => result.data.userGroupRelations),
+    groupId != null
+      ? `/external-user-groups/${groupId}/external-user-group-relations`
+      : null,
+    (endpoint) =>
+      apiv3Get(endpoint).then((result) => result.data.userGroupRelations),
   );
   );
 };
 };
 
 
 export const useSWRxExternalUserGroupRelationList = (
 export const useSWRxExternalUserGroupRelationList = (
-    groupIds: string[] | null, childGroupIds?: string[], initialData?: IExternalUserGroupRelationHasId[],
+  groupIds: string[] | null,
+  childGroupIds?: string[],
+  initialData?: IExternalUserGroupRelationHasId[],
 ): SWRResponse<IExternalUserGroupRelationHasId[], Error> => {
 ): SWRResponse<IExternalUserGroupRelationHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
-    groupIds != null ? ['/external-user-group-relations', groupIds, childGroupIds] : null,
-    ([endpoint, groupIds, childGroupIds]) => apiv3Get<UserGroupRelationListResult<IExternalUserGroupRelationHasId>>(
-      endpoint, { groupIds, childGroupIds },
-    ).then(result => result.data.userGroupRelations),
+    groupIds != null
+      ? ['/external-user-group-relations', groupIds, childGroupIds]
+      : null,
+    ([endpoint, groupIds, childGroupIds]) =>
+      apiv3Get<UserGroupRelationListResult<IExternalUserGroupRelationHasId>>(
+        endpoint,
+        { groupIds, childGroupIds },
+      ).then((result) => result.data.userGroupRelations),
     {
     {
       fallbackData: initialData,
       fallbackData: initialData,
     },
     },
   );
   );
 };
 };
 
 
-export const useSWRxAncestorExternalUserGroups = (groupId: string | null): SWRResponse<IExternalUserGroupHasId[], Error> => {
+export const useSWRxAncestorExternalUserGroups = (
+  groupId: string | null,
+): SWRResponse<IExternalUserGroupHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? ['/external-user-groups/ancestors', groupId] : null,
     groupId != null ? ['/external-user-groups/ancestors', groupId] : null,
-    ([endpoint, groupId]) => apiv3Get(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),
+    ([endpoint, groupId]) =>
+      apiv3Get(endpoint, { groupId }).then(
+        (result) => result.data.ancestorUserGroups,
+      ),
   );
   );
 };
 };

+ 50 - 38
apps/app/src/features/external-user-group/interfaces/external-user-group.ts

@@ -1,62 +1,74 @@
 import type {
 import type {
-  HasObjectId, IUserGroupRelation, Ref, IUserGroup,
+  HasObjectId,
+  IUserGroup,
+  IUserGroupRelation,
+  Ref,
 } from '@growi/core';
 } from '@growi/core';
 
 
-
-export const ExternalGroupProviderType = { ldap: 'ldap', keycloak: 'keycloak' } as const;
-export type ExternalGroupProviderType = typeof ExternalGroupProviderType[keyof typeof ExternalGroupProviderType];
+export const ExternalGroupProviderType = {
+  ldap: 'ldap',
+  keycloak: 'keycloak',
+} as const;
+export type ExternalGroupProviderType =
+  (typeof ExternalGroupProviderType)[keyof typeof ExternalGroupProviderType];
 
 
 export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {
 export interface IExternalUserGroup extends Omit<IUserGroup, 'parent'> {
-  parent: Ref<IExternalUserGroup> | null
-  externalId: string // identifier used in external app/server
-  provider: ExternalGroupProviderType
+  parent: Ref<IExternalUserGroup> | null;
+  externalId: string; // identifier used in external app/server
+  provider: ExternalGroupProviderType;
 }
 }
 
 
 export type IExternalUserGroupHasId = IExternalUserGroup & HasObjectId;
 export type IExternalUserGroupHasId = IExternalUserGroup & HasObjectId;
 
 
-export interface IExternalUserGroupRelation extends Omit<IUserGroupRelation, 'relatedGroup'> {
-  relatedGroup: Ref<IExternalUserGroup>
+export interface IExternalUserGroupRelation
+  extends Omit<IUserGroupRelation, 'relatedGroup'> {
+  relatedGroup: Ref<IExternalUserGroup>;
 }
 }
 
 
-export type IExternalUserGroupRelationHasId = IExternalUserGroupRelation & HasObjectId;
+export type IExternalUserGroupRelationHasId = IExternalUserGroupRelation &
+  HasObjectId;
 
 
-export const LdapGroupMembershipAttributeType = { dn: 'DN', uid: 'UID' } as const;
-type LdapGroupMembershipAttributeType = typeof LdapGroupMembershipAttributeType[keyof typeof LdapGroupMembershipAttributeType];
+export const LdapGroupMembershipAttributeType = {
+  dn: 'DN',
+  uid: 'UID',
+} as const;
+type LdapGroupMembershipAttributeType =
+  (typeof LdapGroupMembershipAttributeType)[keyof typeof LdapGroupMembershipAttributeType];
 
 
 export interface LdapGroupSyncSettings {
 export interface LdapGroupSyncSettings {
-  ldapGroupSearchBase: string
-  ldapGroupMembershipAttribute: string
-  ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType
-  ldapGroupChildGroupAttribute: string
-  autoGenerateUserOnLdapGroupSync: boolean
-  preserveDeletedLdapGroups: boolean
-  ldapGroupNameAttribute: string
-  ldapGroupDescriptionAttribute?: string
+  ldapGroupSearchBase: string;
+  ldapGroupMembershipAttribute: string;
+  ldapGroupMembershipAttributeType: LdapGroupMembershipAttributeType;
+  ldapGroupChildGroupAttribute: string;
+  autoGenerateUserOnLdapGroupSync: boolean;
+  preserveDeletedLdapGroups: boolean;
+  ldapGroupNameAttribute: string;
+  ldapGroupDescriptionAttribute?: string;
 }
 }
 
 
 export interface KeycloakGroupSyncSettings {
 export interface KeycloakGroupSyncSettings {
-  keycloakHost: string
-  keycloakGroupRealm: string
-  keycloakGroupSyncClientRealm: string
-  keycloakGroupSyncClientID: string
-  keycloakGroupSyncClientSecret: string
-  autoGenerateUserOnKeycloakGroupSync: boolean
-  preserveDeletedKeycloakGroups: boolean
-  keycloakGroupDescriptionAttribute?: string
+  keycloakHost: string;
+  keycloakGroupRealm: string;
+  keycloakGroupSyncClientRealm: string;
+  keycloakGroupSyncClientID: string;
+  keycloakGroupSyncClientSecret: string;
+  autoGenerateUserOnKeycloakGroupSync: boolean;
+  preserveDeletedKeycloakGroups: boolean;
+  keycloakGroupDescriptionAttribute?: string;
 }
 }
 
 
 export type ExternalUserInfo = {
 export type ExternalUserInfo = {
-  id: string, // external user id
-  username: string,
-  name?: string,
-  email?: string,
-}
+  id: string; // external user id
+  username: string;
+  name?: string;
+  email?: string;
+};
 
 
 // Data structure to express the tree structure of external groups, before converting to ExternalUserGroup model
 // Data structure to express the tree structure of external groups, before converting to ExternalUserGroup model
 export interface ExternalUserGroupTreeNode {
 export interface ExternalUserGroupTreeNode {
-  id: string // external group id
-  userInfos: ExternalUserInfo[]
-  childGroupNodes: ExternalUserGroupTreeNode[]
-  name: string
-  description?: string
+  id: string; // external group id
+  userInfos: ExternalUserInfo[];
+  childGroupNodes: ExternalUserGroupTreeNode[];
+  name: string;
+  description?: string;
 }
 }

+ 95 - 39
apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts

@@ -5,19 +5,24 @@ import ExternalUserGroupRelation from './external-user-group-relation';
 
 
 // TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
 // TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
 // ref: https://github.com/vitest-dev/vitest/issues/846
 // ref: https://github.com/vitest-dev/vitest/issues/846
-const userSchema = new mongoose.Schema({
-  name: { type: String },
-  username: { type: String, required: true, unique: true },
-  email: { type: String, unique: true, sparse: true },
-}, {
-  timestamps: true,
-});
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 const User = mongoose.model('User', userSchema);
 const User = mongoose.model('User', userSchema);
 
 
 describe('ExternalUserGroupRelation model', () => {
 describe('ExternalUserGroupRelation model', () => {
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let user1;
   let user1;
   const userId1 = new mongoose.Types.ObjectId();
   const userId1 = new mongoose.Types.ObjectId();
 
 
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let user2;
   let user2;
   const userId2 = new mongoose.Types.ObjectId();
   const userId2 = new mongoose.Types.ObjectId();
 
 
@@ -25,51 +30,75 @@ describe('ExternalUserGroupRelation model', () => {
   const groupId2 = new mongoose.Types.ObjectId();
   const groupId2 = new mongoose.Types.ObjectId();
   const groupId3 = new mongoose.Types.ObjectId();
   const groupId3 = new mongoose.Types.ObjectId();
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     user1 = await User.create({
     user1 = await User.create({
-      _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
+      _id: userId1,
+      name: 'user1',
+      username: 'user1',
+      email: 'user1@example.com',
     });
     });
 
 
     user2 = await User.create({
     user2 = await User.create({
-      _id: userId2, name: 'user2', username: 'user2', email: 'user2@example.com',
+      _id: userId2,
+      name: 'user2',
+      username: 'user2',
+      email: 'user2@example.com',
     });
     });
 
 
     await ExternalUserGroup.insertMany([
     await ExternalUserGroup.insertMany([
       {
       {
-        _id: groupId1, name: 'test group 1', externalId: 'testExternalId', provider: 'testProvider',
+        _id: groupId1,
+        name: 'test group 1',
+        externalId: 'testExternalId',
+        provider: 'testProvider',
       },
       },
       {
       {
-        _id: groupId2, name: 'test group 2', externalId: 'testExternalId2', provider: 'testProvider',
+        _id: groupId2,
+        name: 'test group 2',
+        externalId: 'testExternalId2',
+        provider: 'testProvider',
       },
       },
       {
       {
-        _id: groupId3, name: 'test group 3', externalId: 'testExternalId3', provider: 'testProvider',
+        _id: groupId3,
+        name: 'test group 3',
+        externalId: 'testExternalId3',
+        provider: 'testProvider',
       },
       },
     ]);
     ]);
   });
   });
 
 
-  afterEach(async() => {
+  afterEach(async () => {
     await ExternalUserGroupRelation.deleteMany();
     await ExternalUserGroupRelation.deleteMany();
   });
   });
 
 
   describe('createRelations', () => {
   describe('createRelations', () => {
-    it('creates relation for user', async() => {
-      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
+    it('creates relation for user', async () => {
+      await ExternalUserGroupRelation.createRelations(
+        [groupId1, groupId2],
+        user1,
+      );
       const relations = await ExternalUserGroupRelation.find();
       const relations = await ExternalUserGroupRelation.find();
       const idCombinations = relations.map((relation) => {
       const idCombinations = relations.map((relation) => {
         return [relation.relatedGroup, relation.relatedUser];
         return [relation.relatedGroup, relation.relatedUser];
       });
       });
-      expect(idCombinations).toStrictEqual([[groupId1, userId1], [groupId2, userId1]]);
+      expect(idCombinations).toStrictEqual([
+        [groupId1, userId1],
+        [groupId2, userId1],
+      ]);
     });
     });
   });
   });
 
 
   describe('removeAllInvalidRelations', () => {
   describe('removeAllInvalidRelations', () => {
-    beforeAll(async() => {
+    beforeAll(async () => {
       const nonExistentGroupId1 = new mongoose.Types.ObjectId();
       const nonExistentGroupId1 = new mongoose.Types.ObjectId();
       const nonExistentGroupId2 = new mongoose.Types.ObjectId();
       const nonExistentGroupId2 = new mongoose.Types.ObjectId();
-      await ExternalUserGroupRelation.createRelations([nonExistentGroupId1, nonExistentGroupId2], user1);
+      await ExternalUserGroupRelation.createRelations(
+        [nonExistentGroupId1, nonExistentGroupId2],
+        user1,
+      );
     });
     });
 
 
-    it('removes invalid relations', async() => {
+    it('removes invalid relations', async () => {
       const relationsBeforeRemoval = await ExternalUserGroupRelation.find();
       const relationsBeforeRemoval = await ExternalUserGroupRelation.find();
       expect(relationsBeforeRemoval.length).not.toBe(0);
       expect(relationsBeforeRemoval.length).not.toBe(0);
 
 
@@ -81,45 +110,72 @@ describe('ExternalUserGroupRelation model', () => {
   });
   });
 
 
   describe('findAllUserIdsForUserGroups', () => {
   describe('findAllUserIdsForUserGroups', () => {
-    beforeAll(async() => {
-      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
-      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    beforeAll(async () => {
+      await ExternalUserGroupRelation.createRelations(
+        [groupId1, groupId2],
+        user1,
+      );
+      await ExternalUserGroupRelation.create({
+        relatedGroup: groupId3,
+        relatedUser: user2._id,
+      });
     });
     });
 
 
-    it('finds all unique user ids for specified user groups', async() => {
-      const userIds = await ExternalUserGroupRelation.findAllUserIdsForUserGroups([groupId1, groupId2, groupId3]);
+    it('finds all unique user ids for specified user groups', async () => {
+      const userIds =
+        await ExternalUserGroupRelation.findAllUserIdsForUserGroups([
+          groupId1,
+          groupId2,
+          groupId3,
+        ]);
       expect(userIds).toStrictEqual([userId1.toString(), user2._id.toString()]);
       expect(userIds).toStrictEqual([userId1.toString(), user2._id.toString()]);
     });
     });
   });
   });
 
 
   describe('findAllUserGroupIdsRelatedToUser', () => {
   describe('findAllUserGroupIdsRelatedToUser', () => {
-    beforeAll(async() => {
-      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
-      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    beforeAll(async () => {
+      await ExternalUserGroupRelation.createRelations(
+        [groupId1, groupId2],
+        user1,
+      );
+      await ExternalUserGroupRelation.create({
+        relatedGroup: groupId3,
+        relatedUser: user2._id,
+      });
     });
     });
 
 
-    it('finds all group ids related to user', async() => {
-      const groupIds = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+    it('finds all group ids related to user', async () => {
+      const groupIds =
+        await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
       expect(groupIds).toStrictEqual([groupId1, groupId2]);
       expect(groupIds).toStrictEqual([groupId1, groupId2]);
 
 
-      const groupIds2 = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user2);
+      const groupIds2 =
+        await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user2);
       expect(groupIds2).toStrictEqual([groupId3]);
       expect(groupIds2).toStrictEqual([groupId3]);
     });
     });
   });
   });
 
 
   describe('findAllGroupsForUser', () => {
   describe('findAllGroupsForUser', () => {
-    beforeAll(async() => {
-      await ExternalUserGroupRelation.createRelations([groupId1, groupId2], user1);
-      await ExternalUserGroupRelation.create({ relatedGroup: groupId3, relatedUser: user2._id });
+    beforeAll(async () => {
+      await ExternalUserGroupRelation.createRelations(
+        [groupId1, groupId2],
+        user1,
+      );
+      await ExternalUserGroupRelation.create({
+        relatedGroup: groupId3,
+        relatedUser: user2._id,
+      });
     });
     });
 
 
-    it('finds all groups related to user', async() => {
-      const groups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
-      const groupIds = groups.map(group => group._id);
+    it('finds all groups related to user', async () => {
+      const groups =
+        await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const groupIds = groups.map((group) => group._id);
       expect(groupIds).toStrictEqual([groupId1, groupId2]);
       expect(groupIds).toStrictEqual([groupId1, groupId2]);
 
 
-      const groups2 = await ExternalUserGroupRelation.findAllGroupsForUser(user2);
-      const groupIds2 = groups2.map(group => group._id);
+      const groups2 =
+        await ExternalUserGroupRelation.findAllGroupsForUser(user2);
+      const groupIds2 = groups2.map((group) => group._id);
       expect(groupIds2).toStrictEqual([groupId3]);
       expect(groupIds2).toStrictEqual([groupId3]);
     });
     });
   });
   });

+ 55 - 23
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -1,4 +1,4 @@
-import type { Model, Document } from 'mongoose';
+import type { Document, Model } from 'mongoose';
 import { Schema } from 'mongoose';
 import { Schema } from 'mongoose';
 
 
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
@@ -9,32 +9,56 @@ import type { IExternalUserGroupRelation } from '../../interfaces/external-user-
 
 
 import type { ExternalUserGroupDocument } from './external-user-group';
 import type { ExternalUserGroupDocument } from './external-user-group';
 
 
-export interface ExternalUserGroupRelationDocument extends IExternalUserGroupRelation, Document {}
+export interface ExternalUserGroupRelationDocument
+  extends IExternalUserGroupRelation,
+    Document {}
 
 
-export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupRelationDocument> {
-  [x:string]: any, // for old methods
+export interface ExternalUserGroupRelationModel
+  extends Model<ExternalUserGroupRelationDocument> {
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  [x: string]: any; // for old methods
 
 
-  PAGE_ITEMS: 50,
+  PAGE_ITEMS: 50;
 
 
-  removeAllByUserGroups: (groupsToDelete: ExternalUserGroupDocument[]) => Promise<any>,
+  removeAllByUserGroups: (
+    groupsToDelete: ExternalUserGroupDocument[],
+  ) => Promise<any>;
 
 
-  findAllUserIdsForUserGroups: (userGroupIds: ObjectIdLike[]) => Promise<string[]>,
+  findAllUserIdsForUserGroups: (
+    userGroupIds: ObjectIdLike[],
+  ) => Promise<string[]>;
 
 
-  findGroupsWithDescendantsByGroupAndUser: (group: ExternalUserGroupDocument, user) => Promise<ExternalUserGroupDocument[]>,
+  findGroupsWithDescendantsByGroupAndUser: (
+    group: ExternalUserGroupDocument,
+    user,
+  ) => Promise<ExternalUserGroupDocument[]>;
 
 
-  countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
+  countByGroupIdsAndUser: (
+    userGroupIds: ObjectIdLike[],
+    userData,
+  ) => Promise<number>;
 
 
-  findAllGroupsForUser: (user) => Promise<ExternalUserGroupDocument[]>
+  findAllGroupsForUser: (user) => Promise<ExternalUserGroupDocument[]>;
 
 
-  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<ObjectIdLike[]>;
 }
 }
 
 
-const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
-  relatedGroup: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', required: true },
-  relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
+const schema = new Schema<
+  ExternalUserGroupRelationDocument,
+  ExternalUserGroupRelationModel
+>(
+  {
+    relatedGroup: {
+      type: Schema.Types.ObjectId,
+      ref: 'ExternalUserGroup',
+      required: true,
+    },
+    relatedUser: { type: Schema.Types.ObjectId, ref: 'User', required: true },
+  },
+  {
+    timestamps: { createdAt: true, updatedAt: false },
+  },
+);
 
 
 schema.statics.createRelations = UserGroupRelation.createRelations;
 schema.statics.createRelations = UserGroupRelation.createRelations;
 
 
@@ -42,16 +66,24 @@ schema.statics.removeAllByUserGroups = UserGroupRelation.removeAllByUserGroups;
 
 
 schema.statics.findAllRelation = UserGroupRelation.findAllRelation;
 schema.statics.findAllRelation = UserGroupRelation.findAllRelation;
 
 
-schema.statics.removeAllInvalidRelations = UserGroupRelation.removeAllInvalidRelations;
+schema.statics.removeAllInvalidRelations =
+  UserGroupRelation.removeAllInvalidRelations;
 
 
-schema.statics.findGroupsWithDescendantsByGroupAndUser = UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
+schema.statics.findGroupsWithDescendantsByGroupAndUser =
+  UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
 
 
-schema.statics.countByGroupIdsAndUser = UserGroupRelation.countByGroupIdsAndUser;
+schema.statics.countByGroupIdsAndUser =
+  UserGroupRelation.countByGroupIdsAndUser;
 
 
-schema.statics.findAllUserIdsForUserGroups = UserGroupRelation.findAllUserIdsForUserGroups;
+schema.statics.findAllUserIdsForUserGroups =
+  UserGroupRelation.findAllUserIdsForUserGroups;
 
 
-schema.statics.findAllUserGroupIdsRelatedToUser = UserGroupRelation.findAllUserGroupIdsRelatedToUser;
+schema.statics.findAllUserGroupIdsRelatedToUser =
+  UserGroupRelation.findAllUserGroupIdsRelatedToUser;
 
 
 schema.statics.findAllGroupsForUser = UserGroupRelation.findAllGroupsForUser;
 schema.statics.findAllGroupsForUser = UserGroupRelation.findAllGroupsForUser;
 
 
-export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);
+export default getOrCreateModel<
+  ExternalUserGroupRelationDocument,
+  ExternalUserGroupRelationModel
+>('ExternalUserGroupRelation', schema);

Some files were not shown because too many files changed in this diff