Bläddra i källkod

refs 125405: external user group detail

Futa Arai 2 år sedan
förälder
incheckning
93f0ed139b

+ 217 - 0
apps/app/src/components/Admin/ExternalUserGroupDetail/ExternalUserGroupDetail.tsx

@@ -0,0 +1,217 @@
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
+
+import { objectIdUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+
+import {
+  apiv3Put, apiv3Delete,
+} from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { IExternalUserGroupHasId } from '~/interfaces/external-user-group';
+import { useIsAclEnabled } from '~/stores/context';
+import {
+  useSWRxAncestorExternalUserGroups,
+  useSWRxChildExternalUserGroupList, useSWRxExternalUserGroup, useSWRxExternalUserGroupRelationList, useSWRxExternalUserGroupRelations,
+} from '~/stores/external-user-group';
+
+
+import { ExternalUserGroupEditModal } from '../UserGroup/ExternalUserGroup/ExternalUserGroupEditModal';
+import { UserGroupDeleteModal } from '../UserGroup/UserGroupDeleteModal';
+import { UserGroupTable } from '../UserGroup/UserGroupTable';
+
+import { ExternalUserGroupEditForm } from './ExternalUserGroupEditForm';
+import { ExternalUserGroupUserTable } from './ExternalUserGroupUserTable';
+
+import styles from '../UserGroupDetail/UserGroupDetailPage.module.scss';
+
+type Props = {
+  externalUserGroupId: string,
+}
+
+const ExternalUserGroupDetailPage = (props: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+  const router = useRouter();
+  const { externalUserGroupId: currentExternalUserGroupId } = props;
+
+  const { data: currentExternalUserGroup } = useSWRxExternalUserGroup(currentExternalUserGroupId);
+  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 isLoading = currentExternalUserGroup === undefined;
+  const notExistsUerGroup = !isLoading && currentExternalUserGroup == null;
+
+  useEffect(() => {
+    if (!objectIdUtils.isValidObjectId(currentExternalUserGroupId) || notExistsUerGroup) {
+      router.push('/admin/user-groups');
+    }
+  }, [currentExternalUserGroup, currentExternalUserGroupId, notExistsUerGroup, router]);
+
+
+  // TODO: fetch pages (https://redmine.weseek.co.jp/issues/124385)
+  // const { data: userGroupPages } = useSWRxUserGroupPages(currentExternalUserGroupId, 10, 0);
+
+  const { data: externalUserGroupRelations } = useSWRxExternalUserGroupRelations(currentExternalUserGroupId);
+
+  const { data: childExternalUserGroupsList, mutate: mutateExternalChildUserGroups } = useSWRxChildExternalUserGroupList(
+    currentExternalUserGroupId ? [currentExternalUserGroupId] : [], true,
+  );
+  const childUserGroups = childExternalUserGroupsList != null ? childExternalUserGroupsList.childUserGroups : [];
+  const grandChildUserGroups = childExternalUserGroupsList != null ? childExternalUserGroupsList.grandChildUserGroups : [];
+  const childUserGroupIds = childUserGroups.map(group => group._id);
+
+  const { data: externalUserGroupRelationList } = useSWRxExternalUserGroupRelationList(childUserGroupIds);
+  const childUserGroupRelations = externalUserGroupRelationList != null ? externalUserGroupRelationList : [];
+
+  const { data: ancestorExternalUserGroups } = useSWRxAncestorExternalUserGroups(currentExternalUserGroupId);
+
+  const { data: isAclEnabled } = useIsAclEnabled();
+
+  const showUpdateModal = useCallback((group: IExternalUserGroupHasId) => {
+    setUpdateModalShown(true);
+    setSelectedExternalUserGroup(group);
+  }, [setUpdateModalShown]);
+
+  const hideUpdateModal = useCallback(() => {
+    setUpdateModalShown(false);
+    setSelectedExternalUserGroup(undefined);
+  }, [setUpdateModalShown]);
+
+  const updateChildExternalUserGroup = useCallback(async(userGroupData: IExternalUserGroupHasId) => {
+    try {
+      await apiv3Put(`/external-user-groups/${userGroupData._id}`, {
+        description: userGroupData.description,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
+
+      // mutate
+      mutateExternalChildUserGroups();
+
+      hideUpdateModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, mutateExternalChildUserGroups, hideUpdateModal]);
+
+  const showDeleteModal = useCallback(async(group: IExternalUserGroupHasId) => {
+    setSelectedExternalUserGroup(group);
+    setDeleteModalShown(true);
+  }, [setSelectedExternalUserGroup, setDeleteModalShown]);
+
+  const hideDeleteModal = useCallback(() => {
+    setSelectedExternalUserGroup(undefined);
+    setDeleteModalShown(false);
+  }, [setSelectedExternalUserGroup, setDeleteModalShown]);
+
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    try {
+      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      // sync
+      await mutateExternalChildUserGroups();
+
+      setSelectedExternalUserGroup(undefined);
+      setDeleteModalShown(false);
+
+      toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the groups'));
+    }
+  }, [mutateExternalChildUserGroups, setSelectedExternalUserGroup, setDeleteModalShown]);
+
+  /*
+   * Dependencies
+   */
+  if (currentExternalUserGroup == null || currentExternalUserGroupId == null) {
+    return <></>;
+  }
+
+  return (
+    <div>
+      <nav aria-label="breadcrumb">
+        <ol className="breadcrumb">
+          <li className="breadcrumb-item">
+            <Link href="/admin/user-groups" prefetch={false}>
+              {t('user_group_management.group_list')}
+            </Link>
+          </li>
+          {
+            ancestorExternalUserGroups != null && ancestorExternalUserGroups.length > 0
+            && (ancestorExternalUserGroups.map((ancestorExternalUserGroup: IExternalUserGroupHasId) => (
+              <li
+                key={ancestorExternalUserGroup._id}
+                className={`breadcrumb-item ${ancestorExternalUserGroup._id === currentExternalUserGroupId ? 'active' : ''}`}
+                aria-current="page"
+              >
+                { ancestorExternalUserGroup._id === currentExternalUserGroupId ? (
+                  <span>{ancestorExternalUserGroup.name}</span>
+                ) : (
+                  <Link href={`/admin/external-user-group-detail/${ancestorExternalUserGroup._id}`} prefetch={false}>
+                    {ancestorExternalUserGroup.name}
+                  </Link>
+                ) }
+              </li>
+            ))
+            )
+          }
+        </ol>
+      </nav>
+
+      <div className="mt-4 form-box">
+        <ExternalUserGroupEditForm externalUserGroup={currentExternalUserGroup} parent={
+          ancestorExternalUserGroups != null && ancestorExternalUserGroups.length > 0
+            ? ancestorExternalUserGroups[ancestorExternalUserGroups.length - 1] : undefined
+        }/>
+      </div>
+      <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
+      <ExternalUserGroupUserTable
+        externalUserGroupRelations={externalUserGroupRelations}
+      />
+
+      <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
+
+      <ExternalUserGroupEditModal
+        externalUserGroup={selectedExternalUserGroup}
+        onClickSubmit={updateChildExternalUserGroup}
+        isOpen={isUpdateModalShown}
+        onHide={hideUpdateModal}
+      />
+
+      <UserGroupTable
+        userGroups={childUserGroups}
+        childUserGroups={grandChildUserGroups}
+        isAclEnabled={isAclEnabled ?? false}
+        onEdit={showUpdateModal}
+        onDelete={showDeleteModal}
+        userGroupRelations={childUserGroupRelations}
+        isExternal={true}
+      />
+
+      <UserGroupDeleteModal
+        userGroups={childUserGroups}
+        deleteUserGroup={selectedExternalUserGroup}
+        onDelete={deleteChildUserGroupById}
+        isShow={isDeleteModalShown}
+        onHide={hideDeleteModal}
+      />
+
+      {/* show user group pages (https://redmine.weseek.co.jp/issues/124385) */}
+      {/* <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
+      <div className={`page-list ${styles['page-list']}`}>
+        <UserGroupPageList userGroupId={currentExternalUserGroupId} relatedPages={userGroupPages} />
+      </div> */}
+    </div>
+  );
+};
+
+export default ExternalUserGroupDetailPage;

+ 86 - 0
apps/app/src/components/Admin/ExternalUserGroupDetail/ExternalUserGroupEditForm.tsx

@@ -0,0 +1,86 @@
+import { FC, useCallback, useState } from 'react';
+
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'react-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { IExternalUserGroupHasId } from '~/interfaces/external-user-group';
+
+type Props = {
+  externalUserGroup: IExternalUserGroupHasId,
+  parent?: IExternalUserGroupHasId,
+};
+
+export const ExternalUserGroupEditForm: FC<Props> = ({ externalUserGroup, parent }: Props) => {
+  const { t } = useTranslation('admin');
+
+  const [currentDescription, setDescription] = useState(externalUserGroup != null ? externalUserGroup.description : '');
+
+  const onSubmit = useCallback(async(e): Promise<void> => {
+    e.preventDefault();
+    try {
+      await apiv3Put(`/external-user-groups/${externalUserGroup._id}`, {
+        description: currentDescription,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('ExternalUserGroup'), ns: 'commons' }));
+    }
+    catch {
+      toastError(t('toaster.update_failed', { target: t('ExternalUserGroup'), ns: 'commons' }));
+    }
+  }, [t, currentDescription, externalUserGroup._id]);
+
+  return <form onSubmit={onSubmit}
+  >
+
+    <fieldset>
+      <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
+
+      {
+        externalUserGroup?.createdAt != null && (
+          <div className="form-group row">
+            <p className="col-md-2 col-form-label">{t('Created')}</p>
+            <p className="col-md-4 my-auto">{dateFnsFormat(new Date(externalUserGroup.createdAt), 'yyyy-MM-dd')}</p>
+          </div>
+        )
+      }
+
+      <div className="form-group row">
+        <label htmlFor="name" className="col-md-2 col-form-label">
+          {t('user_group_management.group_name')}
+        </label>
+        <div className="col-md-4 my-auto">
+          {externalUserGroup.name}
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label htmlFor="description" className="col-md-2 col-form-label">
+          {t('Description')}
+        </label>
+        <div className="col-md-4">
+          <textarea className="form-control" name="description" value={currentDescription} onChange={(e) => {
+            setDescription(e.target.value);
+          }} />
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <label htmlFor="parent" className="col-md-2 col-form-label">
+          {t('user_group_management.parent_group')}
+        </label>
+        <div className="col-md-4 my-auto">
+          {parent?.name}
+        </div>
+      </div>
+
+      <div className="form-group row">
+        <div className="offset-md-2 col-md-10">
+          <button type="submit" className="btn btn-primary">
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </fieldset>
+  </form>;
+};

+ 53 - 0
apps/app/src/components/Admin/ExternalUserGroupDetail/ExternalUserGroupUserTable.tsx

@@ -0,0 +1,53 @@
+import React from 'react';
+
+import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import type { IUserGroupRelationHasIdPopulatedUser } from '~/interfaces/user-group-response';
+
+type Props = {
+  externalUserGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
+}
+
+export const ExternalUserGroupUserTable = ({
+  externalUserGroupRelations,
+}: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  return (
+    <table className="table table-bordered table-user-list">
+      <thead>
+        <tr>
+          <th style={{ width: '100px' }}>#</th>
+          <th>
+            {t('username')}
+          </th>
+          <th>{t('Name')}</th>
+          <th style={{ width: '100px' }}>{t('Created')}</th>
+          <th style={{ width: '160px' }}>{t('last_login')}</th>
+        </tr>
+      </thead>
+      <tbody>
+        {externalUserGroupRelations != null && externalUserGroupRelations.map((relation) => {
+          const { relatedUser } = relation;
+
+          return (
+            <tr key={relation._id}>
+              <td>
+                <UserPicture user={relatedUser} />
+              </td>
+              <td>
+                <strong>{relatedUser.username}</strong>
+              </td>
+              <td>{relatedUser.name}</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>
+            </tr>
+          );
+        })}
+
+      </tbody>
+    </table>
+  );
+};

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

@@ -136,6 +136,7 @@ export const ExternalGroupManagement: FC = () => {
       onEdit={showUpdateModal}
       onDelete={showDeleteModal}
       userGroupRelations={externalUserGroupRelations}
+      isExternal={true}
     />
 
     <ExternalUserGroupEditModal

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

@@ -16,6 +16,7 @@ type Props = {
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  isExternal?: 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,
+  isExternal = false,
+}: Props) => {
   const { t } = useTranslation('admin');
 
   /*
    * 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
    */
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
-    return props.userGroups.find((group) => {
+    return userGroups.find((group) => {
       return group._id === groupId;
     });
   };
 
   const onClickEdit = async(e) => {
-    if (props.onEdit == null) {
+    if (onEdit == null) {
       return;
     }
 
@@ -81,11 +92,11 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
     }
 
-    props.onEdit(userGroup);
+    onEdit(userGroup);
   };
 
   const onClickRemove = async(e) => {
-    if (props.onRemove == null) {
+    if (onRemove == null) {
       return;
     }
 
@@ -95,7 +106,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
     }
 
     try {
-      await props.onRemove(userGroup);
+      await onRemove(userGroup);
       userGroup.parent = null;
     }
     catch {
@@ -104,7 +115,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   };
 
   const onClickDelete = (e) => { // no preventDefault
-    if (props.onDelete == null) {
+    if (onDelete == null) {
       return;
     }
 
@@ -113,20 +124,22 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
     }
 
-    props.onDelete(userGroup);
+    onDelete(userGroup);
   };
 
+  const groupDetailPageName = isExternal ? 'external-user-group-detail' : 'user-group-detail';
+
   /*
    * useEffect
    */
   useEffect(() => {
-    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
-    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
-  }, [props.userGroupRelations, props.childUserGroups]);
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(childUserGroups));
+  }, [userGroupRelations, childUserGroups]);
 
   return (
     <div data-testid="grw-user-group-table">
-      <h3>{props.headerLabel}</h3>
+      <h3>{headerLabel}</h3>
 
       <table className="table table-bordered table-user-list">
         <thead>
@@ -140,14 +153,14 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           </tr>
         </thead>
         <tbody>
-          {props.userGroups.map((group) => {
+          {userGroups.map((group) => {
             const users = groupIdToUsersMap[group._id];
 
             return (
               <tr key={group._id}>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
-                    <td><a href={`/admin/user-group-detail/${group._id}`}>{group.name}</a></td>
+                    <td><a href={`/admin/${groupDetailPageName}/${group._id}`}>{group.name}</a></td>
                   )
                   : (
                     <td>{group.name}</td>
@@ -166,9 +179,9 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                       return (
                         <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/${groupDetailPageName}/${group._id}`}>{group.name}</a>
                             )
                             : (
                               <p>{group.name}</p>
@@ -180,7 +193,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   </ul>
                 </td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
                     <td>
                       <div className="btn-group admin-group-menu">
@@ -196,9 +209,10 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                           <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')}
                           </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')}
-                          </button>
+                          </button>}
                           <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')}
                           </button>

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

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

+ 61 - 0
apps/app/src/pages/admin/external-user-group-detail/[externalUserGroupId].page.tsx

@@ -0,0 +1,61 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+
+import ExternalUserGroupDetailPage from '~/components/Admin/ExternalUserGroupDetail/ExternalUserGroupDetail';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
+import { useIsAclEnabled, useCurrentUser } from '~/stores/context';
+import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
+
+import { retrieveServerSideProps } from '../../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+
+type Props = CommonProps & {
+  isAclEnabled: boolean
+}
+
+const AdminExternalUserGroupDetailPage: NextPage<Props> = (props: Props) => {
+  const { t } = useTranslation('admin');
+  useIsMaintenanceMode(props.isMaintenanceMode);
+  useCurrentUser(props.currentUser ?? null);
+  const router = useRouter();
+  const { externalUserGroupId } = router.query;
+
+  const title = t('user_group_management.user_group_management');
+  const customTitle = generateCustomTitle(props, title);
+
+  const currentExternalUserGroupId = Array.isArray(externalUserGroupId) ? externalUserGroupId[0] : externalUserGroupId;
+
+  useIsAclEnabled(props.isAclEnabled);
+
+  return (
+    <AdminLayout componentTitle={title}>
+      <Head>
+        <title>{customTitle}</title>
+      </Head>
+      {
+        currentExternalUserGroupId != null && router.isReady
+      && <ExternalUserGroupDetailPage externalUserGroupId={currentExternalUserGroupId} />
+      }
+    </AdminLayout>
+  );
+};
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  props.isAclEnabled = req.crowi.aclService.isAclEnabled();
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+
+  return props;
+};
+
+export default AdminExternalUserGroupDetailPage;

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

@@ -37,12 +37,11 @@ schema.statics.createRelations = async function(userGroupIds, user) {
 };
 
 /**
-   * remove all relation for UserGroup
+   * remove all relation for ExternalUserGroup
    *
    * @static
-   * @param {UserGroup} userGroup related group for remove
+   * @param {ExternalUserGroup} userGroup related group for remove
    * @returns {Promise<any>}
-   * @memberof UserGroupRelation
    */
 schema.statics.removeAllByUserGroups = function(groupsToDelete) {
   if (!Array.isArray(groupsToDelete)) {

+ 3 - 1
apps/app/src/server/models/external-user-group.ts

@@ -2,9 +2,11 @@ import { Schema, Model, Document } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { IExternalUserGroup } from '~/interfaces/external-user-group';
+import loggerFactory from '~/utils/logger';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
+const logger = loggerFactory('growi:models:external-user-groups');
 
 export interface ExternalUserGroupDocument extends IExternalUserGroup, Document {}
 
@@ -37,7 +39,7 @@ schema.statics.findWithPagination = function(opts) {
 
   return this.paginate(query, options)
     .catch((err) => {
-      // debug('Error on pagination:', err); TODO: add logger
+      logger.error(err);
     });
 };
 

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

@@ -9,6 +9,7 @@ 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 ExternalUserGroupRelation from '~/server/models/external-user-group-relation';
 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';
@@ -44,6 +45,9 @@ module.exports = (crowi: Crowi): Router => {
       query('parentIds').optional().isArray(),
       query('includeGrandChildren').optional().isBoolean(),
     ],
+    ancestorGroup: [
+      query('groupId').isString(),
+    ],
     update: [
       body('description').optional().isString(),
     ],
@@ -52,6 +56,9 @@ module.exports = (crowi: Crowi): Router => {
       query('actionName').trim().exists({ checkFalsy: true }),
       query('transferToUserGroupId').trim(),
     ],
+    detail: [
+      param('id').isString(),
+    ],
   };
 
   router.get('/', loginRequiredStrictly, adminRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
@@ -66,8 +73,8 @@ module.exports = (crowi: Crowi): Router => {
       const result = await ExternalUserGroup.findWithPagination({
         page, limit, offset, pagination,
       });
-      const { docs: userGroups, totalDocs: totalUserGroups, limit: pagingLimit } = result;
-      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+      const { docs: externalUserGroups, totalDocs: totalExternalUserGroups, limit: pagingLimit } = result;
+      return res.apiv3({ externalUserGroups, totalExternalUserGroups, pagingLimit });
     }
     catch (err) {
       const msg = 'Error occurred in fetching external user group list';
@@ -76,6 +83,21 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  router.get('/ancestors', loginRequiredStrictly, adminRequired, validators.ancestorGroup, apiV3FormValidator, async(req, res: ApiV3Response) => {
+    const { groupId } = req.query;
+
+    try {
+      const userGroup = await ExternalUserGroup.findById(groupId);
+      const ancestorExternalUserGroups = await ExternalUserGroup.findGroupsWithAncestorsRecursively(userGroup);
+      return res.apiv3({ ancestorExternalUserGroups });
+    }
+    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;
@@ -93,6 +115,20 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  router.get('/:id', loginRequiredStrictly, adminRequired, validators.detail, async(req, res: ApiV3Response) => {
+    const { id } = req.params;
+
+    try {
+      const externalUserGroup = await ExternalUserGroup.findById(id);
+      return res.apiv3({ externalUserGroup });
+    }
+    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;
@@ -134,6 +170,22 @@ module.exports = (crowi: Crowi): Router => {
     }
   });
 
+  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');
+      return res.apiv3({ userGroupRelations });
+    }
+    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) => {
     const settings = {
       ldapGroupSearchBase: configManager?.getConfig('crowi', 'external-user-group:ldap:groupSearchBase'),

+ 23 - 2
apps/app/src/stores/external-user-group.ts

@@ -3,7 +3,7 @@ import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { IExternalUserGroupHasId, IExternalUserGroupRelationHasId, LdapGroupSyncSettings } from '~/interfaces/external-user-group';
-import { ChildUserGroupListResult, UserGroupListResult, UserGroupRelationListResult } from '~/interfaces/user-group-response';
+import { ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupRelationListResult } from '~/interfaces/user-group-response';
 
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
   return useSWR(
@@ -14,10 +14,17 @@ export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSetting
   );
 };
 
+export const useSWRxExternalUserGroup = (groupId: string): SWRResponse<IExternalUserGroupHasId, Error> => {
+  return useSWRImmutable(
+    `/external-user-groups/${groupId}`,
+    endpoint => apiv3Get(endpoint).then(result => result.data.externalUserGroup),
+  );
+};
+
 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),
+    endpoint => apiv3Get(endpoint, { pagination: false }).then(result => result.data.externalUserGroups),
     {
       fallbackData: initialData,
     },
@@ -36,6 +43,13 @@ export const useSWRxChildExternalUserGroupList = (
   );
 };
 
+export const useSWRxExternalUserGroupRelations = (groupId: string): 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[] | undefined, childGroupIds?: string[], initialData?: IExternalUserGroupRelationHasId[],
 ): SWRResponse<IExternalUserGroupRelationHasId[], Error> => {
@@ -49,3 +63,10 @@ export const useSWRxExternalUserGroupRelationList = (
     },
   );
 };
+
+export const useSWRxAncestorExternalUserGroups = (groupId: string | undefined): SWRResponse<IExternalUserGroupHasId[], Error> => {
+  return useSWRImmutable(
+    groupId != null ? ['/external-user-groups/ancestors', groupId] : null,
+    ([endpoint, groupId]) => apiv3Get(endpoint, { groupId }).then(result => result.data.ancestorExternalUserGroups),
+  );
+};