Просмотр исходного кода

Merge branch 'master' into imprv/169644-assistant-remove-confirmation-modal

satof3 7 месяцев назад
Родитель
Сommit
fc8ecede99
46 измененных файлов с 2312 добавлено и 1133 удалено
  1. 1 1
      .devcontainer/compose.yml
  2. 5 3
      .github/workflows/ci-app.yml
  3. 11 6
      .github/workflows/reusable-app-prod.yml
  4. 3 3
      CLAUDE.md
  5. 1 1
      README.md
  6. 1 1
      README_JP.md
  7. 4 0
      apps/app/.eslintrc.js
  8. 1 1
      apps/app/package.json
  9. 22 4
      apps/app/src/client/components/ReactMarkdownComponents/RichAttachment.tsx
  10. 118 79
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  11. 5 3
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  12. 109 45
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  13. 28 18
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  14. 104 47
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  15. 46 26
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  16. 76 33
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  17. 50 38
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  18. 95 39
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  19. 54 23
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  20. 58 22
      apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts
  21. 54 24
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  22. 29 17
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  23. 399 164
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  24. 148 62
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  25. 58 59
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  26. 91 39
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  27. 110 44
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  28. 42 44
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  29. 10 9
      apps/app/src/features/mermaid/services/mermaid.ts
  30. 6 4
      apps/app/src/features/plantuml/services/plantuml.ts
  31. 31 21
      apps/app/src/features/search/client/components/SearchForm.tsx
  32. 72 20
      apps/app/src/features/search/client/components/SearchHelp.tsx
  33. 15 20
      apps/app/src/features/search/client/components/SearchMenuItem.tsx
  34. 29 21
      apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx
  35. 29 17
      apps/app/src/features/search/client/components/SearchModal.tsx
  36. 40 30
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  37. 4 2
      apps/app/src/features/search/client/interfaces/downshift.ts
  38. 24 12
      apps/app/src/features/search/client/stores/search.ts
  39. 1 1
      apps/app/src/server/routes/apiv3/attachment.js
  40. 42 22
      apps/app/src/server/routes/apiv3/export.js
  41. 1 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  42. 7 4
      bin/data-migrations/README.md
  43. 0 4
      biome.json
  44. 2 0
      packages/remark-lsx/src/client/components/Lsx.tsx
  45. 2 2
      packages/remark-lsx/src/client/stores/lsx/lsx.ts
  46. 274 97
      pnpm-lock.yaml

+ 1 - 1
.devcontainer/compose.yml

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

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

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

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

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

+ 3 - 3
CLAUDE.md

@@ -63,15 +63,15 @@ GROWI is a team collaboration software using markdown - a wiki platform with hie
 - **Authentication**: Passport.js with multiple strategies (local, LDAP, OAuth, SAML)
 - **Authentication**: Passport.js with multiple strategies (local, LDAP, OAuth, SAML)
 - **Real-time Features**: Socket.io for collaborative editing and notifications
 - **Real-time Features**: Socket.io for collaborative editing and notifications
 - **Editor**: Custom markdown editor with collaborative editing using Yjs
 - **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
 - **Package Manager**: pnpm with workspace support
 - **Build System**: Turborepo for monorepo orchestration
 - **Build System**: Turborepo for monorepo orchestration
 
 
 ### Development Dependencies
 ### Development Dependencies
 - Node.js v20.x or v22.x
 - Node.js v20.x or v22.x
 - pnpm 10.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
 ## 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
 - npm 10.x
 - pnpm 10.x
 - pnpm 10.x
 - [Turborepo](https://turbo.build/repo)
 - [Turborepo](https://turbo.build/repo)
-- MongoDB 6.0 or above
+- MongoDB v6.x or v8.x
 
 
 ### Optional Dependencies
 ### Optional Dependencies
 
 

+ 1 - 1
README_JP.md

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

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

@@ -33,6 +33,10 @@ module.exports = {
     'src/features/callout/**',
     'src/features/callout/**',
     'src/features/comment/**',
     'src/features/comment/**',
     'src/features/templates/**',
     'src/features/templates/**',
+    'src/features/mermaid/**',
+    'src/features/search/**',
+    'src/features/plantuml/**',
+    'src/features/external-user-group/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

+ 1 - 1
apps/app/package.json

@@ -172,7 +172,7 @@
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
-    "next": "^14.2.30",
+    "next": "^14.2.32",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^0.0.4",
     "next-superjson": "^0.0.4",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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 { Schema } from 'mongoose';
 
 
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 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';
 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;
 schema.statics.createRelations = UserGroupRelation.createRelations;
 
 
@@ -42,16 +65,24 @@ schema.statics.removeAllByUserGroups = UserGroupRelation.removeAllByUserGroups;
 
 
 schema.statics.findAllRelation = UserGroupRelation.findAllRelation;
 schema.statics.findAllRelation = UserGroupRelation.findAllRelation;
 
 
-schema.statics.removeAllInvalidRelations = UserGroupRelation.removeAllInvalidRelations;
+schema.statics.removeAllInvalidRelations =
+  UserGroupRelation.removeAllInvalidRelations;
 
 
-schema.statics.findGroupsWithDescendantsByGroupAndUser = UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
+schema.statics.findGroupsWithDescendantsByGroupAndUser =
+  UserGroupRelation.findGroupsWithDescendantsByGroupAndUser;
 
 
-schema.statics.countByGroupIdsAndUser = UserGroupRelation.countByGroupIdsAndUser;
+schema.statics.countByGroupIdsAndUser =
+  UserGroupRelation.countByGroupIdsAndUser;
 
 
-schema.statics.findAllUserIdsForUserGroups = UserGroupRelation.findAllUserIdsForUserGroups;
+schema.statics.findAllUserIdsForUserGroups =
+  UserGroupRelation.findAllUserIdsForUserGroups;
 
 
-schema.statics.findAllUserGroupIdsRelatedToUser = UserGroupRelation.findAllUserGroupIdsRelatedToUser;
+schema.statics.findAllUserGroupIdsRelatedToUser =
+  UserGroupRelation.findAllUserGroupIdsRelatedToUser;
 
 
 schema.statics.findAllGroupsForUser = UserGroupRelation.findAllGroupsForUser;
 schema.statics.findAllGroupsForUser = UserGroupRelation.findAllGroupsForUser;
 
 
-export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);
+export default getOrCreateModel<
+  ExternalUserGroupRelationDocument,
+  ExternalUserGroupRelationModel
+>('ExternalUserGroupRelation', schema);

+ 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('ExternalUserGroup model', () => {
   describe('findAndUpdateOrCreateGroup', () => {
   describe('findAndUpdateOrCreateGroup', () => {
     const groupId = new mongoose.Types.ObjectId();
     const groupId = new mongoose.Types.ObjectId();
-    beforeAll(async() => {
+    beforeAll(async () => {
       await ExternalUserGroup.create({
       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.id).toBe(groupId.toString());
       expect(group.name).toBe('edited test group');
       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);
       expect(await ExternalUserGroup.count()).toBe(1);
       const newGroup = await ExternalUserGroup.findAndUpdateOrCreateGroup(
       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(await ExternalUserGroup.count()).toBe(2);
       expect(newGroup.parent.toString()).toBe(groupId.toString());
       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 {
       try {
         await ExternalUserGroup.findAndUpdateOrCreateGroup(
         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.');
         expect(e.message).toBe('Parent does not exist.');
       }
       }
     });
     });
@@ -43,31 +57,53 @@ describe('ExternalUserGroup model', () => {
     const parentGroupId = new mongoose.Types.ObjectId();
     const parentGroupId = new mongoose.Types.ObjectId();
     const grandParentGroupId = new mongoose.Types.ObjectId();
     const grandParentGroupId = new mongoose.Types.ObjectId();
 
 
-    beforeAll(async() => {
+    beforeAll(async () => {
       await ExternalUserGroup.deleteMany();
       await ExternalUserGroup.deleteMany();
       await ExternalUserGroup.create({
       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({
       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({
       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 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 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 { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 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 UserGroup from '~/server/models/user-group';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 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: (
   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);
 schema.plugin(mongoosePaginate);
 // group name should be unique for each provider
 // group name should be unique for each provider
 schema.index({ name: 1, provider: 1 }, { unique: true });
 schema.index({ name: 1, provider: 1 }, { unique: true });
@@ -40,7 +51,13 @@ schema.index({ name: 1, provider: 1 }, { unique: true });
  * @param name ExternalUserGroup parentId
  * @param name ExternalUserGroup parentId
  * @returns ExternalUserGroupDocument[]
  * @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;
   let parent: ExternalUserGroupDocument | null = null;
   if (parentId != null) {
   if (parentId != null) {
     parent = await this.findOne({ _id: parentId });
     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.findWithPagination = UserGroup.findWithPagination;
 
 
 schema.statics.findChildrenByParentIds = UserGroup.findChildrenByParentIds;
 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 { 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 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 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 type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
 const { query } = require('express-validator');
 const { query } = require('express-validator');
 
 
-
 const router = express.Router();
 const router = express.Router();
 
 
 module.exports = (crowi: Crowi): 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 adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
 
   const validators = {
   const validators = {
@@ -72,33 +71,46 @@ module.exports = (crowi: Crowi): Router => {
    *                   items:
    *                   items:
    *                     type: object
    *                     type: object
    */
    */
-  router.get('/',
+  router.get(
+    '/',
     accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
     accessTokenParser([SCOPE.READ.ADMIN.USER_GROUP_MANAGEMENT]),
     loginRequiredStrictly,
     loginRequiredStrictly,
     adminRequired,
     adminRequired,
     validators.list,
     validators.list,
-    async(req: Request, res: ApiV3Response) => {
+    async (req: Request, res: ApiV3Response) => {
       const { query } = req;
       const { query } = req;
 
 
       try {
       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)) {
         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';
         const msg = 'Error occurred in fetching user group relations';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   return router;
   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 { ErrorV3 } from '@growi/core/dist/models';
 import type { Request } from 'express';
 import type { Request } from 'express';
 import { Router } 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 ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 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();
 const router = Router();
 
 
 interface AuthorizedRequest extends Request {
 interface AuthorizedRequest extends Request {
-  user?: any
+  user?: any;
 }
 }
 
 
 /**
 /**
@@ -45,55 +43,69 @@ interface AuthorizedRequest extends Request {
  *            type: number
  *            type: number
  */
  */
 module.exports = (crowi: Crowi): 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 adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware();
   const addActivity = generateAddActivityMiddleware();
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
   const isExecutingSync = () => {
   const isExecutingSync = () => {
-    return crowi.ldapUserGroupSyncService?.syncStatus?.isExecutingSync || crowi.keycloakUserGroupSyncService?.syncStatus?.isExecutingSync || false;
+    return (
+      crowi.ldapUserGroupSyncService?.syncStatus?.isExecutingSync ||
+      crowi.keycloakUserGroupSyncService?.syncStatus?.isExecutingSync ||
+      false
+    );
   };
   };
 
 
   const validators = {
   const validators = {
     ldapSyncSettings: [
     ldapSyncSettings: [
       body('ldapGroupSearchBase').optional({ nullable: true }).isString(),
       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('autoGenerateUserOnLdapGroupSync').exists().isBoolean(),
       body('preserveDeletedLdapGroups').exists().isBoolean(),
       body('preserveDeletedLdapGroups').exists().isBoolean(),
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
-      body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+      body('ldapGroupDescriptionAttribute')
+        .optional({ nullable: true })
+        .isString(),
     ],
     ],
     keycloakSyncSettings: [
     keycloakSyncSettings: [
       body('keycloakHost').exists({ checkFalsy: true }).isString(),
       body('keycloakHost').exists({ checkFalsy: true }).isString(),
       body('keycloakGroupRealm').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('keycloakGroupSyncClientID').exists({ checkFalsy: true }).isString(),
-      body('keycloakGroupSyncClientSecret').exists({ checkFalsy: true }).isString(),
+      body('keycloakGroupSyncClientSecret')
+        .exists({ checkFalsy: true })
+        .isString(),
       body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(),
       body('autoGenerateUserOnKeycloakGroupSync').exists().isBoolean(),
       body('preserveDeletedKeycloakGroups').exists().isBoolean(),
       body('preserveDeletedKeycloakGroups').exists().isBoolean(),
-      body('keycloakGroupDescriptionAttribute').optional({ nullable: true }).isString(),
+      body('keycloakGroupDescriptionAttribute')
+        .optional({ nullable: true })
+        .isString(),
     ],
     ],
     listChildren: [
     listChildren: [
       query('parentIds').optional().isArray(),
       query('parentIds').optional().isArray(),
       query('includeGrandChildren').optional().isBoolean(),
       query('includeGrandChildren').optional().isBoolean(),
     ],
     ],
-    ancestorGroup: [
-      query('groupId').isString(),
-    ],
-    update: [
-      body('description').optional().isString(),
-    ],
+    ancestorGroup: [query('groupId').isString()],
+    update: [body('description').optional().isString()],
     delete: [
     delete: [
       param('id').trim().exists({ checkFalsy: true }),
       param('id').trim().exists({ checkFalsy: true }),
       query('actionName').trim().exists({ checkFalsy: true }),
       query('actionName').trim().exists({ checkFalsy: true }),
       query('transferToUserGroupId').trim(),
       query('transferToUserGroupId').trim(),
     ],
     ],
-    detail: [
-      param('id').isString(),
-    ],
+    detail: [param('id').isString()],
   };
   };
 
 
   /**
   /**
@@ -143,28 +155,43 @@ module.exports = (crowi: Crowi): Router => {
    *                     pagingLimit:
    *                     pagingLimit:
    *                       type: integer
    *                       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;
       const { query } = req;
 
 
       try {
       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({
         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 });
         return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in fetching external user group list';
         const msg = 'Error occurred in fetching external user group list';
         logger.error('Error', err);
         logger.error('Error', err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -195,22 +222,30 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                       items:
    *                         type: object
    *                         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;
       const { groupId } = req.query;
 
 
       try {
       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 });
         return res.apiv3({ ancestorUserGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while searching user groups';
         const msg = 'Error occurred while searching user groups';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -251,23 +286,32 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                       items:
    *                         type: object
    *                         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 {
       try {
         const { parentIds, includeGrandChildren = false } = req.query;
         const { parentIds, includeGrandChildren = false } = req.query;
 
 
-        const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+        const externalUserGroupsResult =
+          await ExternalUserGroup.findChildrenByParentIds(
+            parentIds,
+            includeGrandChildren,
+          );
         return res.apiv3({
         return res.apiv3({
           childUserGroups: externalUserGroupsResult.childUserGroups,
           childUserGroups: externalUserGroupsResult.childUserGroups,
           grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
           grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
         });
         });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in fetching child user group list';
         const msg = 'Error occurred in fetching child user group list';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -296,20 +340,25 @@ module.exports = (crowi: Crowi): Router => {
    *                     userGroup:
    *                     userGroup:
    *                       type: object
    *                       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;
       const { id } = req.params;
 
 
       try {
       try {
         const userGroup = await ExternalUserGroup.findById(id);
         const userGroup = await ExternalUserGroup.findById(id);
         return res.apiv3({ userGroup });
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while getting external user group';
         const msg = 'Error occurred while getting external user group';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -356,35 +405,54 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                       items:
    *                         type: object
    *                         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 { id: deleteGroupId } = req.params;
       const { transferToUserGroupId, transferToUserGroupType } = req.query;
       const { transferToUserGroupId, transferToUserGroupType } = req.query;
       const actionName = req.query.actionName as PageActionOnGroupDelete;
       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 {
       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);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ userGroups });
         return res.apiv3({ userGroups });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred while deleting user groups';
         const msg = 'Error occurred while deleting user groups';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -422,28 +490,37 @@ module.exports = (crowi: Crowi): Router => {
    *                     userGroup:
    *                     userGroup:
    *                       type: object
    *                       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 { id } = req.params;
-      const {
-        description,
-      } = req.body;
+      const { description } = req.body;
 
 
       try {
       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);
         activityEvent.emit('update', res.locals.activity._id, parameters);
 
 
         return res.apiv3({ userGroup });
         return res.apiv3({ userGroup });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = 'Error occurred in updating an external user group';
         const msg = 'Error occurred in updating an external user group';
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -474,23 +551,33 @@ module.exports = (crowi: Crowi): Router => {
    *                       items:
    *                       items:
    *                         type: object
    *                         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;
       const { id } = req.params;
 
 
       try {
       try {
         const externalUserGroup = await ExternalUserGroup.findById(id);
         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 });
         return res.apiv3({ userGroupRelations: serialized });
-      }
-      catch (err) {
+      } catch (err) {
         const msg = `Error occurred in fetching user group relations for external user group: ${id}`;
         const msg = `Error occurred in fetching user group relations for external user group: ${id}`;
         logger.error(msg, err);
         logger.error(msg, err);
         return res.apiv3Err(new ErrorV3(msg));
         return res.apiv3Err(new ErrorV3(msg));
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -526,21 +613,42 @@ module.exports = (crowi: Crowi): Router => {
    *                     ldapGroupDescriptionAttribute:
    *                     ldapGroupDescriptionAttribute:
    *                       type: string
    *                       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) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const settings = {
       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);
       return res.apiv3(settings);
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -576,21 +684,42 @@ module.exports = (crowi: Crowi): Router => {
    *                     keycloakGroupDescriptionAttribute:
    *                     keycloakGroupDescriptionAttribute:
    *                       type: string
    *                       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) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const settings = {
       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);
       return res.apiv3(settings);
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -632,43 +761,66 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                 schema:
    *                   type: object
    *                   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,
     validators.ldapSyncSettings,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
       if (!errors.isEmpty()) {
         return res.apiv3Err(
         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 = {
       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';
         params['external-user-group:ldap:groupNameAttribute'] = 'cn';
       }
       }
 
 
       try {
       try {
         await configManager.updateConfigs(params, { skipPubsub: true });
         await configManager.updateConfigs(params, { skipPubsub: true });
         return res.apiv3({}, 204);
         return res.apiv3({}, 204);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(
         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
    * @swagger
@@ -710,38 +862,56 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                 schema:
    *                   type: object
    *                   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,
     validators.keycloakSyncSettings,
-    async(req: AuthorizedRequest, res: ApiV3Response) => {
+    async (req: AuthorizedRequest, res: ApiV3Response) => {
       const errors = validationResult(req);
       const errors = validationResult(req);
       if (!errors.isEmpty()) {
       if (!errors.isEmpty()) {
         return res.apiv3Err(
         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 = {
       const params = {
         'external-user-group:keycloak:host': req.body.keycloakHost,
         'external-user-group:keycloak:host': req.body.keycloakHost,
         'external-user-group:keycloak:groupRealm': req.body.keycloakGroupRealm,
         '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 {
       try {
         await configManager.updateConfigs(params, { skipPubsub: true });
         await configManager.updateConfigs(params, { skipPubsub: true });
         return res.apiv3({}, 204);
         return res.apiv3({}, 204);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(
         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
    * @swagger
@@ -760,27 +930,47 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                 schema:
    *                   type: object
    *                   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()) {
       if (isExecutingSync()) {
         return res.apiv3Err(
         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) {
       if (!isLdapEnabled) {
         return res.apiv3Err(
         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 {
       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(
         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();
       crowi.ldapUserGroupSyncService?.syncExternalUserGroups();
 
 
       return res.apiv3({}, 202);
       return res.apiv3({}, 202);
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -807,34 +998,64 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                 schema:
    *                   type: object
    *                   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()) {
       if (isExecutingSync()) {
         return res.apiv3Err(
         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 = () => {
       const getAuthProviderType = () => {
-        let kcHost = configManager.getConfig('external-user-group:keycloak:host');
+        let kcHost = configManager.getConfig(
+          'external-user-group:keycloak:host',
+        );
         if (kcHost?.endsWith('/')) {
         if (kcHost?.endsWith('/')) {
           kcHost = kcHost.slice(0, -1);
           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
         // starts with kcHost, contains kcGroupRealm in path
         // see: https://regex101.com/r/3ihDmf/1
         // see: https://regex101.com/r/3ihDmf/1
         const regex = new RegExp(`^${kcHost}/.*/${kcGroupRealm}(/|$).*`);
         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;
         return null;
       };
       };
@@ -842,7 +1063,11 @@ module.exports = (crowi: Crowi): Router => {
       const authProviderType = getAuthProviderType();
       const authProviderType = getAuthProviderType();
       if (authProviderType == null) {
       if (authProviderType == null) {
         return res.apiv3Err(
         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();
       crowi.keycloakUserGroupSyncService?.syncExternalUserGroups();
 
 
       return res.apiv3({}, 202);
       return res.apiv3({}, 202);
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -870,11 +1096,16 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                 schema:
    *                   $ref: '#/components/schemas/SyncStatus'
    *                   $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) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
       const syncStatus = crowi.ldapUserGroupSyncService?.syncStatus;
       return res.apiv3({ ...syncStatus });
       return res.apiv3({ ...syncStatus });
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -893,12 +1124,16 @@ module.exports = (crowi: Crowi): Router => {
    *                 schema:
    *                 schema:
    *                   $ref: '#/components/schemas/SyncStatus'
    *                   $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) => {
     (req: AuthorizedRequest, res: ApiV3Response) => {
       const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
       const syncStatus = crowi.keycloakUserGroupSyncService?.syncStatus;
       return res.apiv3({ ...syncStatus });
       return res.apiv3({ ...syncStatus });
-    });
+    },
+  );
 
 
   return router;
   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 { configManager } from '../../../../server/service/config-manager';
 import { externalAccountService } from '../../../../server/service/external-account';
 import { externalAccountService } from '../../../../server/service/external-account';
 import type {
 import type {
-  ExternalGroupProviderType, ExternalUserGroupTreeNode, ExternalUserInfo, IExternalUserGroupHasId,
+  ExternalGroupProviderType,
+  ExternalUserGroupTreeNode,
+  ExternalUserInfo,
+  IExternalUserGroupHasId,
 } from '../../interfaces/external-user-group';
 } from '../../interfaces/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroup from '../models/external-user-group';
 import ExternalUserGroupRelation from '../models/external-user-group-relation';
 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 TREES_BATCH_SIZE = 10;
 const USERS_BATCH_SIZE = 30;
 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 {
 class ExternalUserGroupSyncS2sMessage extends S2sMessage {
-
   syncStatus: SyncStatus;
   syncStatus: SyncStatus;
-
 }
 }
 
 
 abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
-
   groupProviderType: ExternalGroupProviderType; // name of external service that contains user group info (e.g: ldap, keycloak)
   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.
   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 };
   syncStatus: SyncStatus = { isExecutingSync: false, totalCount: 0, count: 0 };
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // 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.groupProviderType = groupProviderType;
     this.s2sMessagingService = s2sMessagingService;
     this.s2sMessagingService = s2sMessagingService;
     this.socketIoService = socketIoService;
     this.socketIoService = socketIoService;
@@ -63,7 +71,9 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  async handleS2sMessage(s2sMessage: ExternalUserGroupSyncS2sMessage): Promise<void> {
+  async handleS2sMessage(
+    s2sMessage: ExternalUserGroupSyncS2sMessage,
+  ): Promise<void> {
     logger.info('Update syncStatus by pubsub notification');
     logger.info('Update syncStatus by pubsub notification');
     this.syncStatus = s2sMessage.syncStatus;
     this.syncStatus = s2sMessage.syncStatus;
   }
   }
@@ -72,15 +82,20 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
     this.syncStatus = syncStatus;
     this.syncStatus = syncStatus;
 
 
     if (this.s2sMessagingService != null) {
     if (this.s2sMessagingService != null) {
-      const s2sMessage = new ExternalUserGroupSyncS2sMessage('switchExternalUserGroupExecSyncStatus', {
-        syncStatus: this.syncStatus,
-      });
+      const s2sMessage = new ExternalUserGroupSyncS2sMessage(
+        'switchExternalUserGroupExecSyncStatus',
+        {
+          syncStatus: this.syncStatus,
+        },
+      );
 
 
       try {
       try {
         await this.s2sMessagingService.publish(s2sMessage);
         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
    * 1. Generate external user group tree
    * 2. Use createUpdateExternalUserGroup on each node in the tree using DFS
    * 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
    * 3. If preserveDeletedLDAPGroups is false、delete all ExternalUserGroups that were not found during tree search
-  */
+   */
   async syncExternalUserGroups(): Promise<void> {
   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 existingExternalUserGroupIds: string[] = [];
 
 
     const socket = this.socketIoService?.getAdminSocket();
     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);
       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
       // Do not use Promise.all, because the number of promises processed can
       // exponentially grow when group tree is enormous
       // exponentially grow when group tree is enormous
       for await (const childNode of node.childGroupNodes) {
       for await (const childNode of node.childGroupNodes) {
@@ -115,12 +149,13 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
 
 
     try {
     try {
       const trees = await this.generateExternalUserGroupTrees();
       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);
         .reduce((sum, current) => sum + current);
 
 
       await this.setSyncStatus({ isExecutingSync: true, totalCount, count: 0 });
       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);
         return syncNode(tree);
       });
       });
 
 
@@ -132,14 +167,22 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
         });
         });
         await ExternalUserGroupRelation.removeAllInvalidRelations();
         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);
       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} node Node of external group tree
    * @param {string} parentId Parent group id (id in GROWI) of the group we want to create/update
    * @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
    * @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;
     return externalUserGroup;
   }
   }
@@ -180,25 +249,41 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
    * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
    * @param {ExternalUserInfo} externalUserInfo Search external app/server using this identifier
    * @returns {Promise<IUserHasId | null>} User when found or created, null when neither
    * @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;
     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) {
       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();
     const externalAccount = await getExternalAccount();
 
 
     if (externalAccount != null) {
     if (externalAccount != null) {
-      return (await externalAccount.populate<{user: IUserHasId | null}>('user')).user;
+      return (
+        await externalAccount.populate<{ user: IUserHasId | null }>('user')
+      ).user;
     }
     }
     return null;
     return null;
   }
   }
@@ -217,9 +302,10 @@ abstract class ExternalUserGroupSyncService implements S2sMessageHandlable {
    * 1. Fetch user group info from external app/server
    * 1. Fetch user group info from external app/server
    * 2. Convert each group tree structure to ExternalUserGroupTreeNode
    * 2. Convert each group tree structure to ExternalUserGroupTreeNode
    * 3. Return the root node of each tree
    * 3. Return the root node of each tree
-  */
-  abstract generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]>
-
+   */
+  abstract generateExternalUserGroupTrees(): Promise<
+    ExternalUserGroupTreeNode[]
+  >;
 }
 }
 
 
 export default ExternalUserGroupSyncService;
 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', () => {
 vi.mock('@keycloak/keycloak-admin-client', () => {
   return {
   return {
     default: class {
     default: class {
-
       auth() {}
       auth() {}
 
 
       groups = {
       groups = {
@@ -40,48 +39,40 @@ vi.mock('@keycloak/keycloak-admin-client', () => {
         // mock group detail
         // mock group detail
         findOne: (payload) => {
         findOne: (payload) => {
           if (payload?.id === 'groupId1') {
           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') {
           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') {
           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') {
           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'));
           return Promise.reject(new Error('not found'));
         },
         },
@@ -128,7 +119,6 @@ vi.mock('@keycloak/keycloak-admin-client', () => {
           return Promise.resolve([]);
           return Promise.resolve([]);
         },
         },
       };
       };
-
     },
     },
   };
   };
 });
 });
@@ -145,49 +135,56 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
     'external-user-group:keycloak:groupSyncClientSecret': '123456',
     'external-user-group:keycloak:groupSyncClientSecret': '123456',
   };
   };
 
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     await configManager.loadConfigs();
     await configManager.loadConfigs();
     await configManager.updateConfigs(configParams, { skipPubsub: true });
     await configManager.updateConfigs(configParams, { skipPubsub: true });
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(null, null);
     keycloakUserGroupSyncService.init('oidc');
     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);
     expect(rootNodes?.length).toBe(2);
 
 
     // check grandParentGroup
     // check grandParentGroup
-    const grandParentNode = rootNodes?.find(node => node.id === 'groupId1');
+    const grandParentNode = rootNodes?.find((node) => node.id === 'groupId1');
     const expectedChildNode = {
     const expectedChildNode = {
       id: 'groupId3',
       id: 'groupId3',
-      userInfos: [{
-        id: 'userId3',
-        username: 'childGroupUser',
-        email: 'user@childGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId3',
+          username: 'childGroupUser',
+          email: 'user@childGroup.com',
+        },
+      ],
       childGroupNodes: [],
       childGroupNodes: [],
       name: 'childGroup',
       name: 'childGroup',
       description: 'this is a child group',
       description: 'this is a child group',
     };
     };
     const expectedParentNode = {
     const expectedParentNode = {
       id: 'groupId2',
       id: 'groupId2',
-      userInfos: [{
-        id: 'userId2',
-        username: 'parentGroupUser',
-        email: 'user@parentGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId2',
+          username: 'parentGroupUser',
+          email: 'user@parentGroup.com',
+        },
+      ],
       childGroupNodes: [expectedChildNode],
       childGroupNodes: [expectedChildNode],
       name: 'parentGroup',
       name: 'parentGroup',
       description: 'this is a parent group',
       description: 'this is a parent group',
     };
     };
     const expectedGrandParentNode = {
     const expectedGrandParentNode = {
       id: 'groupId1',
       id: 'groupId1',
-      userInfos: [{
-        id: 'userId1',
-        username: 'grandParentGroupUser',
-        email: 'user@grandParentGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId1',
+          username: 'grandParentGroupUser',
+          email: 'user@grandParentGroup.com',
+        },
+      ],
       childGroupNodes: [expectedParentNode],
       childGroupNodes: [expectedParentNode],
       name: 'grandParentGroup',
       name: 'grandParentGroup',
       description: 'this is a grand parent group',
       description: 'this is a grand parent group',
@@ -195,14 +192,16 @@ describe('KeycloakUserGroupSyncService.generateExternalUserGroupTrees', () => {
     expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
     expect(grandParentNode).toStrictEqual(expectedGrandParentNode);
 
 
     // check rootGroup
     // check rootGroup
-    const rootNode = rootNodes?.find(node => node.id === 'groupId4');
+    const rootNode = rootNodes?.find((node) => node.id === 'groupId4');
     const expectedRootNode = {
     const expectedRootNode = {
       id: 'groupId4',
       id: 'groupId4',
-      userInfos: [{
-        id: 'userId4',
-        username: 'rootGroupUser',
-        email: 'user@rootGroup.com',
-      }],
+      userInfos: [
+        {
+          id: 'userId4',
+          username: 'rootGroupUser',
+          email: 'user@rootGroup.com',
+        },
+      ],
       childGroupNodes: [],
       childGroupNodes: [],
       name: 'rootGroup',
       name: 'rootGroup',
       description: 'this is a root group',
       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 loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 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 { ExternalGroupProviderType } from '../../interfaces/external-user-group';
 
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 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;
 const TREES_BATCH_SIZE = 10;
 
 
 export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
 export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
-
   kcAdminClient: KeycloakAdminClient;
   kcAdminClient: KeycloakAdminClient;
 
 
   realm: string | undefined; // realm that contains the groups
   realm: string | undefined; // realm that contains the groups
@@ -30,17 +32,33 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   isInitialized = false;
   isInitialized = false;
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // 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 {
   init(authProviderType: 'oidc' | 'saml'): void {
     const kcHost = configManager.getConfig('external-user-group:keycloak:host');
     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.realm = kcGroupRealm;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
     this.groupDescriptionAttribute = kcGroupDescriptionAttribute;
     this.authProviderType = authProviderType;
     this.authProviderType = authProviderType;
@@ -56,23 +74,36 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
     return super.syncExternalUserGroups();
     return super.syncExternalUserGroups();
   }
   }
 
 
-  override async generateExternalUserGroupTrees(): Promise<ExternalUserGroupTreeNode[]> {
+  override async generateExternalUserGroupTrees(): Promise<
+    ExternalUserGroupTreeNode[]
+  > {
     await this.auth();
     await this.auth();
 
 
     // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
     // Type is 'GroupRepresentation', but 'find' does not return 'attributes' field. Hence, attribute for description is not present.
     logger.info('Get groups from keycloak server');
     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
    * Authenticate to group sync client using client credentials grant type
    */
    */
   private async auth(): Promise<void> {
   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({
     await this.kcAdminClient.auth({
       grantType: 'client_credentials',
       grantType: 'client_credentials',
@@ -84,14 +115,19 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   /**
   /**
    * Convert GroupRepresentation response returned from Keycloak to ExternalUserGroupTreeNode
    * 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;
     if (group.id == null || group.name == null) return null;
 
 
     logger.info('Get users from keycloak server');
     logger.info('Get users from keycloak server');
     const userRepresentations = await this.getMembers(group.id);
     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 childGroups = group.subGroups;
 
 
     const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
     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
       // Do not use Promise.all, because the number of promises processed can
       // exponentially grow when group tree is enormous
       // exponentially grow when group tree is enormous
       for await (const childGroup of childGroups) {
       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 {
     return {
       id: group.id,
       id: group.id,
@@ -117,10 +157,12 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
   private async getMembers(groupId: string): Promise<UserRepresentation[]> {
   private async getMembers(groupId: string): Promise<UserRepresentation[]> {
     let allUsers: UserRepresentation[] = [];
     let allUsers: UserRepresentation[] = [];
 
 
-    const fetchUsersWithOffset = async(offset: number) => {
+    const fetchUsersWithOffset = async (offset: number) => {
       await this.auth();
       await this.auth();
       const response = await this.kcAdminClient.groups.listMembers({
       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) {
       if (response != null && response.length > 0) {
@@ -134,7 +176,6 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
     return allUsers;
     return allUsers;
   }
   }
 
 
-
   /**
   /**
    * Fetch group detail from Keycloak and return group description
    * Fetch group detail from Keycloak and return group description
    */
    */
@@ -142,28 +183,39 @@ export class KeycloakUserGroupSyncService extends ExternalUserGroupSyncService {
     if (this.groupDescriptionAttribute == null) return null;
     if (this.groupDescriptionAttribute == null) return null;
 
 
     await this.auth();
     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;
     return typeof description === 'string' ? description : null;
   }
   }
 
 
   /**
   /**
    * Convert UserRepresentation array response returned from Keycloak to ExternalUserInfo
    * 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 loggerFactory from '~/utils/logger';
 import { batchProcessPromiseAll } from '~/utils/promise';
 import { batchProcessPromiseAll } from '~/utils/promise';
 
 
-import type { ExternalUserGroupTreeNode, ExternalUserInfo } from '../../interfaces/external-user-group';
+import type {
+  ExternalUserGroupTreeNode,
+  ExternalUserInfo,
+} from '../../interfaces/external-user-group';
 import {
 import {
-  ExternalGroupProviderType, LdapGroupMembershipAttributeType,
+  ExternalGroupProviderType,
+  LdapGroupMembershipAttributeType,
 } from '../../interfaces/external-user-group';
 } from '../../interfaces/external-user-group';
 
 
 import ExternalUserGroupSyncService from './external-user-group-sync';
 import ExternalUserGroupSyncService from './external-user-group-sync';
@@ -22,19 +26,25 @@ const TREES_BATCH_SIZE = 10;
 const USERS_BATCH_SIZE = 30;
 const USERS_BATCH_SIZE = 30;
 
 
 export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
 export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
-
   passportService: PassportService;
   passportService: PassportService;
 
 
   isInitialized = false;
   isInitialized = false;
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   // 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);
     super(ExternalGroupProviderType.ldap, s2sMessagingService, socketIoService);
     this.authProviderType = 'ldap';
     this.authProviderType = 'ldap';
     this.passportService = passportService;
     this.passportService = passportService;
   }
   }
 
 
-  async init(userBindUsername?: string, userBindPassword?: string): Promise<void> {
+  async init(
+    userBindUsername?: string,
+    userBindPassword?: string,
+  ): Promise<void> {
     await ldapService.initClient(userBindUsername, userBindPassword);
     await ldapService.initClient(userBindUsername, userBindPassword);
     this.isInitialized = true;
     this.isInitialized = true;
   }
   }
@@ -48,11 +58,21 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     return super.syncExternalUserGroups();
     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 groupBase = ldapService.getGroupSearchBase();
 
 
     const groupEntries = await ldapService.searchGroupDir();
     const groupEntries = await ldapService.searchGroupDir();
@@ -60,16 +80,26 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
     const getChildGroupDnsFromGroupEntry = (groupEntry: SearchResultEntry) => {
     const getChildGroupDnsFromGroupEntry = (groupEntry: SearchResultEntry) => {
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // so filter values of groupChildGroupAttribute to ones that include groupBase
       // 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) => {
     const getUserIdsFromGroupEntry = (groupEntry: SearchResultEntry) => {
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // groupChildGroupAttribute and groupMembershipAttribute may be the same,
       // so filter values of groupMembershipAttribute to ones that does not include groupBase
       // 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 (name == null) return null;
 
 
       if (converted.includes(entry.objectName)) {
       if (converted.includes(entry.objectName)) {
@@ -79,21 +109,31 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
 
 
       const userIds = getUserIdsFromGroupEntry(entry);
       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 childGroupDNs = getChildGroupDnsFromGroupEntry(entry);
 
 
       const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
       const childGroupNodesWithNull: (ExternalUserGroupTreeNode | null)[] = [];
       // Do not use Promise.all, because the number of promises processed can
       // Do not use Promise.all, because the number of promises processed can
       // exponentially grow when group tree is enormous
       // exponentially grow when group tree is enormous
       for await (const dn of childGroupDNs) {
       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 {
       return {
         id: entry.objectName,
         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
     // 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
     // root of every tree
     const rootEntries = groupEntries.filter((entry) => {
     const rootEntries = groupEntries.filter((entry) => {
       return !allChildGroupDNs.has(entry.objectName);
       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> {
   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 attrMapName = this.passportService.getLdapAttrNameMappedToName();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
     const attrMapMail = this.passportService.getLdapAttrNameMappedToMail();
 
 
     // get full user info from LDAP server using externalUserInfo (DN or UID)
     // 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');
         return ldapService.search(undefined, userId, 'base');
       }
       }
-      if (groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid) {
+      if (
+        groupMembershipAttributeType === LdapGroupMembershipAttributeType.uid
+      ) {
         return ldapService.search(`(uid=${userId})`, undefined);
         return ldapService.search(`(uid=${userId})`, undefined);
       }
       }
     };
     };
@@ -138,21 +192,33 @@ export class LdapUserGroupSyncService extends ExternalUserGroupSyncService {
 
 
     if (userEntries != null && userEntries.length > 0) {
     if (userEntries != null && userEntries.length > 0) {
       const userEntry = userEntries[0];
       const userEntry = userEntries[0];
-      const uid = ldapService.getStringValFromSearchResultEntry(userEntry, 'uid');
+      const uid = ldapService.getStringValFromSearchResultEntry(
+        userEntry,
+        'uid',
+      );
       if (uid != null) {
       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;
     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 mermaid from 'mermaid';
+import React, { type JSX, useEffect, useRef } from 'react';
 import { v7 as uuidV7 } from 'uuid';
 import { v7 as uuidV7 } from 'uuid';
 
 
 import { useNextThemes } from '~/stores-universal/use-next-themes';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
@@ -9,48 +8,47 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:features:mermaid:MermaidViewer');
 const logger = loggerFactory('growi:features:mermaid:MermaidViewer');
 
 
 type MermaidViewerProps = {
 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';
 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) {
 function rewriteNode(node: Code) {
   // replace node
   // replace node
-  const data = node.data ?? (node.data = {});
+  if (node.data == null) {
+    node.data = {};
+  }
+  const data = node.data;
   data.hName = 'mermaid';
   data.hName = 'mermaid';
   data.hProperties = {
   data.hProperties = {
     value: node.value,
     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 = {
 export const sanitizeOption: SanitizeOption = {

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

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

@@ -1,37 +1,45 @@
 import React, {
 import React, {
-  useCallback, useRef, useEffect, useMemo, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
 } from 'react';
 } from 'react';
 
 
 import type { GetInputProps } from '../interfaces/downshift';
 import type { GetInputProps } from '../interfaces/downshift';
 
 
 type Props = {
 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 => {
 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 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(() => {
   const inputOptions = useMemo(() => {
     return getInputProps({
     return getInputProps({
@@ -60,7 +68,9 @@ export const SearchForm = (props: Props): JSX.Element => {
       <button
       <button
         type="button"
         type="button"
         className="btn btn-neutral-secondary text-muted position-absolute bottom-0 end-0 w-auto h-100 border-0"
         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>
         <span className="material-symbols-outlined p-0">cancel</span>
       </button>
       </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 { useTranslation } from 'next-i18next';
+import React, { type JSX, useState } from 'react';
 import { Collapse } from 'reactstrap';
 import { Collapse } from 'reactstrap';
 
 
 export const SearchHelp = (): JSX.Element => {
 export const SearchHelp = (): JSX.Element => {
@@ -10,47 +9,100 @@ export const SearchHelp = (): JSX.Element => {
 
 
   return (
   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 className="material-symbols-outlined me-2 p-0">help</span>
         <span>{t('search_help.title')}</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>
       </button>
       <Collapse isOpen={isOpen}>
       <Collapse isOpen={isOpen}>
         <table className="table table-borderless m-0">
         <table className="table table-borderless m-0">
           <tbody>
           <tbody>
             <tr className="border-bottom">
             <tr className="border-bottom">
               <th className="py-2">
               <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>
               </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>
             <tr className="border-bottom">
             <tr className="border-bottom">
               <th className="py-2">
               <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>
               </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>
             <tr className="border-bottom">
             <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>
             <tr className="border-bottom">
             <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>
             <tr className="border-bottom">
             <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>
             <tr className="border-bottom">
             <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>
             <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>
             </tr>
           </tbody>
           </tbody>
         </table>
         </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 type { GetItemProps } from '../interfaces/downshift';
 
 
 import styles from './SearchMenuItem.module.scss';
 import styles from './SearchMenuItem.module.scss';
 
 
 type Props = {
 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 => {
 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 (
   return (
     <div className={`search-menu-item ${styles['search-menu-item']}`}>
     <div className={`search-menu-item ${styles['search-menu-item']}`}>
-      <li {...itemMenuOptions}>
-        { children }
-      </li>
+      <li {...itemMenuOptions}>{children}</li>
     </div>
     </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 { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import React, { type JSX } from 'react';
 
 
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 
 
@@ -10,30 +9,28 @@ import type { GetItemProps } from '../interfaces/downshift';
 import { SearchMenuItem } from './SearchMenuItem';
 import { SearchMenuItem } from './SearchMenuItem';
 
 
 type Props = {
 type Props = {
-  activeIndex: number | null
-  searchKeyword: string
-  getItemProps: GetItemProps
-}
+  activeIndex: number | null;
+  searchKeyword: string;
+  getItemProps: GetItemProps;
+};
 
 
 export const SearchMethodMenuItem = (props: Props): JSX.Element => {
 export const SearchMethodMenuItem = (props: Props): JSX.Element => {
-  const {
-    activeIndex, searchKeyword, getItemProps,
-  } = props;
+  const { activeIndex, searchKeyword, getItemProps } = props;
 
 
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
 
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
 
 
-  const dPagePath = (new DevidedPagePath(currentPagePath ?? '', true, true));
+  const dPagePath = new DevidedPagePath(currentPagePath ?? '', true, true);
   const currentPageName = `
   const currentPageName = `
-  ${(!(dPagePath.isRoot || dPagePath.isFormerRoot) ? '...' : '')}/${(dPagePath.isRoot ? '' : `${dPagePath.latter}/`)}
+  ${!(dPagePath.isRoot || dPagePath.isFormerRoot) ? '...' : ''}/${dPagePath.isRoot ? '' : `${dPagePath.latter}/`}
   `;
   `;
 
 
   const shouldShowMenuItem = searchKeyword.trim().length > 0;
   const shouldShowMenuItem = searchKeyword.trim().length > 0;
 
 
   return (
   return (
     <div>
     <div>
-      { shouldShowMenuItem && (
+      {shouldShowMenuItem && (
         <div data-testid="search-all-menu-item">
         <div data-testid="search-all-menu-item">
           <SearchMenuItem
           <SearchMenuItem
             index={0}
             index={0}
@@ -41,10 +38,14 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
             getItemProps={getItemProps}
             getItemProps={getItemProps}
             url={`/_search?q=${searchKeyword}`}
             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">
             <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="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>
             </div>
           </SearchMenuItem>
           </SearchMenuItem>
         </div>
         </div>
@@ -56,30 +57,37 @@ export const SearchMethodMenuItem = (props: Props): JSX.Element => {
           getItemProps={getItemProps}
           getItemProps={getItemProps}
           url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
           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">
           <div className="w-100 d-flex align-items-md-center flex-md-row align-items-start flex-column">
             <code className="text-break">{currentPageName}</code>
             <code className="text-break">{currentPageName}</code>
             <span className="ms-md-2 text-break me-auto">{searchKeyword}</span>
             <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>
           </div>
         </SearchMenuItem>
         </SearchMenuItem>
       </div>
       </div>
 
 
-      { shouldShowMenuItem && (
+      {shouldShowMenuItem && (
         <SearchMenuItem
         <SearchMenuItem
           index={2}
           index={2}
           isActive={activeIndex === 2}
           isActive={activeIndex === 2}
           getItemProps={getItemProps}
           getItemProps={getItemProps}
           url={`/_search?q="${searchKeyword}"`}
           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">
           <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="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>
           </div>
         </SearchMenuItem>
         </SearchMenuItem>
-      ) }
+      )}
     </div>
     </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 { useRouter } from 'next/router';
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import { Modal, ModalBody } from 'reactstrap';
 import { Modal, ModalBody } from 'reactstrap';
 
 
 import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
 import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
@@ -17,7 +16,6 @@ import { SearchMethodMenuItem } from './SearchMethodMenuItem';
 import { SearchResultMenuItem } from './SearchResultMenuItem';
 import { SearchResultMenuItem } from './SearchResultMenuItem';
 
 
 const SearchModal = (): JSX.Element => {
 const SearchModal = (): JSX.Element => {
-
   const [searchKeyword, setSearchKeyword] = useState('');
   const [searchKeyword, setSearchKeyword] = useState('');
   const [isMenthionedToAi, setMenthionedToAi] = useState(false);
   const [isMenthionedToAi, setMenthionedToAi] = useState(false);
 
 
@@ -29,10 +27,13 @@ const SearchModal = (): JSX.Element => {
     setSearchKeyword(searchText);
     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 submitHandler = useCallback(() => {
     const url = new URL('_search', 'http://example.com');
     const url = new URL('_search', 'http://example.com');
@@ -41,7 +42,10 @@ const SearchModal = (): JSX.Element => {
     closeSearchModal();
     closeSearchModal();
   }, [closeSearchModal, router, searchKeyword]);
   }, [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
     // Do not update highlightedIndex on mouse hover
     if (changes.type === Downshift.stateChangeTypes.itemMouseEnter) {
     if (changes.type === Downshift.stateChangeTypes.itemMouseEnter) {
       return {
       return {
@@ -59,8 +63,7 @@ const SearchModal = (): JSX.Element => {
     }
     }
     if (searchModalData?.searchKeyword == null) {
     if (searchModalData?.searchKeyword == null) {
       setSearchKeyword('');
       setSearchKeyword('');
-    }
-    else {
+    } else {
       setSearchKeyword(searchModalData.searchKeyword);
       setSearchKeyword(searchModalData.searchKeyword);
     }
     }
   }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
   }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
@@ -72,7 +75,12 @@ const SearchModal = (): JSX.Element => {
   const searchKeywordWithoutAi = removeAiMenthion(searchKeyword);
   const searchKeywordWithoutAi = removeAiMenthion(searchKeyword);
 
 
   return (
   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">
       <ModalBody className="pb-2">
         <Downshift
         <Downshift
           onSelect={selectSearchMenuItemHandler}
           onSelect={selectSearchMenuItemHandler}
@@ -88,7 +96,9 @@ const SearchModal = (): JSX.Element => {
           }) => (
           }) => (
             <div {...getRootProps({}, { suppressRefError: true })}>
             <div {...getRootProps({}, { suppressRefError: true })}>
               <div className="text-muted d-flex justify-content-center align-items-center p-1">
               <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'}
                   {isMenthionedToAi ? 'psychology' : 'search'}
                 </span>
                 </span>
                 <SearchForm
                 <SearchForm
@@ -102,7 +112,9 @@ const SearchModal = (): JSX.Element => {
                   className="btn border-0 d-flex justify-content-center p-0"
                   className="btn border-0 d-flex justify-content-center p-0"
                   onClick={closeSearchModal}
                   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>
                 </button>
               </div>
               </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 { PagePathLabel, UserPicture } from '@growi/ui/dist/components';
+import React, { type JSX, useCallback } from 'react';
 import { useDebounce } from 'usehooks-ts';
 import { useDebounce } from 'usehooks-ts';
 
 
 import { useSWRxSearch } from '~/stores/search';
 import { useSWRxSearch } from '~/stores/search';
@@ -10,10 +9,10 @@ import type { GetItemProps } from '../interfaces/downshift';
 import { SearchMenuItem } from './SearchMenuItem';
 import { SearchMenuItem } from './SearchMenuItem';
 
 
 type Props = {
 type Props = {
-  activeIndex: number | null,
-  searchKeyword: string,
-  getItemProps: GetItemProps,
-}
+  activeIndex: number | null;
+  searchKeyword: string;
+  getItemProps: GetItemProps;
+};
 export const SearchResultMenuItem = (props: Props): JSX.Element => {
 export const SearchResultMenuItem = (props: Props): JSX.Element => {
   const { activeIndex, searchKeyword, getItemProps } = props;
   const { activeIndex, searchKeyword, getItemProps } = props;
 
 
@@ -21,16 +20,23 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
 
 
   const isEmptyKeyword = searchKeyword.trim().length === 0;
   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).
    *  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.
    *  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.
    *  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) {
   if (isLoading) {
     return (
     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 <></>;
   }
   }
 
 
   return (
   return (
     <div>
     <div>
       <div className="border-top mt-2 mb-2" />
       <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>
             </span>
-          </SearchMenuItem>
-        ))
-      }
+            <span className="fs-6">{item.data.seenUsers.length}</span>
+          </span>
+        </SearchMenuItem>
+      ))}
     </div>
     </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 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';
 import { useStaticSWR } from '~/stores/use-static-swr';
 
 
 type SearchModalStatus = {
 type SearchModalStatus = {
-  isOpened: boolean,
-  searchKeyword?: string,
-}
+  isOpened: boolean;
+  searchKeyword?: string;
+};
 
 
 type SearchModalUtils = {
 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 initialStatus = { isOpened: false };
-  const swrResponse = useStaticSWR<SearchModalStatus, Error>('SearchModal', status, { fallbackData: initialStatus });
+  const swrResponse = useStaticSWR<SearchModalStatus, Error>(
+    'SearchModal',
+    status,
+    { fallbackData: initialStatus },
+  );
 
 
   return {
   return {
     ...swrResponse,
     ...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 {
       try {
-        const page = await Page.findById(pageId);
+        const page = await Page.findOne({ _id: { $eq: pageId } });
 
 
         // check the user is accessible
         // check the user is accessible
         const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
         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 sanitize from 'sanitize-filename';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { exportService } from '~/server/service/export';
 import { exportService } from '~/server/service/export';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -11,11 +16,6 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
 
 
 const logger = loggerFactory('growi:routes:apiv3:export');
 const logger = loggerFactory('growi:routes:apiv3:export');
-const fs = require('fs');
-
-const express = require('express');
-const { param } = require('express-validator');
-
 const router = express.Router();
 const router = express.Router();
 
 
 /**
 /**
@@ -145,6 +145,25 @@ module.exports = (crowi) => {
   });
   });
 
 
   const validator = {
   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: [
     deleteFile: [
       // https://regex101.com/r/mD4eZs/6
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing delete file (path traversal attack)
       // prevent from unexpecting attack doing delete file (path traversal attack)
@@ -214,27 +233,28 @@ module.exports = (crowi) => {
    *                    type: boolean
    *                    type: boolean
    *                    description: whether the request is succeeded
    *                    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
     // 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
       // TODO: use ApiV3Error
-      logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
-    }
-  });
+        logger.error(err);
+        return res.status(500).send({ status: 'ERROR' });
+      }
+    });
 
 
   /**
   /**
    * @swagger
    * @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);
       const sanitizeRevisionId = revisionId == null ? undefined : generalXssFilter.process(revisionId);
 
 
       // check page existence
       // check page existence
-      const isExist = await Page.count({ _id: pageId }) > 0;
+      const isExist = await Page.count({ _id: { $eq: pageId } }) > 0;
       if (!isExist) {
       if (!isExist) {
         return res.apiv3Err(new ErrorV3(`Page('${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 400);
         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
 cd growi/bin/data-migrations
 
 
 NETWORK=growi_devcontainer_default
 NETWORK=growi_devcontainer_default
-MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi
+MONGO_URI=mongodb://growi-devcontainer_mongo-1/growi
 
 
 docker run --rm \
 docker run --rm \
   --network $NETWORK \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -v "$(pwd)"/src:/opt \
   -w /opt \
   -w /opt \
   -e MIGRATION_MODULE=v60x \
   -e MIGRATION_MODULE=v60x \
-  mongo:6.0 \
+  mongo:8.0 \
   /bin/mongosh $MONGO_URI index.js
   /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
 ## Variables
 | Variable              | Description                                                                    | Default |
 | Variable              | Description                                                                    | Default |
 | --------------------- | ------------------------------------------------------------------------------ | ------- |
 | --------------------- | ------------------------------------------------------------------------------ | ------- |
@@ -81,12 +84,12 @@ git clone https://github.com/weseek/growi
 cd growi/bin/data-migrations
 cd growi/bin/data-migrations
 
 
 NETWORK=growi_devcontainer_default \
 NETWORK=growi_devcontainer_default \
-MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
+MONGO_URI=mongodb://growi-devcontainer_mongo-1/growi
 docker run --rm \
 docker run --rm \
   --network $NETWORK \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -v "$(pwd)"/src:/opt \
   -w /opt \
   -w /opt \
   -e MIGRATION_MODULE=custom \
   -e MIGRATION_MODULE=custom \
-  mongo:6.0 \
+  mongo:8.0 \
   /bin/mongosh $MONGO_URI index.js
   /bin/mongosh $MONGO_URI index.js
 ```
 ```

+ 0 - 4
biome.json

@@ -27,15 +27,11 @@
       "!apps/app/public/**",
       "!apps/app/public/**",
       "!apps/app/src/client/**",
       "!apps/app/src/client/**",
       "!apps/app/src/components/**",
       "!apps/app/src/components/**",
-      "!apps/app/src/features/external-user-group/**",
       "!apps/app/src/features/growi-plugin/**",
       "!apps/app/src/features/growi-plugin/**",
-      "!apps/app/src/features/mermaid/**",
       "!apps/app/src/features/openai/**",
       "!apps/app/src/features/openai/**",
       "!apps/app/src/features/opentelemetry/**",
       "!apps/app/src/features/opentelemetry/**",
       "!apps/app/src/features/page-bulk-export/**",
       "!apps/app/src/features/page-bulk-export/**",
-      "!apps/app/src/features/plantuml/**",
       "!apps/app/src/features/rate-limiter/**",
       "!apps/app/src/features/rate-limiter/**",
-      "!apps/app/src/features/search/**",
       "!apps/app/src/interfaces/**",
       "!apps/app/src/interfaces/**",
       "!apps/app/src/models/**",
       "!apps/app/src/models/**",
       "!apps/app/src/pages/**",
       "!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>{' '}
             <span className="material-symbols-outlined me-1">warning</span>{' '}
             {lsxContext.toString()}
             {lsxContext.toString()}
           </summary>
           </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>
           <small className="ms-3 text-muted">{errorMessage}</small>
         </details>
         </details>
       );
       );

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

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

+ 274 - 97
pnpm-lock.yaml

@@ -78,7 +78,7 @@ importers:
         version: 8.41.0
         version: 8.41.0
       eslint-config-next:
       eslint-config-next:
         specifier: ^12.1.6
         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:
       eslint-config-weseek:
         specifier: ^2.1.1
         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)
         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)
@@ -150,7 +150,7 @@ importers:
         version: 5.0.1(stylelint@16.5.0(typescript@5.0.4))
         version: 5.0.1(stylelint@16.5.0(typescript@5.0.4))
       stylelint-config-recommended-scss:
       stylelint-config-recommended-scss:
         specifier: ^14.0.0
         specifier: ^14.0.0
-        version: 14.0.0(postcss@8.5.5)(stylelint@16.5.0(typescript@5.0.4))
+        version: 14.0.0(postcss@8.5.6)(stylelint@16.5.0(typescript@5.0.4))
       ts-deepmerge:
       ts-deepmerge:
         specifier: ^6.2.0
         specifier: ^6.2.0
         version: 6.2.0
         version: 6.2.0
@@ -330,7 +330,7 @@ importers:
         version: 3.9.1
         version: 3.9.1
       babel-plugin-superjson-next:
       babel-plugin-superjson-next:
         specifier: ^0.4.2
         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@1.13.3)
+        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@1.13.3)
       body-parser:
       body-parser:
         specifier: ^1.20.3
         specifier: ^1.20.3
         version: 1.20.3
         version: 1.20.3
@@ -527,20 +527,20 @@ importers:
         specifier: ^4.2.0
         specifier: ^4.2.0
         version: 4.2.0
         version: 4.2.0
       next:
       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:
       next-dynamic-loading-props:
         specifier: ^0.1.1
         specifier: ^0.1.1
         version: 0.1.1(react@18.2.0)
         version: 0.1.1(react@18.2.0)
       next-i18next:
       next-i18next:
         specifier: ^15.3.1
         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:
       next-superjson:
         specifier: ^0.0.4
         specifier: ^0.0.4
-        version: 0.0.4(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@1.13.3)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+        version: 0.0.4(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@1.13.3)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17)))
       next-themes:
       next-themes:
         specifier: ^0.2.1
         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:
       nocache:
         specifier: ^4.0.0
         specifier: ^4.0.0
         version: 4.0.0
         version: 4.0.0
@@ -802,10 +802,10 @@ importers:
         version: 2.11.8
         version: 2.11.8
       '@swc-node/jest':
       '@swc-node/jest':
         specifier: ^1.8.1
         specifier: ^1.8.1
-        version: 1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)
+        version: 1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)(typescript@5.4.2)
       '@swc/jest':
       '@swc/jest':
         specifier: ^0.2.36
         specifier: ^0.2.36
-        version: 0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.15))
+        version: 0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.17))
       '@testing-library/dom':
       '@testing-library/dom':
         specifier: ^10.4.0
         specifier: ^10.4.0
         version: 10.4.0
         version: 10.4.0
@@ -877,7 +877,7 @@ importers:
         version: 10.0.0
         version: 10.0.0
       babel-loader:
       babel-loader:
         specifier: ^8.2.5
         specifier: ^8.2.5
-        version: 8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+        version: 8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17)))
       bootstrap:
       bootstrap:
         specifier: '=5.3.2'
         specifier: '=5.3.2'
         version: 5.3.2(@popperjs/core@2.11.8)
         version: 5.3.2(@popperjs/core@2.11.8)
@@ -898,7 +898,7 @@ importers:
         version: 3.1.0
         version: 3.1.0
       eslint-plugin-jest:
       eslint-plugin-jest:
         specifier: ^26.5.3
         specifier: ^26.5.3
-        version: 26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2)))(typescript@5.4.2)
+        version: 26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2)))(typescript@5.4.2)
       fastest-levenshtein:
       fastest-levenshtein:
         specifier: ^1.0.16
         specifier: ^1.0.16
         version: 1.0.16
         version: 1.0.16
@@ -925,7 +925,7 @@ importers:
         version: 4.2.0
         version: 4.2.0
       jest:
       jest:
         specifier: ^29.5.0
         specifier: ^29.5.0
-        version: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+        version: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
       jest-date-mock:
       jest-date-mock:
         specifier: ^1.0.8
         specifier: ^1.0.8
         version: 1.0.10
         version: 1.0.10
@@ -952,7 +952,7 @@ importers:
         version: 1.10.0
         version: 1.10.0
       null-loader:
       null-loader:
         specifier: ^4.0.1
         specifier: ^4.0.1
-        version: 4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+        version: 4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17)))
       openapi-typescript:
       openapi-typescript:
         specifier: ^7.8.0
         specifier: ^7.8.0
         version: 7.8.0(typescript@5.4.2)
         version: 7.8.0(typescript@5.4.2)
@@ -1000,7 +1000,7 @@ importers:
         version: 4.8.1
         version: 4.8.1
       source-map-loader:
       source-map-loader:
         specifier: ^4.0.1
         specifier: ^4.0.1
-        version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+        version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17)))
       swagger2openapi:
       swagger2openapi:
         specifier: ^7.0.8
         specifier: ^7.0.8
         version: 7.0.8(encoding@0.1.13)
         version: 7.0.8(encoding@0.1.13)
@@ -1082,10 +1082,10 @@ importers:
     devDependencies:
     devDependencies:
       '@swc-node/register':
       '@swc-node/register':
         specifier: ^1.10.9
         specifier: ^1.10.9
-        version: 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)
+        version: 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)(typescript@5.4.2)
       '@swc/core':
       '@swc/core':
         specifier: ^1.9.2
         specifier: ^1.9.2
-        version: 1.10.7(@swc/helpers@0.5.15)
+        version: 1.10.7(@swc/helpers@0.5.17)
       '@types/connect':
       '@types/connect':
         specifier: ^3.4.38
         specifier: ^3.4.38
         version: 3.4.38
         version: 3.4.38
@@ -1106,7 +1106,7 @@ importers:
         version: 7.1.1
         version: 7.1.1
       unplugin-swc:
       unplugin-swc:
         specifier: ^1.5.3
         specifier: ^1.5.3
-        version: 1.5.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(rollup@4.41.0)
+        version: 1.5.3(@swc/core@1.10.7(@swc/helpers@0.5.17))(rollup@4.41.0)
 
 
   apps/slackbot-proxy:
   apps/slackbot-proxy:
     dependencies:
     dependencies:
@@ -3465,6 +3465,9 @@ packages:
   '@next/env@14.2.30':
   '@next/env@14.2.30':
     resolution: {integrity: sha512-KBiBKrDY6kxTQWGzKjQB7QirL3PiiOkV7KW98leHFjtVRKtft76Ra5qSA/SL75xT44dp6hOcqiiJ6iievLOYug==}
     resolution: {integrity: sha512-KBiBKrDY6kxTQWGzKjQB7QirL3PiiOkV7KW98leHFjtVRKtft76Ra5qSA/SL75xT44dp6hOcqiiJ6iievLOYug==}
 
 
+  '@next/env@14.2.32':
+    resolution: {integrity: sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==}
+
   '@next/eslint-plugin-next@12.1.6':
   '@next/eslint-plugin-next@12.1.6':
     resolution: {integrity: sha512-yNUtJ90NEiYFT6TJnNyofKMPYqirKDwpahcbxBgSIuABwYOdkGwzos1ZkYD51Qf0diYwpQZBeVqElTk7Q2WNqw==}
     resolution: {integrity: sha512-yNUtJ90NEiYFT6TJnNyofKMPYqirKDwpahcbxBgSIuABwYOdkGwzos1ZkYD51Qf0diYwpQZBeVqElTk7Q2WNqw==}
 
 
@@ -3474,54 +3477,108 @@ packages:
     cpu: [arm64]
     cpu: [arm64]
     os: [darwin]
     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':
   '@next/swc-darwin-x64@14.2.30':
     resolution: {integrity: sha512-TyO7Wz1IKE2kGv8dwQ0bmPL3s44EKVencOqwIY69myoS3rdpO1NPg5xPM5ymKu7nfX4oYJrpMxv8G9iqLsnL4A==}
     resolution: {integrity: sha512-TyO7Wz1IKE2kGv8dwQ0bmPL3s44EKVencOqwIY69myoS3rdpO1NPg5xPM5ymKu7nfX4oYJrpMxv8G9iqLsnL4A==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [x64]
     cpu: [x64]
     os: [darwin]
     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':
   '@next/swc-linux-arm64-gnu@14.2.30':
     resolution: {integrity: sha512-I5lg1fgPJ7I5dk6mr3qCH1hJYKJu1FsfKSiTKoYwcuUf53HWTrEkwmMI0t5ojFKeA6Vu+SfT2zVy5NS0QLXV4Q==}
     resolution: {integrity: sha512-I5lg1fgPJ7I5dk6mr3qCH1hJYKJu1FsfKSiTKoYwcuUf53HWTrEkwmMI0t5ojFKeA6Vu+SfT2zVy5NS0QLXV4Q==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     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':
   '@next/swc-linux-arm64-musl@14.2.30':
     resolution: {integrity: sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==}
     resolution: {integrity: sha512-8GkNA+sLclQyxgzCDs2/2GSwBc92QLMrmYAmoP2xehe5MUKBLB2cgo34Yu242L1siSkwQkiV4YLdCnjwc/Micw==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [arm64]
     cpu: [arm64]
     os: [linux]
     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':
   '@next/swc-linux-x64-gnu@14.2.30':
     resolution: {integrity: sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==}
     resolution: {integrity: sha512-8Ly7okjssLuBoe8qaRCcjGtcMsv79hwzn/63wNeIkzJVFVX06h5S737XNr7DZwlsbTBDOyI6qbL2BJB5n6TV/w==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     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':
   '@next/swc-linux-x64-musl@14.2.30':
     resolution: {integrity: sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==}
     resolution: {integrity: sha512-dBmV1lLNeX4mR7uI7KNVHsGQU+OgTG5RGFPi3tBJpsKPvOPtg9poyav/BYWrB3GPQL4dW5YGGgalwZ79WukbKQ==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [x64]
     cpu: [x64]
     os: [linux]
     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':
   '@next/swc-win32-arm64-msvc@14.2.30':
     resolution: {integrity: sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==}
     resolution: {integrity: sha512-6MMHi2Qc1Gkq+4YLXAgbYslE1f9zMGBikKMdmQRHXjkGPot1JY3n5/Qrbg40Uvbi8//wYnydPnyvNhI1DMUW1g==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [arm64]
     cpu: [arm64]
     os: [win32]
     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':
   '@next/swc-win32-ia32-msvc@14.2.30':
     resolution: {integrity: sha512-pVZMnFok5qEX4RT59mK2hEVtJX+XFfak+/rjHpyFh7juiT52r177bfFKhnlafm0UOSldhXjj32b+LZIOdswGTg==}
     resolution: {integrity: sha512-pVZMnFok5qEX4RT59mK2hEVtJX+XFfak+/rjHpyFh7juiT52r177bfFKhnlafm0UOSldhXjj32b+LZIOdswGTg==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [ia32]
     cpu: [ia32]
     os: [win32]
     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':
   '@next/swc-win32-x64-msvc@14.2.30':
     resolution: {integrity: sha512-4KCo8hMZXMjpTzs3HOqOGYYwAXymXIy7PEPAXNEcEOyKqkjiDlECumrWziy+JEF0Oi4ILHGxzgQ3YiMGG2t/Lg==}
     resolution: {integrity: sha512-4KCo8hMZXMjpTzs3HOqOGYYwAXymXIy7PEPAXNEcEOyKqkjiDlECumrWziy+JEF0Oi4ILHGxzgQ3YiMGG2t/Lg==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
     cpu: [x64]
     cpu: [x64]
     os: [win32]
     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':
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
     resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
 
 
@@ -5034,6 +5091,9 @@ packages:
   '@swc/helpers@0.5.15':
   '@swc/helpers@0.5.15':
     resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
     resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
 
 
+  '@swc/helpers@0.5.17':
+    resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
+
   '@swc/helpers@0.5.5':
   '@swc/helpers@0.5.5':
     resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
     resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
 
 
@@ -6574,8 +6634,8 @@ packages:
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     hasBin: true
     hasBin: true
 
 
-  browserslist@4.25.3:
-    resolution: {integrity: sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==}
+  browserslist@4.25.4:
+    resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==}
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
     hasBin: true
     hasBin: true
 
 
@@ -6722,8 +6782,8 @@ packages:
   caniuse-lite@1.0.30001727:
   caniuse-lite@1.0.30001727:
     resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
     resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
 
 
-  caniuse-lite@1.0.30001735:
-    resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==}
+  caniuse-lite@1.0.30001739:
+    resolution: {integrity: sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==}
 
 
   capital-case@1.0.4:
   capital-case@1.0.4:
     resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
     resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -8228,8 +8288,8 @@ packages:
   electron-to-chromium@1.5.190:
   electron-to-chromium@1.5.190:
     resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==}
     resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==}
 
 
-  electron-to-chromium@1.5.207:
-    resolution: {integrity: sha512-mryFrrL/GXDTmAtIVMVf+eIXM09BBPlO5IQ7lUyKmK8d+A4VpRGG+M3ofoVef6qyF8s60rJei8ymlJxjUA8Faw==}
+  electron-to-chromium@1.5.211:
+    resolution: {integrity: sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==}
 
 
   emittery@0.13.1:
   emittery@0.13.1:
     resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
     resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
@@ -11450,6 +11510,24 @@ packages:
       sass:
       sass:
         optional: true
         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:
   nice-try@1.0.4:
     resolution: {integrity: sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==}
     resolution: {integrity: sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==}
 
 
@@ -12185,6 +12263,10 @@ packages:
     resolution: {integrity: sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==}
     resolution: {integrity: sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==}
     engines: {node: ^10 || ^12 || >=14}
     engines: {node: ^10 || ^12 || >=14}
 
 
+  postcss@8.5.6:
+    resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+    engines: {node: ^10 || ^12 || >=14}
+
   postgres-array@3.0.4:
   postgres-array@3.0.4:
     resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
     resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
     engines: {node: '>=12'}
     engines: {node: '>=12'}
@@ -13767,6 +13849,10 @@ packages:
     resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
     resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
     engines: {node: '>=6'}
     engines: {node: '>=6'}
 
 
+  tapable@2.2.3:
+    resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==}
+    engines: {node: '>=6'}
+
   tar-fs@3.0.6:
   tar-fs@3.0.6:
     resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==}
     resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==}
 
 
@@ -17340,7 +17426,7 @@ snapshots:
       jest-util: 29.7.0
       jest-util: 29.7.0
       slash: 3.0.0
       slash: 3.0.0
 
 
-  '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))':
+  '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))':
     dependencies:
     dependencies:
       '@jest/console': 29.7.0
       '@jest/console': 29.7.0
       '@jest/reporters': 29.7.0
       '@jest/reporters': 29.7.0
@@ -17354,7 +17440,7 @@ snapshots:
       exit: 0.1.2
       exit: 0.1.2
       graceful-fs: 4.2.11
       graceful-fs: 4.2.11
       jest-changed-files: 29.7.0
       jest-changed-files: 29.7.0
-      jest-config: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      jest-config: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
       jest-haste-map: 29.7.0
       jest-haste-map: 29.7.0
       jest-message-util: 29.7.0
       jest-message-util: 29.7.0
       jest-regex-util: 29.6.3
       jest-regex-util: 29.6.3
@@ -17834,6 +17920,8 @@ snapshots:
 
 
   '@next/env@14.2.30': {}
   '@next/env@14.2.30': {}
 
 
+  '@next/env@14.2.32': {}
+
   '@next/eslint-plugin-next@12.1.6':
   '@next/eslint-plugin-next@12.1.6':
     dependencies:
     dependencies:
       glob: 7.1.7
       glob: 7.1.7
@@ -17841,30 +17929,57 @@ snapshots:
   '@next/swc-darwin-arm64@14.2.30':
   '@next/swc-darwin-arm64@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-darwin-arm64@14.2.32':
+    optional: true
+
   '@next/swc-darwin-x64@14.2.30':
   '@next/swc-darwin-x64@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-darwin-x64@14.2.32':
+    optional: true
+
   '@next/swc-linux-arm64-gnu@14.2.30':
   '@next/swc-linux-arm64-gnu@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-arm64-gnu@14.2.32':
+    optional: true
+
   '@next/swc-linux-arm64-musl@14.2.30':
   '@next/swc-linux-arm64-musl@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-arm64-musl@14.2.32':
+    optional: true
+
   '@next/swc-linux-x64-gnu@14.2.30':
   '@next/swc-linux-x64-gnu@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-x64-gnu@14.2.32':
+    optional: true
+
   '@next/swc-linux-x64-musl@14.2.30':
   '@next/swc-linux-x64-musl@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-linux-x64-musl@14.2.32':
+    optional: true
+
   '@next/swc-win32-arm64-msvc@14.2.30':
   '@next/swc-win32-arm64-msvc@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-win32-arm64-msvc@14.2.32':
+    optional: true
+
   '@next/swc-win32-ia32-msvc@14.2.30':
   '@next/swc-win32-ia32-msvc@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-win32-ia32-msvc@14.2.32':
+    optional: true
+
   '@next/swc-win32-x64-msvc@14.2.30':
   '@next/swc-win32-x64-msvc@14.2.30':
     optional: true
     optional: true
 
 
+  '@next/swc-win32-x64-msvc@14.2.32':
+    optional: true
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     dependencies:
     dependencies:
       eslint-scope: 5.1.1
       eslint-scope: 5.1.1
@@ -19915,12 +20030,17 @@ snapshots:
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
       '@swc/types': 0.1.17
       '@swc/types': 0.1.17
 
 
-  '@swc-node/jest@1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)':
+  '@swc-node/core@1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)':
+    dependencies:
+      '@swc/core': 1.10.7(@swc/helpers@0.5.17)
+      '@swc/types': 0.1.17
+
+  '@swc-node/jest@1.8.3(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)(typescript@5.4.2)':
     dependencies:
     dependencies:
       '@node-rs/xxhash': 1.7.3
       '@node-rs/xxhash': 1.7.3
-      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)
-      '@swc-node/register': 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)
+      '@swc-node/register': 1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)(typescript@5.4.2)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.17)
       '@swc/types': 0.1.17
       '@swc/types': 0.1.17
       typescript: 5.4.2
       typescript: 5.4.2
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -19941,11 +20061,11 @@ snapshots:
       - '@swc/types'
       - '@swc/types'
       - supports-color
       - supports-color
 
 
-  '@swc-node/register@1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)(typescript@5.4.2)':
+  '@swc-node/register@1.10.9(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)(typescript@5.4.2)':
     dependencies:
     dependencies:
-      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(@swc/types@0.1.17)
+      '@swc-node/core': 1.13.3(@swc/core@1.10.7(@swc/helpers@0.5.17))(@swc/types@0.1.17)
       '@swc-node/sourcemap-support': 0.5.1
       '@swc-node/sourcemap-support': 0.5.1
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.17)
       colorette: 2.0.20
       colorette: 2.0.20
       debug: 4.4.1(supports-color@5.5.0)
       debug: 4.4.1(supports-color@5.5.0)
       oxc-resolver: 1.12.0
       oxc-resolver: 1.12.0
@@ -20008,21 +20128,42 @@ snapshots:
       '@swc/core-win32-x64-msvc': 1.10.7
       '@swc/core-win32-x64-msvc': 1.10.7
       '@swc/helpers': 0.5.15
       '@swc/helpers': 0.5.15
 
 
+  '@swc/core@1.10.7(@swc/helpers@0.5.17)':
+    dependencies:
+      '@swc/counter': 0.1.3
+      '@swc/types': 0.1.17
+    optionalDependencies:
+      '@swc/core-darwin-arm64': 1.10.7
+      '@swc/core-darwin-x64': 1.10.7
+      '@swc/core-linux-arm-gnueabihf': 1.10.7
+      '@swc/core-linux-arm64-gnu': 1.10.7
+      '@swc/core-linux-arm64-musl': 1.10.7
+      '@swc/core-linux-x64-gnu': 1.10.7
+      '@swc/core-linux-x64-musl': 1.10.7
+      '@swc/core-win32-arm64-msvc': 1.10.7
+      '@swc/core-win32-ia32-msvc': 1.10.7
+      '@swc/core-win32-x64-msvc': 1.10.7
+      '@swc/helpers': 0.5.17
+
   '@swc/counter@0.1.3': {}
   '@swc/counter@0.1.3': {}
 
 
   '@swc/helpers@0.5.15':
   '@swc/helpers@0.5.15':
     dependencies:
     dependencies:
       tslib: 2.8.1
       tslib: 2.8.1
 
 
+  '@swc/helpers@0.5.17':
+    dependencies:
+      tslib: 2.8.1
+
   '@swc/helpers@0.5.5':
   '@swc/helpers@0.5.5':
     dependencies:
     dependencies:
       '@swc/counter': 0.1.3
       '@swc/counter': 0.1.3
       tslib: 2.8.1
       tslib: 2.8.1
 
 
-  '@swc/jest@0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.15))':
+  '@swc/jest@0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.17))':
     dependencies:
     dependencies:
       '@jest/create-cache-key-function': 29.7.0
       '@jest/create-cache-key-function': 29.7.0
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.17)
       '@swc/counter': 0.1.3
       '@swc/counter': 0.1.3
       jsonc-parser: 3.2.0
       jsonc-parser: 3.2.0
 
 
@@ -21665,7 +21806,7 @@ snapshots:
 
 
   apache-arrow@19.0.1:
   apache-arrow@19.0.1:
     dependencies:
     dependencies:
-      '@swc/helpers': 0.5.15
+      '@swc/helpers': 0.5.17
       '@types/command-line-args': 5.2.3
       '@types/command-line-args': 5.2.3
       '@types/command-line-usage': 5.0.4
       '@types/command-line-usage': 5.0.4
       '@types/node': 20.14.0
       '@types/node': 20.14.0
@@ -21940,14 +22081,14 @@ snapshots:
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
-  babel-loader@8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  babel-loader@8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))):
     dependencies:
     dependencies:
       '@babel/core': 7.24.6
       '@babel/core': 7.24.6
       find-cache-dir: 3.3.2
       find-cache-dir: 3.3.2
       loader-utils: 2.0.4
       loader-utils: 2.0.4
       make-dir: 3.1.0
       make-dir: 3.1.0
       schema-utils: 2.7.1
       schema-utils: 2.7.1
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))
 
 
   babel-plugin-istanbul@6.1.1:
   babel-plugin-istanbul@6.1.1:
     dependencies:
     dependencies:
@@ -21966,12 +22107,12 @@ snapshots:
       '@types/babel__core': 7.20.5
       '@types/babel__core': 7.20.5
       '@types/babel__traverse': 7.0.7
       '@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@1.13.3):
+  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@1.13.3):
     dependencies:
     dependencies:
       '@babel/helper-module-imports': 7.24.6
       '@babel/helper-module-imports': 7.24.6
       '@babel/types': 7.25.6
       '@babel/types': 7.25.6
       hoist-non-react-statics: 3.3.2
       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: 1.13.3
       superjson: 1.13.3
 
 
   babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.6):
   babel-preset-current-node-syntax@1.0.1(@babel/core@7.24.6):
@@ -22200,17 +22341,17 @@ snapshots:
 
 
   browserslist@4.25.1:
   browserslist@4.25.1:
     dependencies:
     dependencies:
-      caniuse-lite: 1.0.30001727
+      caniuse-lite: 1.0.30001739
       electron-to-chromium: 1.5.190
       electron-to-chromium: 1.5.190
       node-releases: 2.0.19
       node-releases: 2.0.19
       update-browserslist-db: 1.1.3(browserslist@4.25.1)
       update-browserslist-db: 1.1.3(browserslist@4.25.1)
 
 
-  browserslist@4.25.3:
+  browserslist@4.25.4:
     dependencies:
     dependencies:
-      caniuse-lite: 1.0.30001735
-      electron-to-chromium: 1.5.207
+      caniuse-lite: 1.0.30001739
+      electron-to-chromium: 1.5.211
       node-releases: 2.0.19
       node-releases: 2.0.19
-      update-browserslist-db: 1.1.3(browserslist@4.25.3)
+      update-browserslist-db: 1.1.3(browserslist@4.25.4)
 
 
   bs-recipes@1.3.4: {}
   bs-recipes@1.3.4: {}
 
 
@@ -22390,7 +22531,7 @@ snapshots:
 
 
   caniuse-lite@1.0.30001727: {}
   caniuse-lite@1.0.30001727: {}
 
 
-  caniuse-lite@1.0.30001735: {}
+  caniuse-lite@1.0.30001739: {}
 
 
   capital-case@1.0.4:
   capital-case@1.0.4:
     dependencies:
     dependencies:
@@ -22967,13 +23108,13 @@ snapshots:
       isobject: 3.0.1
       isobject: 3.0.1
       lazy-cache: 2.0.2
       lazy-cache: 2.0.2
 
 
-  create-jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2)):
+  create-jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2)):
     dependencies:
     dependencies:
       '@jest/types': 29.6.3
       '@jest/types': 29.6.3
       chalk: 4.1.2
       chalk: 4.1.2
       exit: 0.1.2
       exit: 0.1.2
       graceful-fs: 4.2.11
       graceful-fs: 4.2.11
-      jest-config: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      jest-config: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
       jest-util: 29.7.0
       jest-util: 29.7.0
       prompts: 2.4.2
       prompts: 2.4.2
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -23636,7 +23777,7 @@ snapshots:
 
 
   electron-to-chromium@1.5.190: {}
   electron-to-chromium@1.5.190: {}
 
 
-  electron-to-chromium@1.5.207: {}
+  electron-to-chromium@1.5.211: {}
 
 
   emittery@0.13.1: {}
   emittery@0.13.1: {}
 
 
@@ -23709,7 +23850,7 @@ snapshots:
   enhanced-resolve@5.18.3:
   enhanced-resolve@5.18.3:
     dependencies:
     dependencies:
       graceful-fs: 4.2.11
       graceful-fs: 4.2.11
-      tapable: 2.2.2
+      tapable: 2.2.3
 
 
   enquirer@2.4.1:
   enquirer@2.4.1:
     dependencies:
     dependencies:
@@ -23929,7 +24070,7 @@ snapshots:
       object.assign: 4.1.5
       object.assign: 4.1.5
       object.entries: 1.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:
     dependencies:
       '@next/eslint-plugin-next': 12.1.6
       '@next/eslint-plugin-next': 12.1.6
       '@rushstack/eslint-patch': 1.1.3
       '@rushstack/eslint-patch': 1.1.3
@@ -23941,7 +24082,7 @@ snapshots:
       eslint-plugin-jsx-a11y: 6.5.1(eslint@8.41.0)
       eslint-plugin-jsx-a11y: 6.5.1(eslint@8.41.0)
       eslint-plugin-react: 7.30.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)
       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:
     optionalDependencies:
       typescript: 5.0.4
       typescript: 5.0.4
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -24076,13 +24217,13 @@ snapshots:
       - typescript
       - typescript
     optional: true
     optional: true
 
 
-  eslint-plugin-jest@26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2)))(typescript@5.4.2):
+  eslint-plugin-jest@26.9.0(@typescript-eslint/eslint-plugin@5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2)))(typescript@5.4.2):
     dependencies:
     dependencies:
       '@typescript-eslint/utils': 5.59.7(eslint@8.41.0)(typescript@5.4.2)
       '@typescript-eslint/utils': 5.59.7(eslint@8.41.0)(typescript@5.4.2)
       eslint: 8.41.0
       eslint: 8.41.0
     optionalDependencies:
     optionalDependencies:
       '@typescript-eslint/eslint-plugin': 5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2)
       '@typescript-eslint/eslint-plugin': 5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.4.2))(eslint@8.41.0)(typescript@5.4.2)
-      jest: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      jest: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
       - typescript
       - typescript
@@ -25910,16 +26051,16 @@ snapshots:
       - babel-plugin-macros
       - babel-plugin-macros
       - supports-color
       - supports-color
 
 
-  jest-cli@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2)):
+  jest-cli@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2)):
     dependencies:
     dependencies:
-      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
       '@jest/test-result': 29.7.0
       '@jest/test-result': 29.7.0
       '@jest/types': 29.6.3
       '@jest/types': 29.6.3
       chalk: 4.1.2
       chalk: 4.1.2
-      create-jest: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      create-jest: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
       exit: 0.1.2
       exit: 0.1.2
       import-local: 3.1.0
       import-local: 3.1.0
-      jest-config: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      jest-config: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
       jest-util: 29.7.0
       jest-util: 29.7.0
       jest-validate: 29.7.0
       jest-validate: 29.7.0
       yargs: 17.7.2
       yargs: 17.7.2
@@ -25929,7 +26070,7 @@ snapshots:
       - supports-color
       - supports-color
       - ts-node
       - ts-node
 
 
-  jest-config@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2)):
+  jest-config@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2)):
     dependencies:
     dependencies:
       '@babel/core': 7.24.6
       '@babel/core': 7.24.6
       '@jest/test-sequencer': 29.7.0
       '@jest/test-sequencer': 29.7.0
@@ -25955,7 +26096,7 @@ snapshots:
       strip-json-comments: 3.1.1
       strip-json-comments: 3.1.1
     optionalDependencies:
     optionalDependencies:
       '@types/node': 22.15.21
       '@types/node': 22.15.21
-      ts-node: 10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2)
+      ts-node: 10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - babel-plugin-macros
       - babel-plugin-macros
       - supports-color
       - supports-color
@@ -26185,12 +26326,12 @@ snapshots:
       merge-stream: 2.0.0
       merge-stream: 2.0.0
       supports-color: 8.1.1
       supports-color: 8.1.1
 
 
-  jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2)):
+  jest@29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2)):
     dependencies:
     dependencies:
-      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
       '@jest/types': 29.6.3
       '@jest/types': 29.6.3
       import-local: 3.1.0
       import-local: 3.1.0
-      jest-cli: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2))
+      jest-cli: 29.7.0(@types/node@22.15.21)(ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2))
     transitivePeerDependencies:
     transitivePeerDependencies:
       - '@types/node'
       - '@types/node'
       - babel-plugin-macros
       - babel-plugin-macros
@@ -27745,7 +27886,7 @@ snapshots:
     dependencies:
     dependencies:
       react: 18.2.0
       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:
     dependencies:
       '@babel/runtime': 7.25.4
       '@babel/runtime': 7.25.4
       '@types/hoist-non-react-statics': 3.3.5
       '@types/hoist-non-react-statics': 3.3.5
@@ -27753,26 +27894,26 @@ snapshots:
       hoist-non-react-statics: 3.3.2
       hoist-non-react-statics: 3.3.2
       i18next: 23.16.5
       i18next: 23.16.5
       i18next-fs-backend: 2.3.2
       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: 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)
       react-i18next: 15.1.1(i18next@23.16.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
 
 
-  next-superjson@0.0.4(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@1.13.3)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  next-superjson@0.0.4(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@1.13.3)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))):
     dependencies:
     dependencies:
       '@babel/core': 7.24.6
       '@babel/core': 7.24.6
       '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.6)
       '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.6)
       '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.24.6)
       '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.24.6)
-      babel-loader: 8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
-      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@1.13.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)
+      babel-loader: 8.3.0(@babel/core@7.24.6)(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17)))
+      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@1.13.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)
     transitivePeerDependencies:
     transitivePeerDependencies:
       - superjson
       - superjson
       - supports-color
       - supports-color
       - webpack
       - webpack
 
 
-  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:
     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: 18.2.0
       react-dom: 18.2.0(react@18.2.0)
       react-dom: 18.2.0(react@18.2.0)
 
 
@@ -27804,6 +27945,34 @@ snapshots:
       - '@babel/core'
       - '@babel/core'
       - babel-plugin-macros
       - 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.30001739
+      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: {}
   nice-try@1.0.4: {}
 
 
   nimma@0.2.2:
   nimma@0.2.2:
@@ -27990,11 +28159,11 @@ snapshots:
     dependencies:
     dependencies:
       boolbase: 1.0.0
       boolbase: 1.0.0
 
 
-  null-loader@4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  null-loader@4.0.1(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))):
     dependencies:
     dependencies:
       loader-utils: 2.0.4
       loader-utils: 2.0.4
       schema-utils: 3.3.0
       schema-utils: 3.3.0
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))
 
 
   numbro@2.5.0:
   numbro@2.5.0:
     dependencies:
     dependencies:
@@ -28609,18 +28778,18 @@ snapshots:
     dependencies:
     dependencies:
       postcss: 8.5.5
       postcss: 8.5.5
 
 
-  postcss-scss@4.0.9(postcss@8.5.5):
+  postcss-scss@4.0.9(postcss@8.5.6):
     dependencies:
     dependencies:
-      postcss: 8.5.5
+      postcss: 8.5.6
 
 
   postcss-selector-parser@6.1.0:
   postcss-selector-parser@6.1.0:
     dependencies:
     dependencies:
       cssesc: 3.0.0
       cssesc: 3.0.0
       util-deprecate: 1.0.2
       util-deprecate: 1.0.2
 
 
-  postcss-sorting@8.0.2(postcss@8.5.5):
+  postcss-sorting@8.0.2(postcss@8.5.6):
     dependencies:
     dependencies:
-      postcss: 8.5.5
+      postcss: 8.5.6
 
 
   postcss-value-parser@4.2.0: {}
   postcss-value-parser@4.2.0: {}
 
 
@@ -28636,6 +28805,12 @@ snapshots:
       picocolors: 1.1.1
       picocolors: 1.1.1
       source-map-js: 1.2.1
       source-map-js: 1.2.1
 
 
+  postcss@8.5.6:
+    dependencies:
+      nanoid: 3.3.11
+      picocolors: 1.1.1
+      source-map-js: 1.2.1
+
   postgres-array@3.0.4: {}
   postgres-array@3.0.4: {}
 
 
   postgres-bytea@3.0.0:
   postgres-bytea@3.0.0:
@@ -30091,11 +30266,11 @@ snapshots:
 
 
   source-map-js@1.2.1: {}
   source-map-js@1.2.1: {}
 
 
-  source-map-loader@4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  source-map-loader@4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))):
     dependencies:
     dependencies:
       iconv-lite: 0.6.3
       iconv-lite: 0.6.3
       source-map-js: 1.2.1
       source-map-js: 1.2.1
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))
 
 
   source-map-support@0.5.13:
   source-map-support@0.5.13:
     dependencies:
     dependencies:
@@ -30402,14 +30577,14 @@ snapshots:
       stylelint: 16.5.0(typescript@5.0.4)
       stylelint: 16.5.0(typescript@5.0.4)
       stylelint-order: 6.0.4(stylelint@16.5.0(typescript@5.0.4))
       stylelint-order: 6.0.4(stylelint@16.5.0(typescript@5.0.4))
 
 
-  stylelint-config-recommended-scss@14.0.0(postcss@8.5.5)(stylelint@16.5.0(typescript@5.0.4)):
+  stylelint-config-recommended-scss@14.0.0(postcss@8.5.6)(stylelint@16.5.0(typescript@5.0.4)):
     dependencies:
     dependencies:
-      postcss-scss: 4.0.9(postcss@8.5.5)
+      postcss-scss: 4.0.9(postcss@8.5.6)
       stylelint: 16.5.0(typescript@5.0.4)
       stylelint: 16.5.0(typescript@5.0.4)
       stylelint-config-recommended: 14.0.0(stylelint@16.5.0(typescript@5.0.4))
       stylelint-config-recommended: 14.0.0(stylelint@16.5.0(typescript@5.0.4))
       stylelint-scss: 6.3.0(stylelint@16.5.0(typescript@5.0.4))
       stylelint-scss: 6.3.0(stylelint@16.5.0(typescript@5.0.4))
     optionalDependencies:
     optionalDependencies:
-      postcss: 8.5.5
+      postcss: 8.5.6
 
 
   stylelint-config-recommended@14.0.0(stylelint@16.5.0(typescript@5.0.4)):
   stylelint-config-recommended@14.0.0(stylelint@16.5.0(typescript@5.0.4)):
     dependencies:
     dependencies:
@@ -30417,8 +30592,8 @@ snapshots:
 
 
   stylelint-order@6.0.4(stylelint@16.5.0(typescript@5.0.4)):
   stylelint-order@6.0.4(stylelint@16.5.0(typescript@5.0.4)):
     dependencies:
     dependencies:
-      postcss: 8.5.5
-      postcss-sorting: 8.0.2(postcss@8.5.5)
+      postcss: 8.5.6
+      postcss-sorting: 8.0.2(postcss@8.5.6)
       stylelint: 16.5.0(typescript@5.0.4)
       stylelint: 16.5.0(typescript@5.0.4)
 
 
   stylelint-scss@6.3.0(stylelint@16.5.0(typescript@5.0.4)):
   stylelint-scss@6.3.0(stylelint@16.5.0(typescript@5.0.4)):
@@ -30658,6 +30833,8 @@ snapshots:
 
 
   tapable@2.2.2: {}
   tapable@2.2.2: {}
 
 
+  tapable@2.2.3: {}
+
   tar-fs@3.0.6:
   tar-fs@3.0.6:
     dependencies:
     dependencies:
       pump: 3.0.0
       pump: 3.0.0
@@ -30702,16 +30879,16 @@ snapshots:
 
 
   term-size@2.2.1: {}
   term-size@2.2.1: {}
 
 
-  terser-webpack-plugin@5.3.14(@swc/core@1.10.7(@swc/helpers@0.5.15))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))):
+  terser-webpack-plugin@5.3.14(@swc/core@1.10.7(@swc/helpers@0.5.17))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))):
     dependencies:
     dependencies:
       '@jridgewell/trace-mapping': 0.3.30
       '@jridgewell/trace-mapping': 0.3.30
       jest-worker: 27.5.1
       jest-worker: 27.5.1
       schema-utils: 4.3.2
       schema-utils: 4.3.2
       serialize-javascript: 6.0.2
       serialize-javascript: 6.0.2
       terser: 5.43.1
       terser: 5.43.1
-      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      webpack: 5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17))
     optionalDependencies:
     optionalDependencies:
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.17)
 
 
   terser@5.43.1:
   terser@5.43.1:
     dependencies:
     dependencies:
@@ -30881,7 +31058,7 @@ snapshots:
     optionalDependencies:
     optionalDependencies:
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
       '@swc/core': 1.10.7(@swc/helpers@0.5.15)
 
 
-  ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.15))(@types/node@22.15.21)(typescript@5.4.2):
+  ts-node@10.9.2(@swc/core@1.10.7(@swc/helpers@0.5.17))(@types/node@22.15.21)(typescript@5.4.2):
     dependencies:
     dependencies:
       '@cspotcode/source-map-support': 0.8.1
       '@cspotcode/source-map-support': 0.8.1
       '@tsconfig/node10': 1.0.9
       '@tsconfig/node10': 1.0.9
@@ -30899,7 +31076,7 @@ snapshots:
       v8-compile-cache-lib: 3.0.1
       v8-compile-cache-lib: 3.0.1
       yn: 3.1.1
       yn: 3.1.1
     optionalDependencies:
     optionalDependencies:
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.17)
     optional: true
     optional: true
 
 
   ts-patch@3.2.0:
   ts-patch@3.2.0:
@@ -31291,10 +31468,10 @@ snapshots:
 
 
   unpipe@1.0.0: {}
   unpipe@1.0.0: {}
 
 
-  unplugin-swc@1.5.3(@swc/core@1.10.7(@swc/helpers@0.5.15))(rollup@4.41.0):
+  unplugin-swc@1.5.3(@swc/core@1.10.7(@swc/helpers@0.5.17))(rollup@4.41.0):
     dependencies:
     dependencies:
       '@rollup/pluginutils': 5.1.4(rollup@4.41.0)
       '@rollup/pluginutils': 5.1.4(rollup@4.41.0)
-      '@swc/core': 1.10.7(@swc/helpers@0.5.15)
+      '@swc/core': 1.10.7(@swc/helpers@0.5.17)
       load-tsconfig: 0.2.5
       load-tsconfig: 0.2.5
       unplugin: 2.3.5
       unplugin: 2.3.5
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -31324,9 +31501,9 @@ snapshots:
       escalade: 3.2.0
       escalade: 3.2.0
       picocolors: 1.1.1
       picocolors: 1.1.1
 
 
-  update-browserslist-db@1.1.3(browserslist@4.25.3):
+  update-browserslist-db@1.1.3(browserslist@4.25.4):
     dependencies:
     dependencies:
-      browserslist: 4.25.3
+      browserslist: 4.25.4
       escalade: 3.2.0
       escalade: 3.2.0
       picocolors: 1.1.1
       picocolors: 1.1.1
 
 
@@ -31688,7 +31865,7 @@ snapshots:
 
 
   webpack-virtual-modules@0.6.2: {}
   webpack-virtual-modules@0.6.2: {}
 
 
-  webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)):
+  webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17)):
     dependencies:
     dependencies:
       '@types/eslint-scope': 3.7.7
       '@types/eslint-scope': 3.7.7
       '@types/estree': 1.0.8
       '@types/estree': 1.0.8
@@ -31697,7 +31874,7 @@ snapshots:
       '@webassemblyjs/wasm-parser': 1.14.1
       '@webassemblyjs/wasm-parser': 1.14.1
       acorn: 8.15.0
       acorn: 8.15.0
       acorn-import-attributes: 1.9.5(acorn@8.15.0)
       acorn-import-attributes: 1.9.5(acorn@8.15.0)
-      browserslist: 4.25.3
+      browserslist: 4.25.4
       chrome-trace-event: 1.0.4
       chrome-trace-event: 1.0.4
       enhanced-resolve: 5.18.3
       enhanced-resolve: 5.18.3
       es-module-lexer: 1.7.0
       es-module-lexer: 1.7.0
@@ -31710,8 +31887,8 @@ snapshots:
       mime-types: 2.1.35
       mime-types: 2.1.35
       neo-async: 2.6.2
       neo-async: 2.6.2
       schema-utils: 3.3.0
       schema-utils: 3.3.0
-      tapable: 2.2.2
-      terser-webpack-plugin: 5.3.14(@swc/core@1.10.7(@swc/helpers@0.5.15))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+      tapable: 2.2.3
+      terser-webpack-plugin: 5.3.14(@swc/core@1.10.7(@swc/helpers@0.5.17))(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.17)))
       watchpack: 2.4.4
       watchpack: 2.4.4
       webpack-sources: 3.3.3
       webpack-sources: 3.3.3
     transitivePeerDependencies:
     transitivePeerDependencies: