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

refs 125405: external user group index ui

Futa Arai 2 лет назад
Родитель
Сommit
b8103dec9c

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

@@ -1046,6 +1046,7 @@
     "execute_sync": "Execute Sync",
     "sync": "Sync",
     "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",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",

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

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

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

@@ -1054,6 +1054,7 @@
     "execute_sync": "Execute Sync",
     "sync": "Sync",
     "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",
     "ldap": {
       "group_sync_settings": "LDAP Group Sync Settings",
       "group_search_base_DN": "Group Search Base DN",

+ 84 - 0
apps/app/src/components/Admin/UserGroup/ExternalUserGroup/ExternalUserGroupEditModal.tsx

@@ -0,0 +1,84 @@
+import React, {
+  FC, useState, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { IExternalUserGroupHasId } from '~/interfaces/external-user-group';
+
+type Props = {
+  externalUserGroup?: IExternalUserGroupHasId,
+  onClickSubmit?: (userGroupData: Partial<IExternalUserGroupHasId>) => Promise<IExternalUserGroupHasId | void>
+  isOpen?: boolean
+  onHide?: () => Promise<void> | void
+};
+
+export const ExternalUserGroupEditModal: FC<Props> = (props: Props) => {
+
+  const { t } = useTranslation('admin');
+
+  const {
+    externalUserGroup, onClickSubmit, isOpen, onHide,
+  } = props;
+
+  const [currentDescription, setDescription] = useState('');
+
+  const onChangeDescriptionHandler = useCallback((e) => {
+    setDescription(e.target.value);
+  }, []);
+
+  const onSubmitHandler = useCallback(async(e) => {
+    e.preventDefault(); // no reload
+
+    if (onClickSubmit == null) {
+      return;
+    }
+
+    await onClickSubmit({
+      _id: externalUserGroup?._id,
+      description: currentDescription,
+    });
+  }, [externalUserGroup, currentDescription, onClickSubmit]);
+
+  // componentDidMount
+  useEffect(() => {
+    if (externalUserGroup != null) {
+      setDescription(externalUserGroup.description);
+    }
+  }, [externalUserGroup]);
+
+  return (
+    <Modal className="modal-md" isOpen={isOpen} toggle={onHide}>
+      <form onSubmit={onSubmitHandler}>
+        <ModalHeader tag="h4" toggle={onHide} className="bg-primary text-light">
+          {t('user_group_management.basic_info')}
+        </ModalHeader>
+
+        <ModalBody>
+          <div className="form-group">
+            <label htmlFor="description">
+              {t('Description')}
+            </label>
+            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.description_form_detail')}
+              </small>
+            </p>
+          </div>
+        </ModalBody>
+
+        <ModalFooter>
+          <div className="form-group">
+            <button type="submit" className="btn btn-primary">
+              {t('Update')}
+            </button>
+          </div>
+        </ModalFooter>
+      </form>
+    </Modal>
+  );
+};

+ 126 - 1
apps/app/src/components/Admin/UserGroup/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -1,17 +1,117 @@
-import { FC, useMemo, useState } from 'react';
+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 CustomNav from '~/components/CustomNavigation/CustomNav';
+import { IExternalUserGroupHasId } from '~/interfaces/external-user-group';
+import { useIsAclEnabled } from '~/stores/context';
+import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '~/stores/external-user-group';
 
+import { UserGroupDeleteModal } from '../UserGroupDeleteModal';
+import { UserGroupTable } from '../UserGroupTable';
+
+import { ExternalUserGroupEditModal } from './ExternalUserGroupEditModal';
 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();
+
+      setSelectedExternalUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${selectedExternalUserGroup?.name} group.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateExternalUserGroups, selectedExternalUserGroup]);
+
   const switchActiveTab = (selectedTab) => {
     setActiveTab(selectedTab);
     setActiveComponents(activeComponents.add(selectedTab));
@@ -28,6 +128,31 @@ export const ExternalGroupManagement: FC = () => {
 
   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}
+    />
+
+    <ExternalUserGroupEditModal
+      externalUserGroup={selectedExternalUserGroup}
+      onClickSubmit={updateExternalUserGroup}
+      isOpen={isUpdateModalShown}
+      onHide={hideUpdateModal}
+    />
+
+    <UserGroupDeleteModal
+      userGroups={externalUserGroups}
+      deleteUserGroup={selectedExternalUserGroup}
+      onDelete={deleteExternalUserGroupById}
+      isShow={isDeleteModalShown}
+      onHide={hideDeleteModal}
+    />
+
     <CustomNav
       activeTab={activeTab}
       navTabMapping={navTabMapping}

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

@@ -17,6 +17,8 @@ export interface IExternalUserGroupRelation extends Omit<IUserGroupRelation, 're
   relatedGroup: Ref<IExternalUserGroup>
 }
 
+export type IExternalUserGroupRelationHasId = IExternalUserGroupRelation & HasObjectId;
+
 export interface LdapGroupSyncSettings {
   ldapGroupSearchBase: string
   ldapGroupMembershipAttribute: string

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

@@ -9,17 +9,17 @@ export type UserGroupResult = {
   userGroup: IUserGroupHasId,
 }
 
-export type UserGroupListResult = {
-  userGroups: IUserGroupHasId[],
+export type UserGroupListResult<TUSERGROUP = IUserGroupHasId> = {
+  userGroups: TUSERGROUP[],
 };
 
-export type ChildUserGroupListResult = {
-  childUserGroups: IUserGroupHasId[],
-  grandChildUserGroups: IUserGroupHasId[],
+export type ChildUserGroupListResult<TUSERGROUP = IUserGroupHasId> = {
+  childUserGroups: TUSERGROUP[],
+  grandChildUserGroups: TUSERGROUP[],
 };
 
-export type UserGroupRelationListResult = {
-  userGroupRelations: IUserGroupRelationHasId[],
+export type UserGroupRelationListResult<TUSERGROUPRELATION = IUserGroupRelationHasId> = {
+  userGroupRelations: TUSERGROUPRELATION[],
 };
 
 export type IUserGroupRelationHasIdPopulatedUser = {

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

@@ -36,4 +36,20 @@ schema.statics.createRelations = async function(userGroupIds, user) {
   return this.insertMany(documentsToInsert);
 };
 
+/**
+   * remove all relation for UserGroup
+   *
+   * @static
+   * @param {UserGroup} userGroup related group for remove
+   * @returns {Promise<any>}
+   * @memberof UserGroupRelation
+   */
+schema.statics.removeAllByUserGroups = function(groupsToDelete) {
+  if (!Array.isArray(groupsToDelete)) {
+    throw Error('groupsToDelete must be an array.');
+  }
+
+  return this.deleteMany({ relatedGroup: { $in: groupsToDelete } });
+};
+
 export default getOrCreateModel<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>('ExternalUserGroupRelation', schema);

+ 57 - 0
apps/app/src/server/models/external-user-group.ts

@@ -1,4 +1,5 @@
 import { Schema, Model, Document } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { IExternalUserGroup } from '~/interfaces/external-user-group';
 
@@ -20,6 +21,44 @@ const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
 }, {
   timestamps: true,
 });
+schema.plugin(mongoosePaginate);
+
+const PAGE_ITEMS = 10;
+
+schema.statics.findWithPagination = function(opts) {
+  const query = { parent: null };
+  const options = Object.assign({}, opts);
+  if (options.page == null) {
+    options.page = 1;
+  }
+  if (options.limit == null) {
+    options.limit = PAGE_ITEMS;
+  }
+
+  return this.paginate(query, options)
+    .catch((err) => {
+      // debug('Error on pagination:', err); TODO: add logger
+    });
+};
+
+schema.statics.findChildrenByParentIds = async function(parentIds, includeGrandChildren = false) {
+  if (!Array.isArray(parentIds)) {
+    throw Error('parentIds must be an array.');
+  }
+
+  const childUserGroups = await this.find({ parent: { $in: parentIds } });
+
+  let grandChildUserGroups: ExternalUserGroupDocument[] | null = null;
+  if (includeGrandChildren) {
+    const childExternalUserGroupIds = childUserGroups.map(group => group._id);
+    grandChildUserGroups = await this.find({ parent: { $in: childExternalUserGroupIds } });
+  }
+
+  return {
+    childUserGroups,
+    grandChildUserGroups,
+  };
+};
 
 /**
  * Find group that has specified externalId and update, or create one if it doesn't exist.
@@ -68,4 +107,22 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
   return this.findGroupsWithAncestorsRecursively(parent, ancestors);
 };
 
+/**
+ * TODO: use $graphLookup
+ * Find all descendant groups starting from the UserGroups in the initial groups in "groups".
+ * Set "descendants" as "[]" if the initial groups are unnecessary as result.
+ * @param groups UserGroupDocument[] including at least one UserGroup
+ * @param descendants UserGroupDocument[]
+ * @returns UserGroupDocument[]
+ */
+schema.statics.findGroupsWithDescendantsRecursively = async function(groups, descendants = groups) {
+  const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
+
+  if (nextGroups.length === 0) {
+    return descendants;
+  }
+
+  return this.findGroupsWithDescendantsRecursively(nextGroups, descendants.concat(nextGroups));
+};
+
 export default getOrCreateModel<ExternalUserGroupDocument, ExternalUserGroupModel>('ExternalUserGroup', schema);

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

@@ -32,7 +32,7 @@ schema.plugin(mongoosePaginate);
 
 const PAGE_ITEMS = 10;
 
-schema.statics.findUserGroupsWithPagination = function(opts) {
+schema.statics.findWithPagination = function(opts) {
   const query = { parent: null };
   const options = Object.assign({}, opts);
   if (options.page == null) {
@@ -49,7 +49,7 @@ schema.statics.findUserGroupsWithPagination = function(opts) {
 };
 
 
-schema.statics.findChildUserGroupsByParentIds = async function(parentIds, includeGrandChildren = false) {
+schema.statics.findChildrenByParentIds = async function(parentIds, includeGrandChildren = false) {
   if (!Array.isArray(parentIds)) {
     throw Error('parentIds must be an array.');
   }

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

@@ -0,0 +1,54 @@
+import { ErrorV3 } from '@growi/core';
+import { Router, Request } from 'express';
+
+import Crowi from '~/server/crowi';
+import ExternalUserGroupRelation from '~/server/models/external-user-group-relation';
+import loggerFactory from '~/utils/logger';
+
+import { ApiV3Response } from './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('../../models/serializers/user-group-relation-serializer');
+
+const router = express.Router();
+
+module.exports = (crowi: Crowi): Router => {
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../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;
+};

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

@@ -1,7 +1,14 @@
+import { ErrorV3 } from '@growi/core';
 import { Router, Request } from 'express';
-import { body, validationResult } from 'express-validator';
+import {
+  body, param, query, validationResult,
+} from 'express-validator';
 
+import { SupportedAction } from '~/interfaces/activity';
 import Crowi from '~/server/crowi';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import ExternalUserGroup from '~/server/models/external-user-group';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
 import LdapUserGroupSyncService from '~/server/service/external-group/ldap-user-group-sync-service';
@@ -18,6 +25,9 @@ interface AuthorizedRequest extends Request {
 module.exports = (crowi: Crowi): Router => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
   const validators = {
     ldapSyncSettings: [
@@ -30,8 +40,100 @@ module.exports = (crowi: Crowi): Router => {
       body('ldapGroupNameAttribute').optional({ nullable: true }).isString(),
       body('ldapGroupDescriptionAttribute').optional({ nullable: true }).isString(),
     ],
+    listChildren: [
+      query('parentIds').optional().isArray(),
+      query('includeGrandChildren').optional().isBoolean(),
+    ],
+    update: [
+      body('description').optional().isString(),
+    ],
+    delete: [
+      param('id').trim().exists({ checkFalsy: true }),
+      query('actionName').trim().exists({ checkFalsy: true }),
+      query('transferToUserGroupId').trim(),
+    ],
   };
 
+  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('/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.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.removeCompletelyByRootGroupId(deleteGroupId, actionName, transferToUserGroupId, req.user, true);
+
+        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 externalUserGroup = 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({ externalUserGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating an external user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
   router.get('/ldap/sync-settings', loginRequiredStrictly, adminRequired, validators.ldapSyncSettings, (req: AuthorizedRequest, res: ApiV3Response) => {
     const settings = {
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),

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

@@ -75,7 +75,7 @@ module.exports = (crowi, app) => {
   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('/external-user-group-relations', require('./external-user-group-relation')(crowi));
 
   router.use('/statistics', require('./statistics')(crowi));
 

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

@@ -111,17 +111,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      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;
 
-    // TODO 85062: improve sort
     try {
       const page = query.page != null ? parseInt(query.page) : undefined;
       const limit = query.limit != null ? parseInt(query.limit) : undefined;
       const offset = query.offset != null ? parseInt(query.offset) : undefined;
       const pagination = query.pagination != null ? query.pagination !== 'false' : undefined;
 
-      const result = await UserGroup.findUserGroupsWithPagination({
+      const result = await UserGroup.findWithPagination({
         page, limit, offset, pagination,
       });
       const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
@@ -179,12 +178,11 @@ module.exports = (crowi) => {
     }
   });
 
-  // TODO 85062: improve sort
   router.get('/children', loginRequiredStrictly, adminRequired, validator.listChildren, async(req, res) => {
     try {
       const { parentIds, includeGrandChildren = false } = req.query;
 
-      const userGroupsResult = await UserGroup.findChildUserGroupsByParentIds(parentIds, includeGrandChildren);
+      const userGroupsResult = await UserGroup.findChildrenByParentIds(parentIds, includeGrandChildren);
       return res.apiv3({
         childUserGroups: userGroupsResult.childUserGroups,
         grandChildUserGroups: userGroupsResult.grandChildUserGroups,

+ 12 - 5
apps/app/src/server/service/user-group.ts

@@ -3,10 +3,13 @@ import mongoose from 'mongoose';
 
 import { IUser } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import ExternalUserGroup from '~/server/models/external-user-group';
+import ExternalUserGroupRelation from '~/server/models/external-user-group-relation';
 import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds, isIncludesObjectId } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
 
@@ -114,20 +117,24 @@ class UserGroupService {
     return userGroup.save();
   }
 
-  async removeCompletelyByRootGroupId(deleteRootGroupId, action, transferToUserGroupId, user) {
-    const rootGroup = await UserGroup.findById(deleteRootGroupId);
+  async removeCompletelyByRootGroupId(deleteRootGroupId, action, transferToUserGroupId, user, isExternal = false) {
+    const userGroupModel = isExternal ? ExternalUserGroup : UserGroup;
+    const userGroupRelationModel = isExternal ? ExternalUserGroupRelation : UserGroupRelation;
+
+    const rootGroup = await userGroupModel.findById(deleteRootGroupId);
     if (rootGroup == null) {
       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
+    // 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);
     // 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
-    await UserGroupRelation.removeAllByUserGroups(groupsToDelete);
+    await userGroupRelationModel.removeAllByUserGroups(groupsToDelete);
 
     return deletedGroups;
   }

+ 39 - 1
apps/app/src/stores/external-user-group.ts

@@ -1,7 +1,9 @@
 import useSWR, { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { LdapGroupSyncSettings } from '~/interfaces/external-user-group';
+import { IExternalUserGroupHasId, IExternalUserGroupRelationHasId, LdapGroupSyncSettings } from '~/interfaces/external-user-group';
+import { ChildUserGroupListResult, UserGroupListResult, UserGroupRelationListResult } from '~/interfaces/user-group-response';
 
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
   return useSWR(
@@ -11,3 +13,39 @@ export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSetting
     }),
   );
 };
+
+export const useSWRxExternalUserGroupList = (initialData?: IExternalUserGroupHasId[]): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    '/external-user-groups',
+    endpoint => apiv3Get<UserGroupListResult<IExternalUserGroupHasId>>(endpoint, { pagination: false }).then(result => result.data.userGroups),
+    {
+      fallbackData: initialData,
+    },
+  );
+};
+
+export const useSWRxChildExternalUserGroupList = (
+    parentIds?: string[], includeGrandChildren?: boolean,
+): SWRResponse<ChildUserGroupListResult<IExternalUserGroupHasId>, Error> => {
+  const shouldFetch = parentIds != null && parentIds.length > 0;
+  return useSWRImmutable(
+    shouldFetch ? ['/external-user-groups/children', parentIds, includeGrandChildren] : null,
+    ([endpoint, parentIds, includeGrandChildren]) => apiv3Get<ChildUserGroupListResult<IExternalUserGroupHasId>>(
+      endpoint, { parentIds, includeGrandChildren },
+    ).then((result => result.data)),
+  );
+};
+
+export const useSWRxExternalUserGroupRelationList = (
+    groupIds: string[] | undefined, 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,
+    },
+  );
+};