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

Merge pull request #6443 from weseek/fix/102159-admin-user-group-detail-is-null

Fix/102159 admin user group detail is null
cao 3 лет назад
Родитель
Сommit
9a76d41387

+ 3 - 3
packages/app/_obsolete/src/client/admin.jsx

@@ -23,7 +23,7 @@ import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityConta
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+// import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import loggerFactory from '~/utils/logger';
@@ -67,7 +67,7 @@ const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appCon
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
-const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
+// const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const injectableContainers = [
   appContainer,
@@ -81,7 +81,7 @@ const injectableContainers = [
   adminNotificationContainer,
   adminSlackIntegrationLegacyContainer,
   adminMarkDownContainer,
-  adminUserGroupDetailContainer,
+  // adminUserGroupDetailContainer,
   socketIoContainer,
 ];
 

+ 0 - 202
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -1,202 +0,0 @@
-/*
- * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
- */
-
-import { isServer } from '@growi/core';
-import { Container } from 'unstated';
-
-import {
-  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
-} from '~/client/util/apiv3-client';
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
-
-/**
- * Service container for admin user group detail page (UserGroupDetailPage.jsx)
- * @extends {Container} unstated Container
- */
-export default class AdminUserGroupDetailContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    if (isServer()) {
-      return;
-    }
-
-    this.appContainer = appContainer;
-
-    const rootElem = document.getElementById('admin-user-group-detail');
-
-    if (rootElem == null) {
-      return;
-    }
-
-    this.state = {
-      // TODO: [SPA] get userGroup from props
-      userGroup: JSON.parse(rootElem.getAttribute('data-user-group')),
-      userGroupRelations: [], // For user list
-
-      // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
-      childUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-      grandChildUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-
-      childUserGroupRelations: [], // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list users
-      relatedPages: [], // For page list
-      isUserGroupUserModalOpen: false,
-      searchType: 'partial',
-      isAlsoMailSearched: false,
-      isAlsoNameSearched: false,
-    };
-
-    this.init();
-
-    this.switchIsAlsoMailSearched = this.switchIsAlsoMailSearched.bind(this);
-    this.switchIsAlsoNameSearched = this.switchIsAlsoNameSearched.bind(this);
-    this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
-    this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
-    this.addUserByUsername = this.addUserByUsername.bind(this);
-    this.removeUserByUsername = this.removeUserByUsername.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'AdminUserGroupDetailContainer';
-  }
-
-  /**
-   * retrieve user group data
-   */
-  async init() {
-    try {
-      const [
-        userGroupRelations,
-        relatedPages,
-      ] = await Promise.all([
-        apiv3Get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
-        apiv3Get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
-      ]);
-
-      await this.setState({
-        userGroupRelations,
-        relatedPages,
-      });
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to fetch data'));
-    }
-  }
-
-  /**
-   * switch isAlsoMailSearched
-   */
-  switchIsAlsoMailSearched() {
-    this.setState({ isAlsoMailSearched: !this.state.isAlsoMailSearched });
-  }
-
-  /**
-   * switch isAlsoNameSearched
-   */
-  switchIsAlsoNameSearched() {
-    this.setState({ isAlsoNameSearched: !this.state.isAlsoNameSearched });
-  }
-
-  /**
-   * switch searchType
-   */
-  switchSearchType(searchType) {
-    this.setState({ searchType });
-  }
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {object} param update param for user group
-   * @return {object} response object
-   */
-  async updateUserGroup(param) {
-    const res = await apiv3Put(`/user-groups/${this.state.userGroup._id}`, param);
-    const { userGroup } = res.data;
-
-    await this.setState({ userGroup });
-
-    return res;
-  }
-
-  /**
-   * open a modal
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   */
-  async openUserGroupUserModal() {
-    await this.setState({ isUserGroupUserModalOpen: true });
-  }
-
-  /**
-   * close a modal
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   */
-  async closeUserGroupUserModal() {
-    await this.setState({ isUserGroupUserModalOpen: false });
-  }
-
-  /**
-   * search user for invitation
-   * @param {string} username username of the user to be searched
-   */
-  async fetchApplicableUsers(searchWord) {
-    const res = await apiv3Get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
-      searchWord,
-      searchType: this.state.searchType,
-      isAlsoMailSearched: this.state.isAlsoMailSearched,
-      isAlsoNameSearched: this.state.isAlsoNameSearched,
-    });
-
-    const { users } = res.data;
-
-    return users;
-  }
-
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {string} username username of the user to be added to the group
-   */
-  async addUserByUsername(username) {
-    const res = await apiv3Post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
-
-    // do not add users for ducaplicate
-    if (res.data.userGroupRelation == null) { return }
-
-    this.init();
-  }
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {string} username username of the user to be removed from the group
-   */
-  async removeUserByUsername(username) {
-    const res = await apiv3Delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
-
-    this.setState((prevState) => {
-      return {
-        userGroupRelations: prevState.userGroupRelations.filter((u) => { return u._id !== res.data.userGroupRelation._id }),
-      };
-    });
-  }
-
-}

+ 0 - 4
packages/app/src/client/services/AdminUsersContainer.js

@@ -2,14 +2,10 @@ import { isServer } from '@growi/core';
 import { debounce } from 'throttle-debounce';
 import { Container } from 'unstated';
 
-import loggerFactory from '~/utils/logger';
-
 import {
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
 } from '../util/apiv3-client';
 
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 
 /**
  * Service container for admin users page (Users.jsx)

+ 48 - 31
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,7 +1,8 @@
 import React, {
-  FC, useState, useCallback, useEffect,
+  useState, useCallback, useEffect, useMemo,
 } from 'react';
 
+import { objectIdUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
@@ -10,8 +11,9 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
-import { IPageHasId } from '~/interfaces/page';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { SearchTypes, SearchType } from '~/interfaces/user-group';
+import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import {
@@ -19,7 +21,11 @@ import {
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 
-import { isValidObjectId } from '../../../../../core/src/utils/objectid-utils';
+
+const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+
+const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 
 const UserGroupDeleteModal = dynamic(() => import('../UserGroup/UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupDropdown = dynamic(() => import('../UserGroup/UserGroupDropdown').then(mod => mod.UserGroupDropdown), { ssr: false });
@@ -27,38 +33,33 @@ const UserGroupForm = dynamic(() => import('../UserGroup/UserGroupForm').then(mo
 const UserGroupModal = dynamic(() => import('../UserGroup/UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('../UserGroup/UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
 const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModal').then(mod => mod.UpdateParentConfirmModal), { ssr: false });
-// import UserGroupPageList from './UserGroupPageList';
-// import UserGroupUserModal from './UserGroupUserModal';
-// import UserGroupUserTable from './UserGroupUserTable';
 
 
 type Props = {
   userGroupId?: string,
 }
 
-const UserGroupDetailPage = (props: Props) => {
+const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
+  const xss = useMemo(() => new Xss(), []);
   const { userGroupId: currentUserGroupId } = props;
 
-  /*
-   * State (from AdminUserGroupDetailContainer)
-   */
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
-  const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
-  const [searchType, setSearchType] = useState<string>('partial');
+  const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+  const [isUserGroupUserModalShown, setIsUserGroupUserModalShown] = useState<boolean>(false);
 
   const isLoading = currentUserGroup === undefined;
   const notExistsUerGroup = !isLoading && currentUserGroup == null;
 
   useEffect(() => {
-    if (!isValidObjectId(currentUserGroupId) || notExistsUerGroup) {
+    if (!objectIdUtils.isValidObjectId(currentUserGroupId) || notExistsUerGroup) {
       router.push('/admin/user-groups');
     }
   }, [currentUserGroup, currentUserGroupId, notExistsUerGroup, router]);
@@ -87,21 +88,18 @@ const UserGroupDetailPage = (props: Props) => {
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
-
   /*
    * Function
    */
-  // TODO 85062: old name: switchIsAlsoMailSearched
   const toggleIsAlsoMailSearched = useCallback(() => {
     setAlsoMailSearched(prev => !prev);
   }, []);
 
-  // TODO 85062: old name: switchIsAlsoNameSearched
   const toggleAlsoNameSearched = useCallback(() => {
     setAlsoNameSearched(prev => !prev);
   }, []);
 
-  const switchSearchType = useCallback((searchType) => {
+  const switchSearchType = useCallback((searchType: SearchType) => {
     setSearchType(searchType);
   }, []);
 
@@ -161,7 +159,7 @@ const UserGroupDetailPage = (props: Props) => {
     }
   }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
 
-  const fetchApplicableUsers = useCallback(async(searchWord) => {
+  const fetchApplicableUsers = useCallback(async(searchWord: string) => {
     const res = await apiv3Get(`/user-groups/${currentUserGroupId}/unrelated-users`, {
       searchWord,
       searchType,
@@ -174,16 +172,23 @@ const UserGroupDetailPage = (props: Props) => {
     return users;
   }, [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched]);
 
-  // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
     await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
+    setIsUserGroupUserModalShown(false);
     mutateUserGroupRelations();
   }, [currentUserGroupId, mutateUserGroupRelations]);
 
+  // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
   const removeUserByUsername = useCallback(async(username: string) => {
-    await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
-    mutateUserGroupRelations();
-  }, [currentUserGroupId, mutateUserGroupRelations]);
+    try {
+      await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
+      toastSuccess(`Removed "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`);
+      mutateUserGroupRelations();
+    }
+    catch (err) {
+      toastError(new Error(`Unable to remove "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`));
+    }
+  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelations, xss]);
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
@@ -309,7 +314,7 @@ const UserGroupDetailPage = (props: Props) => {
   /*
    * Dependencies
    */
-  if (currentUserGroup == null) {
+  if (currentUserGroup == null || currentUserGroupId == null) {
     return <></>;
   }
 
@@ -344,11 +349,25 @@ const UserGroupDetailPage = (props: Props) => {
         />
       </div>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
-      {/* These compoents will be successfully shown in https://redmine.weseek.co.jp/issues/102159 */}
-      {/* <UserGroupUserTable /> */}
-      UserGroupUserTable
-      {/* <UserGroupUserModal /> */}
-      UserGroupUserModal
+      <UserGroupUserTable
+        userGroup={currentUserGroup}
+        userGroupRelations={childUserGroupRelations}
+        onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
+        onClickRemoveUserBtn={removeUserByUsername}
+      />
+      <UserGroupUserModal
+        isOpen={isUserGroupUserModalShown}
+        userGroup={currentUserGroup}
+        searchType={searchType}
+        isAlsoMailSearched={isAlsoMailSearched}
+        isAlsoNameSearched={isAlsoNameSearched}
+        onClickAddUserBtn={addUserByUsername}
+        onSearchApplicableUsers={fetchApplicableUsers}
+        onSwitchSearchType={switchSearchType}
+        onClose={() => setIsUserGroupUserModalShown(false)}
+        onToggleIsAlsoMailSearched={toggleIsAlsoMailSearched}
+        onToggleIsAlsoNameSearched={toggleAlsoNameSearched}
+      />
 
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
@@ -394,9 +413,7 @@ const UserGroupDetailPage = (props: Props) => {
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
-        {/* This compoent will be successfully shown in https://redmine.weseek.co.jp/issues/102159 */}
-        {/* <UserGroupPageList /> */}
-        UserGroupPageList
+        <UserGroupPageList userGroupId={currentUserGroupId} relatedPages={userGroupPages} />
       </div>
     </div>
   );

+ 0 - 97
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -1,97 +0,0 @@
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AppContainer from '~/client/services/AppContainer';
-import { toastError } from '~/client/util/apiNotification';
-import { apiv3Get } from '~/client/util/apiv3-client';
-
-import PageListItemS from '../../PageList/PageListItemS';
-import PaginationWrapper from '../../PaginationWrapper';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class UserGroupPageList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      currentPages: [],
-      activePage: 1,
-      total: 0,
-      pagingLimit: 10,
-    };
-
-    this.handlePageChange = this.handlePageChange.bind(this);
-  }
-
-  async componentDidMount() {
-    await this.handlePageChange(this.state.activePage);
-  }
-
-  async handlePageChange(pageNum) {
-    const limit = this.state.pagingLimit;
-    const offset = (pageNum - 1) * limit;
-
-    try {
-      const res = await apiv3Get(`/user-groups/${this.props.adminUserGroupDetailContainer.state.userGroup._id}/pages`, {
-        limit,
-        offset,
-      });
-      const { total, pages } = res.data;
-
-      this.setState({
-        total,
-        activePage: pageNum,
-        currentPages: pages,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-    const { relatedPages } = adminUserGroupDetailContainer.state;
-
-    return (
-      <Fragment>
-        <ul className="page-list-ul page-list-ul-flat mb-3">
-          {this.state.currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
-        </ul>
-        {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePageChange}
-            totalItemsCount={this.state.total}
-            pagingLimit={this.state.pagingLimit}
-            align="center"
-            size="sm"
-          />
-        )}
-      </Fragment>
-    );
-  }
-
-}
-
-UserGroupPageList.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupPageListWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupPageList t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageListWrapper = withUnstatedContainers(UserGroupPageListWrapperFC, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default UserGroupPageListWrapper;

+ 69 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -0,0 +1,69 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IPageHasId } from '~/interfaces/page';
+
+import PageListItemS from '../../PageList/PageListItemS';
+import PaginationWrapper from '../../PaginationWrapper';
+
+const pagingLimit = 10;
+
+type Props = {
+  userGroupId: string,
+  relatedPages?: IPageHasId[],
+}
+
+const UserGroupPageList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { userGroupId, relatedPages } = props;
+
+  const [currentPages, setCurrentPages] = useState<IPageHasId[]>([]);
+  const [activePage, setActivePage] = useState(1);
+  const [total, setTotal] = useState(0);
+
+  const handlePageChange = useCallback(async(pageNum) => {
+    const offset = (pageNum - 1) * pagingLimit;
+
+    try {
+      const res = await apiv3Get(`/user-groups/${userGroupId}/pages`, {
+        limit: pagingLimit,
+        offset,
+      });
+      const { total, pages } = res.data;
+
+      setTotal(total);
+      setActivePage(pageNum);
+      setCurrentPages(pages);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [userGroupId]);
+
+  useEffect(() => {
+    handlePageChange(activePage);
+  }, [activePage, handlePageChange]);
+
+  return (
+    <>
+      <ul className="page-list-ul page-list-ul-flat mb-3">
+        {currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
+      </ul>
+      {relatedPages != null && relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={handlePageChange}
+          totalItemsCount={total}
+          pagingLimit={pagingLimit}
+          align="center"
+          size="sm"
+        />
+      )}
+    </>
+  );
+};
+
+export default UserGroupPageList;

+ 15 - 23
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -6,12 +6,9 @@ import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { debounce } from 'throttle-debounce';
 
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import Xss from '~/services/xss';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
 class UserGroupUserFormByInput extends React.Component {
 
   constructor(props) {
@@ -38,16 +35,13 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   async addUserBySubmit() {
-    const { adminUserGroupDetailContainer } = this.props;
-    const { userGroup } = adminUserGroupDetailContainer.state;
+    const { userGroup, onClickAddUserBtn } = this.props;
 
     if (this.state.inputUser.length === 0) { return }
     const userName = this.state.inputUser[0].username;
 
     try {
-      await adminUserGroupDetailContainer.addUserByUsername(userName);
-      await adminUserGroupDetailContainer.init();
-      await adminUserGroupDetailContainer.closeUserGroupUserModal();
+      await onClickAddUserBtn(userName);
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       this.setState({ inputUser: '' });
     }
@@ -63,10 +57,10 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   async searhApplicableUsers() {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { onSearchApplicableUsers } = this.props;
 
     try {
-      const users = await adminUserGroupDetailContainer.fetchApplicableUsers(this.state.keyword);
+      const users = await onSearchApplicableUsers(this.state.keyword);
       this.setState({ applicableUsers: users, isLoading: false });
     }
     catch (err) {
@@ -83,7 +77,6 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   handleSearch(keyword) {
-
     if (keyword === '') {
       return;
     }
@@ -100,15 +93,15 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   renderMenuItemChildren(option) {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { isAlsoNameSearched, isAlsoMailSearched } = this.props;
     const user = option;
     return (
-      <React.Fragment>
+      <>
         <UserPicture user={user} size="sm" noLink noTooltip />
         <strong className="ml-2">{user.username}</strong>
-        {adminUserGroupDetailContainer.state.isAlsoNameSearched && <span className="ml-2">{user.name}</span>}
-        {adminUserGroupDetailContainer.state.isAlsoMailSearched && <span className="ml-2">{user.email}</span>}
-      </React.Fragment>
+        {isAlsoNameSearched && <span className="ml-2">{user.name}</span>}
+        {isAlsoMailSearched && <span className="ml-2">{user.email}</span>}
+      </>
     );
   }
 
@@ -161,7 +154,11 @@ class UserGroupUserFormByInput extends React.Component {
 
 UserGroupUserFormByInput.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
+  isAlsoMailSearched: PropTypes.bool.isRequired,
+  isAlsoNameSearched: PropTypes.bool.isRequired,
+  onClickAddUserBtn: PropTypes.func,
+  onSearchApplicableUsers: PropTypes.func,
+  userGroup: PropTypes.object,
 };
 
 const UserGroupUserFormByInputWrapperFC = (props) => {
@@ -169,9 +166,4 @@ const UserGroupUserFormByInputWrapperFC = (props) => {
   return <UserGroupUserFormByInput t={t} {...props} />;
 };
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserFormByInputWrapper = withUnstatedContainers(UserGroupUserFormByInputWrapperFC, [AdminUserGroupDetailContainer]);
-
-export default UserGroupUserFormByInputWrapper;
+export default UserGroupUserFormByInputWrapperFC;

+ 0 - 95
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -1,95 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
-import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
-import UserGroupUserFormByInput from './UserGroupUserFormByInput';
-
-class UserGroupUserModal extends React.Component {
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <Modal isOpen={adminUserGroupDetailContainer.state.isUserGroupUserModalOpen} toggle={adminUserGroupDetailContainer.closeUserGroupUserModal}>
-        <ModalHeader tag="h4" toggle={adminUserGroupDetailContainer.closeUserGroupUserModal} className="bg-info text-light">
-          {t('admin:user_group_management.add_modal.add_user') }
-        </ModalHeader>
-        <ModalBody>
-          <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
-          <div className="p-3">
-            <UserGroupUserFormByInput />
-          </div>
-          <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
-          <div className="row mt-4">
-            <div className="col-6">
-              <div className="mb-5">
-                <CheckBoxForSerchUserOption
-                  option="mail"
-                  checked={adminUserGroupDetailContainer.state.isAlsoMailSearched}
-                  onChange={adminUserGroupDetailContainer.switchIsAlsoMailSearched}
-                />
-              </div>
-              <div className="mb-5">
-                <CheckBoxForSerchUserOption
-                  option="name"
-                  checked={adminUserGroupDetailContainer.state.isAlsoNameSearched}
-                  onChange={adminUserGroupDetailContainer.switchIsAlsoNameSearched}
-                />
-              </div>
-            </div>
-            <div className="col-6">
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="forward"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'forward'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('forward') }}
-                />
-              </div>
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="partial"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'partial'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('partial') }}
-                />
-              </div>
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="backward"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'backword'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('backword') }}
-                />
-              </div>
-            </div>
-          </div>
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-}
-
-UserGroupUserModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupUserModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserModal t={t} {...props} />;
-};
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserModalWrapper = withUnstatedContainers(UserGroupUserModalWrapperFC, [AdminUserGroupDetailContainer]);
-
-export default UserGroupUserModalWrapper;

+ 109 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx

@@ -0,0 +1,109 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+import { SearchTypes, SearchType } from '~/interfaces/user-group';
+
+import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
+import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
+import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+
+type Props = {
+  isOpen: boolean,
+  userGroup: IUserGroupHasId,
+  searchType: SearchType,
+  isAlsoMailSearched: boolean,
+  isAlsoNameSearched: boolean,
+  onClickAddUserBtn: (username: string) => Promise<void>,
+  onSearchApplicableUsers: (searchWord: string) => Promise<void>,
+  onSwitchSearchType: (searchType: SearchType) => void
+  onClose: () => void,
+  onToggleIsAlsoMailSearched: () => void,
+  onToggleIsAlsoNameSearched: () => void,
+}
+
+const UserGroupUserModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isOpen,
+    userGroup,
+    searchType,
+    onClickAddUserBtn,
+    onSearchApplicableUsers,
+    onSwitchSearchType,
+    onClose,
+    isAlsoMailSearched,
+    isAlsoNameSearched,
+    onToggleIsAlsoMailSearched,
+    onToggleIsAlsoNameSearched,
+  } = props;
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+        {t('admin:user_group_management.add_modal.add_user') }
+      </ModalHeader>
+      <ModalBody>
+        <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
+        <div className="p-3">
+          <UserGroupUserFormByInput
+            userGroup={userGroup}
+            onClickAddUserBtn={onClickAddUserBtn}
+            onSearchApplicableUsers={onSearchApplicableUsers}
+            onClose={onClose}
+            isAlsoNameSearched={isAlsoNameSearched}
+            isAlsoMailSearched={isAlsoMailSearched}
+          />
+        </div>
+        <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
+        <div className="row mt-4">
+          <div className="col-6">
+            <div className="mb-5">
+              <CheckBoxForSerchUserOption
+                option="mail"
+                checked={isAlsoMailSearched}
+                onChange={onToggleIsAlsoMailSearched}
+              />
+            </div>
+            <div className="mb-5">
+              <CheckBoxForSerchUserOption
+                option="name"
+                checked={isAlsoNameSearched}
+                onChange={onToggleIsAlsoNameSearched}
+              />
+            </div>
+          </div>
+          <div className="col-6">
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="forward"
+                checked={searchType === SearchTypes.FORWARD}
+                onChange={() => onSwitchSearchType(SearchTypes.FORWARD)}
+              />
+            </div>
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="partial"
+                checked={searchType === SearchTypes.PARTIAL}
+                onChange={() => onSwitchSearchType(SearchTypes.PARTIAL)}
+              />
+            </div>
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="backward"
+                checked={searchType === SearchTypes.BACKWORD}
+                onChange={() => onSwitchSearchType(SearchTypes.BACKWORD)}
+              />
+            </div>
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+export default UserGroupUserModal;

+ 0 - 129
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -1,129 +0,0 @@
-import React from 'react';
-
-import { UserPicture } from '@growi/ui';
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import Xss from '~/services/xss';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class UserGroupUserTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.xss = new Xss();
-
-    this.removeUser = this.removeUser.bind(this);
-  }
-
-  async removeUser(username) {
-    try {
-      await this.props.adminUserGroupDetailContainer.removeUserByUsername(username);
-      toastSuccess(`Removed "${this.xss.process(username)}" from "${this.xss.process(this.props.adminUserGroupDetailContainer.state.userGroup.name)}"`);
-    }
-    catch (err) {
-      // eslint-disable-next-line max-len
-      toastError(new Error(`Unable to remove "${this.xss.process(username)}" from "${this.xss.process(this.props.adminUserGroupDetailContainer.state.userGroup.name)}"`));
-    }
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <table className="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th width="100px">#</th>
-            <th>
-              {t('username')}
-            </th>
-            <th>{t('Name')}</th>
-            <th width="100px">{t('Created')}</th>
-            <th width="160px">{t('Last_Login')}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {adminUserGroupDetailContainer.state.userGroupRelations.map((sRelation) => {
-            const { relatedUser } = sRelation;
-
-            return (
-              <tr key={sRelation._id}>
-                <td>
-                  <UserPicture user={relatedUser} className="picture rounded-circle" />
-                </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>
-                <td>
-                  <div className="btn-group admin-user-menu">
-                    <button
-                      type="button"
-                      id={`admin-group-menu-button-${relatedUser._id}`}
-                      className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                      data-toggle="dropdown"
-                    >
-                      <i className="icon-settings"></i>
-                    </button>
-                    <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
-                      <button
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => {
-                          return this.removeUser(relatedUser.username);
-                        }}
-                      >
-                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
-                      </button>
-                    </div>
-                  </div>
-                </td>
-              </tr>
-            );
-          })}
-
-          <tr>
-            <td></td>
-            <td className="text-center">
-              <button className="btn btn-outline-secondary" type="button" onClick={adminUserGroupDetailContainer.openUserGroupUserModal}>
-                <i className="ti ti-plus"></i>
-              </button>
-            </td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-          </tr>
-
-        </tbody>
-      </table>
-    );
-  }
-
-}
-
-UserGroupUserTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupUserTableWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserTable t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserTableWrapper = withUnstatedContainers(UserGroupUserTableWrapperFC, [AdminUserGroupDetailContainer]);
-
-export default UserGroupUserTableWrapper;

+ 98 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -0,0 +1,98 @@
+import React from 'react';
+
+import { UserPicture } from '@growi/ui';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import { IUserGroupHasId, IUserGroupRelation } from '~/interfaces/user';
+import { useSWRxUserGroupRelations } from '~/stores/user-group';
+
+type Props = {
+  userGroupRelations: IUserGroupRelation[],
+  userGroup: IUserGroupHasId,
+  onClickRemoveUserBtn: (username: string) => Promise<void>,
+  onClickPlusBtn: () => void,
+}
+
+export const UserGroupUserTable = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    userGroup, onClickRemoveUserBtn, onClickPlusBtn,
+  } = props;
+  const { data: userGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+
+
+  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>
+          <th style={{ width: '70px' }}></th>
+        </tr>
+      </thead>
+      <tbody>
+        {userGroupRelations != null && userGroupRelations.map((relation) => {
+          const { relatedUser } = relation;
+
+          return (
+            <tr key={relation._id}>
+              <td>
+                <UserPicture user={relatedUser} className="picture rounded-circle" />
+              </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>
+              <td>
+                <div className="btn-group admin-user-menu">
+                  <button
+                    type="button"
+                    id={`admin-group-menu-button-${relatedUser._id}`}
+                    className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                    data-toggle="dropdown"
+                  >
+                    <i className="icon-settings"></i>
+                  </button>
+                  <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => onClickRemoveUserBtn(relatedUser.username)}
+                    >
+                      <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                    </button>
+                  </div>
+                </div>
+              </td>
+            </tr>
+          );
+        })}
+
+        <tr>
+          <td></td>
+          <td className="text-center">
+            <button className="btn btn-outline-secondary" type="button" onClick={onClickPlusBtn}>
+              <i className="ti ti-plus"></i>
+            </button>
+          </td>
+          <td></td>
+          <td></td>
+          <td></td>
+          <td></td>
+        </tr>
+
+      </tbody>
+    </table>
+  );
+};
+
+export default UserGroupUserTable;

+ 15 - 1
packages/app/src/interfaces/user-group-response.ts

@@ -1,5 +1,9 @@
-import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
+import { HasObjectId, Ref } from '@growi/core';
+
 import { IPageHasId } from './page';
+import {
+  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+} from './user';
 
 export type UserGroupResult = {
   userGroup: IUserGroupHasId,
@@ -18,6 +22,16 @@ export type UserGroupRelationListResult = {
   userGroupRelations: IUserGroupRelationHasId[],
 };
 
+export type IUserGroupRelationHasIdPopulatedUser = {
+  relatedGroup: Ref<IUserGroup>,
+  relatedUser: IUser & HasObjectId,
+  createdAt: Date,
+} & HasObjectId;
+
+export type UserGroupRelationsResult = {
+  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[],
+};
+
 export type UserGroupPagesResult = {
   pages: IPageHasId[],
 }

+ 7 - 0
packages/app/src/interfaces/user-group.ts

@@ -0,0 +1,7 @@
+export const SearchTypes = {
+  FORWARD: 'forward',
+  PARTIAL: 'partial',
+  BACKWORD: 'backword',
+} as const;
+
+export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];

+ 0 - 3
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -27,7 +27,6 @@ import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityConta
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import PluginUtils from '~/server/plugins/plugin-utils';
@@ -208,7 +207,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
     const adminNotificationContainer = new AdminNotificationContainer();
     const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
     const adminMarkDownContainer = new AdminMarkDownContainer();
-    const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer();
 
     injectableContainers.push(
       adminAppContainer,
@@ -220,7 +218,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       adminNotificationContainer,
       adminSlackIntegrationLegacyContainer,
       adminMarkDownContainer,
-      adminUserGroupDetailContainer,
     );
   }
 

+ 0 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -765,7 +765,6 @@ module.exports = (crowi) => {
     try {
       const userGroup = await UserGroup.findById(id);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-
       return res.apiv3({ userGroupRelations });
     }
     catch (err) {

+ 4 - 3
packages/app/src/stores/user-group.tsx

@@ -6,7 +6,8 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
-  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult,
+  IUserGroupRelationHasIdPopulatedUser,
+  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 
@@ -51,10 +52,10 @@ export const useSWRxChildUserGroupList = (
   );
 };
 
-export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasId[], Error> => {
+export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
   return useSWRImmutable(
     groupId != null ? [`/user-groups/${groupId}/user-group-relations`] : null,
-    endpoint => apiv3Get<UserGroupRelationListResult>(endpoint).then(result => result.data.userGroupRelations),
+    endpoint => apiv3Get<UserGroupRelationsResult>(endpoint).then(result => result.data.userGroupRelations),
   );
 };
 

+ 2 - 0
packages/core/src/interfaces/user.ts

@@ -17,6 +17,8 @@ export type IUser = {
   isEmailPublished: boolean,
   lang: Lang,
   slackMemberId?: string,
+  createdAt: Date,
+  lastLoginAt?: Date,
 }
 
 export type IUserGroupRelation = {