jam411 3 лет назад
Родитель
Сommit
96f88a4338

+ 7 - 0
packages/app/public/static/locales/en_US/admin.json

@@ -3,8 +3,15 @@
     "display_name": "English"
     "display_name": "English"
   },
   },
   "wiki_management_home_page": "Wiki Management Home Page",
   "wiki_management_home_page": "Wiki Management Home Page",
+  "app_settings": "App Settings",
+  "last_login": "Last login",
+  "anyone_with_the_link": "anyone with the link",
+  "only_me": "only me",
+  "only_inside_the_group": "only inside the group",
   "security_settings": {
   "security_settings": {
     "security_settings": "Security Settings",
     "security_settings": "Security Settings",
+    "scope_of_page_disclosure": "Scope of page disclosure",
+    "set_point": "Set point",
     "Guest Users Access": "Guest users access",
     "Guest Users Access": "Guest users access",
     "always_hidden": "Always hidden",
     "always_hidden": "Always hidden",
     "always_displayed": "Always displayed",
     "always_displayed": "Always displayed",

+ 0 - 3
packages/app/public/static/locales/en_US/translation.json

@@ -78,7 +78,6 @@
   "username": "Username",
   "username": "Username",
   "Created": "Created",
   "Created": "Created",
   "Last updated": "Updated",
   "Last updated": "Updated",
-  "last_login": "Last login",
   "Share": "Share",
   "Share": "Share",
   "Markdown Link": "Markdown Link",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
   "Create/Edit Template": "Create/Edit template page",
@@ -129,8 +128,6 @@
   "Only me": "Only me",
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
   "page_list": "Page List",
-  "scope_of_page_disclosure": "Scope of page disclosure",
-  "set_point": "Set point",
   "Reselect the group": "Reselect the group",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",

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

@@ -9,6 +9,7 @@
   "Created": "创建",
   "Created": "创建",
   "Edit": "编辑",
   "Edit": "编辑",
   "Description": "描述",
   "Description": "描述",
+  "last_login": "上次登录",
   "wiki_management_home_page": "Wiki管理首页",
   "wiki_management_home_page": "Wiki管理首页",
   "public": "公共",
   "public": "公共",
   "anyone_with_the_link": "任何人",
   "anyone_with_the_link": "任何人",

+ 0 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -73,7 +73,6 @@
   "username": "用户名",
   "username": "用户名",
 	"Created": "创建",
 	"Created": "创建",
 	"Last updated": "上次更新",
 	"Last updated": "上次更新",
-  "last_login": "上次登录",
 	"Share": "分享",
 	"Share": "分享",
   "Share Link": "分享链接",
   "Share Link": "分享链接",
 	"Markdown Link": "Markdown链接",
 	"Markdown Link": "Markdown链接",

+ 12 - 9
packages/app/src/components/Admin/ManageExternalAccount.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useEffect } from 'react';
 import React, { useCallback, useEffect } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
@@ -21,7 +22,7 @@ const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element =
   const { adminExternalAccountsContainer } = props;
   const { adminExternalAccountsContainer } = props;
   const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
   const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
 
 
-  const handleExternalAccountPage = useCallback(async(selectedPage) => {
+  const ExternalAccountPageHandler = useCallback(async(selectedPage) => {
     try {
     try {
       await adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
       await adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
     }
     }
@@ -30,15 +31,15 @@ const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element =
     }
     }
   }, [adminExternalAccountsContainer]);
   }, [adminExternalAccountsContainer]);
 
 
-  // componentDidMount
+  // for Next routing
   useEffect(() => {
   useEffect(() => {
-    handleExternalAccountPage(1);
-  }, []);
+    ExternalAccountPageHandler(1);
+  }, [ExternalAccountPageHandler]);
 
 
   const pager = (
   const pager = (
     <PaginationWrapper
     <PaginationWrapper
       activePage={activePage}
       activePage={activePage}
-      changePage={handleExternalAccountPage}
+      changePage={ExternalAccountPageHandler}
       totalItemsCount={totalAccounts}
       totalItemsCount={totalAccounts}
       pagingLimit={pagingLimit}
       pagingLimit={pagingLimit}
       align="center"
       align="center"
@@ -49,10 +50,12 @@ const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element =
   return (
   return (
     <>
     <>
       <p>
       <p>
-        <a className="btn btn-outline-secondary" href="/admin/users">
-          <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
-          {t('admin:user_management.back_to_user_management')}
-        </a>
+        <Link href="/admin/users" prefetch={false}>
+          <a className="btn btn-outline-secondary">
+            <i className="icon-fw ti ti-arrow-left" aria-hidden="true"></i>
+            {t('admin:user_management.back_to_user_management')}
+          </a>
+        </Link>
       </p>
       </p>
       <h2>{t('admin:user_management.external_account_list')}</h2>
       <h2>{t('admin:user_management.external_account_list')}</h2>
       {(totalAccounts !== 0) ? (
       {(totalAccounts !== 0) ? (

+ 6 - 3
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -1,6 +1,7 @@
 import React, { useMemo, useState } from 'react';
 import React, { useMemo, useState } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
 
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 import CustomNav from '../../CustomNavigation/CustomNav';
@@ -95,9 +96,11 @@ const SecurityManagementContents = () => {
       <div className="mb-5">
       <div className="mb-5">
         <h2 className="border-bottom">{t('security_settings.xss_prevent_setting')}</h2>
         <h2 className="border-bottom">{t('security_settings.xss_prevent_setting')}</h2>
         <div className="text-center">
         <div className="text-center">
-          <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
-            <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
-          </a>
+          <Link href="/admin/markdown/#preventXSS" prefetch={false}>
+            <a style={{ fontSize: 'large' }}>
+              <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
+            </a>
+          </Link>
         </div>
         </div>
       </div>
       </div>
 
 

+ 0 - 208
packages/app/src/components/Admin/Security/ShareLinkSetting.jsx

@@ -1,208 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiv3Delete } from '~/client/util/apiv3-client';
-
-import PaginationWrapper from '../../PaginationWrapper';
-import ShareLinkList from '../../ShareLink/ShareLinkList';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
-import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
-
-const Pager = (props) => {
-  if (props.links.length === 0) {
-    return null;
-  }
-  return (
-    <PaginationWrapper
-      activePage={props.activePage}
-      changePage={props.handlePage}
-      totalItemsCount={props.totalLinks}
-      pagingLimit={props.limit}
-      align="center"
-      size="sm"
-    />
-  );
-};
-
-Pager.propTypes = {
-  links: PropTypes.array.isRequired,
-  activePage: PropTypes.number.isRequired,
-  handlePage: PropTypes.func.isRequired,
-  totalLinks: PropTypes.number.isRequired,
-  limit: PropTypes.number.isRequired,
-};
-
-class ShareLinkSetting extends React.Component {
-
-  constructor() {
-    super();
-
-    this.state = {
-      isDeleteConfirmModalShown: false,
-    };
-    this.getShareLinkList = this.getShareLinkList.bind(this);
-    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
-    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
-    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
-    this.deleteLinkById = this.deleteLinkById.bind(this);
-    this.switchDisableLinkSharing = this.switchDisableLinkSharing.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.getShareLinkList(1);
-  }
-
-  async getShareLinkList(page) {
-    try {
-      await this.props.adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-  }
-
-  showDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: true });
-  }
-
-  closeDeleteConfirmModal() {
-    this.setState({ isDeleteConfirmModalShown: false });
-  }
-
-  async deleteAllLinksButtonHandler() {
-    const { t } = this.props;
-
-    try {
-      const res = await apiv3Delete('/share-links/all');
-      const { deletedCount } = res.data;
-      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.getShareLinkList(1);
-  }
-
-  async deleteLinkById(shareLinkId) {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    const { shareLinksActivePage } = adminGeneralSecurityContainer.state;
-
-    try {
-      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
-      const { deletedShareLink } = res.data;
-      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-    this.getShareLinkList(shareLinksActivePage);
-  }
-
-  async switchDisableLinkSharing() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    try {
-      await adminGeneralSecurityContainer.switchDisableLinkSharing();
-      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  render() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    const {
-      shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit, disableLinkSharing,
-    } = adminGeneralSecurityContainer.state;
-
-    return (
-      <Fragment>
-        <div className="mb-3">
-          <button
-            className="pull-right btn btn-danger"
-            disabled={shareLinks.length === 0}
-            type="button"
-            onClick={this.showDeleteConfirmModal}
-          >
-            {t('share_links.delete_all_share_links')}
-          </button>
-          <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
-        </div>
-        <h4>{t('security_settings.share_link_rights')}</h4>
-        <div className="row mb-5">
-          <div className="col-6 offset-3">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                type="checkbox"
-                className="custom-control-input"
-                id="disableLinkSharing"
-                checked={!disableLinkSharing}
-                onChange={() => this.switchDisableLinkSharing()}
-              />
-              <label className="custom-control-label" htmlFor="disableLinkSharing">
-                {t('security_settings.enable_link_sharing')}
-              </label>
-            </div>
-            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && disableLinkSharing && (
-              <div className="badge badge-warning">{t('security_settings.setup_is_not_yet_complete')}</div>
-            )}
-          </div>
-        </div>
-        <h4>{t('security_settings.all_share_links')}</h4>
-        <Pager
-          links={shareLinks}
-          activePage={shareLinksActivePage}
-          handlePage={this.getShareLinkList}
-          totalLinks={totalshareLinks}
-          limit={shareLinksPagingLimit}
-        />
-
-        {(shareLinks.length !== 0) ? (
-          <ShareLinkList
-            shareLinks={shareLinks}
-            onClickDeleteButton={this.deleteLinkById}
-            isAdmin
-          />
-        )
-          : (<p className="text-center">{t('share_links.No_share_links')}</p>
-          )
-        }
-
-
-        <DeleteAllShareLinksModal
-          isOpen={this.state.isDeleteConfirmModalShown}
-          onClose={this.closeDeleteConfirmModal}
-          onClickDeleteButton={this.deleteAllLinksButtonHandler}
-        />
-
-      </Fragment>
-    );
-  }
-
-}
-
-ShareLinkSetting.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-};
-
-const ShareLinkSettingWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <ShareLinkSetting t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSettingWrapperFC, [AdminGeneralSecurityContainer]);
-
-export default ShareLinkSettingWrapper;

+ 171 - 0
packages/app/src/components/Admin/Security/ShareLinkSetting.tsx

@@ -0,0 +1,171 @@
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Delete } from '~/client/util/apiv3-client';
+
+import PaginationWrapper from '../../PaginationWrapper';
+import ShareLinkList from '../../ShareLink/ShareLinkList';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+
+import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
+
+type PagerProps = {
+  activePage: number,
+  handlePage: (page: any) => Promise<void>,
+  totalLinks: number,
+  limit: number,
+}
+
+type ShareLinkSettingProps = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer,
+}
+
+const Pager = (props: PagerProps) => {
+  const {
+    activePage, handlePage, totalLinks, limit,
+  } = props;
+
+  return (
+    <PaginationWrapper
+      activePage={activePage}
+      changePage={handlePage}
+      totalItemsCount={totalLinks}
+      pagingLimit={limit}
+      align="center"
+      size="sm"
+    />
+  );
+};
+
+const ShareLinkSetting = (props: ShareLinkSettingProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminGeneralSecurityContainer } = props;
+  const {
+    shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
+    disableLinkSharing, setupStrategies,
+  } = adminGeneralSecurityContainer.state;
+  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>();
+
+  const getShareLinkList = useCallback(async(page: number) => {
+    try {
+      await adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminGeneralSecurityContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    getShareLinkList(1);
+  }, [getShareLinkList]);
+
+  const deleteAllLinksButtonHandler = useCallback(async() => {
+    try {
+      const res = await apiv3Delete('/share-links/all');
+      const { deletedCount } = res.data;
+      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    getShareLinkList(1);
+  }, [getShareLinkList, t]);
+
+  const deleteLinkById = useCallback(async(shareLinkId: string) => {
+    try {
+      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    getShareLinkList(shareLinksActivePage);
+  }, [shareLinksActivePage, getShareLinkList, t]);
+
+  const switchDisableLinkSharing = useCallback(async() => {
+    try {
+      await adminGeneralSecurityContainer.switchDisableLinkSharing();
+      toastSuccess(t('toaster.switch_disable_link_sharing_success'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminGeneralSecurityContainer, t]);
+
+  return (
+    <>
+      <div className="mb-3">
+        <button
+          className="pull-right btn btn-danger"
+          disabled={shareLinks.length === 0}
+          type="button"
+          onClick={() => setIsDeleteConfirmModalShown(true)}
+        >
+          {t('share_links.delete_all_share_links')}
+        </button>
+        <h2 className="alert-anchor border-bottom">{t('share_links.share_link_management')}</h2>
+      </div>
+      <h4>{t('security_settings.share_link_rights')}</h4>
+      <div className="row mb-5">
+        <div className="col-6 offset-3">
+          <div className="custom-control custom-switch custom-checkbox-success">
+            <input
+              type="checkbox"
+              className="custom-control-input"
+              id="disableLinkSharing"
+              checked={!disableLinkSharing}
+              onChange={() => switchDisableLinkSharing()}
+            />
+            <label className="custom-control-label" htmlFor="disableLinkSharing">
+              {t('security_settings.enable_link_sharing')}
+            </label>
+          </div>
+          {!setupStrategies.includes('local') && disableLinkSharing && (
+            <div className="badge badge-warning">{t('security_settings.setup_is_not_yet_complete')}</div>
+          )}
+        </div>
+      </div>
+      <h4>{t('security_settings.all_share_links')}</h4>
+      <Pager
+        activePage={shareLinksActivePage}
+        handlePage={getShareLinkList}
+        totalLinks={totalshareLinks}
+        limit={shareLinksPagingLimit}
+      />
+
+      {(shareLinks.length !== 0) ? (
+        <ShareLinkList
+          shareLinks={shareLinks}
+          onClickDeleteButton={deleteLinkById}
+          isAdmin
+        />
+      )
+        : (<p className="text-center">{t('share_links.No_share_links')}</p>
+        )
+      }
+
+      <DeleteAllShareLinksModal
+        isOpen={isDeleteConfirmModalShown}
+        onClose={() => setIsDeleteConfirmModalShown(false)}
+        onClickDeleteButton={deleteAllLinksButtonHandler}
+      />
+
+    </>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [AdminGeneralSecurityContainer]);
+
+export default ShareLinkSettingWrapper;

+ 0 - 235
packages/app/src/components/Admin/UserManagement.jsx

@@ -1,235 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastError } from '~/client/util/apiNotification';
-
-import PaginationWrapper from '../PaginationWrapper';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-import InviteUserControl from './Users/InviteUserControl';
-import PasswordResetModal from './Users/PasswordResetModal';
-import UserTable from './Users/UserTable';
-
-import styles from './UserManagement.module.scss';
-
-class UserManagement extends React.Component {
-
-  constructor(props) {
-    super();
-
-    this.state = {
-      isNotifyCommentShow: false,
-    };
-
-    this.handlePage = this.handlePage.bind(this);
-    this.handleChangeSearchText = this.handleChangeSearchText.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.handlePage(1);
-  }
-
-  async handlePage(selectedPage) {
-    try {
-      await this.props.adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * For checking same check box twice
-   * @param {string} statusType
-   */
-  async handleClick(statusType) {
-    const { adminUsersContainer } = this.props;
-    if (!this.validateToggleStatus(statusType)) {
-      return this.setState({ isNotifyCommentShow: true });
-    }
-
-    if (this.state.isNotifyCommentShow) {
-      await this.setState({ isNotifyCommentShow: false });
-    }
-    adminUsersContainer.handleClick(statusType);
-  }
-
-  /**
-   * Workaround user status check box
-   * @param {string} statusType
-   */
-  validateToggleStatus(statusType) {
-    if (this.props.adminUsersContainer.isSelected(statusType)) {
-      return this.props.adminUsersContainer.state.selectedStatusList.size > 1;
-    }
-    return true;
-  }
-
-  /**
-   * Reset button
-   */
-  resetButtonClickHandler() {
-    const { adminUsersContainer } = this.props;
-    try {
-      adminUsersContainer.resetAllChanges();
-      this.searchUserElement.value = '';
-      this.setState({ isNotifyCommentShow: false });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * Workaround increamental search
-   * @param {string} event
-   */
-  handleChangeSearchText(event) {
-    this.props.adminUsersContainer.handleChangeSearchText(event.target.value);
-  }
-
-  renderCheckbox(status, statusLabel, statusColor) {
-    return (
-      <div className={`custom-control custom-checkbox custom-checkbox-${statusColor} mr-2`}>
-        <input
-          className="custom-control-input"
-          type="checkbox"
-          id={`c_${status}`}
-          checked={this.props.adminUsersContainer.isSelected(status)}
-          onChange={() => { this.handleClick(status) }}
-        />
-        <label className="custom-control-label" htmlFor={`c_${status}`}>
-          <span className={`badge badge-pill badge-${statusColor} d-inline-block vt mt-1`}>
-            {statusLabel}
-          </span>
-        </label>
-      </div>
-    );
-  }
-
-  render() {
-    const { t, adminUsersContainer } = this.props;
-
-    const pager = (
-      <div className="my-3">
-        <PaginationWrapper
-          activePage={adminUsersContainer.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={adminUsersContainer.state.totalUsers}
-          pagingLimit={adminUsersContainer.state.pagingLimit}
-          align="center"
-          size="sm"
-        />
-      </div>
-    );
-
-    const clearButton = (
-      adminUsersContainer.state.searchText.length > 0
-        ? (
-          <i
-            className={`icon-close ${styles['search-clear']}`}
-            onClick={() => {
-              adminUsersContainer.clearSearchText();
-              this.searchUserElement.value = '';
-            }}
-          />
-        )
-        : ''
-    );
-
-    return (
-      <div data-testid="admin-users">
-        {adminUsersContainer.state.userForPasswordResetModal != null
-        && (
-          <PasswordResetModal
-            isOpen={adminUsersContainer.state.isPasswordResetModalShown}
-            onClose={adminUsersContainer.hidePasswordResetModal}
-            userForPasswordResetModal={adminUsersContainer.state.userForPasswordResetModal}
-          />
-        )}
-        <p>
-          <InviteUserControl />
-          <a className="btn btn-outline-secondary ml-2" href="/admin/users/external-accounts" role="button">
-            <i className="icon-user-follow" aria-hidden="true"></i>
-            {t('admin:user_management.external_account')}
-          </a>
-        </p>
-
-        <h2>{t('user_management.user_management')}</h2>
-        <div className="border-top border-bottom">
-
-          <div className="row d-flex justify-content-start align-items-center my-2">
-            <div className="col-md-3 d-flex align-items-center my-2">
-              <i className="icon-magnifier mr-1"></i>
-              <span className="search-typeahead">
-                <input
-                  className="w-100"
-                  type="text"
-                  ref={(searchUserElement) => { this.searchUserElement = searchUserElement }}
-                  onChange={this.handleChangeSearchText}
-                />
-                { clearButton }
-              </span>
-            </div>
-
-            <div className="offset-md-1 col-md-6 my-2">
-              <div className="form-inline">
-                {this.renderCheckbox('all', 'All', 'secondary')}
-                {this.renderCheckbox('registered', 'Approval Pending', 'info')}
-                {this.renderCheckbox('active', 'Active', 'success')}
-                {this.renderCheckbox('suspended', 'Suspended', 'warning')}
-                {this.renderCheckbox('invited', 'Invited', 'pink')}
-              </div>
-              <div>
-                {
-                  this.state.isNotifyCommentShow
-                  && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span>
-                }
-              </div>
-            </div>
-
-            <div className="col-md-2 my-2">
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-sm"
-                onClick={() => { this.resetButtonClickHandler() }}
-              >
-                <span
-                  className="icon-refresh mr-1"
-                >
-                </span>
-                Reset
-              </button>
-            </div>
-          </div>
-        </div>
-
-
-        {pager}
-        <UserTable />
-        {pager}
-
-      </div>
-    );
-  }
-
-}
-
-
-UserManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-};
-
-const UserManagementFc = (props) => {
-  const { t } = useTranslation('admin');
-  return <UserManagement t={t} {...props} />;
-};
-
-const UserManagementWrapper = withUnstatedContainers(UserManagementFc, [AdminUsersContainer]);
-
-export default UserManagementWrapper;

+ 1 - 1
packages/app/src/components/Admin/UserManagement.module.scss

@@ -1,5 +1,5 @@
 // styles for admin user search
 // styles for admin user search
 .search-clear :global {
 .search-clear :global {
-  top: 90px;
+  top: 7px;
   right: 4px;
   right: 4px;
 }
 }

+ 202 - 0
packages/app/src/components/Admin/UserManagement.tsx

@@ -0,0 +1,202 @@
+import React, {
+  useEffect, useState, useRef, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import InviteUserControl from './Users/InviteUserControl';
+import PasswordResetModal from './Users/PasswordResetModal';
+import UserTable from './Users/UserTable';
+
+import styles from './UserManagement.module.scss';
+
+type UserManagementProps = {
+  adminUsersContainer: AdminUsersContainer
+}
+
+const UserManagement = (props: UserManagementProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminUsersContainer } = props;
+  const [isNotifyCommentShow, setIsNotifyCommentShow] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const pagingHandler = useCallback(async(selectedPage: number) => {
+    try {
+      await adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    pagingHandler(1);
+  }, [pagingHandler]);
+
+  const validateToggleStatus = (statusType: string) => {
+    return (adminUsersContainer.isSelected(statusType)) ? (
+      adminUsersContainer.state.selectedStatusList.size > 1
+    )
+      : (
+        true
+      );
+  };
+
+  const clickHandler = (statusType: string) => {
+    if (!validateToggleStatus(statusType)) {
+      return setIsNotifyCommentShow(true);
+    }
+
+    if (isNotifyCommentShow) {
+      setIsNotifyCommentShow(false);
+    }
+    adminUsersContainer.handleClick(statusType);
+  };
+
+  const resetButtonClickHandler = useCallback(async() => {
+    try {
+      await adminUsersContainer.resetAllChanges();
+      setIsNotifyCommentShow(false);
+      if (inputRef.current != null) {
+        inputRef.current.value = '';
+      }
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer]);
+
+  const changeSearchTextHandler = useCallback(async(e: React.FormEvent<HTMLInputElement>) => {
+    await adminUsersContainer.handleChangeSearchText(e?.currentTarget.value);
+  }, [adminUsersContainer]);
+
+  const renderCheckbox = (status: string, statusLabel: string, statusColor: string) => {
+    return (
+      <div className={`custom-control custom-checkbox custom-checkbox-${statusColor} mr-2`}>
+        <input
+          className="custom-control-input"
+          type="checkbox"
+          id={`c_${status}`}
+          checked={adminUsersContainer.isSelected(status)}
+          onChange={() => clickHandler(status)}
+        />
+        <label className="custom-control-label" htmlFor={`c_${status}`}>
+          <span className={`badge badge-pill badge-${statusColor} d-inline-block vt mt-1`}>
+            {statusLabel}
+          </span>
+        </label>
+      </div>
+    );
+  };
+
+  const pager = (
+    <div className="my-3">
+      <PaginationWrapper
+        activePage={adminUsersContainer.state.activePage}
+        changePage={pagingHandler}
+        totalItemsCount={adminUsersContainer.state.totalUsers}
+        pagingLimit={adminUsersContainer.state.pagingLimit}
+        align="center"
+        size="sm"
+      />
+    </div>
+  );
+
+  return (
+    <div data-testid="admin-users">
+      { adminUsersContainer.state.userForPasswordResetModal != null
+      && (
+        <PasswordResetModal
+          isOpen={adminUsersContainer.state.isPasswordResetModalShown}
+          onClose={adminUsersContainer.hidePasswordResetModal}
+          userForPasswordResetModal={adminUsersContainer.state.userForPasswordResetModal}
+        />
+      ) }
+      <p>
+        <InviteUserControl />
+        <Link href="/admin/users/external-accounts" prefetch={false}>
+          <a className="btn btn-outline-secondary ml-2" role="button">
+            <i className="icon-user-follow mr-1" aria-hidden="true"></i>
+            {t('admin:user_management.external_account')}
+          </a>
+        </Link>
+      </p>
+
+      <h2>{t('user_management.user_management')}</h2>
+      <div className="border-top border-bottom">
+
+        <div className="row d-flex justify-content-start align-items-center my-2">
+          <div className="col-md-3 d-flex align-items-center my-2">
+            <i className="icon-magnifier mr-1"></i>
+            <span className="search-typeahead">
+              <input
+                className="w-100"
+                type="text"
+                ref={inputRef}
+                onChange={changeSearchTextHandler}
+              />
+              {/* TODO: Fix position */}
+              {
+                adminUsersContainer.state.searchText.length > 0
+                  ? (<i
+                    className={`icon-close ${styles['search-clear']}`}
+                    onClick={async() => {
+                      await adminUsersContainer.clearSearchText();
+                      if (inputRef.current != null) {
+                        inputRef.current.value = '';
+                      }
+                    }}
+                  />
+                  )
+                  : ''
+              }
+            </span>
+          </div>
+
+          <div className="offset-md-1 col-md-6 my-2">
+            <div className="form-inline">
+              {renderCheckbox('all', 'All', 'secondary')}
+              {renderCheckbox('registered', 'Approval Pending', 'info')}
+              {renderCheckbox('active', 'Active', 'success')}
+              {renderCheckbox('suspended', 'Suspended', 'warning')}
+              {renderCheckbox('invited', 'Invited', 'pink')}
+            </div>
+            <div>
+              { isNotifyCommentShow && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span> }
+            </div>
+          </div>
+
+          <div className="col-md-2 my-2">
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-sm"
+              onClick={resetButtonClickHandler}
+            >
+              <span className="icon-refresh mr-1"></span>
+              Reset
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {pager}
+      <UserTable />
+      {pager}
+
+    </div>
+  );
+
+};
+
+const UserManagementWrapper = withUnstatedContainers(UserManagement, [AdminUsersContainer]);
+
+export default UserManagementWrapper;

+ 83 - 63
packages/app/src/components/Admin/Users/ExternalAccountTable.tsx

@@ -18,14 +18,14 @@ type ExternalAccountTableProps = {
 
 
 const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element => {
 const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element => {
 
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
 
   const { adminExternalAccountsContainer } = props;
   const { adminExternalAccountsContainer } = props;
 
 
   const removeExtenalAccount = useCallback(async(externalAccountId) => {
   const removeExtenalAccount = useCallback(async(externalAccountId) => {
     try {
     try {
       const accountId = await adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
       const accountId = await adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
-      toastSuccess(t('admin:toaster.remove_external_user_success', { accountId }));
+      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
@@ -33,68 +33,88 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
   }, [adminExternalAccountsContainer, t]);
   }, [adminExternalAccountsContainer, t]);
 
 
   return (
   return (
-    <table className={`${styles['ea-table']} table table-bordered table-user-list`}>
-      <thead>
-        <tr>
-          <th style={{ width: '140px' }}>{t('admin:user_management.authentication_provider')}</th>
-          <th style={{ width: '390px' }}><code>accountId</code></th>
-          <th style={{ width: '390px' }}>{t('admin:user_management.related_username')}<code>username</code></th>
-          <th style={{ width: '160px' }}>
-            {t('admin:user_management.password_setting')}
-            {/* TODO: Enable popper */}
-            <span
-              role="button"
-              className="text-muted px-2"
-              data-toggle="popper"
-              data-placement="top"
-              data-trigger="hover"
-              data-html="true"
-              title={t('admin:user_management.password_setting_help')}
-            >
-              <small><i className="icon-question" aria-hidden="true"></i></small>
-            </span>
-          </th>
-          <th style={{ width: '140px' }}>{t('admin:Created')}</th>
-          <th style={{ width: '70px' }}></th>
-        </tr>
-      </thead>
-      <tbody>
-        { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
-          return (
-            <tr key={ea._id}>
-              <td><span>{ea.providerType}</span></td>
-              <td><strong>{ea.accountId}</strong></td>
-              <td><strong>{ea.user.username}</strong></td>
-              <td>
-                {ea.user.password
-                  ? (<span className="badge badge-info">{t('admin:user_management.set')}</span>)
-                  : (<span className="badge badge-warning">{t('admin:user_management.unset')}</span>)
-                }
-              </td>
-              <td><span>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</span></td>
-              <td>
-                <div className="btn-group admin-user-menu">
-                  <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
-                    <i className="icon-settings"></i> <span className="caret"></span>
-                  </button>
-                  <ul className="dropdown-menu" role="menu">
-                    <li className="dropdown-header">{t('admin:user_management.user_table.edit_menu')}</li>
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      role="button"
-                      onClick={() => removeExtenalAccount(ea._id)}
-                    >
-                      <i className="icon-fw icon-fire text-danger"></i> {t('admin:Delete')}
+    <div className="table-responsive text-nowrap h-100">
+      <table className={`${styles['ea-table']} table table-bordered table-user-list`}>
+        <thead>
+          <tr>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.authentication_provider')}
+              </div>
+            </th>
+            <th style={{ width: '200px' }}>
+              <div className="d-flex align-items-center">
+                <code>accountId</code>
+              </div>
+            </th>
+            <th style={{ width: '200px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.related_username')}<code className="ml-2">username</code>
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('user_management.password_setting')}
+                {/* TODO: Enable popper */}
+                <span
+                  role="button"
+                  className="text-muted mx-2"
+                  data-toggle="popper"
+                  data-placement="top"
+                  data-trigger="hover"
+                  data-html="true"
+                  title={t('user_management.password_setting_help')}
+                >
+                  <small><i className="icon-question" aria-hidden="true"></i></small>
+                </span>
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                {t('Created')}
+              </div>
+            </th>
+            <th style={{ width: '70px' }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminExternalAccountsContainer.state.externalAccounts.map((ea: IAdminExternalAccount) => {
+            return (
+              <tr key={ea._id}>
+                <td><span>{ea.providerType}</span></td>
+                <td><strong>{ea.accountId}</strong></td>
+                <td><strong>{ea.user.username}</strong></td>
+                <td>
+                  {ea.user.password
+                    ? (<span className="badge badge-info">{t('user_management.set')}</span>)
+                    : (<span className="badge badge-warning">{t('user_management.unset')}</span>)
+                  }
+                </td>
+                <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
+                <td>
+                  <div className="btn-group admin-user-menu">
+                    <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-toggle="dropdown">
+                      <i className="icon-settings"></i> <span className="caret"></span>
                     </button>
                     </button>
-                  </ul>
-                </div>
-              </td>
-            </tr>
-          );
-        }) }
-      </tbody>
-    </table>
+                    <ul className="dropdown-menu" role="menu">
+                      <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        role="button"
+                        onClick={() => removeExtenalAccount(ea._id)}
+                      >
+                        <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
+                      </button>
+                    </ul>
+                  </div>
+                </td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    </div>
   );
   );
 };
 };
 
 

+ 0 - 231
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -1,231 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { UserPicture } from '@growi/ui';
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import { SortIcons } from './SortIcons';
-import UserMenu from './UserMenu';
-
-
-class UserTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-
-    };
-
-    this.getUserStatusLabel = this.getUserStatusLabel.bind(this);
-  }
-
-  /**
-   * return status label element by `userStatus`
-   * @param {string} userStatus
-   * @return status label element
-   */
-  getUserStatusLabel(userStatus) {
-    let additionalClassName;
-    let text;
-
-    switch (userStatus) {
-      case 1:
-        additionalClassName = 'badge-info';
-        text = 'Approval Pending';
-        break;
-      case 2:
-        additionalClassName = 'badge-success';
-        text = 'Active';
-        break;
-      case 3:
-        additionalClassName = 'badge-warning';
-        text = 'Suspended';
-        break;
-      case 4:
-        additionalClassName = 'badge-danger';
-        text = 'Deleted';
-        break;
-      case 5:
-        additionalClassName = 'badge-pink';
-        text = 'Invited';
-        break;
-    }
-
-    return (
-      <span className={`badge badge-pill ${additionalClassName}`}>
-        {text}
-      </span>
-    );
-  }
-
-  /**
-   * return admin label element by `isAdmin`
-   * @param {string} isAdmin
-   * @return admin label element
-   */
-  getUserAdminLabel(isAdmin) {
-    const { t } = this.props;
-
-    if (isAdmin) {
-      return <span className="badge badge-indigo badge-pill ml-2">{t('admin:user_management.user_table.administrator')}</span>;
-    }
-  }
-
-  sortIconsClickedHandler(sort, sortOrder) {
-    const isAsc = sortOrder === 'asc';
-
-    const { adminUsersContainer } = this.props;
-    adminUsersContainer.sort(sort, isAsc);
-  }
-
-  render() {
-    const { t, adminUsersContainer } = this.props;
-
-    const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
-
-    return (
-      <Fragment>
-        <div className="table-responsive text-nowrap h-100">
-          <table className="table table-default table-bordered table-user-list">
-            <thead>
-              <tr>
-                <th width="100px">#</th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('user_management.status')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'status'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('status', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      <code>username</code>
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'username'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('username', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Name')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'name'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('name', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th>
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Email')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'email'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('email', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="100px">
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('Created')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'createdAt'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('createdAt', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="150px">
-                  <div className="d-flex align-items-center">
-                    <div className="mr-3">
-                      {t('last_login')}
-                    </div>
-                    <SortIcons
-                      isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
-                      isAsc={isCurrentSortOrderAsc}
-                      onClick={(sortOrder) => {
-                        this.sortIconsClickedHandler('lastLoginAt', sortOrder);
-                      }}
-                    />
-                  </div>
-                </th>
-                <th width="70px"></th>
-              </tr>
-            </thead>
-            <tbody>
-              {adminUsersContainer.state.users.map((user) => {
-                return (
-                  <tr data-testid="user-table-tr" key={user._id}>
-                    <td>
-                      <UserPicture user={user} className="picture rounded-circle" />
-                    </td>
-                    <td>{this.getUserStatusLabel(user.status)} {this.getUserAdminLabel(user.admin)}</td>
-                    <td>
-                      <strong>{user.username}</strong>
-                    </td>
-                    <td>{user.name}</td>
-                    <td>{user.email}</td>
-                    <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
-                    <td>
-                      {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
-                    </td>
-                    <td>
-                      <UserMenu user={user} />
-                    </td>
-                  </tr>
-                );
-              })}
-            </tbody>
-          </table>
-        </div>
-      </Fragment>
-    );
-  }
-
-}
-
-
-UserTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-};
-
-const UserTableWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <UserTable t={t} {...props} />;
-};
-
-const UserTableWrapper = withUnstatedContainers(UserTableWrapperFC, [AdminUsersContainer]);
-
-export default UserTableWrapper;

+ 185 - 0
packages/app/src/components/Admin/Users/UserTable.tsx

@@ -0,0 +1,185 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import { SortIcons } from './SortIcons';
+import UserMenu from './UserMenu';
+
+type UserTableProps = {
+  adminUsersContainer: AdminUsersContainer,
+}
+
+const UserTable = (props: UserTableProps) => {
+
+  const { t } = useTranslation('admin');
+  const { adminUsersContainer } = props;
+
+  const getUserStatusLabel = (userStatus: number) => {
+    let additionalClassName = 'badge-info';
+    let text = 'Approval Pending';
+
+    switch (userStatus) {
+      case 1:
+        additionalClassName = 'badge-info';
+        text = 'Approval Pending';
+        break;
+      case 2:
+        additionalClassName = 'badge-success';
+        text = 'Active';
+        break;
+      case 3:
+        additionalClassName = 'badge-warning';
+        text = 'Suspended';
+        break;
+      case 4:
+        additionalClassName = 'badge-danger';
+        text = 'Deleted';
+        break;
+      case 5:
+        additionalClassName = 'badge-pink';
+        text = 'Invited';
+        break;
+    }
+
+    return (
+      <span className={`badge badge-pill ${additionalClassName}`}>
+        {text}
+      </span>
+    );
+  };
+
+  const sortIconsClickedHandler = useCallback(async(sort: string, sortOrder: string) => {
+    const isAsc = sortOrder === 'asc';
+    await adminUsersContainer.sort(sort, isAsc);
+  }, [adminUsersContainer]);
+
+  const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
+
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th style={{ width: '100px' }}>#</th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('user_management.status')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'status'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('status', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  <code>username</code>
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'username'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('username', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Name')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'name'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('name', sortOrder)}
+                />
+              </div>
+            </th>
+            <th>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Email')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'email'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('email', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '100px' }}>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('Created')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'createdAt'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('createdAt', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '150px' }}>
+              <div className="d-flex align-items-center">
+                <div className="mr-3">
+                  {t('last_login')}
+                </div>
+                <SortIcons
+                  isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
+                  isAsc={isCurrentSortOrderAsc}
+                  onClick={sortOrder => sortIconsClickedHandler('lastLoginAt', sortOrder)}
+                />
+              </div>
+            </th>
+            <th style={{ width: '70px' }}></th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminUsersContainer.state.users.map((user: IUserHasId) => {
+            return (
+              <tr data-testid="user-table-tr" key={user._id}>
+                <td>
+                  <UserPicture user={user} className="picture rounded-circle" />
+                </td>
+                <td>
+                  {getUserStatusLabel(user.status)}
+                  {(user.admin) && (
+                    <span className="badge badge-indigo badge-pill ml-2">
+                      {t('admin:user_management.user_table.administrator')}
+                    </span>
+                  )}
+                </td>
+                <td>
+                  <strong>{user.username}</strong>
+                </td>
+                <td>{user.name}</td>
+                <td>{user.email}</td>
+                <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
+                <td>
+                  {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
+                </td>
+                <td>
+                  <UserMenu user={user} />
+                </td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    </div>
+  );
+
+};
+
+const UserTableWrapper = withUnstatedContainers(UserTable, [AdminUsersContainer]);
+
+export default UserTableWrapper;

+ 16 - 18
packages/app/src/components/User/Username.jsx

@@ -1,30 +1,28 @@
 import React from 'react';
 import React from 'react';
+
+import Link from 'next/link';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-export default class Username extends React.Component {
+const Username = (props) => {
+  const { user } = props;
 
 
-  renderForNull() {
+  if (user == null) {
     return <span>anyone</span>;
     return <span>anyone</span>;
   }
   }
 
 
-  render() {
-    const { user } = this.props;
-
-    if (user == null) {
-      return this.renderForNull();
-    }
+  const name = user.name || '(no name)';
+  const username = user.username;
+  const href = `/user/${user.username}`;
 
 
-    const name = user.name || '(no name)';
-    const username = user.username;
-    const href = `/user/${user.username}`;
-
-    return (
-      <a href={href}>{name} (@{username})</a>
-    );
-  }
-
-}
+  return (
+    <Link href={href}>
+      <a>{name} (@{username})</a>
+    </Link>
+  );
+};
 
 
 Username.propTypes = {
 Username.propTypes = {
   user: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), // Possibility of receiving a string of 'null'
   user: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), // Possibility of receiving a string of 'null'
 };
 };
+
+export default Username;