Browse Source

Merge branch 'master' into support/use-jotai

Yuki Takei 7 months ago
parent
commit
6f62cc4790
80 changed files with 2937 additions and 1475 deletions
  1. 1 1
      .devcontainer/compose.yml
  2. 2 2
      .github/mergify.yml
  3. 5 3
      .github/workflows/ci-app.yml
  4. 11 6
      .github/workflows/reusable-app-prod.yml
  5. 3 3
      CLAUDE.md
  6. 1 1
      README.md
  7. 1 1
      README_JP.md
  8. 5 0
      apps/app/.eslintrc.js
  9. 1 1
      apps/app/package.json
  10. 4 0
      apps/app/public/static/locales/en_US/translation.json
  11. 5 1
      apps/app/public/static/locales/fr_FR/translation.json
  12. 4 0
      apps/app/public/static/locales/ja_JP/translation.json
  13. 4 0
      apps/app/public/static/locales/ko_KR/translation.json
  14. 4 0
      apps/app/public/static/locales/zh_CN/translation.json
  15. 22 4
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  16. 117 78
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  17. 5 3
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  18. 109 45
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  19. 28 18
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  20. 104 47
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  21. 46 26
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  22. 76 33
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  23. 50 38
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  24. 95 39
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  25. 54 23
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  26. 58 22
      apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts
  27. 54 24
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  28. 29 17
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  29. 399 164
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  30. 148 62
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  31. 58 59
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  32. 91 39
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  33. 110 44
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  34. 42 44
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  35. 10 9
      apps/app/src/features/mermaid/services/mermaid.ts
  36. 51 17
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx
  37. 72 0
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx
  38. 20 16
      apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts
  39. 23 21
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts
  40. 23 20
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts
  41. 49 35
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts
  42. 30 17
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts
  43. 39 29
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts
  44. 12 9
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts
  45. 15 16
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts
  46. 10 5
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts
  47. 28 25
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts
  48. 15 8
      apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts
  49. 32 17
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts
  50. 27 14
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts
  51. 1 1
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  52. 48 22
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts
  53. 26 12
      apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts
  54. 10 6
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts
  55. 12 6
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts
  56. 1 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/index.ts
  57. 1 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts
  58. 3 1
      apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts
  59. 11 13
      apps/app/src/features/opentelemetry/server/logger.ts
  60. 44 30
      apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts
  61. 1 2
      apps/app/src/features/opentelemetry/server/node-sdk-resource.ts
  62. 68 50
      apps/app/src/features/opentelemetry/server/node-sdk.spec.ts
  63. 52 22
      apps/app/src/features/opentelemetry/server/node-sdk.ts
  64. 6 4
      apps/app/src/features/plantuml/services/plantuml.ts
  65. 31 21
      apps/app/src/features/search/client/components/SearchForm.tsx
  66. 72 20
      apps/app/src/features/search/client/components/SearchHelp.tsx
  67. 15 20
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  68. 29 21
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  69. 29 17
      apps/app/src/features/search/client/components/SearchModal.tsx
  70. 40 30
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  71. 4 2
      apps/app/src/features/search/client/interfaces/downshift.ts
  72. 24 12
      apps/app/src/features/search/client/stores/search.ts
  73. 1 1
      apps/app/src/server/routes/apiv3/attachment.js
  74. 42 22
      apps/app/src/server/routes/apiv3/export.js
  75. 1 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  76. 7 4
      bin/data-migrations/README.md
  77. 0 5
      biome.json
  78. 2 0
      packages/remark-lsx/src/client/components/Lsx.tsx
  79. 2 2
      packages/remark-lsx/src/client/stores/lsx/lsx.ts
  80. 152 20
      pnpm-lock.yaml

+ 1 - 1
.devcontainer/compose.yml

@@ -15,7 +15,7 @@ services:
     - opentelemetry-collector-dev-setup_default
 
   mongo:
-    image: mongo:6.0
+    image: mongo:8.0
     restart: unless-stopped
     ports:
       - 27017

+ 2 - 2
.github/mergify.yml

@@ -12,8 +12,8 @@ queue_rules:
       - 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 / build-prod
+      - check-success ~= test-prod-node22 / launch-prod
       - check-success ~= test-prod-node22 / run-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-

+ 5 - 3
.github/workflows/ci-app.yml

@@ -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
 

+ 11 - 6
.github/workflows/reusable-app-prod.yml

@@ -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 }}

+ 3 - 3
CLAUDE.md

@@ -65,15 +65,15 @@ GROWI is a team collaboration software using markdown - a wiki platform with hie
 - **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 6.0+ with migration system using migrate-mongo
+- **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 6.0+
-- Optional: Redis 3.x, Elasticsearch 7.x/8.x (for full-text search)
+- MongoDB v6.x or v8.x
+- Optional: Redis 3.x, Elasticsearch 7.x/8.x/9.x (for full-text search)
 
 ## File Organization Patterns
 

+ 1 - 1
README.md

@@ -85,7 +85,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 - npm 10.x
 - pnpm 10.x
 - [Turborepo](https://turbo.build/repo)
-- MongoDB 6.0 or above
+- MongoDB v6.x or v8.x
 
 ### Optional Dependencies
 

+ 1 - 1
README_JP.md

@@ -85,7 +85,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 - npm 10.x
 - pnpm 10.x
 - [Turborepo](https://turbo.build/repo)
-- MongoDB 6.0 以上
+- MongoDB v6.x or v8.x
 
 ### オプションの依存関係
 

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

@@ -33,6 +33,11 @@ module.exports = {
     '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/opentelemetry/**',
     'src/states/**',
   ],
   settings: {

+ 1 - 1
apps/app/package.json

@@ -174,7 +174,7 @@
     "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": "^1.0.7",

+ 4 - 0
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": {

+ 5 - 1
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": {

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

@@ -708,6 +708,10 @@
       "thread_deleted_failed": "スレッドの削除に失敗しました",
       "ai_assistant_set_default_success": "デフォルトアシスタントを設定しました",
       "ai_assistant_set_default_failed": "デフォルトアシスタントの設定に失敗しました"
+      },
+    "delete_modal": {
+      "title": "アシスタントを削除する",
+      "confirm_message": "本当にアシスタントを削除しますか?"
     }
   },
   "link_edit": {

+ 4 - 0
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": {

+ 4 - 0
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": {

+ 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" />

+ 117 - 78
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';
 
@@ -17,34 +16,52 @@ import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { useIsAclEnabled } from '~/states/server-configurations';
 import { useSWRxUserGroupList } from '~/stores/user-group';
 
-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 [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 (err) {
+        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]);
     });
   });

+ 54 - 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,55 @@ 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> {
+  [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 +65,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);

+ 58 - 22
apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts

@@ -5,34 +5,48 @@ import ExternalUserGroup from './external-user-group';
 describe('ExternalUserGroup model', () => {
   describe('findAndUpdateOrCreateGroup', () => {
     const groupId = new mongoose.Types.ObjectId();
-    beforeAll(async() => {
+    beforeAll(async () => {
       await ExternalUserGroup.create({
-        _id: groupId, name: 'test group', externalId: 'testExternalId', provider: 'testProvider',
+        _id: groupId,
+        name: 'test group',
+        externalId: 'testExternalId',
+        provider: 'testProvider',
       });
     });
 
-    it('finds and updates existing group', async() => {
-      const group = await ExternalUserGroup.findAndUpdateOrCreateGroup('edited test group', 'testExternalId', 'testProvider');
+    it('finds and updates existing group', async () => {
+      const group = await ExternalUserGroup.findAndUpdateOrCreateGroup(
+        'edited test group',
+        'testExternalId',
+        'testProvider',
+      );
       expect(group.id).toBe(groupId.toString());
       expect(group.name).toBe('edited test group');
     });
 
-    it('creates new group with parent', async() => {
+    it('creates new group with parent', async () => {
       expect(await ExternalUserGroup.count()).toBe(1);
       const newGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
-        'new group', 'nonExistentExternalId', 'testProvider', undefined, groupId.toString(),
+        'new group',
+        'nonExistentExternalId',
+        'testProvider',
+        undefined,
+        groupId.toString(),
       );
       expect(await ExternalUserGroup.count()).toBe(2);
       expect(newGroup.parent.toString()).toBe(groupId.toString());
     });
 
-    it('throws error when parent does not exist', async() => {
+    it('throws error when parent does not exist', async () => {
       try {
         await ExternalUserGroup.findAndUpdateOrCreateGroup(
-          'new group', 'nonExistentExternalId', 'testProvider', undefined, new mongoose.Types.ObjectId(),
+          'new group',
+          'nonExistentExternalId',
+          'testProvider',
+          undefined,
+          new mongoose.Types.ObjectId(),
         );
-      }
-      catch (e) {
+      } catch (e) {
         expect(e.message).toBe('Parent does not exist.');
       }
     });
@@ -43,31 +57,53 @@ describe('ExternalUserGroup model', () => {
     const parentGroupId = new mongoose.Types.ObjectId();
     const grandParentGroupId = new mongoose.Types.ObjectId();
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       await ExternalUserGroup.deleteMany();
       await ExternalUserGroup.create({
-        _id: grandParentGroupId, name: 'grand parent group', externalId: 'grandParentExternalId', provider: 'testProvider',
+        _id: grandParentGroupId,
+        name: 'grand parent group',
+        externalId: 'grandParentExternalId',
+        provider: 'testProvider',
       });
       await ExternalUserGroup.create({
-        _id: parentGroupId, name: 'parent group', externalId: 'parentExternalId', provider: 'testProvider', parent: grandParentGroupId,
+        _id: parentGroupId,
+        name: 'parent group',
+        externalId: 'parentExternalId',
+        provider: 'testProvider',
+        parent: grandParentGroupId,
       });
       await ExternalUserGroup.create({
-        _id: childGroupId, name: 'child group', externalId: 'childExternalId', provider: 'testProvider', parent: parentGroupId,
+        _id: childGroupId,
+        name: 'child group',
+        externalId: 'childExternalId',
+        provider: 'testProvider',
+        parent: parentGroupId,
       });
     });
 
-    it('finds ancestors for child', async() => {
+    it('finds ancestors for child', async () => {
       const childGroup = await ExternalUserGroup.findById(childGroupId);
-      const groups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(childGroup);
-      const groupIds = groups.map(group => group.id);
-      expect(groupIds).toStrictEqual([grandParentGroupId.toString(), parentGroupId.toString(), childGroupId.toString()]);
+      const groups =
+        await ExternalUserGroup.findGroupsWithAncestorsRecursively(childGroup);
+      const groupIds = groups.map((group) => group.id);
+      expect(groupIds).toStrictEqual([
+        grandParentGroupId.toString(),
+        parentGroupId.toString(),
+        childGroupId.toString(),
+      ]);
     });
 
-    it('finds ancestors for child, excluding child', async() => {
+    it('finds ancestors for child, excluding child', async () => {
       const childGroup = await ExternalUserGroup.findById(childGroupId);
-      const groups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(childGroup, []);
-      const groupIds = groups.map(group => group.id);
-      expect(groupIds).toStrictEqual([grandParentGroupId.toString(), parentGroupId.toString()]);
+      const groups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(
+        childGroup,
+        [],
+      );
+      const groupIds = groups.map((group) => group.id);
+      expect(groupIds).toStrictEqual([
+        grandParentGroupId.toString(),
+        parentGroupId.toString(),
+      ]);
     });
   });
 });

+ 54 - 24
apps/app/src/features/external-user-group/server/models/external-user-group.ts

@@ -1,4 +1,4 @@
-import type { Model, Document } from 'mongoose';
+import type { Document, Model } from 'mongoose';
 import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
@@ -6,27 +6,38 @@ import type { IExternalUserGroup } from '~/features/external-user-group/interfac
 import UserGroup from '~/server/models/user-group';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-export interface ExternalUserGroupDocument extends IExternalUserGroup, Document {}
+export interface ExternalUserGroupDocument
+  extends IExternalUserGroup,
+    Document {}
 
-export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument> {
-  [x:string]: any, // for old methods
+export interface ExternalUserGroupModel
+  extends Model<ExternalUserGroupDocument> {
+  [x: string]: any; // for old methods
 
-  PAGE_ITEMS: 10,
+  PAGE_ITEMS: 10;
 
   findGroupsWithDescendantsRecursively: (
-    groups: ExternalUserGroupDocument[], descendants?: ExternalUserGroupDocument[]
-  ) => Promise<ExternalUserGroupDocument[]>,
+    groups: ExternalUserGroupDocument[],
+    descendants?: ExternalUserGroupDocument[],
+  ) => Promise<ExternalUserGroupDocument[]>;
 }
 
-const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
-  name: { type: String, required: true },
-  parent: { type: Schema.Types.ObjectId, ref: 'ExternalUserGroup', index: true },
-  description: { type: String, default: '' },
-  externalId: { type: String, required: true, unique: true },
-  provider: { type: String, required: true },
-}, {
-  timestamps: true,
-});
+const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>(
+  {
+    name: { type: String, required: true },
+    parent: {
+      type: Schema.Types.ObjectId,
+      ref: 'ExternalUserGroup',
+      index: true,
+    },
+    description: { type: String, default: '' },
+    externalId: { type: String, required: true, unique: true },
+    provider: { type: String, required: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 schema.plugin(mongoosePaginate);
 // group name should be unique for each provider
 schema.index({ name: 1, provider: 1 }, { unique: true });
@@ -40,7 +51,13 @@ schema.index({ name: 1, provider: 1 }, { unique: true });
  * @param name ExternalUserGroup parentId
  * @returns ExternalUserGroupDocument[]
  */
-schema.statics.findAndUpdateOrCreateGroup = async function(name: string, externalId: string, provider: string, description?: string, parentId?: string) {
+schema.statics.findAndUpdateOrCreateGroup = async function (
+  name: string,
+  externalId: string,
+  provider: string,
+  description?: string,
+  parentId?: string,
+) {
   let parent: ExternalUserGroupDocument | null = null;
   if (parentId != null) {
     parent = await this.findOne({ _id: parentId });
@@ -49,19 +66,32 @@ schema.statics.findAndUpdateOrCreateGroup = async function(name: string, externa
     }
   }
 
-  return this.findOneAndUpdate({ externalId }, {
-    name, description, provider, parent,
-  }, { upsert: true, new: true });
+  return this.findOneAndUpdate(
+    { externalId },
+    {
+      name,
+      description,
+      provider,
+      parent,
+    },
+    { upsert: true, new: true },
+  );
 };
 
 schema.statics.findWithPagination = UserGroup.findWithPagination;
 
 schema.statics.findChildrenByParentIds = UserGroup.findChildrenByParentIds;
 
-schema.statics.findGroupsWithAncestorsRecursively = UserGroup.findGroupsWithAncestorsRecursively;
+schema.statics.findGroupsWithAncestorsRecursively =
+  UserGroup.findGroupsWithAncestorsRecursively;
 
-schema.statics.findGroupsWithDescendantsRecursively = UserGroup.findGroupsWithDescendantsRecursively;
+schema.statics.findGroupsWithDescendantsRecursively =
+  UserGroup.findGroupsWithDescendantsRecursively;
 
-schema.statics.findGroupsWithDescendantsById = UserGroup.findGroupsWithDescendantsById;
+schema.statics.findGroupsWithDescendantsById =
+  UserGroup.findGroupsWithDescendantsById;
 
-export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);
+export default getOrCreateModel<
+  ExternalUserGroupDocument,
+  ExternalUserGroupModel
+>('ExternalUserGroup', schema);

+ 29 - 17
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -1,26 +1,25 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
-import type { Router, Request } from 'express';
-
+import type { Request, Router } from 'express';
 import type { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { query } = require('express-validator');
 
-
 const router = express.Router();
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const validators = {
@@ -72,33 +71,46 @@ module.exports = (crowi: Crowi): Router => {
    *                   items:
    *                     type: object
    */
-  router.get('/',
+  router.get(
+    '/',
     accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
     loginRequiredStrictly,
     adminRequired,
     validators.list,
-    async(req: Request, res: ApiV3Response) => {
+    async (req: Request, res: ApiV3Response) => {
       const { query } = req;
 
       try {
-        const relations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+        const relations = await ExternalUserGroupRelation.find({
+          relatedGroup: { $in: query.groupIds },
+        }).populate('relatedUser');
 
-        let relationsOfChildGroups: IExternalUserGroupRelationHasId[] | null = null;
+        let relationsOfChildGroups: IExternalUserGroupRelationHasId[] | null =
+          null;
         if (Array.isArray(query.childGroupIds)) {
-          const _relationsOfChildGroups = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.childGroupIds } }).populate('relatedUser');
-          relationsOfChildGroups = _relationsOfChildGroups.map(relation => serializeUserGroupRelationSecurely(relation)); // serialize
+          const _relationsOfChildGroups = await ExternalUserGroupRelation.find({
+            relatedGroup: { $in: query.childGroupIds },
+          }).populate('relatedUser');
+          relationsOfChildGroups = _relationsOfChildGroups.map((relation) =>
+            serializeUserGroupRelationSecurely(relation),
+          ); // serialize
         }
 
-        const serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+        const serialized = relations.map((relation) =>
+          serializeUserGroupRelationSecurely(relation),
+        );
 
-        return res.apiv3({ userGroupRelations: serialized, relationsOfChildGroups });
-      }
-      catch (err) {
+        return res.apiv3({
+          userGroupRelations: serialized,
+          relationsOfChildGroups,
+        });
+      } catch (err) {
         const msg = 'Error occurred in fetching user group relations';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   return router;
 };

+ 399 - 164
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -3,9 +3,7 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request } from 'express';
 import { Router } from 'express';
-import {
-  body, param, query, validationResult,
-} from 'express-validator';
+import { body, param, query, validationResult } from 'express-validator';
 
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
@@ -26,7 +24,7 @@ const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 const router = Router();
 
 interface AuthorizedRequest extends Request {
-  user?: any
+  user?: any;
 }
 
 /**
@@ -45,55 +43,69 @@ interface AuthorizedRequest extends Request {
  *            type: number
  */
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.event('activity');
 
   const isExecutingSync = () => {
-    return crowi.ldapUserGroupSyncService?.syncStatus?.isExecutingSync || crowi.keycloakUserGroupSyncService?.syncStatus?.isExecutingSync || false;
+    return (
+      crowi.ldapUserGroupSyncService?.syncStatus?.isExecutingSync ||
+      crowi.keycloakUserGroupSyncService?.syncStatus?.isExecutingSync ||
+      false
+    );
   };
 
   const validators = {
     ldapSyncSettings: [
       body('ldapGroupSearchBase').optional({ nullable: true }).isString(),
-      body('ldapGroupMembershipAttribute').exists({ checkFalsy: true }).isString(),
-      body('ldapGroupMembershipAttributeType').exists({ checkFalsy: true }).isString(),
-      body('ldapGroupChildGroupAttribute').exists({ checkFalsy: true }).isString(),
+      body('ldapGroupMembershipAttribute')
+        .exists({ checkFalsy: true })
+        .isString(),
+      body('ldapGroupMembershipAttributeType')
+        .exists({ checkFalsy: true })
+        .isString(),
+      body('ldapGroupChildGroupAttribute')
+        .exists({ checkFalsy: true })
+        .isString(),
       body('autoGenerateUserOnLdapGroupSync').exists().isBoolean(),
       body('preserveDeletedLdapGroups').exists().isBoolean(),
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
-      body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+      body('ldapGroupDescriptionAttribute')
+        .optional({ nullable: true })
+        .isString(),
     ],
     keycloakSyncSettings: [
       body('keycloakHost').exists({ checkFalsy: true }).isString(),
       body('keycloakGroupRealm').exists({ checkFalsy: true }).isString(),
-      body('keycloakGroupSyncClientRealm').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientRealm')
+        .exists({ checkFalsy: true })
+        .isString(),
       body('keycloakGroupSyncClientID').exists({ checkFalsy: true }).isString(),
-      body('keycloakGroupSyncClientSecret').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientSecret')
+        .exists({ checkFalsy: true })
+        .isString(),
       body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(),
       body('preserveDeletedKeycloakGroups').exists().isBoolean(),
-      body('keycloakGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+      body('keycloakGroupDescriptionAttribute')
+        .optional({ nullable: true })
+        .isString(),
     ],
     listChildren: [
       query('parentIds').optional().isArray(),
       query('includeGrandChildren').optional().isBoolean(),
     ],
-    ancestorGroup: [
-      query('groupId').isString(),
-    ],
-    update: [
-      body('description').optional().isString(),
-    ],
+    ancestorGroup: [query('groupId').isString()],
+    update: [body('description').optional().isString()],
     delete: [
       param('id').trim().exists({ checkFalsy: true }),
       query('actionName').trim().exists({ checkFalsy: true }),
       query('transferToUserGroupId').trim(),
     ],
-    detail: [
-      param('id').isString(),
-    ],
+    detail: [param('id').isString()],
   };
 
   /**
@@ -143,28 +155,43 @@ module.exports = (crowi: Crowi): Router => {
    *                     pagingLimit:
    *                       type: integer
    */
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const { query } = req;
 
       try {
-        const page = query.page != null ? parseInt(query.page as string) : undefined;
-        const limit = query.limit != null ? parseInt(query.limit as string) : undefined;
-        const offset = query.offset != null ? parseInt(query.offset as string) : undefined;
-        const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
+        const page =
+          query.page != null ? parseInt(query.page as string) : undefined;
+        const limit =
+          query.limit != null ? parseInt(query.limit as string) : undefined;
+        const offset =
+          query.offset != null ? parseInt(query.offset as string) : undefined;
+        const pagination =
+          query.pagination != null ? query.pagination !== 'false' : undefined;
 
         const result = await ExternalUserGroup.findWithPagination({
-          page, limit, offset, pagination,
+          page,
+          limit,
+          offset,
+          pagination,
         });
-        const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
+        const {
+          docs: userGroups,
+          totalDocs: totalUserGroups,
+          limit: pagingLimit,
+        } = result;
         return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in fetching external user group list';
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -195,22 +222,30 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.get('/ancestors', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validators.ancestorGroup, apiV3FormValidator,
-    async(req, res: ApiV3Response) => {
+  router.get(
+    '/ancestors',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validators.ancestorGroup,
+    apiV3FormValidator,
+    async (req, res: ApiV3Response) => {
       const { groupId } = req.query;
 
       try {
-        const userGroup = await ExternalUserGroup.findOne({ _id: { $eq: groupId } });
-        const ancestorUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
+        const userGroup = await ExternalUserGroup.findOne({
+          _id: { $eq: groupId },
+        });
+        const ancestorUserGroups =
+          await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
         return res.apiv3({ ancestorUserGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -251,23 +286,32 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.get('/children', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.listChildren,
-    async(req, res) => {
+  router.get(
+    '/children',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validators.listChildren,
+    async (req, res) => {
       try {
         const { parentIds, includeGrandChildren = false } = req.query;
 
-        const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+        const externalUserGroupsResult =
+          await ExternalUserGroup.findChildrenByParentIds(
+            parentIds,
+            includeGrandChildren,
+          );
         return res.apiv3({
           childUserGroups: externalUserGroupsResult.childUserGroups,
           grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
         });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in fetching child user group list';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -296,20 +340,25 @@ module.exports = (crowi: Crowi): Router => {
    *                     userGroup:
    *                       type: object
    */
-  router.get('/:id', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired, validators.detail,
-    async(req, res: ApiV3Response) => {
+  router.get(
+    '/:id',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validators.detail,
+    async (req, res: ApiV3Response) => {
       const { id } = req.params;
 
       try {
         const userGroup = await ExternalUserGroup.findById(id);
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while getting external user group';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -356,35 +405,54 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.delete('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validators.delete, apiV3FormValidator, addActivity,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.delete(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validators.delete,
+    apiV3FormValidator,
+    addActivity,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const { id: deleteGroupId } = req.params;
       const { transferToUserGroupId, transferToUserGroupType } = req.query;
       const actionName = req.query.actionName as PageActionOnGroupDelete;
 
-      const transferToUserGroup = typeof transferToUserGroupId === 'string'
-        && (transferToUserGroupType === GroupType.userGroup || transferToUserGroupType === GroupType.externalUserGroup)
-        ? {
-          item: transferToUserGroupId,
-          type: transferToUserGroupType,
-        } : undefined;
+      const transferToUserGroup =
+        typeof transferToUserGroupId === 'string' &&
+        (transferToUserGroupType === GroupType.userGroup ||
+          transferToUserGroupType === GroupType.externalUserGroup)
+          ? {
+              item: transferToUserGroupId,
+              type: transferToUserGroupType,
+            }
+          : undefined;
 
       try {
-        const userGroups = await (crowi.userGroupService as UserGroupService)
-          .removeCompletelyByRootGroupId(deleteGroupId, actionName, req.user, transferToUserGroup, ExternalUserGroup, ExternalUserGroupRelation);
+        const userGroups = await (
+          crowi.userGroupService as UserGroupService
+        ).removeCompletelyByRootGroupId(
+          deleteGroupId,
+          actionName,
+          req.user,
+          transferToUserGroup,
+          ExternalUserGroup,
+          ExternalUserGroupRelation,
+        );
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ userGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while deleting user groups';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -422,28 +490,37 @@ module.exports = (crowi: Crowi): Router => {
    *                     userGroup:
    *                       type: object
    */
-  router.put('/:id', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    validators.update, apiV3FormValidator, addActivity,
-    async(req, res: ApiV3Response) => {
+  router.put(
+    '/:id',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    validators.update,
+    apiV3FormValidator,
+    addActivity,
+    async (req, res: ApiV3Response) => {
       const { id } = req.params;
-      const {
-        description,
-      } = req.body;
+      const { description } = req.body;
 
       try {
-        const userGroup = await ExternalUserGroup.findOneAndUpdate({ _id: id }, { $set: { description } });
+        const userGroup = await ExternalUserGroup.findOneAndUpdate(
+          { _id: id },
+          { $set: { description } },
+        );
 
-        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+        const parameters = {
+          action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE,
+        };
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating an external user group';
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -474,23 +551,33 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                         type: object
    */
-  router.get('/:id/external-user-group-relations', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req: Request<{id: string}, Response, undefined>, res: ApiV3Response) => {
+  router.get(
+    '/:id/external-user-group-relations',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (
+      req: Request<{ id: string }, Response, undefined>,
+      res: ApiV3Response,
+    ) => {
       const { id } = req.params;
 
       try {
         const externalUserGroup = await ExternalUserGroup.findById(id);
-        const userGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: externalUserGroup })
-          .populate('relatedUser');
-        const serialized = userGroupRelations.map(relation => serializeUserGroupRelationSecurely(relation));
+        const userGroupRelations = await ExternalUserGroupRelation.find({
+          relatedGroup: externalUserGroup,
+        }).populate('relatedUser');
+        const serialized = userGroupRelations.map((relation) =>
+          serializeUserGroupRelationSecurely(relation),
+        );
         return res.apiv3({ userGroupRelations: serialized });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching user group relations for external user group: ${id}`;
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -526,21 +613,42 @@ module.exports = (crowi: Crowi): Router => {
    *                     ldapGroupDescriptionAttribute:
    *                       type: string
    */
-  router.get('/ldap/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/ldap/sync-settings',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const settings = {
-        ldapGroupSearchBase: configManager.getConfig('external-user-group:ldap:groupSearchBase'),
-        ldapGroupMembershipAttribute: configManager.getConfig('external-user-group:ldap:groupMembershipAttribute'),
-        ldapGroupMembershipAttributeType: configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType'),
-        ldapGroupChildGroupAttribute: configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute'),
-        autoGenerateUserOnLdapGroupSync: configManager.getConfig('external-user-group:ldap:autoGenerateUserOnGroupSync'),
-        preserveDeletedLdapGroups: configManager.getConfig('external-user-group:ldap:preserveDeletedGroups'),
-        ldapGroupNameAttribute: configManager.getConfig('external-user-group:ldap:groupNameAttribute'),
-        ldapGroupDescriptionAttribute: configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute'),
+        ldapGroupSearchBase: configManager.getConfig(
+          'external-user-group:ldap:groupSearchBase',
+        ),
+        ldapGroupMembershipAttribute: configManager.getConfig(
+          'external-user-group:ldap:groupMembershipAttribute',
+        ),
+        ldapGroupMembershipAttributeType: configManager.getConfig(
+          'external-user-group:ldap:groupMembershipAttributeType',
+        ),
+        ldapGroupChildGroupAttribute: configManager.getConfig(
+          'external-user-group:ldap:groupChildGroupAttribute',
+        ),
+        autoGenerateUserOnLdapGroupSync: configManager.getConfig(
+          'external-user-group:ldap:autoGenerateUserOnGroupSync',
+        ),
+        preserveDeletedLdapGroups: configManager.getConfig(
+          'external-user-group:ldap:preserveDeletedGroups',
+        ),
+        ldapGroupNameAttribute: configManager.getConfig(
+          'external-user-group:ldap:groupNameAttribute',
+        ),
+        ldapGroupDescriptionAttribute: configManager.getConfig(
+          'external-user-group:ldap:groupDescriptionAttribute',
+        ),
       };
 
       return res.apiv3(settings);
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -576,21 +684,42 @@ module.exports = (crowi: Crowi): Router => {
    *                     keycloakGroupDescriptionAttribute:
    *                       type: string
    */
-  router.get('/keycloak/sync-settings', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/keycloak/sync-settings',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const settings = {
-        keycloakHost: configManager.getConfig('external-user-group:keycloak:host'),
-        keycloakGroupRealm: configManager.getConfig('external-user-group:keycloak:groupRealm'),
-        keycloakGroupSyncClientRealm: configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm'),
-        keycloakGroupSyncClientID: configManager.getConfig('external-user-group:keycloak:groupSyncClientID'),
-        keycloakGroupSyncClientSecret: configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret'),
-        autoGenerateUserOnKeycloakGroupSync: configManager.getConfig('external-user-group:keycloak:autoGenerateUserOnGroupSync'),
-        preserveDeletedKeycloakGroups: configManager.getConfig('external-user-group:keycloak:preserveDeletedGroups'),
-        keycloakGroupDescriptionAttribute: configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute'),
+        keycloakHost: configManager.getConfig(
+          'external-user-group:keycloak:host',
+        ),
+        keycloakGroupRealm: configManager.getConfig(
+          'external-user-group:keycloak:groupRealm',
+        ),
+        keycloakGroupSyncClientRealm: configManager.getConfig(
+          'external-user-group:keycloak:groupSyncClientRealm',
+        ),
+        keycloakGroupSyncClientID: configManager.getConfig(
+          'external-user-group:keycloak:groupSyncClientID',
+        ),
+        keycloakGroupSyncClientSecret: configManager.getConfig(
+          'external-user-group:keycloak:groupSyncClientSecret',
+        ),
+        autoGenerateUserOnKeycloakGroupSync: configManager.getConfig(
+          'external-user-group:keycloak:autoGenerateUserOnGroupSync',
+        ),
+        preserveDeletedKeycloakGroups: configManager.getConfig(
+          'external-user-group:keycloak:preserveDeletedGroups',
+        ),
+        keycloakGroupDescriptionAttribute: configManager.getConfig(
+          'external-user-group:keycloak:groupDescriptionAttribute',
+        ),
       };
 
       return res.apiv3(settings);
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -632,43 +761,66 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   type: object
    */
-  router.put('/ldap/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.put(
+    '/ldap/sync-settings',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validators.ldapSyncSettings,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
         return res.apiv3Err(
-          new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+          new ErrorV3(
+            'Invalid sync settings',
+            'external_user_group.invalid_sync_settings',
+          ),
+          400,
         );
       }
 
       const params = {
-        'external-user-group:ldap:groupSearchBase': req.body.ldapGroupSearchBase,
-        'external-user-group:ldap:groupMembershipAttribute': req.body.ldapGroupMembershipAttribute,
-        'external-user-group:ldap:groupMembershipAttributeType': req.body.ldapGroupMembershipAttributeType,
-        'external-user-group:ldap:groupChildGroupAttribute': req.body.ldapGroupChildGroupAttribute,
-        'external-user-group:ldap:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnLdapGroupSync,
-        'external-user-group:ldap:preserveDeletedGroups': req.body.preserveDeletedLdapGroups,
-        'external-user-group:ldap:groupNameAttribute': req.body.ldapGroupNameAttribute,
-        'external-user-group:ldap:groupDescriptionAttribute': req.body.ldapGroupDescriptionAttribute,
+        'external-user-group:ldap:groupSearchBase':
+          req.body.ldapGroupSearchBase,
+        'external-user-group:ldap:groupMembershipAttribute':
+          req.body.ldapGroupMembershipAttribute,
+        'external-user-group:ldap:groupMembershipAttributeType':
+          req.body.ldapGroupMembershipAttributeType,
+        'external-user-group:ldap:groupChildGroupAttribute':
+          req.body.ldapGroupChildGroupAttribute,
+        'external-user-group:ldap:autoGenerateUserOnGroupSync':
+          req.body.autoGenerateUserOnLdapGroupSync,
+        'external-user-group:ldap:preserveDeletedGroups':
+          req.body.preserveDeletedLdapGroups,
+        'external-user-group:ldap:groupNameAttribute':
+          req.body.ldapGroupNameAttribute,
+        'external-user-group:ldap:groupDescriptionAttribute':
+          req.body.ldapGroupDescriptionAttribute,
       };
 
-      if (params['external-user-group:ldap:groupNameAttribute'] == null || params['external-user-group:ldap:groupNameAttribute'] === '') {
-      // default is cn
+      if (
+        params['external-user-group:ldap:groupNameAttribute'] == null ||
+        params['external-user-group:ldap:groupNameAttribute'] === ''
+      ) {
+        // default is cn
         params['external-user-group:ldap:groupNameAttribute'] = 'cn';
       }
 
       try {
         await configManager.updateConfigs(params, { skipPubsub: true });
         return res.apiv3({}, 204);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(
-          new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+          new ErrorV3(
+            'Sync settings update failed',
+            'external_user_group.update_sync_settings_failed',
+          ),
+          500,
         );
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -710,38 +862,56 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   type: object
    */
-  router.put('/keycloak/sync-settings', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.put(
+    '/keycloak/sync-settings',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     validators.keycloakSyncSettings,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
         return res.apiv3Err(
-          new ErrorV3('Invalid sync settings', 'external_user_group.invalid_sync_settings'), 400,
+          new ErrorV3(
+            'Invalid sync settings',
+            'external_user_group.invalid_sync_settings',
+          ),
+          400,
         );
       }
 
       const params = {
         'external-user-group:keycloak:host': req.body.keycloakHost,
         'external-user-group:keycloak:groupRealm': req.body.keycloakGroupRealm,
-        'external-user-group:keycloak:groupSyncClientRealm': req.body.keycloakGroupSyncClientRealm,
-        'external-user-group:keycloak:groupSyncClientID': req.body.keycloakGroupSyncClientID,
-        'external-user-group:keycloak:groupSyncClientSecret': req.body.keycloakGroupSyncClientSecret,
-        'external-user-group:keycloak:autoGenerateUserOnGroupSync': req.body.autoGenerateUserOnKeycloakGroupSync,
-        'external-user-group:keycloak:preserveDeletedGroups': req.body.preserveDeletedKeycloakGroups,
-        'external-user-group:keycloak:groupDescriptionAttribute': req.body.keycloakGroupDescriptionAttribute,
+        'external-user-group:keycloak:groupSyncClientRealm':
+          req.body.keycloakGroupSyncClientRealm,
+        'external-user-group:keycloak:groupSyncClientID':
+          req.body.keycloakGroupSyncClientID,
+        'external-user-group:keycloak:groupSyncClientSecret':
+          req.body.keycloakGroupSyncClientSecret,
+        'external-user-group:keycloak:autoGenerateUserOnGroupSync':
+          req.body.autoGenerateUserOnKeycloakGroupSync,
+        'external-user-group:keycloak:preserveDeletedGroups':
+          req.body.preserveDeletedKeycloakGroups,
+        'external-user-group:keycloak:groupDescriptionAttribute':
+          req.body.keycloakGroupDescriptionAttribute,
       };
 
       try {
         await configManager.updateConfigs(params, { skipPubsub: true });
         return res.apiv3({}, 204);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         return res.apiv3Err(
-          new ErrorV3('Sync settings update failed', 'external_user_group.update_sync_settings_failed'), 500,
+          new ErrorV3(
+            'Sync settings update failed',
+            'external_user_group.update_sync_settings_failed',
+          ),
+          500,
         );
       }
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -760,27 +930,47 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   type: object
    */
-  router.put('/ldap/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.put(
+    '/ldap/sync',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       if (isExecutingSync()) {
         return res.apiv3Err(
-          new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+          new ErrorV3(
+            'There is an ongoing sync process',
+            'external_user_group.sync_being_executed',
+          ),
+          409,
         );
       }
 
-      const isLdapEnabled = await configManager.getConfig('security:passport-ldap:isEnabled');
+      const isLdapEnabled = await configManager.getConfig(
+        'security:passport-ldap:isEnabled',
+      );
       if (!isLdapEnabled) {
         return res.apiv3Err(
-          new ErrorV3('Authentication using ldap is not set', 'external_user_group.ldap.auth_not_set'), 422,
+          new ErrorV3(
+            'Authentication using ldap is not set',
+            'external_user_group.ldap.auth_not_set',
+          ),
+          422,
         );
       }
 
       try {
-        await crowi.ldapUserGroupSyncService?.init(req.user.name, req.body.password);
-      }
-      catch (e) {
+        await crowi.ldapUserGroupSyncService?.init(
+          req.user.name,
+          req.body.password,
+        );
+      } catch (e) {
         return res.apiv3Err(
-          new ErrorV3('LDAP group sync failed', 'external_user_group.sync_failed'), 500,
+          new ErrorV3(
+            'LDAP group sync failed',
+            'external_user_group.sync_failed',
+          ),
+          500,
         );
       }
 
@@ -788,7 +978,8 @@ module.exports = (crowi: Crowi): Router => {
       crowi.ldapUserGroupSyncService?.syncExternalUserGroups();
 
       return res.apiv3({}, 202);
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -807,34 +998,64 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   type: object
    */
-  router.put('/keycloak/sync', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+  router.put(
+    '/keycloak/sync',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       if (isExecutingSync()) {
         return res.apiv3Err(
-          new ErrorV3('There is an ongoing sync process', 'external_user_group.sync_being_executed'), 409,
+          new ErrorV3(
+            'There is an ongoing sync process',
+            'external_user_group.sync_being_executed',
+          ),
+          409,
         );
       }
 
       const getAuthProviderType = () => {
-        let kcHost = configManager.getConfig('external-user-group:keycloak:host');
+        let kcHost = configManager.getConfig(
+          'external-user-group:keycloak:host',
+        );
         if (kcHost?.endsWith('/')) {
           kcHost = kcHost.slice(0, -1);
         }
-        const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
+        const kcGroupRealm = configManager.getConfig(
+          'external-user-group:keycloak:groupRealm',
+        );
 
         // starts with kcHost, contains kcGroupRealm in path
         // see: https://regex101.com/r/3ihDmf/1
         const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
 
-        const isOidcEnabled = configManager.getConfig('security:passport-oidc:isEnabled');
-        const oidcIssuerHost = configManager.getConfig('security:passport-oidc:issuerHost');
+        const isOidcEnabled = configManager.getConfig(
+          'security:passport-oidc:isEnabled',
+        );
+        const oidcIssuerHost = configManager.getConfig(
+          'security:passport-oidc:issuerHost',
+        );
 
-        if (isOidcEnabled && oidcIssuerHost != null && regex.test(oidcIssuerHost)) return 'oidc';
+        if (
+          isOidcEnabled &&
+          oidcIssuerHost != null &&
+          regex.test(oidcIssuerHost)
+        )
+          return 'oidc';
 
-        const isSamlEnabled = configManager.getConfig('security:passport-saml:isEnabled');
-        const samlEntryPoint = configManager.getConfig('security:passport-saml:entryPoint');
+        const isSamlEnabled = configManager.getConfig(
+          'security:passport-saml:isEnabled',
+        );
+        const samlEntryPoint = configManager.getConfig(
+          'security:passport-saml:entryPoint',
+        );
 
-        if (isSamlEnabled && samlEntryPoint != null && regex.test(samlEntryPoint)) return 'saml';
+        if (
+          isSamlEnabled &&
+          samlEntryPoint != null &&
+          regex.test(samlEntryPoint)
+        )
+          return 'saml';
 
         return null;
       };
@@ -842,7 +1063,11 @@ module.exports = (crowi: Crowi): Router => {
       const authProviderType = getAuthProviderType();
       if (authProviderType == null) {
         return res.apiv3Err(
-          new ErrorV3('Authentication using keycloak is not set', 'external_user_group.keycloak.auth_not_set'), 422,
+          new ErrorV3(
+            'Authentication using keycloak is not set',
+            'external_user_group.keycloak.auth_not_set',
+          ),
+          422,
         );
       }
 
@@ -851,7 +1076,8 @@ module.exports = (crowi: Crowi): Router => {
       crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
 
       return res.apiv3({}, 202);
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -870,11 +1096,16 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   $ref: '#/components/schemas/SyncStatus'
    */
-  router.get('/ldap/sync-status', accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/ldap/sync-status',
+    accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
       return res.apiv3({ ...syncStatus });
-    });
+    },
+  );
 
   /**
    * @swagger
@@ -893,12 +1124,16 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                   $ref: '#/components/schemas/SyncStatus'
    */
-  router.get('/keycloak/sync-status', accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]), loginRequiredStrictly, adminRequired,
+  router.get(
+    '/keycloak/sync-status',
+    accessTokenParser([SCOPE.WRITE.ADMIN.USER_GROUP_MANAGEMENT]),
+    loginRequiredStrictly,
+    adminRequired,
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
       return res.apiv3({ ...syncStatus });
-    });
+    },
+  );
 
   return router;
-
 };

+ 148 - 62
apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts

@@ -13,7 +13,10 @@ import { batchProcessPromiseAll } from '~/utils/promise';
 import { configManager } from '../../../../server/service/config-manager';
 import { externalAccountService } from '../../../../server/service/external-account';
 import type {
-  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
+  ExternalGroupProviderType,
+  ExternalUserGroupTreeNode,
+  ExternalUserInfo,
+  IExternalUserGroupHasId,
 } from '../../interfaces/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
@@ -26,16 +29,17 @@ const logger = loggerFactory('growi:service:external-user-group-sync-service');
 const TREES_BATCH_SIZE = 10;
 const USERS_BATCH_SIZE = 30;
 
-type SyncStatus = { isExecutingSync: boolean, totalCount: number, count: number }
+type SyncStatus = {
+  isExecutingSync: boolean;
+  totalCount: number;
+  count: number;
+};
 
 class ExternalUserGroupSyncS2sMessage extends S2sMessage {
-
   syncStatus: SyncStatus;
-
 }
 
 abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
-
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
 
   authProviderType: IExternalAuthProviderType | null; // auth provider type (e.g: ldap, oidc). Has to be set before syncExternalUserGroups execution.
@@ -47,7 +51,11 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
   syncStatus: SyncStatus = { isExecutingSync: false, totalCount: 0, count: 0 };
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(groupProviderType: ExternalGroupProviderType, s2sMessagingService: S2sMessagingService | null, socketIoService) {
+  constructor(
+    groupProviderType: ExternalGroupProviderType,
+    s2sMessagingService: S2sMessagingService | null,
+    socketIoService,
+  ) {
     this.groupProviderType = groupProviderType;
     this.s2sMessagingService = s2sMessagingService;
     this.socketIoService = socketIoService;
@@ -63,7 +71,9 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
   /**
    * @inheritdoc
    */
-  async handleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): Promise<void> {
+  async handleS2sMessage(
+    s2sMessage: ExternalUserGroupSyncS2sMessage,
+  ): Promise<void> {
     logger.info('Update syncStatus by pubsub notification');
     this.syncStatus = s2sMessage.syncStatus;
   }
@@ -72,15 +82,20 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     this.syncStatus = syncStatus;
 
     if (this.s2sMessagingService != null) {
-      const s2sMessage = new ExternalUserGroupSyncS2sMessage('switchExternalUserGroupExecSyncStatus', {
-        syncStatus: this.syncStatus,
-      });
+      const s2sMessage = new ExternalUserGroupSyncS2sMessage(
+        'switchExternalUserGroupExecSyncStatus',
+        {
+          syncStatus: this.syncStatus,
+        },
+      );
 
       try {
         await this.s2sMessagingService.publish(s2sMessage);
-      }
-      catch (e) {
-        logger.error('Failed to publish update message with S2sMessagingService: ', e.message);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
       }
     }
   }
@@ -89,23 +104,42 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
    * 1. Generate external user group tree
    * 2. Use createUpdateExternalUserGroup on each node in the tree using DFS
    * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
-  */
+   */
   async syncExternalUserGroups(): Promise<void> {
-    if (this.authProviderType == null) throw new Error('auth provider type is not set');
-    if (this.syncStatus.isExecutingSync) throw new Error('External user group sync is already being executed');
+    if (this.authProviderType == null)
+      throw new Error('auth provider type is not set');
+    if (this.syncStatus.isExecutingSync)
+      throw new Error('External user group sync is already being executed');
 
-    const preserveDeletedLdapGroups = configManager.getConfig(`external-user-group:${this.groupProviderType}:preserveDeletedGroups`);
+    const preserveDeletedLdapGroups = configManager.getConfig(
+      `external-user-group:${this.groupProviderType}:preserveDeletedGroups`,
+    );
     const existingExternalUserGroupIds: string[] = [];
 
     const socket = this.socketIoService?.getAdminSocket();
 
-    const syncNode = async(node: ExternalUserGroupTreeNode, parentId?: string) => {
-      const externalUserGroup = await this.createUpdateExternalUserGroup(node, parentId);
+    const syncNode = async (
+      node: ExternalUserGroupTreeNode,
+      parentId?: string,
+    ) => {
+      const externalUserGroup = await this.createUpdateExternalUserGroup(
+        node,
+        parentId,
+      );
       existingExternalUserGroupIds.push(externalUserGroup._id);
-      await this.setSyncStatus({ isExecutingSync: true, totalCount: this.syncStatus.totalCount, count:  this.syncStatus.count + 1 });
-      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncProgress, {
-        totalCount: this.syncStatus.totalCount, count: this.syncStatus.count,
+      await this.setSyncStatus({
+        isExecutingSync: true,
+        totalCount: this.syncStatus.totalCount,
+        count: this.syncStatus.count + 1,
       });
+      socket?.emit(
+        SocketEventName.externalUserGroup[this.groupProviderType]
+          .GroupSyncProgress,
+        {
+          totalCount: this.syncStatus.totalCount,
+          count: this.syncStatus.count,
+        },
+      );
       // Do not use Promise.all, because the number of promises processed can
       // exponentially grow when group tree is enormous
       for await (const childNode of node.childGroupNodes) {
@@ -115,12 +149,13 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 
     try {
       const trees = await this.generateExternalUserGroupTrees();
-      const totalCount = trees.map(tree => this.getGroupCountOfTree(tree))
+      const totalCount = trees
+        .map((tree) => this.getGroupCountOfTree(tree))
         .reduce((sum, current) => sum + current);
 
       await this.setSyncStatus({ isExecutingSync: true, totalCount, count: 0 });
 
-      await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, async(tree) => {
+      await batchProcessPromiseAll(trees, TREES_BATCH_SIZE, async (tree) => {
         return syncNode(tree);
       });
 
@@ -132,14 +167,22 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
         });
         await ExternalUserGroupRelation.removeAllInvalidRelations();
       }
-      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncCompleted);
-    }
-    catch (e) {
+      socket?.emit(
+        SocketEventName.externalUserGroup[this.groupProviderType]
+          .GroupSyncCompleted,
+      );
+    } catch (e) {
       logger.error(e.message);
-      socket?.emit(SocketEventName.externalUserGroup[this.groupProviderType].GroupSyncFailed);
-    }
-    finally {
-      await this.setSyncStatus({ isExecutingSync: false, totalCount: 0, count: 0 });
+      socket?.emit(
+        SocketEventName.externalUserGroup[this.groupProviderType]
+          .GroupSyncFailed,
+      );
+    } finally {
+      await this.setSyncStatus({
+        isExecutingSync: false,
+        totalCount: 0,
+        count: 0,
+      });
     }
   }
 
@@ -150,26 +193,52 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
    * @param {string} node Node of external group tree
    * @param {string} parentId Parent group id (id in GROWI) of the group we want to create/update
    * @returns {Promise<IExternalUserGroupHasId>} ExternalUserGroup that was created/updated
-  */
-  private async createUpdateExternalUserGroup(node: ExternalUserGroupTreeNode, parentId?: string): Promise<IExternalUserGroupHasId> {
-    const externalUserGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
-      node.name, node.id, this.groupProviderType, node.description, parentId,
+   */
+  private async createUpdateExternalUserGroup(
+    node: ExternalUserGroupTreeNode,
+    parentId?: string,
+  ): Promise<IExternalUserGroupHasId> {
+    const externalUserGroup =
+      await ExternalUserGroup.findAndUpdateOrCreateGroup(
+        node.name,
+        node.id,
+        this.groupProviderType,
+        node.description,
+        parentId,
+      );
+    await batchProcessPromiseAll(
+      node.userInfos,
+      USERS_BATCH_SIZE,
+      async (userInfo) => {
+        const user = await this.getMemberUser(userInfo);
+
+        if (user != null) {
+          const userGroups =
+            await ExternalUserGroup.findGroupsWithAncestorsRecursively(
+              externalUserGroup,
+            );
+          const userGroupIds = userGroups.map((g) => g._id);
+
+          // remove existing relations from list to create
+          const existingRelations = await ExternalUserGroupRelation.find({
+            relatedGroup: { $in: userGroupIds },
+            relatedUser: user._id,
+          });
+          const existingGroupIds = existingRelations.map((r) =>
+            r.relatedGroup.toString(),
+          );
+          const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(
+            userGroupIds,
+            existingGroupIds,
+          );
+
+          await ExternalUserGroupRelation.createRelations(
+            groupIdsToCreateRelation,
+            user,
+          );
+        }
+      },
     );
-    await batchProcessPromiseAll(node.userInfos, USERS_BATCH_SIZE, async(userInfo) => {
-      const user = await this.getMemberUser(userInfo);
-
-      if (user != null) {
-        const userGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(externalUserGroup);
-        const userGroupIds = userGroups.map(g => g._id);
-
-        // remove existing relations from list to create
-        const existingRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: userGroupIds }, relatedUser: user._id });
-        const existingGroupIds = existingRelations.map(r => r.relatedGroup.toString());
-        const groupIdsToCreateRelation = excludeTestIdsFromTargetIds(userGroupIds, existingGroupIds);
-
-        await ExternalUserGroupRelation.createRelations(groupIdsToCreateRelation, user);
-      }
-    });
 
     return externalUserGroup;
   }
@@ -180,25 +249,41 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
    * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
    * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
    */
-  private async getMemberUser(userInfo: ExternalUserInfo): Promise<IUserHasId | null> {
+  private async getMemberUser(
+    userInfo: ExternalUserInfo,
+  ): Promise<IUserHasId | null> {
     const authProviderType = this.authProviderType;
-    if (authProviderType == null) throw new Error('auth provider type is not set');
+    if (authProviderType == null)
+      throw new Error('auth provider type is not set');
 
-    const autoGenerateUserOnGroupSync = configManager.getConfig(`external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`);
+    const autoGenerateUserOnGroupSync = configManager.getConfig(
+      `external-user-group:${this.groupProviderType}:autoGenerateUserOnGroupSync`,
+    );
 
-    const getExternalAccount = async() => {
+    const getExternalAccount = async () => {
       if (autoGenerateUserOnGroupSync && externalAccountService != null) {
-        return externalAccountService.getOrCreateUser({
-          id: userInfo.id, username: userInfo.username, name: userInfo.name, email: userInfo.email,
-        }, authProviderType);
+        return externalAccountService.getOrCreateUser(
+          {
+            id: userInfo.id,
+            username: userInfo.username,
+            name: userInfo.name,
+            email: userInfo.email,
+          },
+          authProviderType,
+        );
       }
-      return ExternalAccount.findOne({ providerType: this.groupProviderType, accountId: userInfo.id });
+      return ExternalAccount.findOne({
+        providerType: this.groupProviderType,
+        accountId: userInfo.id,
+      });
     };
 
     const externalAccount = await getExternalAccount();
 
     if (externalAccount != null) {
-      return (await externalAccount.populate<{user: IUserHasId | null}>('user')).user;
+      return (
+        await externalAccount.populate<{ user: IUserHasId | null }>('user')
+      ).user;
     }
     return null;
   }
@@ -217,9 +302,10 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
    * 1. Fetch user group info from external app/server
    * 2. Convert each group tree structure to ExternalUserGroupTreeNode
    * 3. Return the root node of each tree
-  */
-  abstract generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]>
-
+   */
+  abstract generateExternalUserGroupTrees(): Promise<
+    ExternalUserGroupTreeNode[]
+  >;
 }
 
 export default ExternalUserGroupSyncService;

+ 58 - 59
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts

@@ -5,7 +5,6 @@ import { KeycloakUserGroupSyncService } from './keycloak-user-group-sync';
 vi.mock('@keycloak/keycloak-admin-client', () => {
   return {
     default: class {
-
       auth() {}
 
       groups = {
@@ -40,48 +39,40 @@ vi.mock('@keycloak/keycloak-admin-client', () => {
         // mock group detail
         findOne: (payload) => {
           if (payload?.id === 'groupId1') {
-            return Promise.resolve(
-              {
-                id: 'groupId1',
-                name: 'grandParentGroup',
-                attributes: {
-                  description: ['this is a grand parent group'],
-                },
+            return Promise.resolve({
+              id: 'groupId1',
+              name: 'grandParentGroup',
+              attributes: {
+                description: ['this is a grand parent group'],
               },
-            );
+            });
           }
           if (payload?.id === 'groupId2') {
-            return Promise.resolve(
-              {
-                id: 'groupId2',
-                name: 'parentGroup',
-                attributes: {
-                  description: ['this is a parent group'],
-                },
+            return Promise.resolve({
+              id: 'groupId2',
+              name: 'parentGroup',
+              attributes: {
+                description: ['this is a parent group'],
               },
-            );
+            });
           }
           if (payload?.id === 'groupId3') {
-            return Promise.resolve(
-              {
-                id: 'groupId3',
-                name: 'childGroup',
-                attributes: {
-                  description: ['this is a child group'],
-                },
+            return Promise.resolve({
+              id: 'groupId3',
+              name: 'childGroup',
+              attributes: {
+                description: ['this is a child group'],
               },
-            );
+            });
           }
           if (payload?.id === 'groupId4') {
-            return Promise.resolve(
-              {
-                id: 'groupId3',
-                name: 'childGroup',
-                attributes: {
-                  description: ['this is a root group'],
-                },
+            return Promise.resolve({
+              id: 'groupId3',
+              name: 'childGroup',
+              attributes: {
+                description: ['this is a root group'],
               },
-            );
+            });
           }
           return Promise.reject(new Error('not found'));
         },
@@ -128,7 +119,6 @@ vi.mock('@keycloak/keycloak-admin-client', () => {
           return Promise.resolve([]);
         },
       };
-
     },
   };
 });
@@ -145,49 +135,56 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
     'external-user-group:keycloak:groupSyncClientSecret': '123456',
   };
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     await configManager.loadConfigs();
     await configManager.updateConfigs(configParams, { skipPubsub: true });
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService.init('oidc');
   });
 
-  it('creates ExternalUserGroupTrees', async() => {
-    const rootNodes = await keycloakUserGroupSyncService?.generateExternalUserGroupTrees();
+  it('creates ExternalUserGroupTrees', async () => {
+    const rootNodes =
+      await keycloakUserGroupSyncService?.generateExternalUserGroupTrees();
 
     expect(rootNodes?.length).toBe(2);
 
     // check grandParentGroup
-    const grandParentNode = rootNodes?.find(node => node.id === 'groupId1');
+    const grandParentNode = rootNodes?.find((node) => node.id === 'groupId1');
     const expectedChildNode = {
       id: 'groupId3',
-      userInfos: [{
-        id: 'userId3',
-        username: 'childGroupUser',
-        email: 'user@childGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId3',
+          username: 'childGroupUser',
+          email: 'user@childGroup.com',
+        },
+      ],
       childGroupNodes: [],
       name: 'childGroup',
       description: 'this is a child group',
     };
     const expectedParentNode = {
       id: 'groupId2',
-      userInfos: [{
-        id: 'userId2',
-        username: 'parentGroupUser',
-        email: 'user@parentGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId2',
+          username: 'parentGroupUser',
+          email: 'user@parentGroup.com',
+        },
+      ],
       childGroupNodes: [expectedChildNode],
       name: 'parentGroup',
       description: 'this is a parent group',
     };
     const expectedGrandParentNode = {
       id: 'groupId1',
-      userInfos: [{
-        id: 'userId1',
-        username: 'grandParentGroupUser',
-        email: 'user@grandParentGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId1',
+          username: 'grandParentGroupUser',
+          email: 'user@grandParentGroup.com',
+        },
+      ],
       childGroupNodes: [expectedParentNode],
       name: 'grandParentGroup',
       description: 'this is a grand parent group',
@@ -195,14 +192,16 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
     expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
 
     // check rootGroup
-    const rootNode = rootNodes?.find(node => node.id === 'groupId4');
+    const rootNode = rootNodes?.find((node) => node.id === 'groupId4');
     const expectedRootNode = {
       id: 'groupId4',
-      userInfos: [{
-        id: 'userId4',
-        username: 'rootGroupUser',
-        email: 'user@rootGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId4',
+          username: 'rootGroupUser',
+          email: 'user@rootGroup.com',
+        },
+      ],
       childGroupNodes: [],
       name: 'rootGroup',
       description: 'this is a root group',

+ 91 - 39
apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts

@@ -7,7 +7,10 @@ import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
-import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import type {
+  ExternalUserGroupTreeNode,
+  ExternalUserInfo,
+} from '../../interfaces/external-user-group';
 import { ExternalGroupProviderType } from '../../interfaces/external-user-group';
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
@@ -20,7 +23,6 @@ const logger = loggerFactory('growi:service:keycloak-user-group-sync-service');
 const TREES_BATCH_SIZE = 10;
 
 export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
-
   kcAdminClient: KeycloakAdminClient;
 
   realm: string | undefined; // realm that contains the groups
@@ -30,17 +32,33 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   isInitialized = false;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(s2sMessagingService: S2sMessagingService | null, socketIoService) {
-    super(ExternalGroupProviderType.keycloak, s2sMessagingService, socketIoService);
+  constructor(
+    s2sMessagingService: S2sMessagingService | null,
+    socketIoService,
+  ) {
+    super(
+      ExternalGroupProviderType.keycloak,
+      s2sMessagingService,
+      socketIoService,
+    );
   }
 
   init(authProviderType: 'oidc' | 'saml'): void {
     const kcHost = configManager.getConfig('external-user-group:keycloak:host');
-    const kcGroupRealm = configManager.getConfig('external-user-group:keycloak:groupRealm');
-    const kcGroupSyncClientRealm = configManager.getConfig('external-user-group:keycloak:groupSyncClientRealm');
-    const kcGroupDescriptionAttribute = configManager.getConfig('external-user-group:keycloak:groupDescriptionAttribute');
-
-    this.kcAdminClient = new KeycloakAdminClient({ baseUrl: kcHost, realmName: kcGroupSyncClientRealm });
+    const kcGroupRealm = configManager.getConfig(
+      'external-user-group:keycloak:groupRealm',
+    );
+    const kcGroupSyncClientRealm = configManager.getConfig(
+      'external-user-group:keycloak:groupSyncClientRealm',
+    );
+    const kcGroupDescriptionAttribute = configManager.getConfig(
+      'external-user-group:keycloak:groupDescriptionAttribute',
+    );
+
+    this.kcAdminClient = new KeycloakAdminClient({
+      baseUrl: kcHost,
+      realmName: kcGroupSyncClientRealm,
+    });
     this.realm = kcGroupRealm;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
     this.authProviderType = authProviderType;
@@ -56,23 +74,36 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
     return super.syncExternalUserGroups();
   }
 
-  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+  override async generateExternalUserGroupTrees(): Promise<
+    ExternalUserGroupTreeNode[]
+  > {
     await this.auth();
 
     // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
     logger.info('Get groups from keycloak server');
-    const rootGroups = await this.kcAdminClient.groups.find({ realm: this.realm });
+    const rootGroups = await this.kcAdminClient.groups.find({
+      realm: this.realm,
+    });
 
-    return (await batchProcessPromiseAll(rootGroups, TREES_BATCH_SIZE, group => this.groupRepresentationToTreeNode(group)))
-      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+    return (
+      await batchProcessPromiseAll(rootGroups, TREES_BATCH_SIZE, (group) =>
+        this.groupRepresentationToTreeNode(group),
+      )
+    ).filter(
+      (node): node is NonNullable<ExternalUserGroupTreeNode> => node != null,
+    );
   }
 
   /**
    * Authenticate to group sync client using client credentials grant type
    */
   private async auth(): Promise<void> {
-    const kcGroupSyncClientID = configManager.getConfig('external-user-group:keycloak:groupSyncClientID');
-    const kcGroupSyncClientSecret = configManager.getConfig('external-user-group:keycloak:groupSyncClientSecret');
+    const kcGroupSyncClientID = configManager.getConfig(
+      'external-user-group:keycloak:groupSyncClientID',
+    );
+    const kcGroupSyncClientSecret = configManager.getConfig(
+      'external-user-group:keycloak:groupSyncClientSecret',
+    );
 
     await this.kcAdminClient.auth({
       grantType: 'client_credentials',
@@ -84,14 +115,19 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   /**
    * Convert GroupRepresentation response returned from Keycloak to ExternalUserGroupTreeNode
    */
-  private async groupRepresentationToTreeNode(group: GroupRepresentation): Promise<ExternalUserGroupTreeNode | null> {
+  private async groupRepresentationToTreeNode(
+    group: GroupRepresentation,
+  ): Promise<ExternalUserGroupTreeNode | null> {
     if (group.id == null || group.name == null) return null;
 
     logger.info('Get users from keycloak server');
     const userRepresentations = await this.getMembers(group.id);
 
-    const userInfos = userRepresentations != null ? this.userRepresentationsToExternalUserInfos(userRepresentations) : [];
-    const description = await this.getGroupDescription(group.id) || undefined;
+    const userInfos =
+      userRepresentations != null
+        ? this.userRepresentationsToExternalUserInfos(userRepresentations)
+        : [];
+    const description = (await this.getGroupDescription(group.id)) || undefined;
     const childGroups = group.subGroups;
 
     const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
@@ -99,11 +135,15 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
       // Do not use Promise.all, because the number of promises processed can
       // exponentially grow when group tree is enormous
       for await (const childGroup of childGroups) {
-        childGroupNodesWithNull.push(await this.groupRepresentationToTreeNode(childGroup));
+        childGroupNodesWithNull.push(
+          await this.groupRepresentationToTreeNode(childGroup),
+        );
       }
     }
-    const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
-      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+    const childGroupNodes: ExternalUserGroupTreeNode[] =
+      childGroupNodesWithNull.filter(
+        (node): node is NonNullable<ExternalUserGroupTreeNode> => node != null,
+      );
 
     return {
       id: group.id,
@@ -117,10 +157,12 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   private async getMembers(groupId: string): Promise<UserRepresentation[]> {
     let allUsers: UserRepresentation[] = [];
 
-    const fetchUsersWithOffset = async(offset: number) => {
+    const fetchUsersWithOffset = async (offset: number) => {
       await this.auth();
       const response = await this.kcAdminClient.groups.listMembers({
-        id: groupId, realm: this.realm, first: offset,
+        id: groupId,
+        realm: this.realm,
+        first: offset,
       });
 
       if (response != null && response.length > 0) {
@@ -134,7 +176,6 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
     return allUsers;
   }
 
-
   /**
    * Fetch group detail from Keycloak and return group description
    */
@@ -142,28 +183,39 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
     if (this.groupDescriptionAttribute == null) return null;
 
     await this.auth();
-    const groupDetail = await this.kcAdminClient.groups.findOne({ id: groupId, realm: this.realm });
+    const groupDetail = await this.kcAdminClient.groups.findOne({
+      id: groupId,
+      realm: this.realm,
+    });
 
-    const description = groupDetail?.attributes?.[this.groupDescriptionAttribute]?.[0];
+    const description =
+      groupDetail?.attributes?.[this.groupDescriptionAttribute]?.[0];
     return typeof description === 'string' ? description : null;
   }
 
   /**
    * Convert UserRepresentation array response returned from Keycloak to ExternalUserInfo
    */
-  private userRepresentationsToExternalUserInfos(userRepresentations: UserRepresentation[]): ExternalUserInfo[] {
-    const externalUserGroupsWithNull: (ExternalUserInfo | null)[] = userRepresentations.map((userRepresentation) => {
-      if (userRepresentation.id != null && userRepresentation.username != null) {
-        return {
-          id: userRepresentation.id,
-          username: userRepresentation.username,
-          email: userRepresentation.email,
-        };
-      }
-      return null;
-    });
+  private userRepresentationsToExternalUserInfos(
+    userRepresentations: UserRepresentation[],
+  ): ExternalUserInfo[] {
+    const externalUserGroupsWithNull: (ExternalUserInfo | null)[] =
+      userRepresentations.map((userRepresentation) => {
+        if (
+          userRepresentation.id != null &&
+          userRepresentation.username != null
+        ) {
+          return {
+            id: userRepresentation.id,
+            username: userRepresentation.username,
+            email: userRepresentation.email,
+          };
+        }
+        return null;
+      });
 
-    return externalUserGroupsWithNull.filter((node): node is NonNullable<ExternalUserInfo> => node != null);
+    return externalUserGroupsWithNull.filter(
+      (node): node is NonNullable<ExternalUserInfo> => node != null,
+    );
   }
-
 }

+ 110 - 44
apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts

@@ -6,9 +6,13 @@ import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
 import loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
-import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import type {
+  ExternalUserGroupTreeNode,
+  ExternalUserInfo,
+} from '../../interfaces/external-user-group';
 import {
-  ExternalGroupProviderType, LdapGroupMembershipAttributeType,
+  ExternalGroupProviderType,
+  LdapGroupMembershipAttributeType,
 } from '../../interfaces/external-user-group';
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
@@ -22,19 +26,25 @@ const TREES_BATCH_SIZE = 10;
 const USERS_BATCH_SIZE = 30;
 
 export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
-
   passportService: PassportService;
 
   isInitialized = false;
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(passportService: PassportService, s2sMessagingService: S2sMessagingService, socketIoService) {
+  constructor(
+    passportService: PassportService,
+    s2sMessagingService: S2sMessagingService,
+    socketIoService,
+  ) {
     super(ExternalGroupProviderType.ldap, s2sMessagingService, socketIoService);
     this.authProviderType = 'ldap';
     this.passportService = passportService;
   }
 
-  async init(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+  async init(
+    userBindUsername?: string,
+    userBindPassword?: string,
+  ): Promise<void> {
     await ldapService.initClient(userBindUsername, userBindPassword);
     this.isInitialized = true;
   }
@@ -48,11 +58,21 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     return super.syncExternalUserGroups();
   }
 
-  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
-    const groupChildGroupAttribute = configManager.getConfig('external-user-group:ldap:groupChildGroupAttribute');
-    const groupMembershipAttribute = configManager.getConfig('external-user-group:ldap:groupMembershipAttribute');
-    const groupNameAttribute = configManager.getConfig('external-user-group:ldap:groupNameAttribute');
-    const groupDescriptionAttribute = configManager.getConfig('external-user-group:ldap:groupDescriptionAttribute');
+  override async generateExternalUserGroupTrees(): Promise<
+    ExternalUserGroupTreeNode[]
+  > {
+    const groupChildGroupAttribute = configManager.getConfig(
+      'external-user-group:ldap:groupChildGroupAttribute',
+    );
+    const groupMembershipAttribute = configManager.getConfig(
+      'external-user-group:ldap:groupMembershipAttribute',
+    );
+    const groupNameAttribute = configManager.getConfig(
+      'external-user-group:ldap:groupNameAttribute',
+    );
+    const groupDescriptionAttribute = configManager.getConfig(
+      'external-user-group:ldap:groupDescriptionAttribute',
+    );
     const groupBase = ldapService.getGroupSearchBase();
 
     const groupEntries = await ldapService.searchGroupDir();
@@ -60,16 +80,26 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     const getChildGroupDnsFromGroupEntry = (groupEntry: SearchResultEntry) => {
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // so filter values of groupChildGroupAttribute to ones that include groupBase
-      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute).filter(attr => attr.includes(groupBase));
+      return ldapService
+        .getArrayValFromSearchResultEntry(groupEntry, groupChildGroupAttribute)
+        .filter((attr) => attr.includes(groupBase));
     };
     const getUserIdsFromGroupEntry = (groupEntry: SearchResultEntry) => {
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // so filter values of groupMembershipAttribute to ones that does not include groupBase
-      return ldapService.getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute).filter(attr => !attr.includes(groupBase));
+      return ldapService
+        .getArrayValFromSearchResultEntry(groupEntry, groupMembershipAttribute)
+        .filter((attr) => !attr.includes(groupBase));
     };
 
-    const convert = async(entry: SearchResultEntry, converted: string[]): Promise<ExternalUserGroupTreeNode | null> => {
-      const name = ldapService.getStringValFromSearchResultEntry(entry, groupNameAttribute);
+    const convert = async (
+      entry: SearchResultEntry,
+      converted: string[],
+    ): Promise<ExternalUserGroupTreeNode | null> => {
+      const name = ldapService.getStringValFromSearchResultEntry(
+        entry,
+        groupNameAttribute,
+      );
       if (name == null) return null;
 
       if (converted.includes(entry.objectName)) {
@@ -79,21 +109,31 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
 
       const userIds = getUserIdsFromGroupEntry(entry);
 
-      const userInfos = (await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
-        return this.getUserInfo(id);
-      })).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
-      const description = ldapService.getStringValFromSearchResultEntry(entry, groupDescriptionAttribute);
+      const userInfos = (
+        await batchProcessPromiseAll(userIds, USERS_BATCH_SIZE, (id) => {
+          return this.getUserInfo(id);
+        })
+      ).filter((info): info is NonNullable<ExternalUserInfo> => info != null);
+      const description = ldapService.getStringValFromSearchResultEntry(
+        entry,
+        groupDescriptionAttribute,
+      );
       const childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
 
       const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
       // Do not use Promise.all, because the number of promises processed can
       // exponentially grow when group tree is enormous
       for await (const dn of childGroupDNs) {
-        const childEntry = groupEntries.find(ge => ge.objectName === dn);
-        childGroupNodesWithNull.push(childEntry != null ? await convert(childEntry, converted) : null);
+        const childEntry = groupEntries.find((ge) => ge.objectName === dn);
+        childGroupNodesWithNull.push(
+          childEntry != null ? await convert(childEntry, converted) : null,
+        );
       }
-      const childGroupNodes: ExternalUserGroupTreeNode[] = childGroupNodesWithNull
-        .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+      const childGroupNodes: ExternalUserGroupTreeNode[] =
+        childGroupNodesWithNull.filter(
+          (node): node is NonNullable<ExternalUserGroupTreeNode> =>
+            node != null,
+        );
 
       return {
         id: entry.objectName,
@@ -105,31 +145,45 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     };
 
     // all the DNs of groups that are not a root of a tree
-    const allChildGroupDNs = new Set(groupEntries.flatMap((entry) => {
-      return getChildGroupDnsFromGroupEntry(entry);
-    }));
+    const allChildGroupDNs = new Set(
+      groupEntries.flatMap((entry) => {
+        return getChildGroupDnsFromGroupEntry(entry);
+      }),
+    );
 
     // root of every tree
     const rootEntries = groupEntries.filter((entry) => {
       return !allChildGroupDNs.has(entry.objectName);
     });
 
-    return (await batchProcessPromiseAll(rootEntries, TREES_BATCH_SIZE, entry => convert(entry, [])))
-      .filter((node): node is NonNullable<ExternalUserGroupTreeNode> => node != null);
+    return (
+      await batchProcessPromiseAll(rootEntries, TREES_BATCH_SIZE, (entry) =>
+        convert(entry, []),
+      )
+    ).filter(
+      (node): node is NonNullable<ExternalUserGroupTreeNode> => node != null,
+    );
   }
 
   private async getUserInfo(userId: string): Promise<ExternalUserInfo | null> {
-    const groupMembershipAttributeType = configManager.getConfig('external-user-group:ldap:groupMembershipAttributeType');
-    const attrMapUsername = this.passportService.getLdapAttrNameMappedToUsername();
+    const groupMembershipAttributeType = configManager.getConfig(
+      'external-user-group:ldap:groupMembershipAttributeType',
+    );
+    const attrMapUsername =
+      this.passportService.getLdapAttrNameMappedToUsername();
     const attrMapName = this.passportService.getLdapAttrNameMappedToName();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
 
     // get full user info from LDAP server using externalUserInfo (DN or UID)
-    const getUserEntries = async() => {
-      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.dn) {
+    const getUserEntries = async () => {
+      if (
+        groupMembershipAttributeType === LdapGroupMembershipAttributeType.dn
+      ) {
         return ldapService.search(undefined, userId, 'base');
       }
-      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid) {
+      if (
+        groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid
+      ) {
         return ldapService.search(`(uid=${userId})`, undefined);
       }
     };
@@ -138,21 +192,33 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
 
     if (userEntries != null && userEntries.length > 0) {
       const userEntry = userEntries[0];
-      const uid = ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
+      const uid = ldapService.getStringValFromSearchResultEntry(
+        userEntry,
+        'uid',
+      );
       if (uid != null) {
-        const usernameToBeRegistered = attrMapUsername === 'uid' ? uid : ldapService.getStringValFromSearchResultEntry(userEntry, attrMapUsername);
-        const nameToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
-        const mailToBeRegistered = ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
-
-        return usernameToBeRegistered != null ? {
-          id: uid,
-          username: usernameToBeRegistered,
-          name: nameToBeRegistered,
-          email: mailToBeRegistered,
-        } : null;
+        const usernameToBeRegistered =
+          attrMapUsername === 'uid'
+            ? uid
+            : ldapService.getStringValFromSearchResultEntry(
+                userEntry,
+                attrMapUsername,
+              );
+        const nameToBeRegistered =
+          ldapService.getStringValFromSearchResultEntry(userEntry, attrMapName);
+        const mailToBeRegistered =
+          ldapService.getStringValFromSearchResultEntry(userEntry, attrMapMail);
+
+        return usernameToBeRegistered != null
+          ? {
+              id: uid,
+              username: usernameToBeRegistered,
+              name: nameToBeRegistered,
+              email: mailToBeRegistered,
+            }
+          : null;
       }
     }
     return null;
   }
-
 }

+ 42 - 44
apps/app/src/features/mermaid/components/MermaidViewer.tsx

@@ -1,6 +1,5 @@
-import React, { useRef, useEffect, type JSX } from 'react';
-
 import mermaid from 'mermaid';
+import React, { type JSX, useEffect, useRef } from 'react';
 import { v7 as uuidV7 } from 'uuid';
 
 import { useNextThemes } from '~/stores-universal/use-next-themes';
@@ -9,48 +8,47 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:features:mermaid:MermaidViewer');
 
 type MermaidViewerProps = {
-  value: string
-}
-
-export const MermaidViewer = React.memo((props: MermaidViewerProps): JSX.Element => {
-  const { value } = props;
-
-  const { isDarkMode } = useNextThemes();
-
-  const ref = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    (async() => {
-      if (ref.current != null && value != null) {
-        mermaid.initialize({
-          theme: isDarkMode ? 'dark' : undefined,
-        });
-        try {
-          // Attempting to render multiple Mermaid diagrams using `mermaid.run` can cause duplicate SVG IDs.
-          // This is because it uses `Date.now()` for ID generation.
-          // ID generation logic: https://github.com/mermaid-js/mermaid/blob/5b241bbb97f81d37df8a84da523dfa53ac13bfd1/packages/mermaid/src/utils.ts#L755-L764
-          // Related issue: https://github.com/mermaid-js/mermaid/issues/4650
-          // Instead of `mermaid.run`, we use `mermaid.render` which allows us to assign a unique ID.
-          const id = `mermaid-${uuidV7()}`;
-          const { svg } = await mermaid.render(id, value, ref.current);
-          ref.current.innerHTML = svg;
-        }
-        catch (err) {
-          logger.error(err);
+  value: string;
+};
+
+export const MermaidViewer = React.memo(
+  (props: MermaidViewerProps): JSX.Element => {
+    const { value } = props;
+
+    const { isDarkMode } = useNextThemes();
+
+    const ref = useRef<HTMLDivElement>(null);
+
+    useEffect(() => {
+      (async () => {
+        if (ref.current != null && value != null) {
+          mermaid.initialize({
+            theme: isDarkMode ? 'dark' : undefined,
+          });
+          try {
+            // Attempting to render multiple Mermaid diagrams using `mermaid.run` can cause duplicate SVG IDs.
+            // This is because it uses `Date.now()` for ID generation.
+            // ID generation logic: https://github.com/mermaid-js/mermaid/blob/5b241bbb97f81d37df8a84da523dfa53ac13bfd1/packages/mermaid/src/utils.ts#L755-L764
+            // Related issue: https://github.com/mermaid-js/mermaid/issues/4650
+            // Instead of `mermaid.run`, we use `mermaid.render` which allows us to assign a unique ID.
+            const id = `mermaid-${uuidV7()}`;
+            const { svg } = await mermaid.render(id, value, ref.current);
+            ref.current.innerHTML = svg;
+          } catch (err) {
+            logger.error(err);
+          }
         }
-      }
-    })();
-  }, [isDarkMode, value]);
-
-  return (
-    value
-      ? (
-        <div ref={ref} key={value}>
-          {value}
-        </div>
-      )
-      : <div key={value}></div>
-  );
-});
+      })();
+    }, [isDarkMode, value]);
+
+    return value ? (
+      <div ref={ref} key={value}>
+        {value}
+      </div>
+    ) : (
+      <div key={value}></div>
+    );
+  },
+);
 
 MermaidViewer.displayName = 'MermaidViewer';

+ 10 - 9
apps/app/src/features/mermaid/services/mermaid.ts

@@ -5,21 +5,22 @@ import { visit } from 'unist-util-visit';
 
 function rewriteNode(node: Code) {
   // replace node
-  const data = node.data ?? (node.data = {});
+  if (node.data == null) {
+    node.data = {};
+  }
+  const data = node.data;
   data.hName = 'mermaid';
   data.hProperties = {
     value: node.value,
   };
 }
 
-export const remarkPlugin: Plugin = function() {
-  return (tree) => {
-    visit(tree, 'code', (node: Code) => {
-      if (node.lang === 'mermaid') {
-        rewriteNode(node);
-      }
-    });
-  };
+export const remarkPlugin: Plugin = () => (tree) => {
+  visit(tree, 'code', (node: Code) => {
+    if (node.lang === 'mermaid') {
+      rewriteNode(node);
+    }
+  });
 };
 
 export const sanitizeOption: SanitizeOption = {

+ 51 - 17
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantList.tsx

@@ -15,6 +15,8 @@ import { deleteAiAssistant, setDefaultAiAssistant } from '../../../services/ai-a
 import { useAiAssistantSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
 import { getShareScopeIcon } from '../../../utils/get-share-scope-Icon';
 
+import { DeleteAiAssistantModal } from './DeleteAiAssistantModal';
+
 const logger = loggerFactory('growi:openai:client:components:AiAssistantList');
 
 /*
@@ -25,8 +27,8 @@ type AiAssistantItemProps = {
   aiAssistant: AiAssistantHasId;
   onEditClick: (aiAssistantData: AiAssistantHasId) => void;
   onItemClick: (aiAssistantData: AiAssistantHasId) => void;
+  onDeleteClick: (aiAssistant: AiAssistantHasId) => void;
   onUpdated?: () => void;
-  onDeleted?: (aiAssistantId: string) => void;
 };
 
 const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
@@ -34,8 +36,8 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
   aiAssistant,
   onEditClick,
   onItemClick,
+  onDeleteClick,
   onUpdated,
-  onDeleted,
 }) => {
 
   const { t } = useTranslation();
@@ -61,18 +63,6 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
     }
   }, [aiAssistant._id, aiAssistant.isDefault, onUpdated, t]);
 
-  const deleteAiAssistantHandler = useCallback(async() => {
-    try {
-      await deleteAiAssistant(aiAssistant._id);
-      onDeleted?.(aiAssistant._id);
-      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_deleted_success'));
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'));
-    }
-  }, [aiAssistant._id, onDeleted, t]);
-
   const isOperable = currentUser?._id != null && getIdStringForRef(aiAssistant.owner) === currentUser._id;
   const isPublicAiAssistantOperable = currentUser?.admin
     && determineShareScope(aiAssistant.shareScope, aiAssistant.accessScope) === AiAssistantShareScope.PUBLIC_ONLY;
@@ -95,7 +85,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
           <p className="text-truncate m-auto">{aiAssistant.name}</p>
         </div>
 
-        <div className="grw-btn-actions opacity-0 d-flex justify-content-center ">
+        <div className="grw-btn-actions opacity-0 d-flex justify-content-center">
           {isPublicAiAssistantOperable && (
             <button
               type="button"
@@ -125,7 +115,7 @@ const AiAssistantItem: React.FC<AiAssistantItemProps> = ({
                 className="btn btn-link text-secondary p-0"
                 onClick={(e) => {
                   e.stopPropagation();
-                  deleteAiAssistantHandler();
+                  onDeleteClick(aiAssistant);
                 }}
               >
                 <span className="material-symbols-outlined fs-5">delete</span>
@@ -160,6 +150,10 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
 
   const [isCollapsed, setIsCollapsed] = useState(false);
 
+  const [aiAssistantToBeDeleted, setAiAssistantToBeDeleted] = useState<AiAssistantHasId | null>(null);
+  const [isDeleteModalShown, setIsDeleteModalShown] = useState(false);
+  const [errorMessageOnDelete, setErrorMessageOnDelete] = useState('');
+
   const toggleCollapse = useCallback(() => {
     setIsCollapsed((prev) => {
       if (!prev) {
@@ -169,6 +163,38 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
     });
   }, [onCollapsed]);
 
+  const onClickDeleteButton = useCallback((aiAssistant: AiAssistantHasId) => {
+    setAiAssistantToBeDeleted(aiAssistant);
+    setIsDeleteModalShown(true);
+  }, []);
+
+  const onCancelDeleteAiAssistant = useCallback(() => {
+    setAiAssistantToBeDeleted(null);
+    setIsDeleteModalShown(false);
+    setErrorMessageOnDelete('');
+  }, []);
+
+  const onDeleteAiAssistantAfterOperation = useCallback((aiAssistantId: string) => {
+    onCancelDeleteAiAssistant();
+    onDeleted?.(aiAssistantId);
+  }, [onCancelDeleteAiAssistant, onDeleted]);
+
+  const onDeleteAiAssistant = useCallback(async() => {
+    if (aiAssistantToBeDeleted == null) return;
+
+    try {
+      await deleteAiAssistant(aiAssistantToBeDeleted._id);
+      onDeleteAiAssistantAfterOperation(aiAssistantToBeDeleted._id);
+      toastSuccess(t('ai_assistant_substance.toaster.ai_assistant_deleted_success'));
+    }
+    catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      setErrorMessageOnDelete(message);
+      logger.error(err);
+      toastError(t('ai_assistant_substance.toaster.ai_assistant_deleted_failed'));
+    }
+  }, [aiAssistantToBeDeleted, onDeleteAiAssistantAfterOperation, t]);
+
   return (
     <>
       <button
@@ -196,12 +222,20 @@ export const AiAssistantList: React.FC<AiAssistantListProps> = ({
               aiAssistant={assistant}
               onEditClick={openAiAssistantManagementModal}
               onItemClick={openChat}
+              onDeleteClick={onClickDeleteButton}
               onUpdated={onUpdated}
-              onDeleted={onDeleted}
             />
           ))}
         </ul>
       </Collapse>
+
+      <DeleteAiAssistantModal
+        isShown={isDeleteModalShown}
+        aiAssistant={aiAssistantToBeDeleted}
+        errorMessage={errorMessageOnDelete}
+        onCancel={onCancelDeleteAiAssistant}
+        onConfirm={onDeleteAiAssistant}
+      />
     </>
   );
 };

+ 72 - 0
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx

@@ -0,0 +1,72 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import type { AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+
+export type DeleteAiAssistantModalProps = {
+  isShown: boolean;
+  aiAssistant: AiAssistantHasId | null;
+  errorMessage?: string;
+  onCancel: () => void;
+  onConfirm: () => void;
+};
+
+export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
+  isShown, aiAssistant, errorMessage, onCancel, onConfirm,
+}) => {
+  const { t } = useTranslation();
+
+  const headerContent = () => {
+    if (!isShown || aiAssistant == null) {
+      return null;
+    }
+    return (
+      <>
+        <span className="material-symbols-outlined me-1">delete_forever</span>
+        <span className="fw-bold">{t('ai_assistant_substance.delete_modal.title')}</span>
+      </>
+    );
+  };
+
+  const bodyContent = () => {
+    if (!isShown || aiAssistant == null) {
+      return null;
+    }
+    return <p className="fw-bold mb-0">{t('ai_assistant_substance.delete_modal.confirm_message')}</p>;
+  };
+
+  const footerContent = () => {
+    if (!isShown || aiAssistant == null) {
+      return null;
+    }
+    return (
+      <>
+        {errorMessage && <span className="text-danger">{errorMessage}</span>}
+        <Button color="outline-neutral-secondary" onClick={onCancel}>
+          {t('Cancel')}
+        </Button>
+        <Button color="danger" onClick={onConfirm}>
+          {t('Delete')}
+        </Button>
+      </>
+    );
+  };
+
+  return (
+    <Modal isOpen={isShown} toggle={onCancel} centered>
+      <ModalHeader tag="h5" toggle={onCancel} className="text-danger px-4">
+        {headerContent()}
+      </ModalHeader>
+      <ModalBody className="px-4">
+        {bodyContent()}
+      </ModalBody>
+      <ModalFooter className="px-4 gap-2">
+        {footerContent()}
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 20 - 16
apps/app/src/features/opentelemetry/server/anonymization/anonymize-http-requests.ts

@@ -2,24 +2,28 @@ import type { InstrumentationConfigMap } from '@opentelemetry/auto-instrumentati
 
 import { anonymizationModules } from './handlers';
 
-export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'] = {
-  startIncomingSpanHook: (request) => {
-    // Get URL from IncomingMessage (server-side requests)
-    const incomingRequest = request;
-    const url = incomingRequest.url || '';
+export const httpInstrumentationConfig: InstrumentationConfigMap['@opentelemetry/instrumentation-http'] =
+  {
+    startIncomingSpanHook: (request) => {
+      // Get URL from IncomingMessage (server-side requests)
+      const incomingRequest = request;
+      const url = incomingRequest.url || '';
 
-    const attributes = {};
+      const attributes = {};
 
-    // Use efficient module-based approach
-    for (const anonymizationModule of anonymizationModules) {
-      if (anonymizationModule.canHandle(url)) {
-        const moduleAttributes = anonymizationModule.handle(incomingRequest, url);
-        if (moduleAttributes != null) {
-          Object.assign(attributes, moduleAttributes);
+      // Use efficient module-based approach
+      for (const anonymizationModule of anonymizationModules) {
+        if (anonymizationModule.canHandle(url)) {
+          const moduleAttributes = anonymizationModule.handle(
+            incomingRequest,
+            url,
+          );
+          if (moduleAttributes != null) {
+            Object.assign(attributes, moduleAttributes);
+          }
         }
       }
-    }
 
-    return attributes;
-  },
-};
+      return attributes;
+    },
+  };

+ 23 - 21
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts

@@ -1,26 +1,26 @@
 import type { IncomingMessage } from 'http';
 
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 import { pageAccessModule } from './page-access-handler';
 
 describe('pageAccessModule', () => {
   describe('canHandle', () => {
     it.each`
-      description                   | url                            | expected
-      ${'root path'}                | ${'/'}                         | ${false}
-      ${'API endpoint'}             | ${'/_api/v3/search'}           | ${false}
-      ${'static resource'}          | ${'/static/css/style.css'}     | ${false}
-      ${'favicon'}                  | ${'/favicon.ico'}              | ${false}
-      ${'assets'}                   | ${'/assets/image.png'}         | ${false}
-      ${'Next.js resource'}         | ${'/_next/chunk.js'}           | ${false}
-      ${'file with extension'}      | ${'/file.pdf'}                 | ${false}
-      ${'Users top page'}           | ${'/user'}                     | ${false}
-      ${'Users homepage'}           | ${'/user/john'}                | ${true}
-      ${'Users page'}               | ${'/user/john/projects'}       | ${true}
-      ${'page path'}                | ${'/path/to/page'}             | ${true}
-      ${'ObjectId path'}            | ${'/58a4569921a8424d00a1aa0e'} | ${false}
-      `('should return $expected for $description', ({ url, expected }) => {
+      description              | url                            | expected
+      ${'root path'}           | ${'/'}                         | ${false}
+      ${'API endpoint'}        | ${'/_api/v3/search'}           | ${false}
+      ${'static resource'}     | ${'/static/css/style.css'}     | ${false}
+      ${'favicon'}             | ${'/favicon.ico'}              | ${false}
+      ${'assets'}              | ${'/assets/image.png'}         | ${false}
+      ${'Next.js resource'}    | ${'/_next/chunk.js'}           | ${false}
+      ${'file with extension'} | ${'/file.pdf'}                 | ${false}
+      ${'Users top page'}      | ${'/user'}                     | ${false}
+      ${'Users homepage'}      | ${'/user/john'}                | ${true}
+      ${'Users page'}          | ${'/user/john/projects'}       | ${true}
+      ${'page path'}           | ${'/path/to/page'}             | ${true}
+      ${'ObjectId path'}       | ${'/58a4569921a8424d00a1aa0e'} | ${false}
+    `('should return $expected for $description', ({ url, expected }) => {
       const result = pageAccessModule.canHandle(url);
       expect(result).toBe(expected);
     });
@@ -29,10 +29,10 @@ describe('pageAccessModule', () => {
   describe('handle', () => {
     describe('URL path anonymization', () => {
       it.each`
-        description                     | url                                 | expectedPath
-        ${'user subpage path'}          | ${'/user/john/projects'}            | ${'/user/[USERNAME_HASHED:96d9632f363564cc]/[HASHED:2577c0f557b2e4b5]'}
-        ${'complex path'}               | ${'/wiki/project/documentation'}    | ${'/[HASHED:22ca1a8b9f281349]'}
-        ${'path with special chars'}    | ${'/user-name_123/project!'}        | ${'/[HASHED:7aa6a8f4468baa96]'}
+        description                  | url                              | expectedPath
+        ${'user subpage path'}       | ${'/user/john/projects'}         | ${'/user/[USERNAME_HASHED:96d9632f363564cc]/[HASHED:2577c0f557b2e4b5]'}
+        ${'complex path'}            | ${'/wiki/project/documentation'} | ${'/[HASHED:22ca1a8b9f281349]'}
+        ${'path with special chars'} | ${'/user-name_123/project!'}     | ${'/[HASHED:7aa6a8f4468baa96]'}
       `('should handle $description', ({ url, expectedPath }) => {
         // Ensure canHandle returns true before calling handle
         expect(pageAccessModule.canHandle(url)).toBe(true);
@@ -56,7 +56,8 @@ describe('pageAccessModule', () => {
       const result = pageAccessModule.handle(mockRequest, url);
 
       expect(result).toEqual({
-        'http.target': '/user/[USERNAME_HASHED:96d9632f363564cc]?tab=projects&sort=date',
+        'http.target':
+          '/user/[USERNAME_HASHED:96d9632f363564cc]?tab=projects&sort=date',
       });
     });
 
@@ -70,7 +71,8 @@ describe('pageAccessModule', () => {
       const result = pageAccessModule.handle(mockRequest, url);
 
       expect(result).toEqual({
-        'http.target': '/[HASHED:2f4a824f8eacbc70]?search=test&tags[]=tag1&tags[]=tag2&limit=10',
+        'http.target':
+          '/[HASHED:2f4a824f8eacbc70]?search=test&tags[]=tag1&tags[]=tag2&limit=10',
       });
     });
   });

+ 23 - 20
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts

@@ -1,20 +1,21 @@
-import { createHash } from 'crypto';
-import type { IncomingMessage } from 'http';
-
 import {
+  getUsernameByPath,
   isCreatablePage,
-  isUsersHomepage,
+  isPermalink,
   isUserPage,
+  isUsersHomepage,
   isUsersTopPage,
-  isPermalink,
-  getUsernameByPath,
 } from '@growi/core/dist/utils/page-path-utils';
 import { diag } from '@opentelemetry/api';
+import { createHash } from 'crypto';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-access-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-access-handler',
+});
 
 /**
  * Create a hash of the given string
@@ -54,7 +55,8 @@ function anonymizeUrlPath(urlPath: string): string {
           const cleanRemainingPath = remainingPath.replace(/^\/+|\/+$/g, '');
           const hashedRemainingPath = hashString(cleanRemainingPath);
           const leadingSlash = remainingPath.startsWith('/') ? '/' : '';
-          const trailingSlash = remainingPath.endsWith('/') && remainingPath.length > 1 ? '/' : '';
+          const trailingSlash =
+            remainingPath.endsWith('/') && remainingPath.length > 1 ? '/' : '';
 
           return `/user/[USERNAME_HASHED:${hashedUsername}]${leadingSlash}[HASHED:${hashedRemainingPath}]${trailingSlash}`;
         }
@@ -72,11 +74,11 @@ function anonymizeUrlPath(urlPath: string): string {
     // Hash the path and return with original slash structure
     const hashedPath = hashString(cleanPath);
     const leadingSlash = urlPath.startsWith('/') ? '/' : '';
-    const trailingSlash = urlPath.endsWith('/') && urlPath.length > 1 ? '/' : '';
+    const trailingSlash =
+      urlPath.endsWith('/') && urlPath.length > 1 ? '/' : '';
 
     return `${leadingSlash}[HASHED:${hashedPath}]${trailingSlash}`;
-  }
-  catch (error) {
+  } catch (error) {
     logger.warn(`Failed to anonymize URL path: ${error}`);
     return urlPath;
   }
@@ -98,11 +100,14 @@ export const pageAccessModule: AnonymizationModule = {
       if (path === '/') return false;
 
       // Exclude static resources first
-      if (path.includes('/static/')
-        || path.includes('/_next/')
-        || path.includes('/favicon')
-        || path.includes('/assets/')
-        || path.includes('.')) { // Exclude file extensions (images, css, js, etc.)
+      if (
+        path.includes('/static/') ||
+        path.includes('/_next/') ||
+        path.includes('/favicon') ||
+        path.includes('/assets/') ||
+        path.includes('.')
+      ) {
+        // Exclude file extensions (images, css, js, etc.)
         return false;
       }
 
@@ -118,8 +123,7 @@ export const pageAccessModule: AnonymizationModule = {
       // Use GROWI's isCreatablePage logic to determine if this is a valid page path
       // This excludes API endpoints, system paths, etc.
       return isCreatablePage(path);
-    }
-    catch {
+    } catch {
       // If URL parsing fails, don't handle it
       return false;
     }
@@ -148,8 +152,7 @@ export const pageAccessModule: AnonymizationModule = {
       }
 
       return null;
-    }
-    catch (error) {
+    } catch (error) {
       logger.warn(`Failed to anonymize page access URL: ${error}`);
       return null;
     }

+ 49 - 35
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { pageApiModule } from './page-api-handler';
 
@@ -15,22 +13,22 @@ describe('pageApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                                          | url                                                              | expected
-      ${'pages list endpoint'}                             | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
-      ${'subordinated list endpoint'}                      | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
-      ${'check page existence endpoint'}                   | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
-      ${'get page paths with descendant count endpoint'}   | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
-      ${'pages list without query'}                        | ${'/_api/v3/pages/list'}                                         | ${true}
-      ${'subordinated list without query'}                 | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
-      ${'check page existence without query'}              | ${'/_api/v3/page/check-page-existence'}                          | ${true}
-      ${'get page paths without query'}                    | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
-      ${'other pages endpoint'}                            | ${'/_api/v3/pages/create'}                                       | ${false}
-      ${'different API version'}                           | ${'/_api/v2/pages/list'}                                         | ${false}
-      ${'non-page API'}                                    | ${'/_api/v3/search'}                                             | ${false}
-      ${'regular page path'}                               | ${'/page/path'}                                                  | ${false}
-      ${'root path'}                                       | ${'/'}                                                           | ${false}
-      ${'empty URL'}                                       | ${''}                                                            | ${false}
-      ${'partial match but different endpoint'}            | ${'/_api/v3/pages-other/list'}                                   | ${false}
+      description                                        | url                                                              | expected
+      ${'pages list endpoint'}                           | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
+      ${'subordinated list endpoint'}                    | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
+      ${'check page existence endpoint'}                 | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
+      ${'get page paths with descendant count endpoint'} | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
+      ${'pages list without query'}                      | ${'/_api/v3/pages/list'}                                         | ${true}
+      ${'subordinated list without query'}               | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
+      ${'check page existence without query'}            | ${'/_api/v3/page/check-page-existence'}                          | ${true}
+      ${'get page paths without query'}                  | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
+      ${'other pages endpoint'}                          | ${'/_api/v3/pages/create'}                                       | ${false}
+      ${'different API version'}                         | ${'/_api/v2/pages/list'}                                         | ${false}
+      ${'non-page API'}                                  | ${'/_api/v3/search'}                                             | ${false}
+      ${'regular page path'}                             | ${'/page/path'}                                                  | ${false}
+      ${'root path'}                                     | ${'/'}                                                           | ${false}
+      ${'empty URL'}                                     | ${''}                                                            | ${false}
+      ${'partial match but different endpoint'}          | ${'/_api/v3/pages-other/list'}                                   | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -66,7 +64,8 @@ describe('pageApiModule', () => {
 
     describe('pages/subordinated-list endpoint', () => {
       it('should anonymize path parameter', () => {
-        const originalUrl = '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
+        const originalUrl =
+          '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -74,12 +73,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
+          'http.target':
+            '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
         });
       });
 
       it('should handle encoded path parameters', () => {
-        const originalUrl = '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
+        const originalUrl =
+          '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -87,14 +88,16 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
+          'http.target':
+            '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
         });
       });
     });
 
     describe('page/check-page-existence endpoint', () => {
       it('should anonymize path parameter', () => {
-        const originalUrl = '/_api/v3/page/check-page-existence?path=/project/wiki';
+        const originalUrl =
+          '/_api/v3/page/check-page-existence?path=/project/wiki';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -102,12 +105,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should handle multiple parameters including path', () => {
-        const originalUrl = '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
+        const originalUrl =
+          '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -115,14 +120,16 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
         });
       });
     });
 
     describe('page/get-page-paths-with-descendant-count endpoint', () => {
       it('should anonymize paths parameter when present', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -130,12 +137,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
         });
       });
 
       it('should handle encoded paths parameter', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -143,12 +152,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
         });
       });
 
       it('should return null when no paths parameter is present', () => {
-        const url = '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
+        const url =
+          '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(url)).toBe(true);
@@ -217,12 +228,14 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should handle empty paths array parameter', () => {
-        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
+        const originalUrl =
+          '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
 
         // Verify canHandle returns true for this URL
         expect(pageApiModule.canHandle(originalUrl)).toBe(true);
@@ -230,7 +243,8 @@ describe('pageApiModule', () => {
         const result = pageApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
+          'http.target':
+            '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
         });
       });
     });

+ 30 - 17
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-api-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-api-handler',
+});
 
 /**
  * Page API anonymization module
@@ -16,10 +17,12 @@ export const pageApiModule: AnonymizationModule = {
    * Check if this module can handle page API endpoints
    */
   canHandle(url: string): boolean {
-    return url.includes('/_api/v3/pages/list')
-      || url.includes('/_api/v3/pages/subordinated-list')
-      || url.includes('/_api/v3/page/check-page-existence')
-      || url.includes('/_api/v3/page/get-page-paths-with-descendant-count');
+    return (
+      url.includes('/_api/v3/pages/list') ||
+      url.includes('/_api/v3/pages/subordinated-list') ||
+      url.includes('/_api/v3/page/check-page-existence') ||
+      url.includes('/_api/v3/page/get-page-paths-with-descendant-count')
+    );
   },
 
   /**
@@ -30,11 +33,12 @@ export const pageApiModule: AnonymizationModule = {
     let hasAnonymization = false;
 
     // Handle endpoints with 'path' parameter
-    if (url.includes('path=') && (
-      url.includes('/_api/v3/pages/list')
-      || url.includes('/_api/v3/pages/subordinated-list')
-      || url.includes('/_api/v3/page/check-page-existence')
-    )) {
+    if (
+      url.includes('path=') &&
+      (url.includes('/_api/v3/pages/list') ||
+        url.includes('/_api/v3/pages/subordinated-list') ||
+        url.includes('/_api/v3/page/check-page-existence'))
+    ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
       hasAnonymization = true;
@@ -42,18 +46,27 @@ export const pageApiModule: AnonymizationModule = {
       // Determine endpoint type for logging
       let endpointType = 'page API';
       if (url.includes('/_api/v3/pages/list')) endpointType = '/pages/list';
-      else if (url.includes('/_api/v3/pages/subordinated-list')) endpointType = '/pages/subordinated-list';
-      else if (url.includes('/_api/v3/page/check-page-existence')) endpointType = '/page/check-page-existence';
+      else if (url.includes('/_api/v3/pages/subordinated-list'))
+        endpointType = '/pages/subordinated-list';
+      else if (url.includes('/_api/v3/page/check-page-existence'))
+        endpointType = '/page/check-page-existence';
 
-      logger.debug(`Anonymized ${endpointType} URL: ${url} -> ${anonymizedUrl}`);
+      logger.debug(
+        `Anonymized ${endpointType} URL: ${url} -> ${anonymizedUrl}`,
+      );
     }
 
     // Handle page/get-page-paths-with-descendant-count endpoint with paths parameter
-    if (url.includes('/_api/v3/page/get-page-paths-with-descendant-count') && url.includes('paths=')) {
+    if (
+      url.includes('/_api/v3/page/get-page-paths-with-descendant-count') &&
+      url.includes('paths=')
+    ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['paths']);
       attributes[ATTR_HTTP_TARGET] = anonymizedUrl;
       hasAnonymization = true;
-      logger.debug(`Anonymized page/get-page-paths-with-descendant-count URL: ${url} -> ${anonymizedUrl}`);
+      logger.debug(
+        `Anonymized page/get-page-paths-with-descendant-count URL: ${url} -> ${anonymizedUrl}`,
+      );
     }
 
     return hasAnonymization ? attributes : null;

+ 39 - 29
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { pageListingApiModule } from './page-listing-api-handler';
 
@@ -15,20 +13,20 @@ describe('pageListingApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                           | url                                                    | expected
-      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'}  | ${true}
-      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}        | ${true}
-      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}            | ${true}
-      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}         | ${true}
-      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                   | ${true}
-      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                       | ${true}
-      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                      | ${false}
-      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                   | ${false}
-      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                              | ${false}
-      ${'regular page path'}                | ${'/page/path'}                                       | ${false}
-      ${'root path'}                        | ${'/'}                                                | ${false}
-      ${'empty URL'}                        | ${''}                                                 | ${false}
-      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}             | ${false}
+      description                           | url                                                  | expected
+      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'} | ${true}
+      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}       | ${true}
+      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}           | ${true}
+      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}        | ${true}
+      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                  | ${true}
+      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                      | ${true}
+      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                     | ${false}
+      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                  | ${false}
+      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                             | ${false}
+      ${'regular page path'}                | ${'/page/path'}                                      | ${false}
+      ${'root path'}                        | ${'/'}                                               | ${false}
+      ${'empty URL'}                        | ${''}                                                | ${false}
+      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}            | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageListingApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -38,7 +36,8 @@ describe('pageListingApiModule', () => {
   describe('handle', () => {
     describe('ancestors-children endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -46,12 +45,14 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
         });
       });
 
       it('should anonymize empty path parameter', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=&limit=5';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=&limit=5';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -60,7 +61,8 @@ describe('pageListingApiModule', () => {
 
         // Empty path parameter should now be anonymized
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=5',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=5',
         });
       });
 
@@ -78,7 +80,8 @@ describe('pageListingApiModule', () => {
 
     describe('children endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/children?path=/docs/api&offset=0&limit=20';
+        const originalUrl =
+          '/_api/v3/page-listing/children?path=/docs/api&offset=0&limit=20';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -86,12 +89,14 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0&limit=20',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0&limit=20',
         });
       });
 
       it('should handle encoded path parameter', () => {
-        const originalUrl = '/_api/v3/page-listing/children?path=%2Fencoded%2Fpath&limit=10';
+        const originalUrl =
+          '/_api/v3/page-listing/children?path=%2Fencoded%2Fpath&limit=10';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -99,7 +104,8 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=10',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=10',
         });
       });
 
@@ -117,7 +123,8 @@ describe('pageListingApiModule', () => {
 
     describe('info endpoint', () => {
       it('should anonymize path parameter when present', () => {
-        const originalUrl = '/_api/v3/page-listing/info?path=/wiki/documentation';
+        const originalUrl =
+          '/_api/v3/page-listing/info?path=/wiki/documentation';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -143,7 +150,8 @@ describe('pageListingApiModule', () => {
 
     describe('edge cases', () => {
       it('should handle URL with complex query parameters', () => {
-        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/complex/path&sort=name&direction=asc&filter=active';
+        const originalUrl =
+          '/_api/v3/page-listing/ancestors-children?path=/complex/path&sort=name&direction=asc&filter=active';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -151,7 +159,8 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&sort=name&direction=asc&filter=active',
+          'http.target':
+            '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&sort=name&direction=asc&filter=active',
         });
       });
 
@@ -165,7 +174,8 @@ describe('pageListingApiModule', () => {
 
         // Fragment should be preserved by anonymizeQueryParams
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D#section',
+          'http.target':
+            '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D#section',
         });
       });
     });

+ 12 - 9
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:page-listing-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:page-listing-handler',
+});
 
 /**
  * Page listing API anonymization module
@@ -16,9 +17,11 @@ export const pageListingApiModule: AnonymizationModule = {
    * Check if this module can handle page-listing API endpoints
    */
   canHandle(url: string): boolean {
-    return url.includes('/_api/v3/page-listing/ancestors-children')
-      || url.includes('/_api/v3/page-listing/children')
-      || url.includes('/_api/v3/page-listing/info');
+    return (
+      url.includes('/_api/v3/page-listing/ancestors-children') ||
+      url.includes('/_api/v3/page-listing/children') ||
+      url.includes('/_api/v3/page-listing/info')
+    );
     // Add other page-listing endpoints here as needed
   },
 
@@ -31,9 +34,9 @@ export const pageListingApiModule: AnonymizationModule = {
 
     // Handle ancestors-children endpoint
     if (
-      url.includes('/_api/v3/page-listing/ancestors-children')
-      || url.includes('/_api/v3/page-listing/children')
-      || url.includes('/_api/v3/page-listing/info')
+      url.includes('/_api/v3/page-listing/ancestors-children') ||
+      url.includes('/_api/v3/page-listing/children') ||
+      url.includes('/_api/v3/page-listing/info')
     ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       // Only set attributes if the URL was actually modified

+ 15 - 16
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts

@@ -1,8 +1,6 @@
 import type { IncomingMessage } from 'http';
 
-import {
-  describe, it, expect, beforeEach,
-} from 'vitest';
+import { beforeEach, describe, expect, it } from 'vitest';
 
 import { searchApiModule } from './search-api-handler';
 
@@ -15,18 +13,18 @@ describe('searchApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                     | url                                 | expected
-      ${'search API endpoint'}        | ${'/_api/search?q=test'}            | ${true}
-      ${'search API without query'}   | ${'/_api/search'}                   | ${true}
-      ${'search endpoint'}            | ${'/_search?q=keyword'}             | ${true}
-      ${'search endpoint without q'}  | ${'/_search'}                       | ${true}
-      ${'nested search API'}          | ${'/admin/_api/search?q=admin'}     | ${true}
-      ${'nested search endpoint'}     | ${'/docs/_search?q=documentation'}  | ${true}
-      ${'other API endpoint'}         | ${'/_api/pages'}                    | ${false}
-      ${'regular page path'}          | ${'/search/results'}                | ${false}
-      ${'similar but different'}      | ${'/_api/search-results'}           | ${false}
-      ${'root path'}                  | ${'/'}                              | ${false}
-      ${'empty URL'}                  | ${''}                               | ${false}
+      description                    | url                                | expected
+      ${'search API endpoint'}       | ${'/_api/search?q=test'}           | ${true}
+      ${'search API without query'}  | ${'/_api/search'}                  | ${true}
+      ${'search endpoint'}           | ${'/_search?q=keyword'}            | ${true}
+      ${'search endpoint without q'} | ${'/_search'}                      | ${true}
+      ${'nested search API'}         | ${'/admin/_api/search?q=admin'}    | ${true}
+      ${'nested search endpoint'}    | ${'/docs/_search?q=documentation'} | ${true}
+      ${'other API endpoint'}        | ${'/_api/pages'}                   | ${false}
+      ${'regular page path'}         | ${'/search/results'}               | ${false}
+      ${'similar but different'}     | ${'/_api/search-results'}          | ${false}
+      ${'root path'}                 | ${'/'}                             | ${false}
+      ${'empty URL'}                 | ${''}                              | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = searchApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -147,7 +145,8 @@ describe('searchApiModule', () => {
 
         // The actual output may have different parameter order due to URL parsing
         expect(result).toEqual({
-          'http.target': '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
+          'http.target':
+            '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
         });
       });
 

+ 10 - 5
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts

@@ -1,12 +1,13 @@
-import type { IncomingMessage } from 'http';
-
 import { diag } from '@opentelemetry/api';
+import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
 import { anonymizeQueryParams } from '../utils/anonymize-query-params';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:search-handler' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:search-handler',
+});
 
 /**
  * Search API anonymization module
@@ -17,8 +18,12 @@ export const searchApiModule: AnonymizationModule = {
    */
   canHandle(url: string): boolean {
     // More precise matching to avoid false positives
-    return url.match(/\/_api\/search(\?|$)/) !== null || url.match(/\/_search(\?|$)/) !== null
-           || url.includes('/_api/search/') || url.includes('/_search/');
+    return (
+      url.match(/\/_api\/search(\?|$)/) !== null ||
+      url.match(/\/_search(\?|$)/) !== null ||
+      url.includes('/_api/search/') ||
+      url.includes('/_search/')
+    );
   },
 
   /**

+ 28 - 25
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.spec.ts

@@ -1,38 +1,41 @@
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
 
 import { anonymizeQueryParams } from './anonymize-query-params';
 
 describe('anonymizeQueryParams', () => {
   /* eslint-disable max-len */
   it.each`
-    description                       | target                                                                 | paramNames         | expected
-    ${'no matching parameters'}       | ${'/_api/v3/test?other=value&another=test'}                            | ${['nonexistent']} | ${'/_api/v3/test?other=value&another=test'}
-    ${'single string parameter'}      | ${'/_api/v3/search?q=sensitive-query'}                                 | ${['q']}           | ${'/_api/v3/search?q=%5BANONYMIZED%5D'}
-    ${'array-style parameters'}       | ${'/_api/v3/page/test?paths[]=/user/john&paths[]=/user/jane'}          | ${['paths']}       | ${'/_api/v3/page/test?paths%5B%5D=%5BANONYMIZED%5D'}
-    ${'JSON array format'}            | ${'/_api/v3/test?paths=["/user/john","/user/jane"]'}                   | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
-    ${'multiple parameters'}          | ${'/_api/v3/test?q=secret&path=/user/john&other=keep'}                 | ${['q', 'path']}   | ${'/_api/v3/test?q=%5BANONYMIZED%5D&path=%5BANONYMIZED%5D&other=keep'}
-    ${'empty parameter value'}        | ${'/_api/v3/test?q=&other=value'}                                      | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
-    ${'parameter without value'}      | ${'/_api/v3/test?q&other=value'}                                       | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
-    ${'mixed array and single'}       | ${'/_api/v3/test?q=search&paths[]=/user/john&paths[]=/user/jane'}      | ${['q', 'paths']}  | ${'/_api/v3/test?q=%5BANONYMIZED%5D&paths%5B%5D=%5BANONYMIZED%5D'}
-    ${'with section'}                 | ${'/_api/v3/test?q=search#section'}                                    | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D#section'}
-    ${'malformed JSON array'}         | ${'/_api/v3/test?paths=["/user/john"'}                                 | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
-    ${'empty JSON array'}             | ${'/_api/v3/test?paths=[]'}                                            | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
-    ${'single item JSON array'}       | ${'/_api/v3/test?paths=["/user/john"]'}                                | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
-    ${'URL with no query params'}     | ${'/_api/v3/test'}                                                     | ${['q']}           | ${'/_api/v3/test'}
-    ${'complex path with encoding'}   | ${'/_api/v3/test?path=%2Fuser%2Fjohn%20doe'}                           | ${['path']}        | ${'/_api/v3/test?path=%5BANONYMIZED%5D'}
+    description                     | target                                                            | paramNames         | expected
+    ${'no matching parameters'}     | ${'/_api/v3/test?other=value&another=test'}                       | ${['nonexistent']} | ${'/_api/v3/test?other=value&another=test'}
+    ${'single string parameter'}    | ${'/_api/v3/search?q=sensitive-query'}                            | ${['q']}           | ${'/_api/v3/search?q=%5BANONYMIZED%5D'}
+    ${'array-style parameters'}     | ${'/_api/v3/page/test?paths[]=/user/john&paths[]=/user/jane'}     | ${['paths']}       | ${'/_api/v3/page/test?paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'JSON array format'}          | ${'/_api/v3/test?paths=["/user/john","/user/jane"]'}              | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'multiple parameters'}        | ${'/_api/v3/test?q=secret&path=/user/john&other=keep'}            | ${['q', 'path']}   | ${'/_api/v3/test?q=%5BANONYMIZED%5D&path=%5BANONYMIZED%5D&other=keep'}
+    ${'empty parameter value'}      | ${'/_api/v3/test?q=&other=value'}                                 | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'parameter without value'}    | ${'/_api/v3/test?q&other=value'}                                  | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D&other=value'}
+    ${'mixed array and single'}     | ${'/_api/v3/test?q=search&paths[]=/user/john&paths[]=/user/jane'} | ${['q', 'paths']}  | ${'/_api/v3/test?q=%5BANONYMIZED%5D&paths%5B%5D=%5BANONYMIZED%5D'}
+    ${'with section'}               | ${'/_api/v3/test?q=search#section'}                               | ${['q']}           | ${'/_api/v3/test?q=%5BANONYMIZED%5D#section'}
+    ${'malformed JSON array'}       | ${'/_api/v3/test?paths=["/user/john"'}                            | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'empty JSON array'}           | ${'/_api/v3/test?paths=[]'}                                       | ${['paths']}       | ${'/_api/v3/test?paths=%5BANONYMIZED%5D'}
+    ${'single item JSON array'}     | ${'/_api/v3/test?paths=["/user/john"]'}                           | ${['paths']}       | ${'/_api/v3/test?paths=%5B%22%5BANONYMIZED%5D%22%5D'}
+    ${'URL with no query params'}   | ${'/_api/v3/test'}                                                | ${['q']}           | ${'/_api/v3/test'}
+    ${'complex path with encoding'} | ${'/_api/v3/test?path=%2Fuser%2Fjohn%20doe'}                      | ${['path']}        | ${'/_api/v3/test?path=%5BANONYMIZED%5D'}
   `('should handle $description', ({ target, paramNames, expected }) => {
-  /* eslint-enable max-len */
+    /* eslint-enable max-len */
     const result = anonymizeQueryParams(target, paramNames);
     expect(result).toBe(expected);
   });
 
   it.each`
-    description                    | target                         | paramNames    | expected
-    ${'invalid URL format'}       | ${'not-a-valid-url'}           | ${['q']}      | ${'not-a-valid-url'}
-    ${'empty string target'}      | ${''}                          | ${['q']}      | ${''}
-    ${'empty paramNames array'}   | ${'/_api/v3/test?q=secret'}    | ${[]}         | ${'/_api/v3/test?q=secret'}
-  `('should handle edge cases: $description', ({ target, paramNames, expected }) => {
-    const result = anonymizeQueryParams(target, paramNames);
-    expect(result).toBe(expected);
-  });
+    description                 | target                      | paramNames | expected
+    ${'invalid URL format'}     | ${'not-a-valid-url'}        | ${['q']}   | ${'not-a-valid-url'}
+    ${'empty string target'}    | ${''}                       | ${['q']}   | ${''}
+    ${'empty paramNames array'} | ${'/_api/v3/test?q=secret'} | ${[]}      | ${'/_api/v3/test?q=secret'}
+  `(
+    'should handle edge cases: $description',
+    ({ target, paramNames, expected }) => {
+      const result = anonymizeQueryParams(target, paramNames);
+      expect(result).toBe(expected);
+    },
+  );
 });

+ 15 - 8
apps/app/src/features/opentelemetry/server/anonymization/utils/anonymize-query-params.ts

@@ -1,6 +1,8 @@
 import { diag } from '@opentelemetry/api';
 
-const logger = diag.createComponentLogger({ namespace: 'growi:anonymization:anonymize-query-params' });
+const logger = diag.createComponentLogger({
+  namespace: 'growi:anonymization:anonymize-query-params',
+});
 
 /**
  * Try to parse JSON array, return null if invalid
@@ -9,8 +11,7 @@ function tryParseJsonArray(value: string): unknown[] | null {
   try {
     const parsed = JSON.parse(value);
     return Array.isArray(parsed) ? parsed : null;
-  }
-  catch {
+  } catch {
     return null;
   }
 }
@@ -21,7 +22,10 @@ function tryParseJsonArray(value: string): unknown[] | null {
  * @param paramNames - Array of parameter names to anonymize
  * @returns Anonymized HTTP target URL
  */
-export function anonymizeQueryParams(target: string, paramNames: string[]): string {
+export function anonymizeQueryParams(
+  target: string,
+  paramNames: string[],
+): string {
   try {
     const url = new URL(target, 'http://localhost');
     const searchParams = new URLSearchParams(url.search);
@@ -54,10 +58,13 @@ export function anonymizeQueryParams(target: string, paramNames: string[]): stri
       }
     }
 
-    return hasChange ? `${url.pathname}?${searchParams.toString()}${url.hash}` : target;
-  }
-  catch (error) {
-    logger.warn(`Failed to anonymize query parameters [${paramNames.join(', ')}]: ${error}`);
+    return hasChange
+      ? `${url.pathname}?${searchParams.toString()}${url.hash}`
+      : target;
+  } catch (error) {
+    logger.warn(
+      `Failed to anonymize query parameters [${paramNames.join(', ')}]: ${error}`,
+    );
     return target;
   }
 }

+ 32 - 17
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts

@@ -1,6 +1,5 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
 import crypto from 'crypto';
-
-import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
 import { mock } from 'vitest-mock-extended';
 
 import { configManager } from '~/server/service/config-manager';
@@ -51,11 +50,17 @@ describe('addApplicationMetrics', () => {
   it('should create observable gauge and set up metrics collection', () => {
     addApplicationMetrics();
 
-    expect(metrics.getMeter).toHaveBeenCalledWith('growi-application-metrics', '1.0.0');
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.configs', {
-      description: 'GROWI instance information (always 1)',
-      unit: '1',
-    });
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-application-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.configs',
+      {
+        description: 'GROWI instance information (always 1)',
+        unit: '1',
+      },
+    );
     expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
       expect.any(Function),
       [mockGauge],
@@ -76,7 +81,7 @@ describe('addApplicationMetrics', () => {
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     });
 
-    it('should observe metrics with site_url when isAppSiteUrlHashed is false', async() => {
+    it('should observe metrics with site_url when isAppSiteUrlHashed is false', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -89,7 +94,9 @@ describe('addApplicationMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: testSiteUrl,
         site_url_hashed: undefined,
@@ -98,7 +105,7 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should observe metrics with site_url_hashed when isAppSiteUrlHashed is true', async() => {
+    it('should observe metrics with site_url_hashed when isAppSiteUrlHashed is true', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return true;
         return undefined;
@@ -116,7 +123,9 @@ describe('addApplicationMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: '[hashed]',
         site_url_hashed: expectedHash,
@@ -125,7 +134,7 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should handle empty external auth types', async() => {
+    it('should handle empty external auth types', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -153,12 +162,14 @@ describe('addApplicationMetrics', () => {
       });
     });
 
-    it('should handle errors in metrics collection gracefully', async() => {
+    it('should handle errors in metrics collection gracefully', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
       });
-      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
       const mockResult = { observe: vi.fn() };
 
       addApplicationMetrics();
@@ -172,7 +183,7 @@ describe('addApplicationMetrics', () => {
       expect(mockResult.observe).not.toHaveBeenCalled();
     });
 
-    it('should handle missing additionalInfo gracefully', async() => {
+    it('should handle missing additionalInfo gracefully', async () => {
       mockConfigManager.getConfig.mockImplementation((key) => {
         if (key === 'otel:isAppSiteUrlHashed') return false;
         return undefined;
@@ -184,14 +195,18 @@ describe('addApplicationMetrics', () => {
         wikiType: 'open',
         additionalInfo: undefined,
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
 
       addApplicationMetrics();
 
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockConfigManager.getConfig).toHaveBeenCalledWith('otel:isAppSiteUrlHashed');
+      expect(mockConfigManager.getConfig).toHaveBeenCalledWith(
+        'otel:isAppSiteUrlHashed',
+      );
       expect(mockResult.observe).toHaveBeenCalledWith(mockGauge, 1, {
         site_url: testSiteUrl,
         site_url_hashed: undefined,

+ 27 - 14
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts

@@ -1,13 +1,15 @@
-import crypto from 'crypto';
-
 import { diag, metrics } from '@opentelemetry/api';
+import crypto from 'crypto';
 
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-metrics:application-metrics');
-const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:application' });
-
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-metrics:application-metrics',
+);
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:application',
+});
 
 function getSiteUrlHashed(siteUrl: string): string {
   const hasher = crypto.createHash('sha256');
@@ -28,25 +30,36 @@ export function addApplicationMetrics(): void {
 
   // Config metrics collection callback
   meter.addBatchObservableCallback(
-    async(result) => {
+    async (result) => {
       try {
         // Dynamic import to avoid circular dependencies
-        const { growiInfoService } = await import('~/server/service/growi-info');
-        const growiInfo = await growiInfoService.getGrowiInfo({ includeAttachmentInfo: true });
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeAttachmentInfo: true,
+        });
 
-        const isAppSiteUrlHashed = configManager.getConfig('otel:isAppSiteUrlHashed');
+        const isAppSiteUrlHashed = configManager.getConfig(
+          'otel:isAppSiteUrlHashed',
+        );
 
         // Config metrics always have value 1, with information stored in labels
         result.observe(growiInfoGauge, 1, {
           // Dynamic information that can change through configuration
           site_url: isAppSiteUrlHashed ? '[hashed]' : growiInfo.appSiteUrl,
-          site_url_hashed: isAppSiteUrlHashed ? getSiteUrlHashed(growiInfo.appSiteUrl) : undefined,
+          site_url_hashed: isAppSiteUrlHashed
+            ? getSiteUrlHashed(growiInfo.appSiteUrl)
+            : undefined,
           wiki_type: growiInfo.wikiType,
-          external_auth_types: growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') || '',
+          external_auth_types:
+            growiInfo.additionalInfo?.activeExternalAccountTypes?.join(',') ||
+            '',
+        });
+      } catch (error) {
+        loggerDiag.error('Failed to collect application config metrics', {
+          error,
         });
-      }
-      catch (error) {
-        loggerDiag.error('Failed to collect application config metrics', { error });
       }
     },
     [growiInfoGauge],

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-metrics/index.ts

@@ -1,7 +1,7 @@
 export { addApplicationMetrics } from './application-metrics';
 export { addUserCountsMetrics } from './user-counts-metrics';
 
-export const setupCustomMetrics = async(): Promise<void> => {
+export const setupCustomMetrics = async (): Promise<void> => {
   const { addApplicationMetrics } = await import('./application-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
 

+ 48 - 22
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.spec.ts

@@ -1,4 +1,4 @@
-import { metrics, type Meter, type ObservableGauge } from '@opentelemetry/api';
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
 import { mock } from 'vitest-mock-extended';
 
 import { addUserCountsMetrics } from './user-counts-metrics';
@@ -25,7 +25,7 @@ vi.mock('@opentelemetry/api', () => ({
 const mockGrowiInfoService = {
   getGrowiInfo: vi.fn(),
 };
-vi.mock('~/server/service/growi-info', async() => ({
+vi.mock('~/server/service/growi-info', async () => ({
   growiInfoService: mockGrowiInfoService,
 }));
 
@@ -49,15 +49,24 @@ describe('addUserCountsMetrics', () => {
   it('should create observable gauges and set up metrics collection', () => {
     addUserCountsMetrics();
 
-    expect(metrics.getMeter).toHaveBeenCalledWith('growi-user-counts-metrics', '1.0.0');
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.total', {
-      description: 'Total number of users in GROWI',
-      unit: 'users',
-    });
-    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith('growi.users.active', {
-      description: 'Number of active users in GROWI',
-      unit: 'users',
-    });
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-user-counts-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.users.total',
+      {
+        description: 'Total number of users in GROWI',
+        unit: 'users',
+      },
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.users.active',
+      {
+        description: 'Number of active users in GROWI',
+        unit: 'users',
+      },
+    );
     expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
       expect.any(Function),
       [mockUserCountGauge, mockActiveUserCountGauge],
@@ -76,7 +85,7 @@ describe('addUserCountsMetrics', () => {
       mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
     });
 
-    it('should observe user count metrics when growi info is available', async() => {
+    it('should observe user count metrics when growi info is available', async () => {
       const mockResult = { observe: vi.fn() };
 
       addUserCountsMetrics();
@@ -85,12 +94,17 @@ describe('addUserCountsMetrics', () => {
       const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
       await callback(mockResult);
 
-      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({ includeUserCountInfo: true });
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+        includeUserCountInfo: true,
+      });
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 150);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 75);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        75,
+      );
     });
 
-    it('should use default values when user counts are missing', async() => {
+    it('should use default values when user counts are missing', async () => {
       const mockResult = { observe: vi.fn() };
 
       const growiInfoWithoutCounts = {
@@ -98,7 +112,9 @@ describe('addUserCountsMetrics', () => {
           // Missing currentUsersCount and currentActiveUsersCount
         },
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutCounts);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutCounts,
+      );
 
       addUserCountsMetrics();
 
@@ -106,16 +122,21 @@ describe('addUserCountsMetrics', () => {
       await callback(mockResult);
 
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        0,
+      );
     });
 
-    it('should handle missing additionalInfo gracefully', async() => {
+    it('should handle missing additionalInfo gracefully', async () => {
       const mockResult = { observe: vi.fn() };
 
       const growiInfoWithoutAdditionalInfo = {
         // Missing additionalInfo entirely
       };
-      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(growiInfoWithoutAdditionalInfo);
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
 
       addUserCountsMetrics();
 
@@ -123,11 +144,16 @@ describe('addUserCountsMetrics', () => {
       await callback(mockResult);
 
       expect(mockResult.observe).toHaveBeenCalledWith(mockUserCountGauge, 0);
-      expect(mockResult.observe).toHaveBeenCalledWith(mockActiveUserCountGauge, 0);
+      expect(mockResult.observe).toHaveBeenCalledWith(
+        mockActiveUserCountGauge,
+        0,
+      );
     });
 
-    it('should handle errors in metrics collection gracefully', async() => {
-      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+    it('should handle errors in metrics collection gracefully', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
       const mockResult = { observe: vi.fn() };
 
       addUserCountsMetrics();

+ 26 - 12
apps/app/src/features/opentelemetry/server/custom-metrics/user-counts-metrics.ts

@@ -3,7 +3,9 @@ import { diag, metrics } from '@opentelemetry/api';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:opentelemetry:custom-metrics:user-counts');
-const loggerDiag = diag.createComponentLogger({ namespace: 'growi:custom-metrics:user-counts' });
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:user-counts',
+});
 
 export function addUserCountsMetrics(): void {
   logger.info('Starting user counts metrics collection');
@@ -17,25 +19,37 @@ export function addUserCountsMetrics(): void {
   });
 
   // Active user count gauge
-  const activeUserCountGauge = meter.createObservableGauge('growi.users.active', {
-    description: 'Number of active users in GROWI',
-    unit: 'users',
-  });
+  const activeUserCountGauge = meter.createObservableGauge(
+    'growi.users.active',
+    {
+      description: 'Number of active users in GROWI',
+      unit: 'users',
+    },
+  );
 
   // User metrics collection callback
   meter.addBatchObservableCallback(
-    async(result) => {
+    async (result) => {
       try {
         // Dynamic import to avoid circular dependencies
-        const { growiInfoService } = await import('~/server/service/growi-info');
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
 
-        const growiInfo = await growiInfoService.getGrowiInfo({ includeUserCountInfo: true });
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includeUserCountInfo: true,
+        });
 
         // Observe user count metrics
-        result.observe(userCountGauge, growiInfo.additionalInfo?.currentUsersCount || 0);
-        result.observe(activeUserCountGauge, growiInfo.additionalInfo?.currentActiveUsersCount || 0);
-      }
-      catch (error) {
+        result.observe(
+          userCountGauge,
+          growiInfo.additionalInfo?.currentUsersCount || 0,
+        );
+        result.observe(
+          activeUserCountGauge,
+          growiInfo.additionalInfo?.currentActiveUsersCount || 0,
+        );
+      } catch (error) {
         loggerDiag.error('Failed to collect user counts metrics', { error });
       }
     },

+ 10 - 6
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.spec.ts

@@ -21,7 +21,7 @@ describe('getApplicationResourceAttributes', () => {
     vi.clearAllMocks();
   });
 
-  it('should return complete application resource attributes when growi info is available', async() => {
+  it('should return complete application resource attributes when growi info is available', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
@@ -43,10 +43,12 @@ describe('getApplicationResourceAttributes', () => {
       'growi.installedAt': '2023-01-01T00:00:00.000Z',
       'growi.installedAt.by_oldest_user': '2023-01-01T00:00:00.000Z',
     });
-    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({ includeInstalledInfo: true });
+    expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+      includeInstalledInfo: true,
+    });
   });
 
-  it('should handle missing additionalInfo gracefully', async() => {
+  it('should handle missing additionalInfo gracefully', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'standalone',
@@ -66,15 +68,17 @@ describe('getApplicationResourceAttributes', () => {
     });
   });
 
-  it('should return empty object when growiInfoService throws error', async() => {
-    mockGrowiInfoService.getGrowiInfo.mockRejectedValue(new Error('Service unavailable'));
+  it('should return empty object when growiInfoService throws error', async () => {
+    mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+      new Error('Service unavailable'),
+    );
 
     const result = await getApplicationResourceAttributes();
 
     expect(result).toEqual({});
   });
 
-  it('should handle partial additionalInfo data', async() => {
+  it('should handle partial additionalInfo data', async () => {
     const mockGrowiInfo = {
       type: 'app',
       deploymentType: 'docker',

+ 12 - 6
apps/app/src/features/opentelemetry/server/custom-resource-attributes/application-resource-attributes.ts

@@ -2,7 +2,9 @@ import type { Attributes } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:application');
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-resource-attributes:application',
+);
 
 /**
  * Get application fixed information as OpenTelemetry Resource Attributes
@@ -15,7 +17,9 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
     // Dynamic import to avoid circular dependencies
     const { growiInfoService } = await import('~/server/service/growi-info');
 
-    const growiInfo = await growiInfoService.getGrowiInfo({ includeInstalledInfo: true });
+    const growiInfo = await growiInfoService.getGrowiInfo({
+      includeInstalledInfo: true,
+    });
 
     const attributes: Attributes = {
       // Service configuration (rarely changes after system setup)
@@ -25,15 +29,17 @@ export async function getApplicationResourceAttributes(): Promise<Attributes> {
 
       // Installation information (fixed values)
       'growi.installedAt': growiInfo.additionalInfo?.installedAt?.toISOString(),
-      'growi.installedAt.by_oldest_user': growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
+      'growi.installedAt.by_oldest_user':
+        growiInfo.additionalInfo?.installedAtByOldestUser?.toISOString(),
     };
 
     logger.info('Application resource attributes collected', { attributes });
 
     return attributes;
-  }
-  catch (error) {
-    logger.error('Failed to collect application resource attributes', { error });
+  } catch (error) {
+    logger.error('Failed to collect application resource attributes', {
+      error,
+    });
     return {};
   }
 }

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-resource-attributes/index.ts

@@ -1,2 +1,2 @@
-export { getOsResourceAttributes } from './os-resource-attributes';
 export { getApplicationResourceAttributes } from './application-resource-attributes';
+export { getOsResourceAttributes } from './os-resource-attributes';

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.spec.ts

@@ -16,7 +16,7 @@ describe('getOsResourceAttributes', () => {
     totalmem: ReturnType<typeof vi.fn>;
   };
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     vi.clearAllMocks();
     // Get the mocked os module
     mockOs = await vi.importMock('node:os');

+ 3 - 1
apps/app/src/features/opentelemetry/server/custom-resource-attributes/os-resource-attributes.ts

@@ -4,7 +4,9 @@ import type { Attributes } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
-const logger = loggerFactory('growi:opentelemetry:custom-resource-attributes:os');
+const logger = loggerFactory(
+  'growi:opentelemetry:custom-resource-attributes:os',
+);
 
 /**
  * Get OS information as OpenTelemetry Resource Attributes

+ 11 - 13
apps/app/src/features/opentelemetry/server/logger.ts

@@ -1,13 +1,14 @@
-import { diag, type DiagLogger } from '@opentelemetry/api';
+import { type DiagLogger, diag } from '@opentelemetry/api';
 
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:opentelemetry:diag');
 
-
 class DiagLoggerBunyanAdapter implements DiagLogger {
-
-  private parseMessage(message: string, args: unknown[]): [logMessage: string, data: object] {
+  private parseMessage(
+    message: string,
+    args: unknown[],
+  ): [logMessage: string, data: object] {
     let logMessage = message;
     let data = {};
 
@@ -17,12 +18,12 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
       if (typeof parsedMessage === 'object' && parsedMessage !== null) {
         data = parsedMessage;
         // if parsed successfully, use 'message' property as log message
-        logMessage = 'message' in data && typeof data.message === 'string'
-          ? data.message
-          : message;
+        logMessage =
+          'message' in data && typeof data.message === 'string'
+            ? data.message
+            : message;
       }
-    }
-    catch (e) {
+    } catch (e) {
       // do nothing if the message is not a JSON string
     }
 
@@ -34,8 +35,7 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
           try {
             const parsed = JSON.parse(arg);
             return { ...acc, ...parsed };
-          }
-          catch (e) {
+          } catch (e) {
             return { ...acc, additionalInfo: arg };
           }
         }
@@ -66,10 +66,8 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
   verbose(message: string, ...args): void {
     logger.trace(...this.parseMessage(message, args));
   }
-
 }
 
-
 export const initLogger = (): void => {
   // Enable global logger for OpenTelemetry
   diag.setLogger(new DiagLoggerBunyanAdapter());

+ 44 - 30
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -5,7 +5,10 @@ import type { Resource } from '@opentelemetry/resources';
 import { resourceFromAttributes } from '@opentelemetry/resources';
 import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
 import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node';
-import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
+import {
+  ATTR_SERVICE_NAME,
+  ATTR_SERVICE_VERSION,
+} from '@opentelemetry/semantic-conventions';
 
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -14,8 +17,8 @@ import { httpInstrumentationConfig as httpInstrumentationConfigForAnonymize } fr
 import { ATTR_SERVICE_INSTANCE_ID } from './semconv';
 
 type Option = {
-  enableAnonymization?: boolean,
-}
+  enableAnonymization?: boolean;
+};
 
 type Configuration = Partial<NodeSDKConfiguration> & {
   resource: Resource;
@@ -34,7 +37,9 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
     });
 
     // Data anonymization configuration
-    const httpInstrumentationConfig = opts?.enableAnonymization ? httpInstrumentationConfigForAnonymize : {};
+    const httpInstrumentationConfig = opts?.enableAnonymization
+      ? httpInstrumentationConfigForAnonymize
+      : {};
 
     configuration = {
       resource,
@@ -43,23 +48,24 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
         exporter: new OTLPMetricExporter(),
         exportIntervalMillis: 300000, // 5 minute
       }),
-      instrumentations: [getNodeAutoInstrumentations({
-        '@opentelemetry/instrumentation-bunyan': {
-          enabled: false,
-        },
-        // disable fs instrumentation since this generates very large amount of traces
-        // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
-        '@opentelemetry/instrumentation-fs': {
-          enabled: false,
-        },
-        // HTTP instrumentation with anonymization
-        '@opentelemetry/instrumentation-http': {
-          enabled: true,
-          ...httpInstrumentationConfig,
-        },
-      })],
+      instrumentations: [
+        getNodeAutoInstrumentations({
+          '@opentelemetry/instrumentation-bunyan': {
+            enabled: false,
+          },
+          // disable fs instrumentation since this generates very large amount of traces
+          // see: https://opentelemetry.io/docs/languages/js/libraries/#registration
+          '@opentelemetry/instrumentation-fs': {
+            enabled: false,
+          },
+          // HTTP instrumentation with anonymization
+          '@opentelemetry/instrumentation-http': {
+            enabled: true,
+            ...httpInstrumentationConfig,
+          },
+        }),
+      ],
     };
-
   }
 
   return configuration;
@@ -69,19 +75,27 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
  * Generate additional attributes after database initialization
  * This function should be called after database is available
  */
-export const generateAdditionalResourceAttributes = async(opts?: Option): Promise<Resource> => {
+export const generateAdditionalResourceAttributes = async (
+  opts?: Option,
+): Promise<Resource> => {
   if (resource == null) {
-    throw new Error('Resource is not initialized. Call generateNodeSDKConfiguration first.');
+    throw new Error(
+      'Resource is not initialized. Call generateNodeSDKConfiguration first.',
+    );
   }
 
-  const serviceInstanceId = configManager.getConfig('otel:serviceInstanceId')
-    ?? configManager.getConfig('app:serviceInstanceId');
+  const serviceInstanceId =
+    configManager.getConfig('otel:serviceInstanceId') ??
+    configManager.getConfig('app:serviceInstanceId');
 
-  const { getApplicationResourceAttributes, getOsResourceAttributes } = await import('./custom-resource-attributes');
+  const { getApplicationResourceAttributes, getOsResourceAttributes } =
+    await import('./custom-resource-attributes');
 
-  return resource.merge(resourceFromAttributes({
-    [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
-    ...await getApplicationResourceAttributes(),
-    ...await getOsResourceAttributes(),
-  }));
+  return resource.merge(
+    resourceFromAttributes({
+      [ATTR_SERVICE_INSTANCE_ID]: serviceInstanceId,
+      ...(await getApplicationResourceAttributes()),
+      ...(await getOsResourceAttributes()),
+    }),
+  );
 };

+ 1 - 2
apps/app/src/features/opentelemetry/server/node-sdk-resource.ts

@@ -23,8 +23,7 @@ export const setResource = (sdk: NodeSDK, resource: Resource): void => {
   // Verify that we can access the _resource property
   try {
     getResource(sdk);
-  }
-  catch (e) {
+  } catch (e) {
     throw new Error('Failed to access SDK resource');
   }
 

+ 68 - 50
apps/app/src/features/opentelemetry/server/node-sdk.spec.ts

@@ -3,7 +3,11 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
 
 import { configManager } from '~/server/service/config-manager';
 
-import { setupAdditionalResourceAttributes, initInstrumentation, startOpenTelemetry } from './node-sdk';
+import {
+  initInstrumentation,
+  setupAdditionalResourceAttributes,
+  startOpenTelemetry,
+} from './node-sdk';
 import { getResource } from './node-sdk-resource';
 
 // Only mock configManager as it's external to what we're testing
@@ -37,24 +41,28 @@ vi.mock('~/server/service/growi-info', () => ({
 describe('node-sdk', () => {
   // Helper functions to reduce duplication
   const mockInstrumentationEnabled = () => {
-    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-      if (key === 'otel:enabled') {
-        return source === ConfigSource.env ? true : undefined;
-      }
-      return undefined;
-    });
+    vi.mocked(configManager.getConfig).mockImplementation(
+      (key: string, source?: ConfigSource) => {
+        if (key === 'otel:enabled') {
+          return source === ConfigSource.env ? true : undefined;
+        }
+        return undefined;
+      },
+    );
   };
 
   const mockInstrumentationDisabled = () => {
-    vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-      if (key === 'otel:enabled') {
-        return source === ConfigSource.env ? false : undefined;
-      }
-      return undefined;
-    });
+    vi.mocked(configManager.getConfig).mockImplementation(
+      (key: string, source?: ConfigSource) => {
+        if (key === 'otel:enabled') {
+          return source === ConfigSource.env ? false : undefined;
+        }
+        return undefined;
+      },
+    );
   };
 
-  beforeEach(async() => {
+  beforeEach(async () => {
     vi.clearAllMocks();
 
     // Reset SDK instance using __testing__ export
@@ -66,14 +74,14 @@ describe('node-sdk', () => {
   });
 
   describe('initInstrumentation', () => {
-    it('should call setupCustomMetrics when instrumentation is enabled', async() => {
+    it('should call setupCustomMetrics when instrumentation is enabled', async () => {
       // Mock instrumentation as enabled
       mockInstrumentationEnabled();
 
       await initInstrumentation();
     });
 
-    it('should not call setupCustomMetrics when instrumentation is disabled', async() => {
+    it('should not call setupCustomMetrics when instrumentation is disabled', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as disabled
@@ -85,7 +93,7 @@ describe('node-sdk', () => {
       expect(setupCustomMetrics).not.toHaveBeenCalled();
     });
 
-    it('should create SDK instance when instrumentation is enabled', async() => {
+    it('should create SDK instance when instrumentation is enabled', async () => {
       // Mock instrumentation as enabled
       mockInstrumentationEnabled();
 
@@ -98,7 +106,7 @@ describe('node-sdk', () => {
       expect(sdkInstance).toBeInstanceOf(NodeSDK);
     });
 
-    it('should not create SDK instance when instrumentation is disabled', async() => {
+    it('should not create SDK instance when instrumentation is disabled', async () => {
       // Mock instrumentation as disabled
       mockInstrumentationDisabled();
 
@@ -112,17 +120,19 @@ describe('node-sdk', () => {
   });
 
   describe('setupAdditionalResourceAttributes', () => {
-    it('should update service.instance.id when app:serviceInstanceId is available', async() => {
+    it('should update service.instance.id when app:serviceInstanceId is available', async () => {
       // Set up mocks for this specific test
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? true : undefined;
-        }
-        // For service instance IDs, only respond when no source is specified
-        if (key === 'app:serviceInstanceId') return 'test-instance-id';
-        return undefined;
-      });
+      vi.mocked(configManager.getConfig).mockImplementation(
+        (key: string, source?: ConfigSource) => {
+          // For otel:enabled, always expect ConfigSource.env
+          if (key === 'otel:enabled') {
+            return source === ConfigSource.env ? true : undefined;
+          }
+          // For service instance IDs, only respond when no source is specified
+          if (key === 'app:serviceInstanceId') return 'test-instance-id';
+          return undefined;
+        },
+      );
 
       // Initialize SDK first
       await initInstrumentation();
@@ -147,25 +157,29 @@ describe('node-sdk', () => {
 
       // Verify that resource was updated with app:serviceInstanceId
       const updatedResource = getResource(sdkInstance);
-      expect(updatedResource.attributes['service.instance.id']).toBe('test-instance-id');
+      expect(updatedResource.attributes['service.instance.id']).toBe(
+        'test-instance-id',
+      );
     });
 
-    it('should update service.instance.id with otel:serviceInstanceId if available', async() => {
+    it('should update service.instance.id with otel:serviceInstanceId if available', async () => {
       // Set up mocks for this specific test
-      vi.mocked(configManager.getConfig).mockImplementation((key: string, source?: ConfigSource) => {
-        // For otel:enabled, always expect ConfigSource.env
-        if (key === 'otel:enabled') {
-          return source === ConfigSource.env ? true : undefined;
-        }
-
-        // For service instance IDs, only respond when no source is specified
-        if (source === undefined) {
-          if (key === 'otel:serviceInstanceId') return 'otel-instance-id';
-          if (key === 'app:serviceInstanceId') return 'test-instance-id';
-        }
-
-        return undefined;
-      });
+      vi.mocked(configManager.getConfig).mockImplementation(
+        (key: string, source?: ConfigSource) => {
+          // For otel:enabled, always expect ConfigSource.env
+          if (key === 'otel:enabled') {
+            return source === ConfigSource.env ? true : undefined;
+          }
+
+          // For service instance IDs, only respond when no source is specified
+          if (source === undefined) {
+            if (key === 'otel:serviceInstanceId') return 'otel-instance-id';
+            if (key === 'app:serviceInstanceId') return 'test-instance-id';
+          }
+
+          return undefined;
+        },
+      );
 
       // Initialize SDK
       await initInstrumentation();
@@ -184,10 +198,12 @@ describe('node-sdk', () => {
 
       // Verify that otel:serviceInstanceId was used
       const updatedResource = getResource(sdkInstance);
-      expect(updatedResource.attributes['service.instance.id']).toBe('otel-instance-id');
+      expect(updatedResource.attributes['service.instance.id']).toBe(
+        'otel-instance-id',
+      );
     });
 
-    it('should handle gracefully when instrumentation is disabled', async() => {
+    it('should handle gracefully when instrumentation is disabled', async () => {
       // Mock instrumentation as disabled
       mockInstrumentationDisabled();
 
@@ -195,12 +211,14 @@ describe('node-sdk', () => {
       await initInstrumentation();
 
       // Call setupAdditionalResourceAttributes should not throw error
-      await expect(setupAdditionalResourceAttributes()).resolves.toBeUndefined();
+      await expect(
+        setupAdditionalResourceAttributes(),
+      ).resolves.toBeUndefined();
     });
   });
 
   describe('startOpenTelemetry', () => {
-    it('should start SDK and call setupCustomMetrics when instrumentation is enabled and SDK instance exists', async() => {
+    it('should start SDK and call setupCustomMetrics when instrumentation is enabled and SDK instance exists', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as enabled
@@ -228,7 +246,7 @@ describe('node-sdk', () => {
       }
     });
 
-    it('should not start SDK when instrumentation is disabled', async() => {
+    it('should not start SDK when instrumentation is disabled', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as disabled
@@ -244,7 +262,7 @@ describe('node-sdk', () => {
       expect(setupCustomMetrics).not.toHaveBeenCalled();
     });
 
-    it('should not start SDK when SDK instance does not exist', async() => {
+    it('should not start SDK when SDK instance does not exist', async () => {
       const { setupCustomMetrics } = await import('./custom-metrics');
 
       // Mock instrumentation as enabled but don't initialize SDK

+ 52 - 22
apps/app/src/features/opentelemetry/server/node-sdk.ts

@@ -16,28 +16,37 @@ let sdkInstance: NodeSDK | undefined;
  * Since otel library sees it.
  */
 function overwriteSdkDisabled(): void {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
-
-  if (instrumentationEnabled && (
-    process.env.OTEL_SDK_DISABLED === 'true'
-    || process.env.OTEL_SDK_DISABLED === '1'
-  )) {
-    logger.warn("OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.");
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
+
+  if (
+    instrumentationEnabled &&
+    (process.env.OTEL_SDK_DISABLED === 'true' ||
+      process.env.OTEL_SDK_DISABLED === '1')
+  ) {
+    logger.warn(
+      "OTEL_SDK_DISABLED overwritten with 'false' since GROWI's 'otel:enabled' config is true.",
+    );
     process.env.OTEL_SDK_DISABLED = 'false';
     return;
   }
 
-  if (!instrumentationEnabled && (
-    process.env.OTEL_SDK_DISABLED === 'false'
-    || process.env.OTEL_SDK_DISABLED === '0'
-  )) {
-    logger.warn("OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.");
+  if (
+    !instrumentationEnabled &&
+    (process.env.OTEL_SDK_DISABLED === 'false' ||
+      process.env.OTEL_SDK_DISABLED === '0')
+  ) {
+    logger.warn(
+      "OTEL_SDK_DISABLED is overwritten with 'true' since GROWI's 'otel:enabled' config is false.",
+    );
     process.env.OTEL_SDK_DISABLED = 'true';
     return;
   }
 }
 
-export const initInstrumentation = async(): Promise<void> => {
+export const initInstrumentation = async (): Promise<void> => {
   if (sdkInstance != null) {
     logger.warn('OpenTelemetry instrumentation already started');
     return;
@@ -48,7 +57,10 @@ export const initInstrumentation = async(): Promise<void> => {
 
   overwriteSdkDisabled();
 
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
   if (instrumentationEnabled) {
     logger.info(`GROWI now collects anonymous telemetry.
 
@@ -66,9 +78,14 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
 
     // instanciate NodeSDK
     const { NodeSDK } = await import('@opentelemetry/sdk-node');
-    const { generateNodeSDKConfiguration } = await import('./node-sdk-configuration');
+    const { generateNodeSDKConfiguration } = await import(
+      './node-sdk-configuration'
+    );
     // get resource from configuration
-    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig(
+      'otel:anonymizeInBestEffort',
+      ConfigSource.env,
+    );
 
     const sdkConfig = generateNodeSDKConfiguration({ enableAnonymization });
 
@@ -76,20 +93,30 @@ For more information, see https://docs.growi.org/en/admin-guide/admin-cookbook/t
   }
 };
 
-export const setupAdditionalResourceAttributes = async(): Promise<void> => {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+export const setupAdditionalResourceAttributes = async (): Promise<void> => {
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
 
   if (instrumentationEnabled) {
     if (sdkInstance == null) {
       throw new Error('OpenTelemetry instrumentation is not initialized');
     }
 
-    const { generateAdditionalResourceAttributes } = await import('./node-sdk-configuration');
+    const { generateAdditionalResourceAttributes } = await import(
+      './node-sdk-configuration'
+    );
     // get resource from configuration
-    const enableAnonymization = configManager.getConfig('otel:anonymizeInBestEffort', ConfigSource.env);
+    const enableAnonymization = configManager.getConfig(
+      'otel:anonymizeInBestEffort',
+      ConfigSource.env,
+    );
 
     // generate additional resource attributes
-    const updatedResource = await generateAdditionalResourceAttributes({ enableAnonymization });
+    const updatedResource = await generateAdditionalResourceAttributes({
+      enableAnonymization,
+    });
 
     // set resource to sdk instance
     setResource(sdkInstance, updatedResource);
@@ -97,7 +124,10 @@ export const setupAdditionalResourceAttributes = async(): Promise<void> => {
 };
 
 export const startOpenTelemetry = (): void => {
-  const instrumentationEnabled = configManager.getConfig('otel:enabled', ConfigSource.env);
+  const instrumentationEnabled = configManager.getConfig(
+    'otel:enabled',
+    ConfigSource.env,
+  );
 
   if (instrumentationEnabled && sdkInstance != null) {
     if (sdkInstance == null) {

+ 6 - 4
apps/app/src/features/plantuml/services/plantuml.ts

@@ -8,9 +8,9 @@ import carbonGrayDarkStyles from '../themes/carbon-gray-dark.puml';
 import carbonGrayLightStyles from '../themes/carbon-gray-light.puml';
 
 type PlantUMLPluginParams = {
-  plantumlUri: string,
-  isDarkMode?: boolean,
-}
+  plantumlUri: string;
+  isDarkMode?: boolean;
+};
 
 export const remarkPlugin: Plugin<[PlantUMLPluginParams]> = (options) => {
   const { plantumlUri, isDarkMode } = options;
@@ -21,7 +21,9 @@ export const remarkPlugin: Plugin<[PlantUMLPluginParams]> = (options) => {
   return (tree, file) => {
     visit(tree, 'code', (node: Code) => {
       if (node.lang === 'plantuml') {
-        const themeStyles = isDarkMode ? carbonGrayDarkStyles : carbonGrayLightStyles;
+        const themeStyles = isDarkMode
+          ? carbonGrayDarkStyles
+          : carbonGrayLightStyles;
         node.value = `${themeStyles}\n${node.value}`;
       }
     });

+ 31 - 21
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -1,37 +1,45 @@
 import React, {
-  useCallback, useRef, useEffect, useMemo, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
 } from 'react';
 
 import type { GetInputProps } from '../interfaces/downshift';
 
 type Props = {
-  searchKeyword: string,
-  onChange?: (text: string) => void,
-  onSubmit?: () => void,
-  getInputProps: GetInputProps,
-}
+  searchKeyword: string;
+  onChange?: (text: string) => void;
+  onSubmit?: () => void;
+  getInputProps: GetInputProps;
+};
 
 export const SearchForm = (props: Props): JSX.Element => {
-  const {
-    searchKeyword, onChange, onSubmit, getInputProps,
-  } = props;
+  const { searchKeyword, onChange, onSubmit, getInputProps } = props;
 
   const inputRef = useRef<HTMLInputElement>(null);
 
-  const changeSearchTextHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    onChange?.(e.target.value);
-  }, [onChange]);
+  const changeSearchTextHandler = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      onChange?.(e.target.value);
+    },
+    [onChange],
+  );
 
-  const submitHandler = useCallback((e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
+  const submitHandler = useCallback(
+    (e: React.FormEvent<HTMLFormElement>) => {
+      e.preventDefault();
 
-    const isEmptyKeyword = searchKeyword.trim().length === 0;
-    if (isEmptyKeyword) {
-      return;
-    }
+      const isEmptyKeyword = searchKeyword.trim().length === 0;
+      if (isEmptyKeyword) {
+        return;
+      }
 
-    onSubmit?.();
-  }, [searchKeyword, onSubmit]);
+      onSubmit?.();
+    },
+    [searchKeyword, onSubmit],
+  );
 
   const inputOptions = useMemo(() => {
     return getInputProps({
@@ -60,7 +68,9 @@ export const SearchForm = (props: Props): JSX.Element => {
       <button
         type="button"
         className="btn btn-neutral-secondary text-muted position-absolute bottom-0 end-0 w-auto h-100 border-0"
-        onClick={() => { onChange?.('') }}
+        onClick={() => {
+          onChange?.('');
+        }}
       >
         <span className="material-symbols-outlined p-0">cancel</span>
       </button>

+ 72 - 20
apps/app/src/features/search/client/components/SearchHelp.tsx

@@ -1,6 +1,5 @@
-import React, { useState, type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
+import React, { type JSX, useState } from 'react';
 import { Collapse } from 'reactstrap';
 
 export const SearchHelp = (): JSX.Element => {
@@ -10,47 +9,100 @@ export const SearchHelp = (): JSX.Element => {
 
   return (
     <>
-      <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0" onClick={() => setIsOpen(!isOpen)}>
+      <button
+        type="button"
+        className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0"
+        onClick={() => setIsOpen(!isOpen)}
+      >
         <span className="material-symbols-outlined me-2 p-0">help</span>
         <span>{t('search_help.title')}</span>
-        <span className="material-symbols-outlined ms-2 p-0">{isOpen ? 'expand_less' : 'expand_more'}</span>
+        <span className="material-symbols-outlined ms-2 p-0">
+          {isOpen ? 'expand_less' : 'expand_more'}
+        </span>
       </button>
       <Collapse isOpen={isOpen}>
         <table className="table table-borderless m-0">
           <tbody>
             <tr className="border-bottom">
               <th className="py-2">
-                <code>word1</code> <code>word2</code><br />
-                <small className="text-muted">({ t('search_help.and.syntax help') })</small>
+                <code>word1</code> <code>word2</code>
+                <br />
+                <small className="text-muted">
+                  ({t('search_help.and.syntax help')})
+                </small>
               </th>
-              <td><h6 className="m-0 text-muted">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
+              <td>
+                <h6 className="m-0 text-muted">
+                  {t('search_help.and.desc', {
+                    word1: 'word1',
+                    word2: 'word2',
+                  })}
+                </h6>
+              </td>
             </tr>
             <tr className="border-bottom">
               <th className="py-2">
-                <code>&quot;This is GROWI&quot;</code><br />
-                <small className="text-muted">({ t('search_help.phrase.syntax help') })</small>
+                <code>&quot;This is GROWI&quot;</code>
+                <br />
+                <small className="text-muted">
+                  ({t('search_help.phrase.syntax help')})
+                </small>
               </th>
-              <td><h6 className="m-0 text-muted">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
+              <td>
+                <h6 className="m-0 text-muted">
+                  {t('search_help.phrase.desc', { phrase: 'This is GROWI' })}
+                </h6>
+              </td>
             </tr>
             <tr className="border-bottom">
-              <th className="py-2"><code>-keyword</code></th>
-              <td><h6 className="m-0 text-muted">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
+              <th className="py-2">
+                <code>-keyword</code>
+              </th>
+              <td>
+                <h6 className="m-0 text-muted">
+                  {t('search_help.exclude.desc', { word: 'keyword' })}
+                </h6>
+              </td>
             </tr>
             <tr className="border-bottom">
-              <th className="py-2"><code>prefix:/user/</code></th>
-              <td><h6 className="m-0 text-muted">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
+              <th className="py-2">
+                <code>prefix:/user/</code>
+              </th>
+              <td>
+                <h6 className="m-0 text-muted">
+                  {t('search_help.prefix.desc', { path: '/user/' })}
+                </h6>
+              </td>
             </tr>
             <tr className="border-bottom">
-              <th className="py-2"><code>-prefix:/user/</code></th>
-              <td><h6 className="m-0 text-muted">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
+              <th className="py-2">
+                <code>-prefix:/user/</code>
+              </th>
+              <td>
+                <h6 className="m-0 text-muted">
+                  {t('search_help.exclude_prefix.desc', { path: '/user/' })}
+                </h6>
+              </td>
             </tr>
             <tr className="border-bottom">
-              <th className="py-2"><code>tag:wiki</code></th>
-              <td><h6 className="m-0 text-muted">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
+              <th className="py-2">
+                <code>tag:wiki</code>
+              </th>
+              <td>
+                <h6 className="m-0 text-muted">
+                  {t('search_help.tag.desc', { tag: 'wiki' })}
+                </h6>
+              </td>
             </tr>
             <tr>
-              <th className="py-2"><code>-tag:wiki</code></th>
-              <td><h6 className="m-0 text-muted">{ t('search_help.exclude_tag.desc', { tag: 'wiki' }) }</h6></td>
+              <th className="py-2">
+                <code>-tag:wiki</code>
+              </th>
+              <td>
+                <h6 className="m-0 text-muted">
+                  {t('search_help.exclude_tag.desc', { tag: 'wiki' })}
+                </h6>
+              </td>
             </tr>
           </tbody>
         </table>

+ 15 - 20
apps/app/src/features/search/client/components/SearchMenuItem.tsx

@@ -1,35 +1,30 @@
-import React, { type JSX } from 'react';
+import type React from 'react';
+import type { JSX } from 'react';
 
 import type { GetItemProps } from '../interfaces/downshift';
 
 import styles from './SearchMenuItem.module.scss';
 
 type Props = {
-  url: string
-  index: number
-  isActive: boolean
-  getItemProps: GetItemProps
-  children: React.ReactNode
-}
+  url: string;
+  index: number;
+  isActive: boolean;
+  getItemProps: GetItemProps;
+  children: React.ReactNode;
+};
 
 export const SearchMenuItem = (props: Props): JSX.Element => {
-  const {
-    url, index, isActive, getItemProps, children,
-  } = props;
+  const { url, index, isActive, getItemProps, children } = props;
 
-  const itemMenuOptions = (
-    getItemProps({
-      index,
-      item: { url },
-      className: `d-flex align-items-center px-2 py-1 text-muted ${isActive ? 'active' : ''}`,
-    })
-  );
+  const itemMenuOptions = getItemProps({
+    index,
+    item: { url },
+    className: `d-flex align-items-center px-2 py-1 text-muted ${isActive ? 'active' : ''}`,
+  });
 
   return (
     <div className={`search-menu-item ${styles['search-menu-item']}`}>
-      <li {...itemMenuOptions}>
-        { children }
-      </li>
+      <li {...itemMenuOptions}>{children}</li>
     </div>
   );
 };

+ 29 - 21
apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx

@@ -1,7 +1,6 @@
-import React, { type JSX } from 'react';
-
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
+import React, { type JSX } from 'react';
 
 import { useCurrentPagePath } from '~/states/page';
 
@@ -10,30 +9,28 @@ import type { GetItemProps } from '../interfaces/downshift';
 import { SearchMenuItem } from './SearchMenuItem';
 
 type Props = {
-  activeIndex: number | null
-  searchKeyword: string
-  getItemProps: GetItemProps
-}
+  activeIndex: number | null;
+  searchKeyword: string;
+  getItemProps: GetItemProps;
+};
 
 export const SearchMethodMenuItem = (props: Props): JSX.Element => {
-  const {
-    activeIndex, searchKeyword, getItemProps,
-  } = props;
+  const { activeIndex, searchKeyword, getItemProps } = props;
 
   const { t } = useTranslation('commons');
 
   const [currentPagePath] = useCurrentPagePath();
 
-  const dPagePath = (new DevidedPagePath(currentPagePath ?? '', true, true));
+  const dPagePath = new DevidedPagePath(currentPagePath ?? '', true, true);
   const currentPageName = `
-  ${(!(dPagePath.isRoot || dPagePath.isFormerRoot) ? '...' : '')}/${(dPagePath.isRoot ? '' : `${dPagePath.latter}/`)}
+  ${!(dPagePath.isRoot || dPagePath.isFormerRoot) ? '...' : ''}/${dPagePath.isRoot ? '' : `${dPagePath.latter}/`}
   `;
 
   const shouldShowMenuItem = searchKeyword.trim().length > 0;
 
   return (
     <div>
-      { shouldShowMenuItem && (
+      {shouldShowMenuItem && (
         <div data-testid="search-all-menu-item">
           <SearchMenuItem
             index={0}
@@ -41,10 +38,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
             getItemProps={getItemProps}
             url={`/_search?q=${searchKeyword}`}
           >
-            <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+            <span className="material-symbols-outlined fs-4 me-3 p-0">
+              search
+            </span>
             <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
               <span className="text-break me-auto">{searchKeyword}</span>
-              <span className="small text-body-tertiary">{t('search_method_menu_item.search_in_all')}</span>
+              <span className="small text-body-tertiary">
+                {t('search_method_menu_item.search_in_all')}
+              </span>
             </div>
           </SearchMenuItem>
         </div>
@@ -56,30 +57,37 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
         >
-          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">
+            search
+          </span>
           <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
             <code className="text-break">{currentPageName}</code>
             <span className="ms-md-2 text-break me-auto">{searchKeyword}</span>
-            <span className="small text-body-tertiary">{t('search_method_menu_item.only_children_of_this_tree')}</span>
+            <span className="small text-body-tertiary">
+              {t('search_method_menu_item.only_children_of_this_tree')}
+            </span>
           </div>
         </SearchMenuItem>
       </div>
 
-      { shouldShowMenuItem && (
+      {shouldShowMenuItem && (
         <SearchMenuItem
           index={2}
           isActive={activeIndex === 2}
           getItemProps={getItemProps}
           url={`/_search?q="${searchKeyword}"`}
         >
-          <span className="material-symbols-outlined fs-4 me-3 p-0">search</span>
+          <span className="material-symbols-outlined fs-4 me-3 p-0">
+            search
+          </span>
           <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
             <span className="text-break me-auto">{`"${searchKeyword}"`}</span>
-            <span className="small text-body-tertiary">{t('search_method_menu_item.exact_mutch')}</span>
+            <span className="small text-body-tertiary">
+              {t('search_method_menu_item.exact_mutch')}
+            </span>
           </div>
         </SearchMenuItem>
-      ) }
+      )}
     </div>
-
   );
 };

+ 29 - 17
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -1,10 +1,9 @@
-
-import React, {
-  useState, useCallback, useEffect, type JSX,
-} from 'react';
-
-import Downshift, { type DownshiftState, type StateChangeOptions } from 'downshift';
+import Downshift, {
+  type DownshiftState,
+  type StateChangeOptions,
+} from 'downshift';
 import { useRouter } from 'next/router';
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { Modal, ModalBody } from 'reactstrap';
 
 import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
@@ -17,7 +16,6 @@ import { SearchMethodMenuItem } from './SearchMethodMenuItem';
 import { SearchResultMenuItem } from './SearchResultMenuItem';
 
 const SearchModal = (): JSX.Element => {
-
   const [searchKeyword, setSearchKeyword] = useState('');
   const [isMenthionedToAi, setMenthionedToAi] = useState(false);
 
@@ -29,10 +27,13 @@ const SearchModal = (): JSX.Element => {
     setSearchKeyword(searchText);
   }, []);
 
-  const selectSearchMenuItemHandler = useCallback((selectedItem: DownshiftItem) => {
-    router.push(selectedItem.url);
-    closeSearchModal();
-  }, [closeSearchModal, router]);
+  const selectSearchMenuItemHandler = useCallback(
+    (selectedItem: DownshiftItem) => {
+      router.push(selectedItem.url);
+      closeSearchModal();
+    },
+    [closeSearchModal, router],
+  );
 
   const submitHandler = useCallback(() => {
     const url = new URL('_search', 'http://example.com');
@@ -41,7 +42,10 @@ const SearchModal = (): JSX.Element => {
     closeSearchModal();
   }, [closeSearchModal, router, searchKeyword]);
 
-  const stateReducer = (state: DownshiftState<DownshiftItem>, changes: StateChangeOptions<DownshiftItem>) => {
+  const stateReducer = (
+    state: DownshiftState<DownshiftItem>,
+    changes: StateChangeOptions<DownshiftItem>,
+  ) => {
     // Do not update highlightedIndex on mouse hover
     if (changes.type === Downshift.stateChangeTypes.itemMouseEnter) {
       return {
@@ -59,8 +63,7 @@ const SearchModal = (): JSX.Element => {
     }
     if (searchModalData?.searchKeyword == null) {
       setSearchKeyword('');
-    }
-    else {
+    } else {
       setSearchKeyword(searchModalData.searchKeyword);
     }
   }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
@@ -72,7 +75,12 @@ const SearchModal = (): JSX.Element => {
   const searchKeywordWithoutAi = removeAiMenthion(searchKeyword);
 
   return (
-    <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">
+    <Modal
+      size="lg"
+      isOpen={searchModalData?.isOpened ?? false}
+      toggle={closeSearchModal}
+      data-testid="search-modal"
+    >
       <ModalBody className="pb-2">
         <Downshift
           onSelect={selectSearchMenuItemHandler}
@@ -88,7 +96,9 @@ const SearchModal = (): JSX.Element => {
           }) => (
             <div {...getRootProps({}, { suppressRefError: true })}>
               <div className="text-muted d-flex justify-content-center align-items-center p-1">
-                <span className={`material-symbols-outlined fs-4 me-3 ${isMenthionedToAi ? 'text-primary' : ''}`}>
+                <span
+                  className={`material-symbols-outlined fs-4 me-3 ${isMenthionedToAi ? 'text-primary' : ''}`}
+                >
                   {isMenthionedToAi ? 'psychology' : 'search'}
                 </span>
                 <SearchForm
@@ -102,7 +112,9 @@ const SearchModal = (): JSX.Element => {
                   className="btn border-0 d-flex justify-content-center p-0"
                   onClick={closeSearchModal}
                 >
-                  <span className="material-symbols-outlined fs-4 ms-3 py-0">close</span>
+                  <span className="material-symbols-outlined fs-4 ms-3 py-0">
+                    close
+                  </span>
                 </button>
               </div>
 

+ 40 - 30
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -1,6 +1,5 @@
-import React, { useCallback, type JSX } from 'react';
-
 import { PagePathLabel, UserPicture } from '@growi/ui/dist/components';
+import React, { type JSX, useCallback } from 'react';
 import { useDebounce } from 'usehooks-ts';
 
 import { useSWRxSearch } from '~/stores/search';
@@ -10,10 +9,10 @@ import type { GetItemProps } from '../interfaces/downshift';
 import { SearchMenuItem } from './SearchMenuItem';
 
 type Props = {
-  activeIndex: number | null,
-  searchKeyword: string,
-  getItemProps: GetItemProps,
-}
+  activeIndex: number | null;
+  searchKeyword: string;
+  getItemProps: GetItemProps;
+};
 export const SearchResultMenuItem = (props: Props): JSX.Element => {
   const { activeIndex, searchKeyword, getItemProps } = props;
 
@@ -21,16 +20,23 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
 
   const isEmptyKeyword = searchKeyword.trim().length === 0;
 
-  const { data: searchResult, isLoading } = useSWRxSearch(isEmptyKeyword ? null : debouncedKeyword, null, { limit: 10 });
+  const { data: searchResult, isLoading } = useSWRxSearch(
+    isEmptyKeyword ? null : debouncedKeyword,
+    null,
+    { limit: 10 },
+  );
 
   /**
    *  SearchMenu is a combination of a list of SearchMethodMenuItem and SearchResultMenuItem (this component).
    *  If no keywords are entered into SearchForm, SearchMethodMenuItem returns a single item. Conversely, when keywords are entered, three items are returned.
    *  For these reasons, the starting index of SearchResultMemuItem changes depending on the presence or absence of the searchKeyword.
    */
-  const getFiexdIndex = useCallback((index: number) => {
-    return (isEmptyKeyword ? 1 : 3) + index;
-  }, [isEmptyKeyword]);
+  const getFiexdIndex = useCallback(
+    (index: number) => {
+      return (isEmptyKeyword ? 1 : 3) + index;
+    },
+    [isEmptyKeyword],
+  );
 
   if (isLoading) {
     return (
@@ -41,35 +47,39 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
     );
   }
 
-  if (isEmptyKeyword || searchResult == null || searchResult.data.length === 0) {
+  if (
+    isEmptyKeyword ||
+    searchResult == null ||
+    searchResult.data.length === 0
+  ) {
     return <></>;
   }
 
   return (
     <div>
       <div className="border-top mt-2 mb-2" />
-      {searchResult?.data
-        .map((item, index) => (
-          <SearchMenuItem
-            key={item.data._id}
-            index={getFiexdIndex(index)}
-            isActive={getFiexdIndex(index) === activeIndex}
-            getItemProps={getItemProps}
-            url={item.data._id}
-          >
-            <UserPicture user={item.data.creator} />
+      {searchResult?.data.map((item, index) => (
+        <SearchMenuItem
+          key={item.data._id}
+          index={getFiexdIndex(index)}
+          isActive={getFiexdIndex(index) === activeIndex}
+          getItemProps={getItemProps}
+          url={item.data._id}
+        >
+          <UserPicture user={item.data.creator} />
 
-            <span className="ms-3 text-break text-wrap">
-              <PagePathLabel path={item.data.path} />
-            </span>
+          <span className="ms-3 text-break text-wrap">
+            <PagePathLabel path={item.data.path} />
+          </span>
 
-            <span className="text-body-tertiary ms-2 d-flex justify-content-center align-items-center">
-              <span className="material-symbols-outlined fs-6 p-0">footprint</span>
-              <span className="fs-6">{item.data.seenUsers.length}</span>
+          <span className="text-body-tertiary ms-2 d-flex justify-content-center align-items-center">
+            <span className="material-symbols-outlined fs-6 p-0">
+              footprint
             </span>
-          </SearchMenuItem>
-        ))
-      }
+            <span className="fs-6">{item.data.seenUsers.length}</span>
+          </span>
+        </SearchMenuItem>
+      ))}
     </div>
   );
 };

+ 4 - 2
apps/app/src/features/search/client/interfaces/downshift.ts

@@ -2,5 +2,7 @@ import type { ControllerStateAndHelpers } from 'downshift';
 
 export type DownshiftItem = { url: string };
 
-export type GetItemProps = ControllerStateAndHelpers<DownshiftItem>['getItemProps']
-export type GetInputProps = ControllerStateAndHelpers<DownshiftItem>['getInputProps']
+export type GetItemProps =
+  ControllerStateAndHelpers<DownshiftItem>['getItemProps'];
+export type GetInputProps =
+  ControllerStateAndHelpers<DownshiftItem>['getInputProps'];

+ 24 - 12
apps/app/src/features/search/client/stores/search.ts

@@ -5,23 +5,35 @@ import type { SWRResponse } from 'swr';
 import { useStaticSWR } from '~/stores/use-static-swr';
 
 type SearchModalStatus = {
-  isOpened: boolean,
-  searchKeyword?: string,
-}
+  isOpened: boolean;
+  searchKeyword?: string;
+};
 
 type SearchModalUtils = {
-  open(keywordOnInit?: string): void
-  close(): void
-}
-export const useSearchModal = (status?: SearchModalStatus): SWRResponse<SearchModalStatus, Error> & SearchModalUtils => {
+  open(keywordOnInit?: string): void;
+  close(): void;
+};
+export const useSearchModal = (
+  status?: SearchModalStatus,
+): SWRResponse<SearchModalStatus, Error> & SearchModalUtils => {
   const initialStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<SearchModalStatus, Error>('SearchModal', status, { fallbackData: initialStatus });
+  const swrResponse = useStaticSWR<SearchModalStatus, Error>(
+    'SearchModal',
+    status,
+    { fallbackData: initialStatus },
+  );
 
   return {
     ...swrResponse,
-    open: useCallback((keywordOnInit?: string) => {
-      swrResponse.mutate({ isOpened: true, searchKeyword: keywordOnInit });
-    }, [swrResponse]),
-    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
+    open: useCallback(
+      (keywordOnInit?: string) => {
+        swrResponse.mutate({ isOpened: true, searchKeyword: keywordOnInit });
+      },
+      [swrResponse],
+    ),
+    close: useCallback(
+      () => swrResponse.mutate({ isOpened: false }),
+      [swrResponse],
+    ),
   };
 };

+ 1 - 1
apps/app/src/server/routes/apiv3/attachment.js

@@ -356,7 +356,7 @@ module.exports = (crowi) => {
       }
 
       try {
-        const page = await Page.findById(pageId);
+        const page = await Page.findOne({ _id: { $eq: pageId } });
 
         // check the user is accessible
         const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);

+ 42 - 22
apps/app/src/server/routes/apiv3/export.js

@@ -1,7 +1,12 @@
+import fs from 'fs';
+
+import { SCOPE } from '@growi/core/dist/interfaces';
+import express from 'express';
+import { param, body } from 'express-validator';
+import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { exportService } from '~/server/service/export';
 import loggerFactory from '~/utils/logger';
@@ -11,11 +16,6 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
 const logger = loggerFactory('growi:routes:apiv3:export');
-const fs = require('fs');
-
-const express = require('express');
-const { param } = require('express-validator');
-
 const router = express.Router();
 
 /**
@@ -145,6 +145,25 @@ module.exports = (crowi) => {
   });
 
   const validator = {
+    generateZipFile: [
+      body('collections')
+        .isArray()
+        .withMessage('"collections" must be an array')
+        .bail()
+
+        .notEmpty()
+        .withMessage('"collections" array cannot be empty')
+        .bail()
+
+        .custom(async(value) => {
+          // Check if all the collections in the request body exist in the database
+          const listCollectionsResult = await mongoose.connection.db.listCollections().toArray();
+          const allCollectionNames = listCollectionsResult.map(collectionObj => collectionObj.name);
+          if (!value.every(v => allCollectionNames.includes(v))) {
+            throw new Error('Invalid collections');
+          }
+        }),
+    ],
     deleteFile: [
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing delete file (path traversal attack)
@@ -214,27 +233,28 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    description: whether the request is succeeded
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired, addActivity, async(req, res) => {
+  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.EXPORT_DATA], { acceptLegacy: true }), loginRequired, adminRequired,
+    validator.generateZipFile, apiV3FormValidator, addActivity, async(req, res) => {
     // TODO: add express validator
-    try {
-      const { collections } = req.body;
+      try {
+        const { collections } = req.body;
 
-      exportService.export(collections);
+        exportService.export(collections);
 
-      const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE };
-      activityEvent.emit('update', res.locals.activity._id, parameters);
+        const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
 
-      // TODO: use res.apiv3
-      return res.status(200).json({
-        ok: true,
-      });
-    }
-    catch (err) {
+        // TODO: use res.apiv3
+        return res.status(200).json({
+          ok: true,
+        });
+      }
+      catch (err) {
       // TODO: use ApiV3Error
-      logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
-    }
-  });
+        logger.error(err);
+        return res.status(500).send({ status: 'ERROR' });
+      }
+    });
 
   /**
    * @swagger

+ 1 - 1
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -145,7 +145,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       const sanitizeRevisionId = revisionId == null ? undefined : generalXssFilter.process(revisionId);
 
       // check page existence
-      const isExist = await Page.count({ _id: pageId }) > 0;
+      const isExist = await Page.count({ _id: { $eq: pageId } }) > 0;
       if (!isExist) {
         return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
       }

+ 7 - 4
bin/data-migrations/README.md

@@ -9,17 +9,20 @@ git clone https://github.com/weseek/growi
 cd growi/bin/data-migrations
 
 NETWORK=growi_devcontainer_default
-MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi
+MONGO_URI=mongodb://growi-devcontainer_mongo-1/growi
 
 docker run --rm \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -w /opt \
   -e MIGRATION_MODULE=v60x \
-  mongo:6.0 \
+  mongo:8.0 \
   /bin/mongosh $MONGO_URI index.js
 ```
 
+> **Note**
+> This script uses MongoDB 8.0 Docker image, but mongosh has backward compatibility and can connect to any MongoDB server version 4.2 or greater. See [MongoDB Shell documentation](https://www.mongodb.com/docs/mongodb-shell/install/) for details.
+
 ## Variables
 | Variable              | Description                                                                    | Default |
 | --------------------- | ------------------------------------------------------------------------------ | ------- |
@@ -81,12 +84,12 @@ git clone https://github.com/weseek/growi
 cd growi/bin/data-migrations
 
 NETWORK=growi_devcontainer_default \
-MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
+MONGO_URI=mongodb://growi-devcontainer_mongo-1/growi
 docker run --rm \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -w /opt \
   -e MIGRATION_MODULE=custom \
-  mongo:6.0 \
+  mongo:8.0 \
   /bin/mongosh $MONGO_URI index.js
 ```

+ 0 - 5
biome.json

@@ -27,15 +27,10 @@
       "!apps/app/public/**",
       "!apps/app/src/client/**",
       "!apps/app/src/components/**",
-      "!apps/app/src/features/external-user-group/**",
       "!apps/app/src/features/growi-plugin/**",
-      "!apps/app/src/features/mermaid/**",
       "!apps/app/src/features/openai/**",
-      "!apps/app/src/features/opentelemetry/**",
       "!apps/app/src/features/page-bulk-export/**",
-      "!apps/app/src/features/plantuml/**",
       "!apps/app/src/features/rate-limiter/**",
-      "!apps/app/src/features/search/**",
       "!apps/app/src/interfaces/**",
       "!apps/app/src/models/**",
       "!apps/app/src/pages/**",

+ 2 - 0
packages/remark-lsx/src/client/components/Lsx.tsx

@@ -66,6 +66,8 @@ const LsxSubstance = React.memo(
             <span className="material-symbols-outlined me-1">warning</span>{' '}
             {lsxContext.toString()}
           </summary>
+          {/* Since error messages may contain user-input strings, use JSX embedding as shown below */}
+          {/* https://legacy.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks */}
           <small className="ms-3 text-muted">{errorMessage}</small>
         </details>
       );

+ 2 - 2
packages/remark-lsx/src/client/stores/lsx/lsx.ts

@@ -77,9 +77,9 @@ export const useSWRxLsx = (
         return res.data;
       } catch (err) {
         if (axios.isAxiosError(err)) {
-          throw new Error(err.response?.data.message);
+          throw new Error(err.response?.data);
         }
-        throw err;
+        throw new Error(err);
       }
     },
 

+ 152 - 20
pnpm-lock.yaml

@@ -78,7 +78,7 @@ importers:
         version: 8.41.0
       eslint-config-next:
         specifier: ^12.1.6
-        version: 12.1.6(eslint@8.41.0)(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4)
+        version: 12.1.6(eslint@8.41.0)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4)
       eslint-config-weseek:
         specifier: ^2.1.1
         version: 2.1.1(@babel/core@7.24.6)(@babel/eslint-parser@7.24.7(@babel/core@7.24.6)(eslint@8.41.0))(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4))(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint-import-resolver-typescript@3.2.5)(eslint-plugin-import@2.26.0)(eslint-plugin-jsx-a11y@6.5.1(eslint@8.41.0))(eslint-plugin-react-hooks@4.6.0(eslint@8.41.0))(eslint-plugin-react@7.30.1(eslint@8.41.0))(eslint-plugin-vue@7.20.0(eslint@8.41.0))(eslint@8.41.0)
@@ -330,7 +330,7 @@ importers:
         version: 3.9.1
       babel-plugin-superjson-next:
         specifier: ^0.4.2
-        version: 0.4.5(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
+        version: 0.4.5(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
       body-parser:
         specifier: ^1.20.3
         version: 1.20.3
@@ -533,20 +533,20 @@ importers:
         specifier: ^4.2.0
         version: 4.2.0
       next:
-        specifier: ^14.2.30
-        version: 14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+        specifier: ^14.2.32
+        version: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       next-dynamic-loading-props:
         specifier: ^0.1.1
         version: 0.1.1(react@18.2.0)
       next-i18next:
         specifier: ^15.3.1
-        version: 15.3.1(i18next@23.16.5)(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
+        version: 15.3.1(i18next@23.16.5)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0)
       next-superjson:
         specifier: ^1.0.7
-        version: 1.0.7(@swc/helpers@0.5.15)(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
+        version: 1.0.7(@swc/helpers@0.5.15)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
       next-themes:
         specifier: ^0.2.1
-        version: 0.2.1(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+        version: 0.2.1(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       nocache:
         specifier: ^4.0.0
         version: 4.0.0
@@ -3505,6 +3505,9 @@ packages:
   '@next/env@14.2.30':
     resolution: {integrity: sha512-KBiBKrDY6kxTQWGzKjQB7QirL3PiiOkV7KW98leHFjtVRKtft76Ra5qSA/SL75xT44dp6hOcqiiJ6iievLOYug==}
 
+  '@next/env@14.2.32':
+    resolution: {integrity: sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==}
+
   '@next/eslint-plugin-next@12.1.6':
     resolution: {integrity: sha512-yNUtJ90NEiYFT6TJnNyofKMPYqirKDwpahcbxBgSIuABwYOdkGwzos1ZkYD51Qf0diYwpQZBeVqElTk7Q2WNqw==}
 
@@ -3514,54 +3517,108 @@ packages:
     cpu: [arm64]
     os: [darwin]
 
+  '@next/swc-darwin-arm64@14.2.32':
+    resolution: {integrity: sha512-osHXveM70zC+ilfuFa/2W6a1XQxJTvEhzEycnjUaVE8kpUS09lDpiDDX2YLdyFCzoUbvbo5r0X1Kp4MllIOShw==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [darwin]
+
   '@next/swc-darwin-x64@14.2.30':
     resolution: {integrity: sha512-TyO7Wz1IKE2kGv8dwQ0bmPL3s44EKVencOqwIY69myoS3rdpO1NPg5xPM5ymKu7nfX4oYJrpMxv8G9iqLsnL4A==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [darwin]
 
+  '@next/swc-darwin-x64@14.2.32':
+    resolution: {integrity: sha512-P9NpCAJuOiaHHpqtrCNncjqtSBi1f6QUdHK/+dNabBIXB2RUFWL19TY1Hkhu74OvyNQEYEzzMJCMQk5agjw1Qg==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [darwin]
+
   '@next/swc-linux-arm64-gnu@14.2.30':
     resolution: {integrity: sha512-I5lg1fgPJ7I5dk6mr3qCH1hJYKJu1FsfKSiTKoYwcuUf53HWTrEkwmMI0t5ojFKeA6Vu+SfT2zVy5NS0QLXV4Q==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
+  '@next/swc-linux-arm64-gnu@14.2.32':
+    resolution: {integrity: sha512-v7JaO0oXXt6d+cFjrrKqYnR2ubrD+JYP7nQVRZgeo5uNE5hkCpWnHmXm9vy3g6foMO8SPwL0P3MPw1c+BjbAzA==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
   '@next/swc-linux-arm64-musl@14.2.30':
     resolution: {integrity: sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [linux]
 
+  '@next/swc-linux-arm64-musl@14.2.32':
+    resolution: {integrity: sha512-tA6sIKShXtSJBTH88i0DRd6I9n3ZTirmwpwAqH5zdJoQF7/wlJXR8DkPmKwYl5mFWhEKr5IIa3LfpMW9RRwKmQ==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [linux]
+
   '@next/swc-linux-x64-gnu@14.2.30':
     resolution: {integrity: sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
+  '@next/swc-linux-x64-gnu@14.2.32':
+    resolution: {integrity: sha512-7S1GY4TdnlGVIdeXXKQdDkfDysoIVFMD0lJuVVMeb3eoVjrknQ0JNN7wFlhCvea0hEk0Sd4D1hedVChDKfV2jw==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
   '@next/swc-linux-x64-musl@14.2.30':
     resolution: {integrity: sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [linux]
 
+  '@next/swc-linux-x64-musl@14.2.32':
+    resolution: {integrity: sha512-OHHC81P4tirVa6Awk6eCQ6RBfWl8HpFsZtfEkMpJ5GjPsJ3nhPe6wKAJUZ/piC8sszUkAgv3fLflgzPStIwfWg==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [linux]
+
   '@next/swc-win32-arm64-msvc@14.2.30':
     resolution: {integrity: sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==}
     engines: {node: '>= 10'}
     cpu: [arm64]
     os: [win32]
 
+  '@next/swc-win32-arm64-msvc@14.2.32':
+    resolution: {integrity: sha512-rORQjXsAFeX6TLYJrCG5yoIDj+NKq31Rqwn8Wpn/bkPNy5rTHvOXkW8mLFonItS7QC6M+1JIIcLe+vOCTOYpvg==}
+    engines: {node: '>= 10'}
+    cpu: [arm64]
+    os: [win32]
+
   '@next/swc-win32-ia32-msvc@14.2.30':
     resolution: {integrity: sha512-pVZMnFok5qEX4RT59mK2hEVtJX+XFfak+/rjHpyFh7juiT52r177bfFKhnlafm0UOSldhXjj32b+LZIOdswGTg==}
     engines: {node: '>= 10'}
     cpu: [ia32]
     os: [win32]
 
+  '@next/swc-win32-ia32-msvc@14.2.32':
+    resolution: {integrity: sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA==}
+    engines: {node: '>= 10'}
+    cpu: [ia32]
+    os: [win32]
+
   '@next/swc-win32-x64-msvc@14.2.30':
     resolution: {integrity: sha512-4KCo8hMZXMjpTzs3HOqOGYYwAXymXIy7PEPAXNEcEOyKqkjiDlECumrWziy+JEF0Oi4ILHGxzgQ3YiMGG2t/Lg==}
     engines: {node: '>= 10'}
     cpu: [x64]
     os: [win32]
 
+  '@next/swc-win32-x64-msvc@14.2.32':
+    resolution: {integrity: sha512-2N0lSoU4GjfLSO50wvKpMQgKd4HdI2UHEhQPPPnlgfBJlOgJxkjpkYBqzk08f1gItBB6xF/n+ykso2hgxuydsA==}
+    engines: {node: '>= 10'}
+    cpu: [x64]
+    os: [win32]
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
 
@@ -11629,6 +11686,24 @@ packages:
       sass:
         optional: true
 
+  next@14.2.32:
+    resolution: {integrity: sha512-fg5g0GZ7/nFc09X8wLe6pNSU8cLWbLRG3TZzPJ1BJvi2s9m7eF991se67wliM9kR5yLHRkyGKU49MMx58s3LJg==}
+    engines: {node: '>=18.17.0'}
+    hasBin: true
+    peerDependencies:
+      '@opentelemetry/api': ^1.1.0
+      '@playwright/test': ^1.41.2
+      react: ^18.2.0
+      react-dom: ^18.2.0
+      sass: ^1.3.0
+    peerDependenciesMeta:
+      '@opentelemetry/api':
+        optional: true
+      '@playwright/test':
+        optional: true
+      sass:
+        optional: true
+
   nice-try@1.0.4:
     resolution: {integrity: sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==}
 
@@ -18144,6 +18219,8 @@ snapshots:
 
   '@next/env@14.2.30': {}
 
+  '@next/env@14.2.32': {}
+
   '@next/eslint-plugin-next@12.1.6':
     dependencies:
       glob: 7.1.7
@@ -18151,30 +18228,57 @@ snapshots:
   '@next/swc-darwin-arm64@14.2.30':
     optional: true
 
+  '@next/swc-darwin-arm64@14.2.32':
+    optional: true
+
   '@next/swc-darwin-x64@14.2.30':
     optional: true
 
+  '@next/swc-darwin-x64@14.2.32':
+    optional: true
+
   '@next/swc-linux-arm64-gnu@14.2.30':
     optional: true
 
+  '@next/swc-linux-arm64-gnu@14.2.32':
+    optional: true
+
   '@next/swc-linux-arm64-musl@14.2.30':
     optional: true
 
+  '@next/swc-linux-arm64-musl@14.2.32':
+    optional: true
+
   '@next/swc-linux-x64-gnu@14.2.30':
     optional: true
 
+  '@next/swc-linux-x64-gnu@14.2.32':
+    optional: true
+
   '@next/swc-linux-x64-musl@14.2.30':
     optional: true
 
+  '@next/swc-linux-x64-musl@14.2.32':
+    optional: true
+
   '@next/swc-win32-arm64-msvc@14.2.30':
     optional: true
 
+  '@next/swc-win32-arm64-msvc@14.2.32':
+    optional: true
+
   '@next/swc-win32-ia32-msvc@14.2.30':
     optional: true
 
+  '@next/swc-win32-ia32-msvc@14.2.32':
+    optional: true
+
   '@next/swc-win32-x64-msvc@14.2.30':
     optional: true
 
+  '@next/swc-win32-x64-msvc@14.2.32':
+    optional: true
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     dependencies:
       eslint-scope: 5.1.1
@@ -22351,12 +22455,12 @@ snapshots:
       '@types/babel__core': 7.20.5
       '@types/babel__traverse': 7.0.7
 
-  babel-plugin-superjson-next@0.4.5(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
+  babel-plugin-superjson-next@0.4.5(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
     dependencies:
       '@babel/helper-module-imports': 7.24.6
       '@babel/types': 7.25.6
       hoist-non-react-statics: 3.3.2
-      next: 14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       superjson: 2.2.2
 
   babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.6):
@@ -24316,7 +24420,7 @@ snapshots:
       object.assign: 4.1.5
       object.entries: 1.1.5
 
-  eslint-config-next@12.1.6(eslint@8.41.0)(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4):
+  eslint-config-next@12.1.6(eslint@8.41.0)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(typescript@5.0.4):
     dependencies:
       '@next/eslint-plugin-next': 12.1.6
       '@rushstack/eslint-patch': 1.1.3
@@ -24328,7 +24432,7 @@ snapshots:
       eslint-plugin-jsx-a11y: 6.5.1(eslint@8.41.0)
       eslint-plugin-react: 7.30.1(eslint@8.41.0)
       eslint-plugin-react-hooks: 4.6.0(eslint@8.41.0)
-      next: 14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
     optionalDependencies:
       typescript: 5.0.4
     transitivePeerDependencies:
@@ -28164,7 +28268,7 @@ snapshots:
     dependencies:
       react: 18.2.0
 
-  next-i18next@15.3.1(i18next@23.16.5)(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
+  next-i18next@15.3.1(i18next@23.16.5)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-i18next@15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react@18.2.0):
     dependencies:
       '@babel/runtime': 7.25.4
       '@types/hoist-non-react-statics': 3.3.5
@@ -28172,29 +28276,29 @@ snapshots:
       hoist-non-react-statics: 3.3.2
       i18next: 23.16.5
       i18next-fs-backend: 2.3.2
-      next: 14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react: 18.2.0
       react-i18next: 15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
 
-  next-superjson-plugin@0.6.3(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
+  next-superjson-plugin@0.6.3(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
     dependencies:
       hoist-non-react-statics: 3.3.2
-      next: 14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       superjson: 2.2.2
 
-  next-superjson@1.0.7(@swc/helpers@0.5.15)(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
+  next-superjson@1.0.7(@swc/helpers@0.5.15)(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2):
     dependencies:
       '@swc/core': 1.4.17(@swc/helpers@0.5.15)
       '@swc/types': 0.1.12
-      next: 14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
-      next-superjson-plugin: 0.6.3(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
+      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next-superjson-plugin: 0.6.3(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(superjson@2.2.2)
     transitivePeerDependencies:
       - '@swc/helpers'
       - superjson
 
-  next-themes@0.2.1(next@14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
+  next-themes@0.2.1(next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
     dependencies:
-      next: 14.2.30(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
+      next: 14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6)
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
 
@@ -28226,6 +28330,34 @@ snapshots:
       - '@babel/core'
       - babel-plugin-macros
 
+  next@14.2.32(@babel/core@7.24.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.77.6):
+    dependencies:
+      '@next/env': 14.2.32
+      '@swc/helpers': 0.5.5
+      busboy: 1.6.0
+      caniuse-lite: 1.0.30001707
+      graceful-fs: 4.2.11
+      postcss: 8.4.31
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+      styled-jsx: 5.1.1(@babel/core@7.24.6)(react@18.2.0)
+    optionalDependencies:
+      '@next/swc-darwin-arm64': 14.2.32
+      '@next/swc-darwin-x64': 14.2.32
+      '@next/swc-linux-arm64-gnu': 14.2.32
+      '@next/swc-linux-arm64-musl': 14.2.32
+      '@next/swc-linux-x64-gnu': 14.2.32
+      '@next/swc-linux-x64-musl': 14.2.32
+      '@next/swc-win32-arm64-msvc': 14.2.32
+      '@next/swc-win32-ia32-msvc': 14.2.32
+      '@next/swc-win32-x64-msvc': 14.2.32
+      '@opentelemetry/api': 1.9.0
+      '@playwright/test': 1.49.1
+      sass: 1.77.6
+    transitivePeerDependencies:
+      - '@babel/core'
+      - babel-plugin-macros
+
   nice-try@1.0.4: {}
 
   nimma@0.2.2: