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

Merge pull request #7833 from weseek/feat/123277-125405-external-user-group-index-ui

Feat/123277 125405 external user group index UI
Futa Arai 2 лет назад
Родитель
Сommit
801c3f0816
32 измененных файлов с 1126 добавлено и 615 удалено
  1. 2 0
      apps/app/public/static/locales/en_US/admin.json
  2. 2 0
      apps/app/public/static/locales/ja_JP/admin.json
  3. 2 0
      apps/app/public/static/locales/zh_CN/admin.json
  4. 50 0
      apps/app/src/client/services/user-group.ts
  5. 0 44
      apps/app/src/components/Admin/UserGroup/ExternalUserGroup/ExternalUserGroupManagement.tsx
  6. 20 10
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  7. 8 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  8. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  9. 34 22
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  10. 56 35
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  11. 9 14
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  12. 171 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  13. 0 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  14. 0 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  15. 75 2
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  16. 2 0
      apps/app/src/features/external-user-group/interfaces/external-user-group.ts
  17. 9 9
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  18. 12 21
      apps/app/src/features/external-user-group/server/models/external-user-group.ts
  19. 54 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts
  20. 164 1
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  21. 9 9
      apps/app/src/interfaces/user-group-response.ts
  22. 1 1
      apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js
  23. 4 2
      apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx
  24. 14 13
      apps/app/src/server/crowi/index.js
  25. 0 2
      apps/app/src/server/models/index.js
  26. 0 391
      apps/app/src/server/models/user-group-relation.js
  27. 368 0
      apps/app/src/server/models/user-group-relation.ts
  28. 6 8
      apps/app/src/server/models/user-group.ts
  29. 1 1
      apps/app/src/server/routes/apiv3/index.js
  30. 6 6
      apps/app/src/server/routes/apiv3/user-group.js
  31. 18 11
      apps/app/src/server/service/user-group.ts
  32. 28 11
      apps/app/src/stores/user-group.tsx

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

@@ -1046,6 +1046,8 @@
     "execute_sync": "Execute Sync",
     "execute_sync": "Execute Sync",
     "sync": "Sync",
     "sync": "Sync",
     "invalid_sync_settings": "Invalid sync settings",
     "invalid_sync_settings": "Invalid sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
       "group_search_base_DN": "Group Search Base DN",

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

@@ -1054,6 +1054,8 @@
     "execute_sync": "同期実行",
     "execute_sync": "同期実行",
     "sync": "同期",
     "sync": "同期",
     "invalid_sync_settings": "同期設定に誤りがあります",
     "invalid_sync_settings": "同期設定に誤りがあります",
+    "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
+    "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP グループ同期設定",
       "group_sync_settings": "LDAP グループ同期設定",
       "group_search_base_DN": "グループ検索ベース DN",
       "group_search_base_DN": "グループ検索ベース DN",

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

@@ -1054,6 +1054,8 @@
     "execute_sync": "Execute Sync",
     "execute_sync": "Execute Sync",
     "sync": "Sync",
     "sync": "Sync",
     "invalid_sync_settings": "Invalid sync settings",
     "invalid_sync_settings": "Invalid sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
     "ldap": {
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",
       "group_search_base_DN": "Group Search Base DN",

+ 50 - 0
apps/app/src/client/services/user-group.ts

@@ -0,0 +1,50 @@
+import {
+  useSWRxAncestorExternalUserGroups,
+  useSWRxChildExternalUserGroupList,
+  useSWRxExternalUserGroup,
+  useSWRxExternalUserGroupRelationList,
+  useSWRxExternalUserGroupRelations,
+} from '~/features/external-user-group/client/stores/external-user-group';
+import {
+  useSWRxAncestorUserGroups,
+  useSWRxChildUserGroupList, useSWRxUserGroup, useSWRxUserGroupRelationList, useSWRxUserGroupRelations,
+} from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroup = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroup(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroup(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelations = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelations(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelations(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useChildUserGroupList = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxChildUserGroupList(
+    !isExternalGroup ? [userGroupId] : [], true,
+  );
+  const externalUserGroupRes = useSWRxChildExternalUserGroupList(
+    isExternalGroup ? [userGroupId] : [], true,
+  );
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelationList = (userGroupIds: string[], isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelationList(isExternalGroup ? null : userGroupIds);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelationList(isExternalGroup ? userGroupIds : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useAncestorUserGroups = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxAncestorUserGroups(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxAncestorExternalUserGroups(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};

+ 0 - 44
apps/app/src/components/Admin/UserGroup/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -1,44 +0,0 @@
-import { FC, useMemo, useState } from 'react';
-
-import { useTranslation } from 'react-i18next';
-import { TabContent, TabPane } from 'reactstrap';
-
-import CustomNav from '~/components/CustomNavigation/CustomNav';
-
-import { LdapGroupManagement } from './LdapGroupManagement';
-
-export const ExternalGroupManagement: FC = () => {
-  const [activeTab, setActiveTab] = useState('ldap');
-  const [activeComponents, setActiveComponents] = useState(new Set(['ldap']));
-  const { t } = useTranslation('admin');
-
-  const switchActiveTab = (selectedTab) => {
-    setActiveTab(selectedTab);
-    setActiveComponents(activeComponents.add(selectedTab));
-  };
-
-  const navTabMapping = useMemo(() => {
-    return {
-      ldap: {
-        Icon: () => <i className="fa fa-sitemap" />,
-        i18n: 'LDAP',
-      },
-    };
-  }, []);
-
-  return <>
-    <h2 className="border-bottom">{t('external_user_group.management')}</h2>
-    <CustomNav
-      activeTab={activeTab}
-      navTabMapping={navTabMapping}
-      onNavSelected={switchActiveTab}
-      hideBorderBottom
-      breakpointToSwitchDropdownDown="md"
-    />
-    <TabContent activeTab={activeTab} className="p-5">
-      <TabPane tabId="ldap">
-        {activeComponents.has('ldap') && <LdapGroupManagement />}
-      </TabPane>
-    </TabContent>
-  </>;
-};

+ 20 - 10
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,4 +1,6 @@
-import React, { FC, useCallback, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 
 
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -11,6 +13,7 @@ type Props = {
   selectableParentUserGroups?: IUserGroupHasId[],
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   submitButtonLabel: string;
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
+  isExternalGroup?: boolean
 };
 };
 
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -18,14 +21,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit, isExternalGroup = false,
   } = props;
   } = props;
   /*
   /*
    * State
    * State
    */
    */
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>();
   /*
   /*
    * Function
    * Function
    */
    */
@@ -37,12 +40,16 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
     setDescription(e.target.value);
   }, []);
   }, []);
 
 
-  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+  const onChangeParentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
     if (userGroup._id !== selectedParent?._id) {
     if (userGroup._id !== selectedParent?._id) {
       setSelectedParent(userGroup);
       setSelectedParent(userGroup);
     }
     }
   }, [selectedParent, setSelectedParent]);
   }, [selectedParent, setSelectedParent]);
 
 
+  useEffect(() => {
+    setSelectedParent(parentUserGroup);
+  }, [parentUserGroup]);
+
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
 
 
   const isChildUserGroup = parentUserGroup !== undefined;
   const isChildUserGroup = parentUserGroup !== undefined;
@@ -61,7 +68,10 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
 
 
       <fieldset>
       <fieldset>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
-
+        {isExternalGroup
+        && <div className='mb-3'>
+          <small className="text-muted">{t('external_user_group.only_description_edit_allowed')}</small>
+        </div>}
         {
         {
           userGroup?.createdAt != null && (
           userGroup?.createdAt != null && (
             <div className="form-group row">
             <div className="form-group row">
@@ -75,7 +85,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
           <label htmlFor="name" className="col-md-2 col-form-label">
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('user_group_management.group_name')}
             {t('user_group_management.group_name')}
           </label>
           </label>
-          <div className="col-md-4">
+          <div className="col-md-4 my-auto">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
@@ -84,6 +94,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               value={currentName}
               value={currentName}
               onChange={onChangeNameHandler}
               onChange={onChangeNameHandler}
               required
               required
+              disabled={isExternalGroup}
             />
             />
           </div>
           </div>
         </div>
         </div>
@@ -106,9 +117,8 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               type="button"
               type="button"
               id="dropdownMenuButton"
               id="dropdownMenuButton"
               data-toggle="dropdown"
               data-toggle="dropdown"
-              className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
-              `}
+              className="btn btn-outline-secondary dropdown-toggle mb-3"
+              disabled={isExternalGroup || !isSelectableParentUserGroups}
             >
             >
               {selectedParent?.name ?? messageAtReleaseParentGroup}
               {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
             </button>
@@ -122,7 +132,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
                           key={userGroup._id}
                           key={userGroup._id}
                           type="button"
                           type="button"
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
-                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                          onClick={() => onChangeParentButtonHandler(userGroup)}
                         >
                         >
                           {userGroup.name}
                           {userGroup.name}
                         </button>
                         </button>

+ 8 - 1
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -16,6 +16,7 @@ type Props = {
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   isShow?: boolean
   onHide?: () => Promise<void> | void
   onHide?: () => Promise<void> | void
+  isExternalGroup?: boolean
 };
 };
 
 
 export const UserGroupModal: FC<Props> = (props: Props) => {
 export const UserGroupModal: FC<Props> = (props: Props) => {
@@ -23,7 +24,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const {
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
   } = props;
   } = props;
 
 
   /*
   /*
@@ -88,6 +89,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               value={currentName}
               value={currentName}
               onChange={onChangeNameHandler}
               onChange={onChangeNameHandler}
               required
               required
+              disabled={isExternalGroup}
             />
             />
           </div>
           </div>
 
 
@@ -96,6 +98,11 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               {t('Description')}
               {t('Description')}
             </label>
             </label>
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+            {isExternalGroup && <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.description_form_detail')}
+              </small>
+            </p>}
           </div>
           </div>
 
 
           {/* TODO 90732: Add a drop-down to show selectable parents */}
           {/* TODO 90732: Add a drop-down to show selectable parents */}

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -5,11 +5,11 @@ import { useTranslation } from 'react-i18next';
 
 
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
 
-import { ExternalGroupManagement } from './ExternalUserGroup/ExternalUserGroupManagement';
 
 
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });

+ 34 - 22
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -16,6 +16,7 @@ type Props = {
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  isExternalGroup?: boolean
 };
 };
 
 
 /*
 /*
@@ -52,27 +53,37 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 };
 };
 
 
 
 
-export const UserGroupTable: FC<Props> = (props: Props) => {
+export const UserGroupTable: FC<Props> = ({
+  headerLabel,
+  userGroups,
+  userGroupRelations,
+  childUserGroups,
+  isAclEnabled,
+  onEdit,
+  onRemove,
+  onDelete,
+  isExternalGroup = false,
+}: Props) => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   /*
   /*
    * State
    * State
    */
    */
-  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(props.userGroupRelations));
-  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(userGroupRelations));
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(childUserGroups));
 
 
   /*
   /*
    * Function
    * Function
    */
    */
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
     const groupId = e.target.getAttribute('data-user-group-id');
-    return props.userGroups.find((group) => {
+    return userGroups.find((group) => {
       return group._id === groupId;
       return group._id === groupId;
     });
     });
   };
   };
 
 
   const onClickEdit = async(e) => {
   const onClickEdit = async(e) => {
-    if (props.onEdit == null) {
+    if (onEdit == null) {
       return;
       return;
     }
     }
 
 
@@ -81,11 +92,11 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
       return;
     }
     }
 
 
-    props.onEdit(userGroup);
+    onEdit(userGroup);
   };
   };
 
 
   const onClickRemove = async(e) => {
   const onClickRemove = async(e) => {
-    if (props.onRemove == null) {
+    if (onRemove == null) {
       return;
       return;
     }
     }
 
 
@@ -95,7 +106,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
     }
     }
 
 
     try {
     try {
-      await props.onRemove(userGroup);
+      await onRemove(userGroup);
       userGroup.parent = null;
       userGroup.parent = null;
     }
     }
     catch {
     catch {
@@ -104,7 +115,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   };
   };
 
 
   const onClickDelete = (e) => { // no preventDefault
   const onClickDelete = (e) => { // no preventDefault
-    if (props.onDelete == null) {
+    if (onDelete == null) {
       return;
       return;
     }
     }
 
 
@@ -113,20 +124,20 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
       return;
     }
     }
 
 
-    props.onDelete(userGroup);
+    onDelete(userGroup);
   };
   };
 
 
   /*
   /*
    * useEffect
    * useEffect
    */
    */
   useEffect(() => {
   useEffect(() => {
-    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
-    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
-  }, [props.userGroupRelations, props.childUserGroups]);
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(childUserGroups));
+  }, [userGroupRelations, childUserGroups]);
 
 
   return (
   return (
     <div data-testid="grw-user-group-table">
     <div data-testid="grw-user-group-table">
-      <h3>{props.headerLabel}</h3>
+      <h3>{headerLabel}</h3>
 
 
       <table className="table table-bordered table-user-list">
       <table className="table table-bordered table-user-list">
         <thead>
         <thead>
@@ -140,14 +151,14 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           </tr>
           </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-          {props.userGroups.map((group) => {
+          {userGroups.map((group) => {
             const users = groupIdToUsersMap[group._id];
             const users = groupIdToUsersMap[group._id];
 
 
             return (
             return (
               <tr key={group._id}>
               <tr key={group._id}>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a></td>
+                    <td><a href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</a></td>
                   )
                   )
                   : (
                   : (
                     <td>{group.name}</td>
                     <td>{group.name}</td>
@@ -166,9 +177,9 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                       return (
                       return (
                         <li key={group._id} className="list-inline-item badge badge-success">
                         <li key={group._id} className="list-inline-item badge badge-success">
-                          {props.isAclEnabled
+                          {isAclEnabled
                             ? (
                             ? (
-                              <a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a>
+                              <a href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</a>
                             )
                             )
                             : (
                             : (
                               <p>{group.name}</p>
                               <p>{group.name}</p>
@@ -180,7 +191,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   </ul>
                   </ul>
                 </td>
                 </td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
                   ? (
                     <td>
                     <td>
                       <div className="btn-group admin-group-menu">
                       <div className="btn-group admin-group-menu">
@@ -196,9 +207,10 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
                           </button>
-                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                          {onRemove != null
+                          && <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
                             <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
                             <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
-                          </button>
+                          </button>}
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>
                           </button>

+ 56 - 35
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -8,19 +8,21 @@ import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
+import {
+  useAncestorUserGroups,
+  useChildUserGroupList, useUserGroup, useUserGroupRelationList, useUserGroupRelations,
+} from '~/client/services/user-group';
 import {
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
-import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
-  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
-} from '~/stores/user-group';
+import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import styles from './UserGroupDetailPage.module.scss';
 import styles from './UserGroupDetailPage.module.scss';
@@ -28,7 +30,7 @@ import styles from './UserGroupDetailPage.module.scss';
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
-const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable').then(mod => mod.UserGroupUserTable), { ssr: false });
 
 
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 
 
@@ -42,15 +44,16 @@ const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModa
 
 
 type Props = {
 type Props = {
   userGroupId: string,
   userGroupId: string,
+  isExternalGroup: boolean,
 }
 }
 
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const router = useRouter();
   const router = useRouter();
   const xss = useMemo(() => new Xss(), []);
   const xss = useMemo(() => new Xss(), []);
-  const { userGroupId: currentUserGroupId } = props;
+  const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
 
-  const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+  const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
 
 
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -76,26 +79,36 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
    */
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
 
 
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(currentUserGroupId);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useUserGroupRelations(currentUserGroupId, isExternalGroup);
 
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList(currentUserGroupId ? [currentUserGroupId] : [], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups, updateChild } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
   const childUserGroupIds = childUserGroups.map(group => group._id);
 
 
-  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useUserGroupRelationList(childUserGroupIds, isExternalGroup);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroupId);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroupId);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
 
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroupId);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
 
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
 
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
 
-  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  const parentUserGroup = (() => {
+    if (isExternalGroup) {
+      return ancestorUserGroups != null && ancestorUserGroups.length > 1
+        ? ancestorUserGroups[ancestorUserGroups.length - 2] : undefined;
+    }
+    return selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  })();
   /*
   /*
    * Function
    * Function
    */
    */
@@ -113,19 +126,26 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
-    await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
-      name: update.name,
-      description: update.description,
-      parentId: parentId ?? null,
-      forceUpdateParents,
-    });
+    if (isExternalGroup) {
+      await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
+        description: update.description,
+      });
+    }
+    else {
+      await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: update.name,
+        description: update.description,
+        parentId: parentId ?? null,
+        forceUpdateParents,
+      });
+    }
 
 
     // mutate
     // mutate
     mutateChildUserGroups();
     mutateChildUserGroups();
     mutateAncestorUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
     mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
 
   const onSubmitUpdateGroup = useCallback(
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -213,23 +233,16 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
 
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
     try {
-      await apiv3Put(`/user-groups/${userGroupData._id}`, {
-        name: userGroupData.name,
-        description: userGroupData.description,
-        parentId: userGroupData.parent,
-      });
+      updateChild(userGroupData);
 
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
 
 
-      // mutate
-      mutateChildUserGroups();
-
       hideUpdateModal();
       hideUpdateModal();
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [t, mutateChildUserGroups, hideUpdateModal]);
+  }, [t, updateChild, hideUpdateModal]);
 
 
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
     // show confirm modal before submiting
     // show confirm modal before submiting
@@ -283,8 +296,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [setSelectedUserGroup, setDeleteModalShown]);
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
     try {
-      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+      const res = await apiv3Delete(url, {
         actionName,
         actionName,
         transferToUserGroupId,
         transferToUserGroupId,
       });
       });
@@ -300,7 +314,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     catch (err) {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
       toastError(new Error('Unable to delete the groups'));
     }
     }
-  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown, isExternalGroup]);
 
 
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
     try {
@@ -348,7 +362,10 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`} prefetch={false}>
+                  <Link href={{
+                    pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
+                    query: { isExternalGroup: 'true' },
+                  }} prefetch={false}>
                     {ancestorUserGroup.name}
                     {ancestorUserGroup.name}
                   </Link>
                   </Link>
                 ) }
                 ) }
@@ -366,6 +383,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           selectableParentUserGroups={selectableParentUserGroups}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
           onSubmit={onClickSubmitForm}
+          isExternalGroup={isExternalGroup}
         />
         />
       </div>
       </div>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
@@ -373,6 +391,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         userGroupRelations={userGroupRelations}
         userGroupRelations={userGroupRelations}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickRemoveUserBtn={removeUserByUsername}
         onClickRemoveUserBtn={removeUserByUsername}
+        isExternalGroup={isExternalGroup}
       />
       />
       <UserGroupUserModal
       <UserGroupUserModal
         isOpen={isUserGroupUserModalShown}
         isOpen={isUserGroupUserModalShown}
@@ -389,11 +408,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       />
       />
 
 
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
-      <UserGroupDropdown
+      {!isExternalGroup && <UserGroupDropdown
         selectableUserGroups={selectableChildUserGroups}
         selectableUserGroups={selectableChildUserGroups}
         onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
         onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
         onClickCreateUserGroupButton={showCreateModal}
         onClickCreateUserGroupButton={showCreateModal}
-      />
+      />}
 
 
       <UserGroupModal
       <UserGroupModal
         userGroup={selectedUserGroup}
         userGroup={selectedUserGroup}
@@ -401,6 +420,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onClickSubmit={updateChildUserGroup}
         onClickSubmit={updateChildUserGroup}
         isShow={isUpdateModalShown}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
         onHide={hideUpdateModal}
+        isExternalGroup={isExternalGroup}
       />
       />
 
 
       <UserGroupModal
       <UserGroupModal
@@ -420,6 +440,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onRemove={removeChildUserGroup}
         onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
         userGroupRelations={childUserGroupRelations}
+        isExternalGroup={isExternalGroup}
       />
       />
 
 
       <UserGroupDeleteModal
       <UserGroupDeleteModal

+ 9 - 14
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -10,15 +10,12 @@ type Props = {
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickPlusBtn: () => void,
   onClickPlusBtn: () => void,
+  isExternalGroup?: boolean
 }
 }
 
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
 export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const {
-    userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
-  } = props;
-
   return (
   return (
     <table className="table table-bordered table-user-list">
     <table className="table table-bordered table-user-list">
       <thead>
       <thead>
@@ -30,11 +27,11 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           <th>{t('Name')}</th>
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
-          <th style={{ width: '70px' }}></th>
+          {!props.isExternalGroup && <th style={{ width: '70px' }}></th>}
         </tr>
         </tr>
       </thead>
       </thead>
       <tbody>
       <tbody>
-        {userGroupRelations != null && userGroupRelations.map((relation) => {
+        {props.userGroupRelations != null && props.userGroupRelations.map((relation) => {
           const { relatedUser } = relation;
           const { relatedUser } = relation;
 
 
           return (
           return (
@@ -48,7 +45,7 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
               <td>{relatedUser.name}</td>
               <td>{relatedUser.name}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
-              <td>
+              {!props.isExternalGroup && <td>
                 <div className="btn-group admin-user-menu">
                 <div className="btn-group admin-user-menu">
                   <button
                   <button
                     type="button"
                     type="button"
@@ -62,21 +59,21 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
                     <button
                     <button
                       className="dropdown-item"
                       className="dropdown-item"
                       type="button"
                       type="button"
-                      onClick={() => onClickRemoveUserBtn(relatedUser.username)}
+                      onClick={() => props.onClickRemoveUserBtn(relatedUser.username)}
                     >
                     >
                       <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
                       <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
                     </button>
                     </button>
                   </div>
                   </div>
                 </div>
                 </div>
-              </td>
+              </td>}
             </tr>
             </tr>
           );
           );
         })}
         })}
 
 
-        <tr>
+        {!props.isExternalGroup && <tr>
           <td></td>
           <td></td>
           <td className="text-center">
           <td className="text-center">
-            <button className="btn btn-outline-secondary" type="button" onClick={onClickPlusBtn}>
+            <button className="btn btn-outline-secondary" type="button" onClick={props.onClickPlusBtn}>
               <i className="ti ti-plus"></i>
               <i className="ti ti-plus"></i>
             </button>
             </button>
           </td>
           </td>
@@ -84,11 +81,9 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           <td></td>
           <td></td>
           <td></td>
           <td></td>
           <td></td>
           <td></td>
-        </tr>
+        </tr>}
 
 
       </tbody>
       </tbody>
     </table>
     </table>
   );
   );
 };
 };
-
-export default UserGroupUserTable;

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

@@ -0,0 +1,171 @@
+import {
+  FC, useCallback, useMemo, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { TabContent, TabPane } from 'reactstrap';
+
+import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { UserGroupDeleteModal } from '~/components/Admin/UserGroup/UserGroupDeleteModal';
+import { UserGroupModal } from '~/components/Admin/UserGroup/UserGroupModal';
+import { UserGroupTable } from '~/components/Admin/UserGroup/UserGroupTable';
+import CustomNav from '~/components/CustomNavigation/CustomNav';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import { useIsAclEnabled } from '~/stores/context';
+
+import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '../../stores/external-user-group';
+
+import { LdapGroupManagement } from './LdapGroupManagement';
+
+export const ExternalGroupManagement: FC = () => {
+  const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
+  const externalUserGroups = externalUserGroupList != null ? externalUserGroupList : [];
+  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 [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 hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedExternalUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
+  const syncUserGroupAndRelations = useCallback(async() => {
+    try {
+      await mutateExternalUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [mutateExternalUserGroups]);
+
+  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: string, transferToUserGroupId: string) => {
+    try {
+      await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // 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);
+    setActiveComponents(activeComponents.add(selectedTab));
+  };
+
+  const navTabMapping = useMemo(() => {
+    return {
+      ldap: {
+        Icon: () => <i className="fa fa-sitemap" />,
+        i18n: 'LDAP',
+      },
+    };
+  }, []);
+
+  return <>
+    <h2 className="border-bottom">{t('external_user_group.management')}</h2>
+    <UserGroupTable
+      headerLabel={t('admin:user_group_management.group_list')}
+      userGroups={externalUserGroups}
+      childUserGroups={childExternalUserGroups}
+      isAclEnabled={isAclEnabled ?? false}
+      onEdit={showUpdateModal}
+      onDelete={showDeleteModal}
+      userGroupRelations={externalUserGroupRelations}
+      isExternalGroup
+    />
+
+    <UserGroupModal
+      userGroup={selectedExternalUserGroup}
+      buttonLabel={t('Update')}
+      onClickSubmit={updateExternalUserGroup}
+      isShow={isUpdateModalShown}
+      onHide={hideUpdateModal}
+      isExternalGroup
+    />
+
+    <UserGroupDeleteModal
+      userGroups={externalUserGroups}
+      deleteUserGroup={selectedExternalUserGroup}
+      onDelete={deleteExternalUserGroupById}
+      isShow={isDeleteModalShown}
+      onHide={hideDeleteModal}
+    />
+
+    <CustomNav
+      activeTab={activeTab}
+      navTabMapping={navTabMapping}
+      onNavSelected={switchActiveTab}
+      hideBorderBottom
+      breakpointToSwitchDropdownDown="md"
+    />
+    <TabContent activeTab={activeTab} className="p-5">
+      <TabPane tabId="ldap">
+        {activeComponents.has('ldap') && <LdapGroupManagement />}
+      </TabPane>
+    </TabContent>
+  </>;
+};

+ 0 - 0
apps/app/src/components/Admin/UserGroup/ExternalUserGroup/LdapGroupManagement.tsx → apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx


+ 0 - 0
apps/app/src/components/Admin/UserGroup/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx → apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx


+ 75 - 2
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -1,7 +1,10 @@
+import { SWRResponseWithUtils, withUtils } from '@growi/core';
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { LdapGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { IExternalUserGroupHasId, IExternalUserGroupRelationHasId, LdapGroupSyncSettings } from '~/features/external-user-group/interfaces/external-user-group';
+import { ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupRelationListResult } from '~/interfaces/user-group-response';
 
 
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
   return useSWR(
   return useSWR(
@@ -11,3 +14,73 @@ export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSetting
     }),
     }),
   );
   );
 };
 };
+
+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),
+  );
+};
+
+export const useSWRxExternalUserGroupList = (initialData?: IExternalUserGroupHasId[]): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    '/external-user-groups',
+    endpoint => apiv3Get(endpoint, { pagination: false }).then(result => result.data.userGroups),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+type ChildExternalUserGroupListUtils = {
+  updateChild(childGroupData: IExternalUserGroupHasId): Promise<void>, // update one child and refresh list
+}
+export const useSWRxChildExternalUserGroupList = (
+    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)),
+  );
+
+  const updateChild = async(childGroupData: IExternalUserGroupHasId) => {
+    await apiv3Put(`/external-user-groups/${childGroupData._id}`, {
+      description: childGroupData.description,
+    });
+    swrResponse.mutate();
+  };
+
+  return withUtils(swrResponse, { updateChild });
+};
+
+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),
+  );
+};
+
+export const useSWRxExternalUserGroupRelationList = (
+    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),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+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),
+  );
+};

+ 2 - 0
apps/app/src/features/external-user-group/interfaces/external-user-group.ts

@@ -17,6 +17,8 @@ export interface IExternalUserGroupRelation extends Omit<IUserGroupRelation, 're
   relatedGroup: Ref<IExternalUserGroup>
   relatedGroup: Ref<IExternalUserGroup>
 }
 }
 
 
+export type IExternalUserGroupRelationHasId = IExternalUserGroupRelation & HasObjectId;
+
 export const LdapGroupMembershipAttributeType = { dn: 'DN', uid: 'UID' } as const;
 export const LdapGroupMembershipAttributeType = { dn: 'DN', uid: 'UID' } as const;
 type LdapGroupMembershipAttributeType = typeof LdapGroupMembershipAttributeType[keyof typeof LdapGroupMembershipAttributeType];
 type LdapGroupMembershipAttributeType = typeof LdapGroupMembershipAttributeType[keyof typeof LdapGroupMembershipAttributeType];
 
 

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

@@ -1,13 +1,20 @@
 import { Schema, Model, Document } from 'mongoose';
 import { Schema, Model, Document } from 'mongoose';
 
 
+import UserGroupRelation from '~/server/models/user-group-relation';
+
 import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
 import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
 import { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
 import { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
 
 
+import { ExternalUserGroupDocument } from './external-user-group';
 
 
 export interface ExternalUserGroupRelationDocument extends IExternalUserGroupRelation, Document {}
 export interface ExternalUserGroupRelationDocument extends IExternalUserGroupRelation, Document {}
 
 
 export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupRelationDocument> {
 export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupRelationDocument> {
   [x:string]: any, // for old methods
   [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 50,
+
+  removeAllByUserGroups: (groupsToDelete: ExternalUserGroupDocument[]) => Promise<any>,
 }
 }
 
 
 const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
 const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({
@@ -17,15 +24,8 @@ const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRe
   timestamps: { createdAt: true, updatedAt: false },
   timestamps: { createdAt: true, updatedAt: false },
 });
 });
 
 
-schema.statics.createRelations = async function(userGroupIds, user) {
-  const documentsToInsert = userGroupIds.map((groupId) => {
-    return {
-      relatedGroup: groupId,
-      relatedUser: user._id,
-    };
-  });
+schema.statics.createRelations = UserGroupRelation.createRelations;
 
 
-  return this.insertMany(documentsToInsert);
-};
+schema.statics.removeAllByUserGroups = UserGroupRelation.removeAllByUserGroups;
 
 
 export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);
 export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);

+ 12 - 21
apps/app/src/features/external-user-group/server/models/external-user-group.ts

@@ -1,14 +1,18 @@
 import { Schema, Model, Document } from 'mongoose';
 import { Schema, Model, Document } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
 
 
+import { IExternalUserGroup } from '~/features/external-user-group/interfaces/external-user-group';
+import UserGroup from '~/server/models/user-group';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 
-import { IExternalUserGroup } from '../../interfaces/external-user-group';
-
-
 export interface ExternalUserGroupDocument extends IExternalUserGroup, Document {}
 export interface ExternalUserGroupDocument extends IExternalUserGroup, Document {}
 
 
 export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument> {
 export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument> {
   [x:string]: any, // for old methods
   [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 10,
+
+  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
 }
 }
 
 
 const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
 const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
@@ -20,6 +24,7 @@ const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
 }, {
 }, {
   timestamps: true,
   timestamps: true,
 });
 });
+schema.plugin(mongoosePaginate);
 
 
 /**
 /**
  * Find group that has specified externalId and update, or create one if it doesn't exist.
  * Find group that has specified externalId and update, or create one if it doesn't exist.
@@ -46,26 +51,12 @@ schema.statics.findAndUpdateOrCreateGroup = async function(name: string, externa
   }, { upsert: true, new: true });
   }, { upsert: true, new: true });
 };
 };
 
 
-/**
- * Find all ancestor groups starting from the UserGroup of the initial "group".
- * Set "ancestors" as "[]" if the initial group is unnecessary as result.
- * @param groups ExternalUserGroupDocument
- * @param ancestors ExternalUserGroupDocument[]
- * @returns ExternalUserGroupDocument[]
- */
-schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancestors = [group]) {
-  if (group == null) {
-    return ancestors;
-  }
+schema.statics.findWithPagination = UserGroup.findWithPagination;
 
 
-  const parent = await this.findOne({ _id: group.parent });
-  if (parent == null) {
-    return ancestors;
-  }
+schema.statics.findChildrenByParentIds = UserGroup.findChildrenByParentIds;
 
 
-  ancestors.unshift(parent);
+schema.statics.findGroupsWithAncestorsRecursively = UserGroup.findGroupsWithAncestorsRecursively;
 
 
-  return this.findGroupsWithAncestorsRecursively(parent, ancestors);
-};
+schema.statics.findGroupsWithDescendantsRecursively = UserGroup.findGroupsWithDescendantsRecursively;
 
 
 export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);
 export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);

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

@@ -0,0 +1,54 @@
+import { ErrorV3 } from '@growi/core';
+import { Router, Request } from 'express';
+
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import Crowi from '~/server/crowi';
+import loggerFactory from '~/utils/logger';
+
+import { ApiV3Response } from '../../../../../server/routes/apiv3/interfaces/apiv3-response';
+
+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 { serializeUserGroupRelationSecurely } = require('~/server/models/serializers/user-group-relation-serializer');
+
+const router = express.Router();
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+
+  const validators = {
+    list: [
+      query('groupIds').isArray(),
+      query('childGroupIds').optional().isArray(),
+    ],
+  };
+
+  router.get('/', loginRequiredStrictly, adminRequired, validators.list, async(req: Request, res: ApiV3Response) => {
+    const { query } = req;
+
+    try {
+      const relations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: query.groupIds } }).populate('relatedUser');
+
+      let relationsOfChildGroups = 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 serialized = relations.map(relation => serializeUserGroupRelationSecurely(relation));
+
+      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;
+};

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

@@ -1,9 +1,21 @@
+import { ErrorV3 } from '@growi/core';
 import { Router, Request } from 'express';
 import { Router, Request } from 'express';
-import { body, validationResult } from 'express-validator';
+import {
+  body, param, query, validationResult,
+} from 'express-validator';
 
 
+import ExternalUserGroup, { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation, {
+  ExternalUserGroupRelationDocument,
+} from '~/features/external-user-group/server/models/external-user-group-relation';
+import { SupportedAction } from '~/interfaces/activity';
 import Crowi from '~/server/crowi';
 import Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
+import UserGroupService from '~/server/service/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import LdapUserGroupSyncService from '../../service/ldap-user-group-sync-service';
 import LdapUserGroupSyncService from '../../service/ldap-user-group-sync-service';
@@ -19,6 +31,9 @@ interface AuthorizedRequest extends Request {
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const validators = {
   const validators = {
     ldapSyncSettings: [
     ldapSyncSettings: [
@@ -31,8 +46,156 @@ module.exports = (crowi: Crowi): Router => {
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
     ],
     ],
+    listChildren: [
+      query('parentIds').optional().isArray(),
+      query('includeGrandChildren').optional().isBoolean(),
+    ],
+    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(),
+    ],
   };
   };
 
 
+  router.get('/', 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 result = await ExternalUserGroup.findWithPagination({
+        page, limit, offset, pagination,
+      });
+      const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
+      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching external user group list';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/ancestors', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await ExternalUserGroup.findById(groupId);
+      const ancestorUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
+      return res.apiv3({ ancestorUserGroups });
+    }
+    catch (err) {
+      const msg = 'Error occurred while searching user groups';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/children', loginRequiredStrictly, adminRequired, validators.listChildren, async(req, res) => {
+    try {
+      const { parentIds, includeGrandChildren = false } = req.query;
+
+      const externalUserGroupsResult = await ExternalUserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
+      return res.apiv3({
+        childUserGroups: externalUserGroupsResult.childUserGroups,
+        grandChildUserGroups: externalUserGroupsResult.grandChildUserGroups,
+      });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching child user group list';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/:id', 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) {
+      const msg = 'Error occurred while getting external user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { id: deleteGroupId } = req.params;
+      const { actionName, transferToUserGroupId } = req.query;
+
+      try {
+        const userGroups = await (crowi.userGroupService as UserGroupService)
+          .removeCompletelyByRootGroupId<
+            ExternalUserGroupDocument,
+            ExternalUserGroupRelationDocument
+          >(deleteGroupId, actionName, transferToUserGroupId, req.user, ExternalUserGroup, ExternalUserGroupRelation);
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
+        return res.apiv3({ userGroups });
+      }
+      catch (err) {
+        const msg = 'Error occurred while deleting user groups';
+        logger.error(msg, err);
+        return res.apiv3Err(new ErrorV3(msg));
+      }
+    });
+
+  router.put('/:id', loginRequiredStrictly, adminRequired, validators.update, apiV3FormValidator, addActivity, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+    const {
+      description,
+    } = req.body;
+
+    try {
+      const userGroup = await ExternalUserGroup.findOneAndUpdate({ _id: id }, { description });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating an external user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
+  router.get('/:id/external-user-group-relations', loginRequiredStrictly, adminRequired, async(req, 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));
+      return res.apiv3({ userGroupRelations: serialized });
+    }
+    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));
+    }
+  });
+
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
     const settings = {
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),

+ 9 - 9
apps/app/src/interfaces/user-group-response.ts

@@ -2,7 +2,7 @@ import { HasObjectId, Ref } from '@growi/core';
 
 
 import { IPageHasId } from './page';
 import { IPageHasId } from './page';
 import {
 import {
-  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+  IUserHasId, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
 } from './user';
 } from './user';
 
 
 export type UserGroupResult = {
 export type UserGroupResult = {
@@ -13,18 +13,18 @@ export type UserGroupListResult = {
   userGroups: IUserGroupHasId[],
   userGroups: IUserGroupHasId[],
 };
 };
 
 
-export type ChildUserGroupListResult = {
-  childUserGroups: IUserGroupHasId[],
-  grandChildUserGroups: IUserGroupHasId[],
+export type ChildUserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
+  childUserGroups: TUSERGROUP[],
+  grandChildUserGroups: TUSERGROUP[],
 };
 };
 
 
-export type UserGroupRelationListResult = {
-  userGroupRelations: IUserGroupRelationHasId[],
+export type UserGroupRelationListResult<TUSERGROUPRELATION extends IUserGroupRelationHasId = IUserGroupRelationHasId> = {
+  userGroupRelations: TUSERGROUPRELATION[],
 };
 };
 
 
-export type IUserGroupRelationHasIdPopulatedUser = {
-  relatedGroup: Ref<IUserGroup>,
-  relatedUser: IUser & HasObjectId,
+export type IUserGroupRelationHasIdPopulatedUser<TUSERGROUP extends IUserGroup = IUserGroup> = {
+  relatedGroup: Ref<TUSERGROUP>,
+  relatedUser: IUserHasId,
   createdAt: Date,
   createdAt: Date,
 } & HasObjectId;
 } & HasObjectId;
 
 

+ 1 - 1
apps/app/src/migrations/20200402160380-remove-deleteduser-from-relationgroup.js

@@ -1,5 +1,6 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import UserGroupRelation from '~/server/models/user-group-relation';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import { getModelSafely, getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -11,7 +12,6 @@ module.exports = {
     mongoose.connect(getMongoUri(), mongoOptions);
     mongoose.connect(getMongoUri(), mongoOptions);
 
 
     const User = getModelSafely('User') || require('~/server/models/user')();
     const User = getModelSafely('User') || require('~/server/models/user')();
-    const UserGroupRelation = getModelSafely('UserGroupRelation') || require('~/server/models/user-group-relation')();
 
 
     const deletedUsers = await User.find({ status: 4 }); // deleted user
     const deletedUsers = await User.find({ status: 4 }); // deleted user
     const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });
     const requests = await UserGroupRelation.remove({ relatedUser: deletedUsers });

+ 4 - 2
apps/app/src/pages/admin/user-group-detail/[userGroupId].page.tsx

@@ -25,13 +25,15 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
   useIsMaintenanceMode(props.isMaintenanceMode);
   useIsMaintenanceMode(props.isMaintenanceMode);
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
   const router = useRouter();
   const router = useRouter();
-  const { userGroupId } = router.query;
+  const { userGroupId, isExternalGroup } = router.query;
 
 
   const title = t('user_group_management.user_group_management');
   const title = t('user_group_management.user_group_management');
   const customTitle = generateCustomTitle(props, title);
   const customTitle = generateCustomTitle(props, title);
 
 
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
   const currentUserGroupId = Array.isArray(userGroupId) ? userGroupId[0] : userGroupId;
 
 
+  const isExternalGroupBool = isExternalGroup === 'true';
+
   useIsAclEnabled(props.isAclEnabled);
   useIsAclEnabled(props.isAclEnabled);
 
 
   return (
   return (
@@ -41,7 +43,7 @@ const AdminUserGroupDetailPage: NextPage<Props> = (props: Props) => {
       </Head>
       </Head>
       {
       {
         currentUserGroupId != null && router.isReady
         currentUserGroupId != null && router.isReady
-      && <UserGroupDetailPage userGroupId={currentUserGroupId} />
+      && <UserGroupDetailPage userGroupId={currentUserGroupId} isExternalGroup={isExternalGroupBool} />
       }
       }
     </AdminLayout>
     </AdminLayout>
   );
   );

+ 14 - 13
apps/app/src/server/crowi/index.js

@@ -22,6 +22,7 @@ import Activity from '../models/activity';
 import PageRedirect from '../models/page-redirect';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
 import UserGroup from '../models/user-group';
+import UserGroupRelation from '../models/user-group-relation';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
@@ -35,6 +36,7 @@ import PageOperationService from '../service/page-operation';
 import PassportService from '../service/passport';
 import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
+import UserGroupService from '../service/user-group';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
@@ -297,21 +299,21 @@ Crowi.prototype.setupSocketIoService = async function() {
 };
 };
 
 
 Crowi.prototype.setupModels = async function() {
 Crowi.prototype.setupModels = async function() {
-  let allModels = {};
-
-  // include models that dependent on crowi
-  allModels = models;
-
-  // include models that independent from crowi
-  allModels.Activity = Activity;
-  allModels.Tag = Tag;
-  allModels.UserGroup = UserGroup;
-  allModels.PageRedirect = PageRedirect;
-
-  Object.keys(allModels).forEach((key) => {
+  Object.keys(models).forEach((key) => {
     return this.model(key, models[key](this));
     return this.model(key, models[key](this));
   });
   });
 
 
+  // include models that are independent from crowi
+  const crowiIndependent = {};
+  crowiIndependent.Activity = Activity;
+  crowiIndependent.Tag = Tag;
+  crowiIndependent.UserGroup = UserGroup;
+  crowiIndependent.UserGroupRelation = UserGroupRelation;
+  crowiIndependent.PageRedirect = PageRedirect;
+
+  Object.keys(crowiIndependent).forEach((key) => {
+    return this.model(key, crowiIndependent[key]);
+  });
 };
 };
 
 
 Crowi.prototype.setupCron = function() {
 Crowi.prototype.setupCron = function() {
@@ -679,7 +681,6 @@ Crowi.prototype.setUpRestQiitaAPI = async function() {
 };
 };
 
 
 Crowi.prototype.setupUserGroupService = async function() {
 Crowi.prototype.setupUserGroupService = async function() {
-  const UserGroupService = require('../service/user-group');
   if (this.userGroupService == null) {
   if (this.userGroupService == null) {
     this.userGroupService = new UserGroupService(this);
     this.userGroupService = new UserGroupService(this);
     return this.userGroupService.init();
     return this.userGroupService.init();

+ 0 - 2
apps/app/src/server/models/index.js

@@ -6,9 +6,7 @@ module.exports = {
   // PageArchive: require('./page-archive'),
   // PageArchive: require('./page-archive'),
   PageTagRelation: require('./page-tag-relation'),
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   User: require('./user'),
-  UserGroupRelation: require('./user-group-relation'),
   Revision: require('./revision'),
   Revision: require('./revision'),
-  Tag: require('./tag'),
   Bookmark: require('./bookmark'),
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),
   Comment: require('./comment'),
   Attachment: require('./attachment'),
   Attachment: require('./attachment'),

+ 0 - 391
apps/app/src/server/models/user-group-relation.js

@@ -1,391 +0,0 @@
-const debug = require('debug')('growi:models:userGroupRelation');
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
-  relatedUser: { type: ObjectId, ref: 'User', required: true },
-}, {
-  timestamps: { createdAt: true, updatedAt: false },
-});
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-
-/**
- * UserGroupRelation Class
- *
- * @class UserGroupRelation
- */
-class UserGroupRelation {
-
-  /**
-   * limit items num for pagination
-   *
-   * @readonly
-   * @static
-   * @memberof UserGroupRelation
-   */
-  static get PAGE_ITEMS() {
-    return 50;
-  }
-
-  static set crowi(crowi) {
-    this._crowi = crowi;
-  }
-
-  static get crowi() {
-    return this._crowi;
-  }
-
-  /**
-   * remove all invalid relations that has reference to unlinked document
-   */
-  static removeAllInvalidRelations() {
-    return this.findAllRelation()
-      .then((relations) => {
-        // filter invalid documents
-        return relations.filter((relation) => {
-          return relation.relatedUser == null || relation.relatedGroup == null;
-        });
-      })
-      .then((invalidRelations) => {
-        const ids = invalidRelations.map((relation) => { return relation._id });
-        return this.deleteMany({ _id: { $in: ids } });
-      });
-  }
-
-  /**
-   * find all user and group relation
-   *
-   * @static
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelation() {
-    return this
-      .find()
-      .populate('relatedUser')
-      .populate('relatedGroup')
-      .exec();
-  }
-
-  /**
-   * find all user and group relation of UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelationForUserGroup(userGroup) {
-    debug('findAllRelationForUserGroup is called', userGroup);
-    return this
-      .find({ relatedGroup: userGroup })
-      .populate('relatedUser')
-      .exec();
-  }
-
-  static async findAllUserIdsForUserGroup(userGroup) {
-    const relations = await this
-      .find({ relatedGroup: userGroup })
-      .select('relatedUser')
-      .exec();
-
-    return relations.map(r => r.relatedUser);
-  }
-
-  /**
-   * find all user and group relation of UserGroups
-   *
-   * @static
-   * @param {UserGroup[]} userGroups
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelationForUserGroups(userGroups) {
-    return this
-      .find({ relatedGroup: { $in: userGroups } })
-      .populate('relatedUser')
-      .exec();
-  }
-
-  /**
-   * find all user and group relation of User
-   *
-   * @static
-   * @param {User} user
-   * @returns {Promise<UserGroupRelation[]>}
-   * @memberof UserGroupRelation
-   */
-  static findAllRelationForUser(user) {
-    return this
-      .find({ relatedUser: user.id })
-      .populate('relatedGroup')
-      // filter documents only relatedGroup is not null
-      .then((userGroupRelations) => {
-        return userGroupRelations.filter((relation) => {
-          return relation.relatedGroup != null;
-        });
-      });
-  }
-
-  /**
-   * find all UserGroup IDs that related to specified User
-   *
-   * @static
-   * @param {User} user
-   * @returns {Promise<ObjectId[]>}
-   */
-  static async findAllUserGroupIdsRelatedToUser(user) {
-    const relations = await this.find({ relatedUser: user._id })
-      .select('relatedGroup')
-      .exec();
-
-    return relations.map((relation) => { return relation.relatedGroup });
-  }
-
-  /**
-   * count by related group id and related user
-   *
-   * @static
-   * @param {string} userGroupId find query param for relatedGroup
-   * @param {User} userData find query param for relatedUser
-   * @returns {Promise<number>}
-   */
-  static async countByGroupIdAndUser(userGroupId, userData) {
-    const query = {
-      relatedGroup: userGroupId,
-      relatedUser: userData.id,
-    };
-
-    return this.count(query);
-  }
-
-  /**
-   * find all "not" related user for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup for find users not related
-   * @returns {Promise<User>}
-   * @memberof UserGroupRelation
-   */
-  static findUserByNotRelatedGroup(userGroup, queryOptions) {
-    const User = UserGroupRelation.crowi.model('User');
-    let searchWord = new RegExp(`${queryOptions.searchWord}`);
-    switch (queryOptions.searchType) {
-      case 'forward':
-        searchWord = new RegExp(`^${queryOptions.searchWord}`);
-        break;
-      case 'backword':
-        searchWord = new RegExp(`${queryOptions.searchWord}$`);
-        break;
-    }
-    const searthField = [
-      { username: searchWord },
-    ];
-    if (queryOptions.isAlsoMailSearched === 'true') { searthField.push({ email: searchWord }) }
-    if (queryOptions.isAlsoNameSearched === 'true') { searthField.push({ name: searchWord }) }
-
-    return this.findAllRelationForUserGroup(userGroup)
-      .then((relations) => {
-        const relatedUserIds = relations.map((relation) => {
-          return relation.relatedUser.id;
-        });
-        const query = {
-          _id: { $nin: relatedUserIds },
-          status: User.STATUS_ACTIVE,
-          $or: searthField,
-        };
-
-        debug('findUserByNotRelatedGroup ', query);
-        return User.find(query).exec();
-      });
-  }
-
-  /**
-   * get if the user has relation for group
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @param {User} user
-   * @returns {Promise<boolean>} is user related for group(or not)
-   * @memberof UserGroupRelation
-   */
-  static isRelatedUserForGroup(userGroup, user) {
-    const query = {
-      relatedGroup: userGroup.id,
-      relatedUser: user.id,
-    };
-
-    return this
-      .count(query)
-      .exec()
-      .then((count) => {
-        // return true or false of the relation is exists(not count)
-        return (count > 0);
-      });
-  }
-
-  /**
-   * create user and group relation
-   *
-   * @static
-   * @param {UserGroup} userGroup
-   * @param {User} user
-   * @returns {Promise<UserGroupRelation>} created relation
-   * @memberof UserGroupRelation
-   */
-  static createRelation(userGroup, user) {
-    return this.create({
-      relatedGroup: userGroup.id,
-      relatedUser: user.id,
-    });
-  }
-
-  static async createRelations(userGroupIds, user) {
-    const documentsToInsertMany = userGroupIds.map((groupId) => {
-      return {
-        relatedGroup: groupId,
-        relatedUser: user._id,
-        createdAt: new Date(),
-      };
-    });
-
-    return this.insertMany(documentsToInsertMany);
-  }
-
-  /**
-   * remove all relation for UserGroup
-   *
-   * @static
-   * @param {UserGroup} userGroup related group for remove
-   * @returns {Promise<any>}
-   * @memberof UserGroupRelation
-   */
-  static removeAllByUserGroups(groupsToDelete) {
-    if (!Array.isArray(groupsToDelete)) {
-      throw Error('groupsToDelete must be an array.');
-    }
-
-    return this.deleteMany({ relatedGroup: { $in: groupsToDelete } });
-  }
-
-  /**
-   * remove relation by id
-   *
-   * @static
-   * @param {ObjectId} id
-   * @returns {Promise<any>}
-   * @memberof UserGroupRelation
-   */
-  static removeById(id) {
-    return this.findById(id)
-      .then((relationData) => {
-        if (relationData == null) {
-          throw new Error('UserGroupRelation data is not exists. id:', id);
-        }
-        else {
-          relationData.remove();
-        }
-      });
-  }
-
-  static async findUserIdsByGroupId(groupId) {
-    const relations = await this.find({ relatedGroup: groupId }, { _id: 0, relatedUser: 1 }).lean().exec(); // .lean() to get not ObjectId but string
-
-    return relations.map(relation => relation.relatedUser);
-  }
-
-  static async createByGroupIdsAndUserIds(groupIds, userIds) {
-    const insertOperations = [];
-
-    groupIds.forEach((groupId) => {
-      userIds.forEach((userId) => {
-        insertOperations.push({
-          insertOne: {
-            document: {
-              relatedGroup: groupId,
-              relatedUser: userId,
-            },
-          },
-        });
-      });
-    });
-
-    await this.bulkWrite(insertOperations);
-  }
-
-  /**
-   * Recursively finds descendant groups by populating relations.
-   * @static
-   * @param {UserGroupDocument[]} groups
-   * @param {UserDocument} user
-   * @returns UserGroupDocument[]
-   */
-  static async findGroupsWithDescendantsByGroupAndUser(group, user) {
-    const descendantGroups = [group];
-
-    const incrementGroupsRecursively = async(groups, user) => {
-      const groupIds = groups.map(g => g._id);
-
-      const populatedRelations = await this.aggregate([
-        {
-          $match: {
-            relatedUser: user._id,
-          },
-        },
-        {
-          $lookup: {
-            from: 'usergroups',
-            localField: 'relatedGroup',
-            foreignField: '_id',
-            as: 'relatedGroup',
-          },
-        },
-        {
-          $unwind: {
-            path: '$relatedGroup',
-          },
-        },
-        {
-          $match: {
-            'relatedGroup.parent': { $in: groupIds },
-          },
-        },
-      ]);
-
-      const nextGroups = populatedRelations.map(d => d.relatedGroup);
-
-      // End
-      const shouldEnd = nextGroups.length === 0;
-      if (shouldEnd) {
-        return;
-      }
-
-      // Increment
-      descendantGroups.push(...nextGroups);
-
-      return incrementGroupsRecursively(nextGroups, user);
-    };
-
-    await incrementGroupsRecursively([group], user);
-
-    return descendantGroups;
-  }
-
-}
-
-module.exports = function(crowi) {
-  UserGroupRelation.crowi = crowi;
-  schema.loadClass(UserGroupRelation);
-  const model = mongoose.model('UserGroupRelation', schema);
-  return model;
-};

+ 368 - 0
apps/app/src/server/models/user-group-relation.ts

@@ -0,0 +1,368 @@
+import { IUserGroupRelation } from '@growi/core';
+import mongoose, { Model, Schema, Document } from 'mongoose';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+import { UserGroupDocument } from './user-group';
+
+const debug = require('debug')('growi:models:userGroupRelation');
+const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
+
+const ObjectId = Schema.Types.ObjectId;
+
+export interface UserGroupRelationDocument extends IUserGroupRelation, Document {}
+
+export interface UserGroupRelationModel extends Model<UserGroupRelationDocument> {
+  [x:string]: any, // for old methods
+
+  PAGE_ITEMS: 50,
+
+  removeAllByUserGroups: (groupsToDelete: UserGroupDocument[]) => Promise<any>,
+}
+
+/*
+ * define schema
+ */
+const schema = new Schema<UserGroupRelationDocument, UserGroupRelationModel>({
+  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
+  relatedUser: { type: ObjectId, ref: 'User', required: true },
+}, {
+  timestamps: { createdAt: true, updatedAt: false },
+});
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+/**
+ * remove all invalid relations that has reference to unlinked document
+ */
+schema.statics.removeAllInvalidRelations = function() {
+  return this.findAllRelation()
+    .then((relations) => {
+      // filter invalid documents
+      return relations.filter((relation) => {
+        return relation.relatedUser == null || relation.relatedGroup == null;
+      });
+    })
+    .then((invalidRelations) => {
+      const ids = invalidRelations.map((relation) => { return relation._id });
+      return this.deleteMany({ _id: { $in: ids } });
+    });
+};
+
+/**
+   * find all user and group relation
+   *
+   * @static
+   * @returns {Promise<UserGroupRelation[]>}
+   * @memberof UserGroupRelation
+   */
+schema.statics.findAllRelation = function() {
+  return this
+    .find()
+    .populate('relatedUser')
+    .populate('relatedGroup')
+    .exec();
+};
+
+/**
+ * find all user and group relation of UserGroup
+ *
+ * @static
+ * @param {UserGroup} userGroup
+ * @returns {Promise<UserGroupRelation[]>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findAllRelationForUserGroup = function(userGroup) {
+  debug('findAllRelationForUserGroup is called', userGroup);
+  return this
+    .find({ relatedGroup: userGroup })
+    .populate('relatedUser')
+    .exec();
+};
+
+schema.statics.findAllUserIdsForUserGroup = async function(userGroup) {
+  const relations = await this
+    .find({ relatedGroup: userGroup })
+    .select('relatedUser')
+    .exec();
+
+  return relations.map(r => r.relatedUser);
+};
+
+/**
+ * find all user and group relation of UserGroups
+ *
+ * @static
+ * @param {UserGroup[]} userGroups
+ * @returns {Promise<UserGroupRelation[]>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findAllRelationForUserGroups = function(userGroups) {
+  return this
+    .find({ relatedGroup: { $in: userGroups } })
+    .populate('relatedUser')
+    .exec();
+};
+
+/**
+ * find all user and group relation of User
+ *
+ * @static
+ * @param {User} user
+ * @returns {Promise<UserGroupRelation[]>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findAllRelationForUser = function(user) {
+  return this
+    .find({ relatedUser: user.id })
+    .populate('relatedGroup')
+    // filter documents only relatedGroup is not null
+    .then((userGroupRelations) => {
+      return userGroupRelations.filter((relation) => {
+        return relation.relatedGroup != null;
+      });
+    });
+};
+
+/**
+ * find all UserGroup IDs that related to specified User
+ *
+ * @static
+ * @param {User} user
+ * @returns {Promise<ObjectId[]>}
+ */
+schema.statics.findAllUserGroupIdsRelatedToUser = async function(user) {
+  const relations = await this.find({ relatedUser: user._id })
+    .select('relatedGroup')
+    .exec();
+
+  return relations.map((relation) => { return relation.relatedGroup });
+};
+
+/**
+ * count by related group id and related user
+ *
+ * @static
+ * @param {string} userGroupId find query param for relatedGroup
+ * @param {User} userData find query param for relatedUser
+ * @returns {Promise<number>}
+ */
+schema.statics.countByGroupIdAndUser = async function(userGroupId, userData) {
+  const query = {
+    relatedGroup: userGroupId,
+    relatedUser: userData.id,
+  };
+
+  return this.count(query);
+};
+
+/**
+ * find all "not" related user for UserGroup
+ *
+ * @static
+ * @param {UserGroup} userGroup for find users not related
+ * @returns {Promise<User>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.findUserByNotRelatedGroup = function(userGroup, queryOptions) {
+  const User = mongoose.model('User') as any;
+  let searchWord = new RegExp(`${queryOptions.searchWord}`);
+  switch (queryOptions.searchType) {
+    case 'forward':
+      searchWord = new RegExp(`^${queryOptions.searchWord}`);
+      break;
+    case 'backword':
+      searchWord = new RegExp(`${queryOptions.searchWord}$`);
+      break;
+  }
+  const searthField: Record<string, RegExp>[] = [
+    { username: searchWord },
+  ];
+  if (queryOptions.isAlsoMailSearched === 'true') { searthField.push({ email: searchWord }) }
+  if (queryOptions.isAlsoNameSearched === 'true') { searthField.push({ name: searchWord }) }
+
+  return this.findAllRelationForUserGroup(userGroup)
+    .then((relations) => {
+      const relatedUserIds = relations.map((relation) => {
+        return relation.relatedUser.id;
+      });
+      const query = {
+        _id: { $nin: relatedUserIds },
+        status: User.STATUS_ACTIVE,
+        $or: searthField,
+      };
+
+      debug('findUserByNotRelatedGroup ', query);
+      return User.find(query).exec();
+    });
+};
+
+/**
+ * get if the user has relation for group
+ *
+ * @static
+ * @param {UserGroup} userGroup
+ * @param {User} user
+ * @returns {Promise<boolean>} is user related for group(or not)
+ * @memberof UserGroupRelation
+ */
+schema.statics.isRelatedUserForGroup = function(userGroup, user) {
+  const query = {
+    relatedGroup: userGroup.id,
+    relatedUser: user.id,
+  };
+
+  return this
+    .count(query)
+    .exec()
+    .then((count) => {
+      // return true or false of the relation is exists(not count)
+      return (count > 0);
+    });
+};
+
+/**
+ * create user and group relation
+ *
+ * @static
+ * @param {UserGroup} userGroup
+ * @param {User} user
+ * @returns {Promise<UserGroupRelation>} created relation
+ * @memberof UserGroupRelation
+ */
+schema.statics.createRelation = function(userGroup, user) {
+  return this.create({
+    relatedGroup: userGroup.id,
+    relatedUser: user.id,
+  });
+};
+
+schema.statics.createRelations = async function(userGroupIds, user) {
+  const documentsToInsertMany = userGroupIds.map((groupId) => {
+    return {
+      relatedGroup: groupId,
+      relatedUser: user._id,
+      createdAt: new Date(),
+    };
+  });
+
+  return this.insertMany(documentsToInsertMany);
+};
+
+/**
+ * remove all relation for UserGroup
+ *
+ * @static
+ * @param {UserGroup} userGroup related group for remove
+ * @returns {Promise<any>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.removeAllByUserGroups = function(groupsToDelete: UserGroupDocument[]) {
+  return this.deleteMany({ relatedGroup: { $in: groupsToDelete } });
+};
+
+/**
+ * remove relation by id
+ *
+ * @static
+ * @param {ObjectId} id
+ * @returns {Promise<any>}
+ * @memberof UserGroupRelation
+ */
+schema.statics.removeById = function(id) {
+  return this.findById(id)
+    .then((relationData) => {
+      if (relationData == null) {
+        throw new Error('UserGroupRelation data is not exists. id:', id);
+      }
+      else {
+        relationData.remove();
+      }
+    });
+};
+
+schema.statics.findUserIdsByGroupId = async function(groupId) {
+  const relations = await this.find({ relatedGroup: groupId }, { _id: 0, relatedUser: 1 }).lean().exec(); // .lean() to get not ObjectId but string
+
+  return relations.map(relation => relation.relatedUser);
+};
+
+schema.statics.createByGroupIdsAndUserIds = async function(groupIds, userIds) {
+  const insertOperations: any[] = [];
+
+  groupIds.forEach((groupId) => {
+    userIds.forEach((userId) => {
+      insertOperations.push({
+        insertOne: {
+          document: {
+            relatedGroup: groupId,
+            relatedUser: userId,
+          },
+        },
+      });
+    });
+  });
+
+  await this.bulkWrite(insertOperations);
+};
+
+/**
+ * Recursively finds descendant groups by populating relations.
+ * @static
+ * @param {UserGroupDocument[]} groups
+ * @param {UserDocument} user
+ * @returns UserGroupDocument[]
+ */
+schema.statics.findGroupsWithDescendantsByGroupAndUser = async function(group, user) {
+  const descendantGroups = [group];
+
+  const incrementGroupsRecursively = async(groups, user) => {
+    const groupIds = groups.map(g => g._id);
+
+    const populatedRelations = await this.aggregate([
+      {
+        $match: {
+          relatedUser: user._id,
+        },
+      },
+      {
+        $lookup: {
+          from: 'usergroups',
+          localField: 'relatedGroup',
+          foreignField: '_id',
+          as: 'relatedGroup',
+        },
+      },
+      {
+        $unwind: {
+          path: '$relatedGroup',
+        },
+      },
+      {
+        $match: {
+          'relatedGroup.parent': { $in: groupIds },
+        },
+      },
+    ]);
+
+    const nextGroups = populatedRelations.map(d => d.relatedGroup);
+
+    // End
+    const shouldEnd = nextGroups.length === 0;
+    if (shouldEnd) {
+      return;
+    }
+
+    // Increment
+    descendantGroups.push(...nextGroups);
+
+    return incrementGroupsRecursively(nextGroups, user);
+  };
+
+  await incrementGroupsRecursively([group], user);
+
+  return descendantGroups;
+};
+
+export default getOrCreateModel<UserGroupRelationDocument, UserGroupRelationModel>('UserGroupRelation', schema);

+ 6 - 8
apps/app/src/server/models/user-group.ts

@@ -1,4 +1,4 @@
-import mongoose, {
+import {
   Schema, Model, Document,
   Schema, Model, Document,
 } from 'mongoose';
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
@@ -14,12 +14,14 @@ export interface UserGroupModel extends Model<UserGroupDocument> {
   [x:string]: any, // for old methods
   [x:string]: any, // for old methods
 
 
   PAGE_ITEMS: 10,
   PAGE_ITEMS: 10,
+
+  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
 }
 }
 
 
 /*
 /*
  * define schema
  * define schema
  */
  */
-const ObjectId = mongoose.Schema.Types.ObjectId;
+const ObjectId = Schema.Types.ObjectId;
 
 
 const schema = new Schema<UserGroupDocument, UserGroupModel>({
 const schema = new Schema<UserGroupDocument, UserGroupModel>({
   name: { type: String, required: true, unique: true },
   name: { type: String, required: true, unique: true },
@@ -32,7 +34,7 @@ schema.plugin(mongoosePaginate);
 
 
 const PAGE_ITEMS = 10;
 const PAGE_ITEMS = 10;
 
 
-schema.statics.findUserGroupsWithPagination = function(opts) {
+schema.statics.findWithPagination = function(opts) {
   const query = { parent: null };
   const query = { parent: null };
   const options = Object.assign({}, opts);
   const options = Object.assign({}, opts);
   if (options.page == null) {
   if (options.page == null) {
@@ -49,11 +51,7 @@ schema.statics.findUserGroupsWithPagination = function(opts) {
 };
 };
 
 
 
 
-schema.statics.findChildUserGroupsByParentIds = async function(parentIds, includeGrandChildren = false) {
-  if (!Array.isArray(parentIds)) {
-    throw Error('parentIds must be an array.');
-  }
-
+schema.statics.findChildrenByParentIds = async function(parentIds: string[], includeGrandChildren = false) {
   const childUserGroups = await this.find({ parent: { $in: parentIds } });
   const childUserGroups = await this.find({ parent: { $in: parentIds } });
 
 
   let grandChildUserGroups: UserGroupDocument[] | null = null;
   let grandChildUserGroups: UserGroupDocument[] | null = null;

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

@@ -77,7 +77,7 @@ module.exports = (crowi, app) => {
   router.use('/personal-setting', require('./personal-setting')(crowi));
   router.use('/personal-setting', require('./personal-setting')(crowi));
 
 
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
   router.use('/user-group-relations', require('./user-group-relation')(crowi));
-  router.use('/user-group-relations', require('./user-group-relation')(crowi));
+  router.use('/external-user-group-relations', require('~/features/external-user-group/server/routes/apiv3/external-user-group-relation')(crowi));
 
 
   router.use('/statistics', require('./statistics')(crowi));
   router.use('/statistics', require('./statistics')(crowi));
 
 

+ 6 - 6
apps/app/src/server/routes/apiv3/user-group.js

@@ -1,6 +1,7 @@
 import { ErrorV3 } from '@growi/core';
 import { ErrorV3 } from '@growi/core';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
+import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -111,17 +112,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: a result of `UserGroup.find`
    *                      description: a result of `UserGroup.find`
    */
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => { // TODO 85062: userGroups with no parent
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
     const { query } = req;
     const { query } = req;
 
 
-    // TODO 85062: improve sort
     try {
     try {
       const page = query.page != null ? parseInt(query.page) : undefined;
       const page = query.page != null ? parseInt(query.page) : undefined;
       const limit = query.limit != null ? parseInt(query.limit) : undefined;
       const limit = query.limit != null ? parseInt(query.limit) : undefined;
       const offset = query.offset != null ? parseInt(query.offset) : undefined;
       const offset = query.offset != null ? parseInt(query.offset) : undefined;
       const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
       const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
 
 
-      const result = await UserGroup.findUserGroupsWithPagination({
+      const result = await UserGroup.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;
@@ -179,12 +179,11 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  // TODO 85062: improve sort
   router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
   router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
     try {
     try {
       const { parentIds, includeGrandChildren = false } = req.query;
       const { parentIds, includeGrandChildren = false } = req.query;
 
 
-      const userGroupsResult = await UserGroup.findChildUserGroupsByParentIds(parentIds, includeGrandChildren);
+      const userGroupsResult = await UserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
       return res.apiv3({
       return res.apiv3({
         childUserGroups: userGroupsResult.childUserGroups,
         childUserGroups: userGroupsResult.childUserGroups,
         grandChildUserGroups: userGroupsResult.grandChildUserGroups,
         grandChildUserGroups: userGroupsResult.grandChildUserGroups,
@@ -765,7 +764,8 @@ module.exports = (crowi) => {
     try {
     try {
       const userGroup = await UserGroup.findById(id);
       const userGroup = await UserGroup.findById(id);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-      return res.apiv3({ userGroupRelations });
+      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 group: ${id}`;
       const msg = `Error occurred in fetching user group relations for group: ${id}`;

+ 18 - 11
apps/app/src/server/service/user-group.ts

@@ -1,16 +1,15 @@
-import mongoose from 'mongoose';
-
+import { Model } from 'mongoose';
 
 
 import { IUser } from '~/interfaces/user';
 import { IUser } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import UserGroup from '~/server/models/user-group';
+import UserGroup, { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds, isIncludesObjectId } from '~/server/util/compare-objectId';
 import { excludeTestIdsFromTargetIds, isIncludesObjectId } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
+import UserGroupRelation, { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
 
 
 
 
-const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
 
 /**
 /**
  * the service class of UserGroupService
  * the service class of UserGroupService
@@ -114,20 +113,28 @@ class UserGroupService {
     return userGroup.save();
     return userGroup.save();
   }
   }
 
 
-  async removeCompletelyByRootGroupId(deleteRootGroupId, action, transferToUserGroupId, user) {
-    const rootGroup = await UserGroup.findById(deleteRootGroupId);
+  async removeCompletelyByRootGroupId<
+    D extends UserGroupDocument,
+    RD extends UserGroupRelationDocument,
+  >(
+      deleteRootGroupId, action, transferToUserGroupId, user,
+      userGroupModel: Model<D> & UserGroupModel = UserGroup,
+      userGroupRelationModel: Model<RD> & UserGroupRelationModel = UserGroupRelation,
+  ) {
+    const rootGroup = await userGroupModel.findById(deleteRootGroupId);
     if (rootGroup == null) {
     if (rootGroup == null) {
       throw new Error(`UserGroup data does not exist. id: ${deleteRootGroupId}`);
       throw new Error(`UserGroup data does not exist. id: ${deleteRootGroupId}`);
     }
     }
 
 
-    const groupsToDelete = await UserGroup.findGroupsWithDescendantsRecursively([rootGroup]);
+    const groupsToDelete = await userGroupModel.findGroupsWithDescendantsRecursively([rootGroup]);
 
 
     // 1. update page & remove all groups
     // 1. update page & remove all groups
+    // TODO: update pageService logic to handle external user groups (https://redmine.weseek.co.jp/issues/124385)
     await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user);
     await this.crowi.pageService.handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroupId, user);
     // 2. remove all groups
     // 2. remove all groups
-    const deletedGroups = await UserGroup.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
+    const deletedGroups = await userGroupModel.deleteMany({ _id: { $in: groupsToDelete.map(g => g._id) } });
     // 3. remove all relations
     // 3. remove all relations
-    await UserGroupRelation.removeAllByUserGroups(groupsToDelete);
+    await userGroupRelationModel.removeAllByUserGroups(groupsToDelete);
 
 
     return deletedGroups;
     return deletedGroups;
   }
   }
@@ -150,4 +157,4 @@ class UserGroupService {
 
 
 }
 }
 
 
-module.exports = UserGroupService;
+export default UserGroupService;

+ 28 - 11
apps/app/src/stores/user-group.tsx

@@ -1,8 +1,9 @@
+import { SWRResponseWithUtils, withUtils } from '@growi/core';
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
-import { apiv3Get } from '~/client/util/apiv3-client';
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
 import {
@@ -23,16 +24,17 @@ export const useSWRxMyUserGroupRelations = (shouldFetch: boolean): SWRResponse<I
   );
   );
 };
 };
 
 
-export const useSWRxUserGroup = (groupId: string | undefined): SWRResponse<IUserGroupHasId, Error> => {
+export const useSWRxUserGroup = (groupId: string | null): SWRResponse<IUserGroupHasId, Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? `/user-groups/${groupId}` : null,
     groupId != null ? `/user-groups/${groupId}` : null,
     endpoint => apiv3Get<UserGroupResult>(endpoint).then(result => result.data.userGroup),
     endpoint => apiv3Get<UserGroupResult>(endpoint).then(result => result.data.userGroup),
   );
   );
 };
 };
 
 
-export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[], isExternalGroup = false): SWRResponse<IUserGroupHasId[], Error> => {
+  const url = isExternalGroup ? '/external-user-groups' : '/user-groups';
   return useSWRImmutable(
   return useSWRImmutable(
-    '/user-groups',
+    url,
     endpoint => apiv3Get<UserGroupListResult>(endpoint, { pagination: false }).then(result => result.data.userGroups),
     endpoint => apiv3Get<UserGroupListResult>(endpoint, { pagination: false }).then(result => result.data.userGroups),
     {
     {
       fallbackData: initialData,
       fallbackData: initialData,
@@ -40,19 +42,34 @@ export const useSWRxUserGroupList = (initialData?: IUserGroupHasId[]): SWRRespon
   );
   );
 };
 };
 
 
+type ChildUserGroupListUtils = {
+  updateChild(childGroupData: IUserGroupHasId): Promise<void>, // update one child and refresh list
+}
 export const useSWRxChildUserGroupList = (
 export const useSWRxChildUserGroupList = (
     parentIds?: string[], includeGrandChildren?: boolean,
     parentIds?: string[], includeGrandChildren?: boolean,
-): SWRResponse<ChildUserGroupListResult, Error> => {
+): SWRResponseWithUtils<ChildUserGroupListUtils, ChildUserGroupListResult, Error> => {
   const shouldFetch = parentIds != null && parentIds.length > 0;
   const shouldFetch = parentIds != null && parentIds.length > 0;
-  return useSWRImmutable(
+
+  const swrResponse = useSWRImmutable(
     shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     shouldFetch ? ['/user-groups/children', parentIds, includeGrandChildren] : null,
     ([endpoint, parentIds, includeGrandChildren]) => apiv3Get<ChildUserGroupListResult>(
     ([endpoint, parentIds, includeGrandChildren]) => apiv3Get<ChildUserGroupListResult>(
       endpoint, { parentIds, includeGrandChildren },
       endpoint, { parentIds, includeGrandChildren },
     ).then((result => result.data)),
     ).then((result => result.data)),
   );
   );
+
+  const updateChild = async(childGroupData: IUserGroupHasId) => {
+    await apiv3Put(`/user-groups/${childGroupData._id}`, {
+      name: childGroupData.name,
+      description: childGroupData.description,
+      parentId: childGroupData.parent,
+    });
+    swrResponse.mutate();
+  };
+
+  return withUtils(swrResponse, { updateChild });
 };
 };
 
 
-export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
+export const useSWRxUserGroupRelations = (groupId: string | null): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? `/user-groups/${groupId}/user-group-relations` : null,
     groupId != null ? `/user-groups/${groupId}/user-group-relations` : null,
     endpoint => apiv3Get<UserGroupRelationsResult>(endpoint).then(result => result.data.userGroupRelations),
     endpoint => apiv3Get<UserGroupRelationsResult>(endpoint).then(result => result.data.userGroupRelations),
@@ -60,7 +77,7 @@ export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGro
 };
 };
 
 
 export const useSWRxUserGroupRelationList = (
 export const useSWRxUserGroupRelationList = (
-    groupIds: string[] | undefined, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
+    groupIds: string[] | null, childGroupIds?: string[], initialData?: IUserGroupRelationHasId[],
 ): SWRResponse<IUserGroupRelationHasId[], Error> => {
 ): SWRResponse<IUserGroupRelationHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupIds != null ? ['/user-group-relations', groupIds, childGroupIds] : null,
     groupIds != null ? ['/user-group-relations', groupIds, childGroupIds] : null,
@@ -80,21 +97,21 @@ export const useSWRxUserGroupPages = (groupId: string | undefined, limit: number
   );
   );
 };
 };
 
 
-export const useSWRxSelectableParentUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxSelectableParentUserGroups = (groupId: string | null): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? ['/user-groups/selectable-parent-groups', groupId] : null,
     groupId != null ? ['/user-groups/selectable-parent-groups', groupId] : null,
     ([endpoint, groupId]) => apiv3Get<SelectableParentUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableParentGroups),
     ([endpoint, groupId]) => apiv3Get<SelectableParentUserGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableParentGroups),
   );
   );
 };
 };
 
 
-export const useSWRxSelectableChildUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxSelectableChildUserGroups = (groupId: string | null): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? ['/user-groups/selectable-child-groups', groupId] : null,
     groupId != null ? ['/user-groups/selectable-child-groups', groupId] : null,
     ([endpoint, groupId]) => apiv3Get<SelectableUserChildGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableChildGroups),
     ([endpoint, groupId]) => apiv3Get<SelectableUserChildGroupsResult>(endpoint, { groupId }).then(result => result.data.selectableChildGroups),
   );
   );
 };
 };
 
 
-export const useSWRxAncestorUserGroups = (groupId: string | undefined): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxAncestorUserGroups = (groupId: string | null): SWRResponse<IUserGroupHasId[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? ['/user-groups/ancestors', groupId] : null,
     groupId != null ? ['/user-groups/ancestors', groupId] : null,
     ([endpoint, groupId]) => apiv3Get<AncestorUserGroupsResult>(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),
     ([endpoint, groupId]) => apiv3Get<AncestorUserGroupsResult>(endpoint, { groupId }).then(result => result.data.ancestorUserGroups),