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

configure biome for external-user-group feature

Futa Arai 7 месяцев назад
Родитель
Сommit
3bcc725c81
20 измененных файлов с 1633 добавлено и 774 удалено
  1. 1 0
      apps/app/.eslintrc.js
  2. 123 81
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  3. 5 3
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupManagement.tsx
  4. 108 40
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  5. 28 18
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  6. 103 42
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  7. 45 25
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  8. 76 33
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  9. 50 38
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  10. 93 39
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.integ.ts
  11. 54 23
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  12. 58 22
      apps/app/src/features/external-user-group/server/models/external-user-group.integ.ts
  13. 54 24
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  14. 29 17
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  15. 399 164
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  16. 148 62
      apps/app/src/features/external-user-group/server/service/external-user-group-sync.ts
  17. 58 59
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.integ.ts
  18. 91 39
      apps/app/src/features/external-user-group/server/service/keycloak-user-group-sync.ts
  19. 110 44
      apps/app/src/features/external-user-group/server/service/ldap-user-group-sync.ts
  20. 0 1
      biome.json

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 0 - 1
biome.json

@@ -27,7 +27,6 @@
       "!apps/app/public/**",
       "!apps/app/src/client/**",
       "!apps/app/src/components/**",
-      "!apps/app/src/features/external-user-group/**",
       "!apps/app/src/features/growi-plugin/**",
       "!apps/app/src/features/mermaid/**",
       "!apps/app/src/features/openai/**",