Răsfoiți Sursa

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

Futa Arai 6 luni în urmă
părinte
comite
feb12ccb40
100 a modificat fișierele cu 2269 adăugiri și 901 ștergeri
  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",
-  "changelog": ["@changesets/changelog-github", { "repo": "weseek/growi" }],
+  "changelog": ["@changesets/changelog-github", { "repo": "growilabs/growi" }],
   "commit": false,
   "fixed": [],
   "linked": [],

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

@@ -8,7 +8,7 @@
 
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
-      "version": "22.17.0"
+      "version": "20.18.3"
     }
   },
 
@@ -23,21 +23,29 @@
   "customizations": {
     "vscode": {
       "extensions": [
+        // AI
+        "anthropic.claude-code",
+        // linter
         "dbaeumer.vscode-eslint",
         "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",
         "eamodio.gitlens",
-        "github.vscode-pull-request-github",
         "cschleiden.vscode-github-actions",
+        // DB
         "cweijan.vscode-database-client2",
         "mongodb.mongodb-vscode",
+        // Debug
         "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": {
         "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
 SHELL=bash pnpm setup
 eval "$(cat /home/vscode/.bashrc)"
+pnpm config set store-dir /workspace/.pnpm-store
 
 # Install turbo
 pnpm install turbo --global
 
+# Install Claude Code
+pnpm install @anthropic-ai/claude-code --global
+
 # Install dependencies
 turbo run bootstrap

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

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

+ 3 - 7
.devcontainer/compose.yml

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

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

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

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

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

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

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

+ 5 - 5
.github/mergify.yml

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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:
   workflow_call:
@@ -16,7 +16,7 @@ on:
       node-version:
         required: true
         type: string
-        default: 20.x
+        default: 22.x
       skip-e2e-test:
         type: boolean
         default: false
@@ -107,13 +107,17 @@ jobs:
     needs: [build-prod]
     runs-on: ubuntu-latest
 
+    strategy:
+      matrix:
+        mongodb-version: ['6.0', '8.0']
+
     services:
       mongodb:
-        image: mongo:6.0
+        image: mongo:${{ matrix.mongodb-version }}
         ports:
         - 27017/tcp
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
+        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
         ports:
         - 9200/tcp
         env:
@@ -182,14 +186,15 @@ jobs:
       matrix:
         browser: [chromium, firefox, webkit]
         shard: [1/2, 2/2]
+        mongodb-version: ['6.0', '8.0']
 
     services:
       mongodb:
-        image: mongo:6.0
+        image: mongo:${{ matrix.mongodb-version }}
         ports:
         - 27017/tcp
       elasticsearch:
-        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
+        image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
         ports:
         - 9200/tcp
         env:
@@ -279,7 +284,7 @@ jobs:
       uses: actions/upload-artifact@v4
       if: always()
       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
         retention-days: 30
 
@@ -288,7 +293,7 @@ jobs:
       if: failure()
       with:
         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'
         isCompactMode: true
         url: ${{ secrets.SLACK_WEBHOOK_URL }}

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

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

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

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

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

+ 2 - 1
.vscode/settings.json

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

Fișier diff suprimat deoarece este prea mare
+ 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
 
-Copyright (c) 2018 WESEEK, Inc.
+Copyright (c) 2018 GROWI, Inc.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal

+ 15 - 15
README.md

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

+ 15 - 15
README_JP.md

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

+ 1 - 1
THIRD-PARTY-NOTICES.md

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

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

@@ -30,6 +30,18 @@ module.exports = {
     'config/**',
     'src/linter-checker/**',
     '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: {
     // 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';
 

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

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

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

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

+ 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 { URL } = require('url');
+const { URL } = require('node:url');
 
 const { getMongoUri, mongoOptions } = isProduction
   ? // eslint-disable-next-line import/extensions, import/no-unresolved

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

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

+ 2 - 2
apps/app/docker/Dockerfile

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 4 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.0-RC.0",
+  "version": "7.3.2-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -166,13 +166,13 @@
     "mkdirp": "^1.0.3",
     "mongodb": "^4.17.2",
     "mongoose": "^6.13.6",
-    "mongoose-gridfs": "^1.2.42",
+    "mongoose-gridfs": "^1.3.0",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.30",
+    "next": "^14.2.32",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^0.0.4",
@@ -294,6 +294,7 @@
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",
     "@types/uuid": "^10.0.0",
+    "@types/ws": "^8.18.1",
     "babel-loader": "^8.2.5",
     "bootstrap": "=5.3.2",
     "commander": "^14.0.0",

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

@@ -313,7 +313,7 @@
       "done": "Copied to clipboard!"
     },
     "bug_report": "Submitting a bug report",
-    "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
+    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
   "v5_page_migration": {
     "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
@@ -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>.",
     "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",
       "ai_assistant_set_default_success": "Default assistant set successfully",
       "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": {
@@ -893,7 +897,7 @@
     "Password field is required": "Password field is required.",
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "user_not_found": "User not found.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   "grid_edit": {
     "create_bootstrap_4_grid": "Create Bootstrap 4 Grid",
@@ -1058,4 +1062,4 @@
     "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"
   }
-}
+}

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

@@ -313,7 +313,7 @@
       "done": "Copié dans le presse-papier!"
     },
     "bug_report": "Informations de diagnostic",
-    "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
+    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
   },
   "v5_page_migration": {
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",
@@ -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>.",
     "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",
       "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_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."
     },
     "copy_to_clipboard": "Copier dans le presse-papiers"
@@ -669,6 +669,10 @@
       "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_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": {
@@ -887,7 +891,7 @@
     "Password field is required": "Mot de passe requis.",
     "Username or E-mail has invalid characters": "Le nom d'utilisateur ou l'adresse courriel contient des caractères invalides",
     "user_not_found": "Utilisateur introuvable.",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   "grid_edit": {
     "create_bootstrap_4_grid": "Créer grille Bootstrap 4",
@@ -1049,4 +1053,4 @@
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
   }
-}
+}

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

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

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

@@ -244,7 +244,7 @@
     "scope_read": "Read",
     "action": "アクション",
     "create_token": "トークンを作成",
-    "no_tokens_found":"アクセストークンが見つかりません",
+    "no_tokens_found": "アクセストークンが見つかりません",
     "new_token": {
       "title": "新しいアクセストークン",
       "copy_to_clipboard": "クリップボードにコピーしました",
@@ -708,6 +708,10 @@
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
+    },
+    "delete_modal": {
+      "title": "アシスタントを削除する",
+      "confirm_message": "本当にアシスタントを削除しますか?"
     }
   },
   "link_edit": {
@@ -926,7 +930,7 @@
     "Password field is required": "パスワードの欄は必ず入力してください",
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "user_not_found": "ユーザーが見つかりません",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/growilabs/growi/issues/193'>こちら: #193</a>.</p>"
   },
   "grid_edit": {
     "create_bootstrap_4_grid": "Bootstrap 4 グリッドを作成",
@@ -1091,4 +1095,4 @@
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "error-toaster": "最新の本文の同期に失敗しました"
   }
-}
+}

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

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

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

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

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

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

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

@@ -666,6 +666,10 @@
       "thread_deleted_failed": "删除会话失败",
       "ai_assistant_set_default_success": "已成功设置默认助手",
       "ai_assistant_set_default_failed": "设置默认助手失败"
+    },
+    "delete_modal": {
+      "title": "删除助手",
+      "confirm_message": "确定要删除此助手吗?"
     }
   },
   "link_edit": {
@@ -898,7 +902,7 @@
     "Password field is required": "密码字段是必需的",
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "user_not_found": "未找到用户",
-    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/growilabs/growi/issues/193'>#193</a>.</p>"
   },
   "grid_edit": {
     "create_bootstrap_4_grid": "创建Bootstrap 4网格",
@@ -1063,4 +1067,4 @@
     "skipped-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 StatusTable from './StatusTable';
 
-const ElasticsearchManagement = () => {
+const ElasticsearchManagement = (): JSX.Element => {
   const { t } = useTranslation('admin');
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: socket } = useAdminSocket();
@@ -43,6 +43,8 @@ const ElasticsearchManagement = () => {
       setIndicesData(info.indices);
       setAliasesData(info.aliases);
       setIsNormalized(info.isNormalized);
+
+      return info.isNormalized;
     }
     catch (errors: unknown) {
       setIsConnected(false);
@@ -60,6 +62,7 @@ const ElasticsearchManagement = () => {
         toastError(errors as Error);
       }
 
+      return false;
     }
     finally {
       setIsInitialized(true);
@@ -67,13 +70,9 @@ const ElasticsearchManagement = () => {
   }, []);
 
   useEffect(() => {
-    const fetchIndicesStatusData = async() => {
-      await retrieveIndicesStatus();
-    };
-    fetchIndicesStatusData();
+    retrieveIndicesStatus();
   }, [retrieveIndicesStatus]);
 
-
   useEffect(() => {
     if (socket == null) {
       return;
@@ -83,7 +82,19 @@ const ElasticsearchManagement = () => {
     });
 
     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);
       setIsRebuildingCompleted(true);
     });
@@ -99,7 +110,6 @@ const ElasticsearchManagement = () => {
     };
   }, [retrieveIndicesStatus, socket]);
 
-
   const reconnect = async() => {
     setIsReconnectingProcessing(true);
 

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

@@ -1,7 +1,9 @@
 import React from 'react';
 
 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';
 
@@ -49,6 +51,8 @@ export default class ImportCollectionItem extends React.Component {
     onOptionChange(collectionName, { mode });
   }
 
+  // No toggle state needed when using UncontrolledDropdown
+
   configButtonClickedHandler() {
     const { collectionName, onConfigButtonClicked } = this.props;
 
@@ -103,40 +107,28 @@ export default class ImportCollectionItem extends React.Component {
     const {
       collectionName, option, isImporting,
     } = 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);
 
     return (
       <span className="d-inline-flex align-items-center">
         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>
     );
   }
@@ -190,7 +182,6 @@ export default class ImportCollectionItem extends React.Component {
         }
       </div>
     );
-
   }
 
   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 (
       <ul className="alert alert-danger">
         {errors.map((err, index) => (
-          <li className={index > 0 ? 'mt-1' : ''}>
+          <small className={index > 0 ? 'mt-1' : ''}>
             {tWithOpt(err.message, err.args)}
-          </li>
+          </small>
         ))}
       </ul>
     );
@@ -165,21 +165,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const renderLocalOrLdapLoginForm = useCallback(() => {
     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 (
       <>
         {/* !! - 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">
           <LoadingSpinner />
         </div>
@@ -191,8 +180,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
           </div>
         )}
-        {loginErrorElementWithDangerouslySetInnerHTML}
-        {loginErrorElement}
 
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <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 (
       <>
         <div className="mt-2">
-          { enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />) }
+          {enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />)}
         </div>
       </>
     );
@@ -342,7 +328,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             {t('page_register.notice.restricted_defail')}
           </p>
         )}
-        { (!isMailerSetup && isEmailAuthenticationEnabled) && (
+        {(!isMailerSetup && isEmailAuthenticationEnabled) && (
           <p className="alert alert-danger">
             <span>{t('commons:alert.please_enable_mailer')}</span>
           </p>
@@ -510,6 +496,25 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           <div className="col-12 px-md-4 pb-5">
             <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
               <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 && isSomeExternalAuthEnabled && (
                   <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 {
   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);
   }, []);
 
-  // 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 initializeSlackEnabled = useCallback(() => {
     setSlackChannels(slackChannelsDataString ?? '');
@@ -186,7 +186,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       const errorMessage = err.message || 'An unknown error occured when posting comment';
       setError(errorMessage);
     }
-  // eslint-disable-next-line max-len
+    // eslint-disable-next-line max-len
   }, [currentCommentId, initializeEditor, onCommented, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
 
   // 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 SavePageButton = (props: {slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean}) => {
+const SavePageButton = (props: { slackChannels: string, isSlackEnabled?: boolean, isDeviceLargerThanMd?: boolean }) => {
 
   const { t } = useTranslation();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
@@ -159,7 +159,7 @@ export const SavePageControls = (): JSX.Element | null => {
   const [slackChannels, setSlackChannels] = useState<string>('');
   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();
   useEffect(() => {
     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 ?? {}),
       });
 
-      // 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();
 
       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 type { IPagePopulatedToShowRevision } from '@growi/core';
 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-labels d-none d-lg-flex">
         {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} />
           : <div className="px-2"></div>}
       </span>

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

@@ -109,13 +109,13 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
       )}
 
       {/* Tags */}
-      { page.revision != null && (
+      {page.revision != null && (
         <div ref={tagsRef}>
           <Suspense fallback={<PageTagsSkeleton />}>
             <Tags pageId={page._id} revisionId={page.revision._id} />
           </Suspense>
         </div>
-      ) }
+      )}
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
         {/* Page list */}
@@ -124,7 +124,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
             <PageAccessoriesControl
               icon={<span className="material-symbols-outlined">subject</span>}
               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}
               offset={1}
               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 { useTranslation } from 'next-i18next';
+import Image from 'next/image';
 import prettyBytes from 'pretty-bytes';
 
+import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores-universal/context';
 import { useSWRxAttachment } from '~/stores/attachment';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 
@@ -21,6 +23,12 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
   const { data: attachment, remove } = useSWRxAttachment(attachmentId);
   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(() => {
     if (attachment == null) {
       return;
@@ -57,7 +65,13 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
       <div className="my-2 p-2 card">
         <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">
-            <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 className="ps-0">
             <div className="d-inline-block">
@@ -69,9 +83,13 @@ export const RichAttachment = React.memo((props: RichAttachmentProps) => {
               <a className="ms-2 attachment-download" href={downloadPathProxied}>
                 <span className="material-symbols-outlined">cloud_download</span>
               </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 className="d-flex align-items-center">
               <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';
 
 // 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 styles from './SearchPageBase.module.scss';
@@ -52,7 +52,7 @@ const SearchResultContent = dynamic(() => import('./SearchResultContent').then(m
   ssr: false,
   loading: () => <></>,
 });
-const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
+const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props: Props, ref) => {
 
   const {
     className,
@@ -63,7 +63,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
     searchControl, searchResultListHead, searchPager,
   } = props;
 
-  const searchResultListRef = useRef<ISelectableAll|null>(null);
+  const searchResultListRef = useRef<ISelectableAll | null>(null);
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
@@ -182,20 +182,20 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
         <div className="overflow-y-scroll">
 
           {/* Loading */}
-          { pages == null && (
+          {pages == null && (
             <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
               <LoadingSpinner className="me-1 fs-3" />
             </div>
-          ) }
+          )}
 
           {/* Loaded */}
-          { pages != null && (
+          {pages != null && (
             <>
               <div className="my-3 px-md-4 px-3">
                 {searchResultListHead}
               </div>
 
-              { pages.length > 0 && (
+              {pages.length > 0 && (
                 <div className={`page-list ${styles['page-list']} px-md-4`}>
                   <SearchResultList
                     ref={searchResultListRef}
@@ -206,12 +206,12 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                     onCheckboxChanged={checkboxChangedHandler}
                   />
                 </div>
-              ) }
+              )}
               <div className="my-4 d-flex justify-content-center">
                 {searchPager}
               </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');
 
     // 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) {
       return 0;
     }
@@ -64,7 +64,7 @@ const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
           data-testid="revision-toc-content"
           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>
         </div>
       </StickyStretchableScroller>

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

@@ -9,6 +9,8 @@ import React, {
   type JSX,
 } from 'react';
 
+import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
+
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
@@ -88,9 +90,10 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     setIsOpen(!isOpen);
   }, [isOpen]);
 
-  // didMount
   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);
   }, [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 [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)*`;
   }

+ 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() => {
     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
     await mutateTagsInfo(); // get from DB
     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 }
 
     // 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;
     if (!supressEditingMarkdownMutation) {
       mutateEditingMarkdown(updatedPage.revision.body);

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

@@ -2,13 +2,14 @@
 
 .page-content-footer :global {
   border-top: solid 1px var(--bs-border-color);
+
   .page-meta {
     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 {
   width: 300px;
   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 {
   // return alternative element
   //   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.
 

+ 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
 
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React from 'react';
 
-import { type Callout } from '../services/consts';
+import type { Callout } from '../services/consts';
 
 import styles from './CalloutViewer.module.scss';
 
@@ -11,7 +11,7 @@ const moduleClass = styles['callout-viewer'];
 
 type CALLOUT_TO = {
   [key in Callout]: string;
-}
+};
 
 const CALLOUT_TO_TYPE: CALLOUT_TO = {
   note: 'Note',
@@ -34,38 +34,39 @@ const CALLOUT_TO_ICON: CALLOUT_TO = {
 };
 
 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 className="callout-title">
-            {label ?? CALLOUT_TO_TYPE[type]}
-          </div>
-        </div>
-        <div className="callout-content">
-          {children}
+          <div className="callout-content">{children}</div>
         </div>
       </div>
-    </div>
-  );
-});
+    );
+  },
+);
 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 { unified } from 'unified';
 import { visit } from 'unist-util-visit';
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 import * as callout from './callout';
 
@@ -23,7 +23,7 @@ This is an info callout.
     const tree = processor.parse(markdown);
     processor.runSync(tree);
 
-    let calloutNode;
+    let calloutNode: ContainerDirective | undefined;
     visit(tree, 'containerDirective', (node) => {
       calloutNode = node;
     });
@@ -41,7 +41,9 @@ This is an info callout.
     assert('value' in calloutNode.children[0].children[0]);
 
     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', () => {
@@ -77,7 +79,9 @@ This is an info callout.
     assert('value' in calloutNode.children[0].children[0]);
 
     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', () => {
@@ -113,6 +117,8 @@ This is an info callout.
     assert('value' in calloutNode.children[0].children[0]);
 
     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 = () => {
   return (tree) => {
     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 data = node.data ?? (node.data = {});
+        if (node.data == null) {
+          node.data = {};
+        }
+        const data = node.data;
 
         // 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
         if (paragraphForDirectiveLabel != null) {
-          node.children.splice(node.children.indexOf(paragraphForDirectiveLabel), 1);
+          node.children.splice(
+            node.children.indexOf(paragraphForDirectiveLabel),
+            1,
+          );
         }
 
         data.hName = 'callout';
@@ -28,7 +40,6 @@ export const remarkPlugin: Plugin = () => {
           type,
           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/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',
   DELETE: 'delete',
 } 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();

+ 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 {
-  Types, Document, Model, Query,
-} from 'mongoose';
+import type { Document, Model, Query, Types } from 'mongoose';
 import { Schema } from 'mongoose';
 
 import type { IComment } from '~/interfaces/comment';
@@ -11,11 +9,10 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:models:comment');
 
 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 = (
   pageId: Types.ObjectId,
   creatorId: Types.ObjectId,
@@ -24,38 +21,45 @@ type Add = (
   commentPosition: number,
   replyTo?: Types.ObjectId | null,
 ) => 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> {
-  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> {
   try {
     const data = await this.create({
@@ -69,35 +73,36 @@ const add: Add = async function(
     logger.debug('Comment saved.', data);
 
     return data;
-  }
-  catch (err) {
+  } catch (err) {
     logger.debug('Error on saving comment.', err);
     throw err;
   }
 };
 commentSchema.statics.add = add;
 
-commentSchema.statics.findCommentsByPageId = function(id) {
+commentSchema.statics.findCommentsByPageId = function (id) {
   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 });
 };
 
-commentSchema.statics.findCreatorsByPage = async function(page) {
+commentSchema.statics.findCreatorsByPage = async function (page) {
   return this.distinct('creator', { page }).exec();
 };
 
-commentSchema.statics.countCommentByPageId = async function(page) {
+commentSchema.statics.countCommentByPageId = async function (page) {
   return this.count({ page });
 };
 
-commentSchema.statics.removeWithReplies = async function(comment) {
+commentSchema.statics.removeWithReplies = async function (comment) {
   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 { GroupType, getIdForRef } from '@growi/core';
+import type { FC } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 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 type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
-import { useIsAclEnabled } from '~/stores-universal/context';
 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 { LdapGroupManagement } from './LdapGroupManagement';
 
 export const ExternalGroupManagement: FC = () => {
-  const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const { data: externalUserGroupList, mutate: mutateExternalUserGroups } =
+    useSWRxExternalUserGroupList();
   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 [activeTab, setActiveTab] = useState('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 [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
 
@@ -53,79 +70,95 @@ export const ExternalGroupManagement: FC = () => {
   const showUpdateModal = useCallback((group: IExternalUserGroupHasId) => {
     setUpdateModalShown(true);
     setSelectedExternalUserGroup(group);
-  }, [setUpdateModalShown]);
+  }, []);
 
   const hideUpdateModal = useCallback(() => {
     setUpdateModalShown(false);
     setSelectedExternalUserGroup(undefined);
-  }, [setUpdateModalShown]);
+  }, []);
 
-  const syncUserGroupAndRelations = useCallback(async() => {
+  const syncUserGroupAndRelations = useCallback(async () => {
     try {
       await mutateExternalUserGroups();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [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(() => {
     setSelectedExternalUserGroup(undefined);
     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) => {
     setActiveTab(selectedTab);
@@ -135,7 +168,9 @@ export const ExternalGroupManagement: FC = () => {
   const navTabMapping = useMemo(() => {
     return {
       ldap: {
-        Icon: () => <span className="material-symbols-outlined">network_node</span>,
+        Icon: () => (
+          <span className="material-symbols-outlined">network_node</span>
+        ),
         i18n: 'LDAP',
       },
       keycloak: {
@@ -147,7 +182,9 @@ export const ExternalGroupManagement: FC = () => {
 
   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
         headerLabel={t('admin:user_group_management.group_list')}
         userGroups={externalUserGroups}
@@ -169,7 +206,9 @@ export const ExternalGroupManagement: FC = () => {
       />
 
       <UserGroupDeleteModal
-        userGroups={userGroupsForDeleteModal.concat(externalUserGroupsForDeleteModal)}
+        userGroups={userGroupsForDeleteModal.concat(
+          externalUserGroupsForDeleteModal,
+        )}
         deleteUserGroup={selectedExternalUserGroup}
         onDelete={deleteExternalUserGroupById}
         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';
 
 export const KeycloakGroupManagement: FC = () => {
-
-  const requestSyncAPI = useCallback(async() => {
+  const requestSyncAPI = useCallback(async () => {
     await apiv3Put('/external-user-groups/keycloak/sync');
   }, []);
 
   return (
     <>
       <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 = () => {
   const { t } = useTranslation('admin');
 
-  const { data: keycloakGroupSyncSettings } = useSWRxKeycloakGroupSyncSettings();
+  const { data: keycloakGroupSyncSettings } =
+    useSWRxKeycloakGroupSyncSettings();
 
   const [formValues, setFormValues] = useState<KeycloakGroupSyncSettings>({
     keycloakHost: '',
@@ -28,22 +29,31 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
     if (keycloakGroupSyncSettings != null) {
       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 (
     <>
-      <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}>
         <div className="row form-group">
           <label
@@ -59,7 +69,9 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakHost"
               id="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">
               <small>{t('external_user_group.keycloak.host_detail')}</small>
@@ -67,7 +79,10 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
           </div>
         </div>
         <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')}
           </label>
           <div className="col-md-9">
@@ -78,7 +93,12 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupRealm"
               id="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">
               <small>
@@ -88,7 +108,10 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
           </div>
         </div>
         <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')}
           </label>
           <div className="col-md-9">
@@ -99,17 +122,28 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupSyncClientRealm"
               id="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">
               <small>
-                {t('external_user_group.keycloak.group_sync_client_realm_detail')} <br />
+                {t(
+                  'external_user_group.keycloak.group_sync_client_realm_detail',
+                )}{' '}
+                <br />
               </small>
             </p>
           </div>
         </div>
         <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')}
           </label>
           <div className="col-md-9">
@@ -120,17 +154,26 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupSyncClientID"
               id="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">
               <small>
-                {t('external_user_group.keycloak.group_sync_client_id_detail')} <br />
+                {t('external_user_group.keycloak.group_sync_client_id_detail')}{' '}
+                <br />
               </small>
             </p>
           </div>
         </div>
         <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')}
           </label>
           <div className="col-md-9">
@@ -141,21 +184,25 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupSyncClientSecret"
               id="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">
               <small>
-                {t('external_user_group.keycloak.group_sync_client_secret_detail')} <br />
+                {t(
+                  'external_user_group.keycloak.group_sync_client_secret_detail',
+                )}{' '}
+                <br />
               </small>
             </p>
           </div>
         </div>
         <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="custom-control custom-checkbox custom-checkbox-info">
               <input
@@ -164,7 +211,13 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
                 name="autoGenerateUserOnKeycloakGroupSync"
                 id="autoGenerateUserOnKeycloakGroupSync"
                 checked={formValues.autoGenerateUserOnKeycloakGroupSync}
-                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnKeycloakGroupSync: !formValues.autoGenerateUserOnKeycloakGroupSync })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    autoGenerateUserOnKeycloakGroupSync:
+                      !formValues.autoGenerateUserOnKeycloakGroupSync,
+                  })
+                }
               />
               <label
                 className="custom-control-label"
@@ -176,11 +229,7 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
           </div>
         </div>
         <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="custom-control custom-checkbox custom-checkbox-info">
               <input
@@ -189,22 +238,35 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
                 name="preserveDeletedKeycloakGroups"
                 id="preserveDeletedKeycloakGroups"
                 checked={formValues.preserveDeletedKeycloakGroups}
-                onChange={() => setFormValues({ ...formValues, preserveDeletedKeycloakGroups: !formValues.preserveDeletedKeycloakGroups })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    preserveDeletedKeycloakGroups:
+                      !formValues.preserveDeletedKeycloakGroups,
+                  })
+                }
               />
               <label
                 className="custom-control-label"
                 htmlFor="preserveDeletedKeycloakGroups"
               >
-                {t('external_user_group.keycloak.preserve_deleted_keycloak_groups')}
+                {t(
+                  'external_user_group.keycloak.preserve_deleted_keycloak_groups',
+                )}
               </label>
             </div>
           </div>
         </div>
         <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 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')}
           </label>
           <div className="col-md-9">
@@ -214,7 +276,12 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
               name="keycloakGroupDescriptionAttribute"
               id="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">
               <small>
@@ -226,10 +293,7 @@ export const KeycloakGroupSyncSettingsForm: FC = () => {
 
         <div className="row my-3">
           <div className="offset-3 col-5">
-            <button
-              type="submit"
-              className="btn btn-primary"
-            >
+            <button type="submit" className="btn btn-primary">
               {t('Update')}
             </button>
           </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 {
-  useCallback, useEffect, useState, type JSX,
-} from 'react';
+import { type JSX, useCallback, useEffect, useState } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
@@ -17,33 +15,39 @@ export const LdapGroupManagement: FC = () => {
   const { t } = useTranslation('admin');
 
   useEffect(() => {
-    const getIsUserBind = async() => {
+    const getIsUserBind = async () => {
       try {
         const response = await apiv3Get('/security-setting/');
         const { ldapAuth } = response.data.securityParams;
         setIsUserBind(ldapAuth.isUserBind);
-      }
-      catch (e) {
+      } catch (e) {
         toastError(e);
       }
     };
     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 => {
     return isUserBind ? (
       <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">
           <input
             className="form-control"
@@ -56,13 +60,19 @@ export const LdapGroupManagement: FC = () => {
           </p>
         </div>
       </div>
-    ) : <></>;
+    ) : (
+      <></>
+    );
   };
 
   return (
     <>
       <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) {
       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 (
     <>
-      <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}>
         <div className="row form-group">
           <label
@@ -60,15 +64,25 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupSearchBase"
               id="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">
-              <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>
           </div>
         </div>
         <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')}
           </label>
           <div className="col-md-9">
@@ -79,18 +93,27 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupMembershipAttribute"
               id="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">
               <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>
               </small>
             </p>
           </div>
         </div>
         <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')}
           </label>
           <div className="col-md-9">
@@ -101,8 +124,14 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               id="ldapGroupMembershipAttributeType"
               value={formValues.ldapGroupMembershipAttributeType}
               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 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')}
           </label>
           <div className="col-md-9">
@@ -128,22 +160,24 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupChildGroupAttribute"
               id="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">
               <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>
               </small>
             </p>
           </div>
         </div>
         <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="custom-control custom-checkbox custom-checkbox-info">
               <input
@@ -152,7 +186,13 @@ export const LdapGroupSyncSettingsForm: FC = () => {
                 name="autoGenerateUserOnLdapGroupSync"
                 id="autoGenerateUserOnLdapGroupSync"
                 checked={formValues.autoGenerateUserOnLdapGroupSync}
-                onChange={() => setFormValues({ ...formValues, autoGenerateUserOnLdapGroupSync: !formValues.autoGenerateUserOnLdapGroupSync })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    autoGenerateUserOnLdapGroupSync:
+                      !formValues.autoGenerateUserOnLdapGroupSync,
+                  })
+                }
               />
               <label
                 className="custom-control-label"
@@ -164,11 +204,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           </div>
         </div>
         <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="custom-control custom-checkbox custom-checkbox-info">
               <input
@@ -177,7 +213,13 @@ export const LdapGroupSyncSettingsForm: FC = () => {
                 name="preserveDeletedLdapGroups"
                 id="preserveDeletedLdapGroups"
                 checked={formValues.preserveDeletedLdapGroups}
-                onChange={() => setFormValues({ ...formValues, preserveDeletedLdapGroups: !formValues.preserveDeletedLdapGroups })}
+                onChange={() =>
+                  setFormValues({
+                    ...formValues,
+                    preserveDeletedLdapGroups:
+                      !formValues.preserveDeletedLdapGroups,
+                  })
+                }
               />
               <label
                 className="custom-control-label"
@@ -189,10 +231,17 @@ export const LdapGroupSyncSettingsForm: FC = () => {
           </div>
         </div>
         <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 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">
             <input
               className="form-control"
@@ -200,18 +249,24 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupNameAttribute"
               id="ldapGroupNameAttribute"
               value={formValues.ldapGroupNameAttribute}
-              onChange={e => setFormValues({ ...formValues, ldapGroupNameAttribute: e.target.value })}
+              onChange={(e) =>
+                setFormValues({
+                  ...formValues,
+                  ldapGroupNameAttribute: e.target.value,
+                })
+              }
               placeholder="Default: cn"
             />
             <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>
           </div>
         </div>
         <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')}
           </label>
           <div className="col-md-9">
@@ -221,7 +276,12 @@ export const LdapGroupSyncSettingsForm: FC = () => {
               name="ldapGroupDescriptionAttribute"
               id="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">
               <small>
@@ -233,10 +293,7 @@ export const LdapGroupSyncSettingsForm: FC = () => {
 
         <div className="row my-3">
           <div className="offset-3 col-5">
-            <button
-              type="submit"
-              className="btn btn-primary"
-            >
+            <button type="submit" className="btn btn-primary">
               {t('Update')}
             </button>
           </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 { 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 { apiv3Get } from '~/client/util/apiv3-client';
@@ -14,10 +14,10 @@ import { useAdminSocket } from '~/stores/socket-io';
 import { useSWRxExternalUserGroupList } from '../../stores/external-user-group';
 
 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 {
   beforeSync,
@@ -34,14 +34,17 @@ export const SyncExecution = ({
   const { t } = useTranslation('admin');
   const { data: socket } = useAdminSocket();
   const { mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
-  const [syncStatus, setSyncStatus] = useState<SyncStatus>(SyncStatus.beforeSync);
+  const [syncStatus, setSyncStatus] = useState<SyncStatus>(
+    SyncStatus.beforeSync,
+  );
   const [progress, setProgress] = useState({
     total: 0,
     current: 0,
   });
   const [isAlertModalOpen, setIsAlertModalOpen] = useState(false);
   // 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(() => {
     if (socket == null) return;
@@ -77,8 +80,10 @@ export const SyncExecution = ({
 
   // get sync status on load, since next socket data may take a while
   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) {
         setSyncStatus(SyncStatus.syncExecuting);
         setProgress({ total: res.data.totalCount, current: res.data.count });
@@ -93,15 +98,14 @@ export const SyncExecution = ({
     setIsAlertModalOpen(true);
   };
 
-  const onSyncExecConfirmBtnClick = useCallback(async() => {
+  const onSyncExecConfirmBtnClick = useCallback(async () => {
     setIsAlertModalOpen(false);
     try {
       // set sync status before requesting to API, so that setting to syncFailed does not get overwritten
       setSyncStatus(SyncStatus.syncExecuting);
       setProgress({ total: 0, current: 0 });
       await requestSyncAPI(currentSubmitEvent);
-    }
-    catch (errs) {
+    } catch (errs) {
       setSyncStatus(SyncStatus.syncFailed);
       toastError(t(errs[0]?.code));
     }
@@ -110,14 +114,12 @@ export const SyncExecution = ({
   const renderProgressBar = () => {
     if (syncStatus === SyncStatus.beforeSync) return null;
 
-    let header;
+    let header: string;
     if (syncStatus === SyncStatus.syncExecuting) {
       header = 'Processing..';
-    }
-    else if (syncStatus === SyncStatus.syncCompleted) {
+    } else if (syncStatus === SyncStatus.syncCompleted) {
       header = 'Completed';
-    }
-    else {
+    } else {
       header = 'Failed';
     }
 
@@ -132,18 +134,22 @@ export const SyncExecution = ({
 
   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="col-md-3"></div>
-        <div className="col-md-9">
-          {renderProgressBar()}
-        </div>
+        <div className="col-md-9">{renderProgressBar()}</div>
       </div>
       <form onSubmit={onSyncBtnClick}>
         <AdditionalForm />
         <div className="row">
           <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>
       </form>
 
@@ -151,9 +157,17 @@ export const SyncExecution = ({
         isOpen={isAlertModalOpen}
         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>
         <ModalBody>
           <ul>
@@ -161,7 +175,13 @@ export const SyncExecution = ({
             <li>{t('external_user_group.parallel_sync_forbidden')}</li>
           </ul>
           <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>
         </ModalBody>
       </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 type {
-  IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
+  IExternalUserGroupHasId,
+  IExternalUserGroupRelationHasId,
+  KeycloakGroupSyncSettings,
+  LdapGroupSyncSettings,
 } 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;
     }),
   );
 };
 
-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;
     }),
   );
 };
 
-export const useSWRxExternalUserGroup = (groupId: string | null): SWRResponse<IExternalUserGroupHasId, Error> => {
+export const useSWRxExternalUserGroup = (
+  groupId: string | null,
+): SWRResponse<IExternalUserGroupHasId, Error> => {
   return useSWRImmutable(
     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(
     '/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,
     },
@@ -45,21 +63,30 @@ export const useSWRxExternalUserGroupList = (initialData?: IExternalUserGroupHas
 };
 
 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 = (
-    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 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}`, {
       description: childGroupData.description,
     });
@@ -69,30 +96,46 @@ export const useSWRxChildExternalUserGroupList = (
   return withUtils(swrResponse, { updateChild });
 };
 
-export const useSWRxExternalUserGroupRelations = (groupId: string | null): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
+export const useSWRxExternalUserGroupRelations = (
+  groupId: string | null,
+): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
   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 = (
-    groupIds: string[] | null, childGroupIds?: string[], initialData?: IExternalUserGroupRelationHasId[],
+  groupIds: string[] | null,
+  childGroupIds?: string[],
+  initialData?: IExternalUserGroupRelationHasId[],
 ): SWRResponse<IExternalUserGroupRelationHasId[], Error> => {
   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,
     },
   );
 };
 
-export const useSWRxAncestorExternalUserGroups = (groupId: string | null): SWRResponse<IExternalUserGroupHasId[], Error> => {
+export const useSWRxAncestorExternalUserGroups = (
+  groupId: string | null,
+): SWRResponse<IExternalUserGroupHasId[], Error> => {
   return useSWRImmutable(
     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 {
-  HasObjectId, IUserGroupRelation, Ref, IUserGroup,
+  HasObjectId,
+  IUserGroup,
+  IUserGroupRelation,
+  Ref,
 } 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'> {
-  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 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 {
-  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 {
-  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 = {
-  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
 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
 // 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);
 
 describe('ExternalUserGroupRelation model', () => {
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let user1;
   const userId1 = new mongoose.Types.ObjectId();
 
+  // biome-ignore lint/suspicious/noImplicitAnyLet: ignore
   let user2;
   const userId2 = new mongoose.Types.ObjectId();
 
@@ -25,51 +30,75 @@ describe('ExternalUserGroupRelation model', () => {
   const groupId2 = new mongoose.Types.ObjectId();
   const groupId3 = new mongoose.Types.ObjectId();
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     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({
-      _id: userId2, name: 'user2', username: 'user2', email: 'user2@example.com',
+      _id: userId2,
+      name: 'user2',
+      username: 'user2',
+      email: 'user2@example.com',
     });
 
     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();
   });
 
   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 idCombinations = relations.map((relation) => {
         return [relation.relatedGroup, relation.relatedUser];
       });
-      expect(idCombinations).toStrictEqual([[groupId1, userId1], [groupId2, userId1]]);
+      expect(idCombinations).toStrictEqual([
+        [groupId1, userId1],
+        [groupId2, userId1],
+      ]);
     });
   });
 
   describe('removeAllInvalidRelations', () => {
-    beforeAll(async() => {
+    beforeAll(async () => {
       const nonExistentGroupId1 = 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();
       expect(relationsBeforeRemoval.length).not.toBe(0);
 
@@ -81,45 +110,72 @@ describe('ExternalUserGroupRelation model', () => {
   });
 
   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()]);
     });
   });
 
   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]);
 
-      const groupIds2 = await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user2);
+      const groupIds2 =
+        await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(user2);
       expect(groupIds2).toStrictEqual([groupId3]);
     });
   });
 
   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]);
 
-      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]);
     });
   });

+ 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 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';
 
-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;
 
@@ -42,16 +66,24 @@ schema.statics.removeAllByUserGroups = UserGroupRelation.removeAllByUserGroups;
 
 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;
 
-export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);
+export default getOrCreateModel<
+  ExternalUserGroupRelationDocument,
+  ExternalUserGroupRelationModel
+>('ExternalUserGroupRelation', schema);

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff