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

Merge pull request #6842 from weseek/feat/admin-typescriptzation-integrate

feat: Admin typescriptzation integrate
Kaori Tokashiki 3 лет назад
Родитель
Сommit
a50a1c7f13
41 измененных файлов с 1051 добавлено и 1068 удалено
  1. 13 0
      packages/app/public/static/locales/en_US/admin.json
  2. 0 4
      packages/app/public/static/locales/en_US/translation.json
  3. 7 0
      packages/app/public/static/locales/ja_JP/admin.json
  4. 0 1
      packages/app/public/static/locales/ja_JP/translation.json
  5. 8 0
      packages/app/public/static/locales/zh_CN/admin.json
  6. 1 3
      packages/app/public/static/locales/zh_CN/translation.json
  7. 0 91
      packages/app/src/components/Admin/ManageExternalAccount.jsx
  8. 79 0
      packages/app/src/components/Admin/ManageExternalAccount.tsx
  9. 4 4
      packages/app/src/components/Admin/Security/DeleteAllShareLinksModal.jsx
  10. 6 3
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  11. 0 208
      packages/app/src/components/Admin/Security/ShareLinkSetting.jsx
  12. 170 0
      packages/app/src/components/Admin/Security/ShareLinkSetting.tsx
  13. 1 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  14. 0 235
      packages/app/src/components/Admin/UserManagement.jsx
  15. 43 3
      packages/app/src/components/Admin/UserManagement.module.scss
  16. 202 0
      packages/app/src/components/Admin/UserManagement.tsx
  17. 0 132
      packages/app/src/components/Admin/Users/ExternalAccountTable.jsx
  18. 8 0
      packages/app/src/components/Admin/Users/ExternalAccountTable.module.scss
  19. 122 0
      packages/app/src/components/Admin/Users/ExternalAccountTable.tsx
  20. 0 60
      packages/app/src/components/Admin/Users/GiveAdminButton.jsx
  21. 44 0
      packages/app/src/components/Admin/Users/GiveAdminButton.tsx
  22. 67 0
      packages/app/src/components/Admin/Users/RemoveAdminButton.tsx
  23. 1 1
      packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx
  24. 9 13
      packages/app/src/components/Admin/Users/SortIcons.tsx
  25. 1 1
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  26. 0 231
      packages/app/src/components/Admin/Users/UserTable.jsx
  27. 185 0
      packages/app/src/components/Admin/Users/UserTable.tsx
  28. 24 27
      packages/app/src/components/Navbar/AuthorInfo.tsx
  29. 4 3
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  30. 1 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  31. 4 4
      packages/app/src/components/PageComment.tsx
  32. 1 1
      packages/app/src/components/PageComment/Comment.tsx
  33. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.tsx
  34. 3 2
      packages/app/src/components/PageContentFooter.tsx
  35. 1 1
      packages/app/src/components/PageHistory/Revision.tsx
  36. 1 1
      packages/app/src/components/PageStatusAlert.jsx
  37. 5 5
      packages/app/src/components/ShareLink/ShareLinkList.tsx
  38. 1 1
      packages/app/src/components/User/UserInfo.tsx
  39. 0 30
      packages/app/src/components/User/Username.jsx
  40. 26 0
      packages/app/src/components/User/Username.tsx
  41. 8 0
      packages/core/src/interfaces/user.ts

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

@@ -3,8 +3,14 @@
     "display_name": "English"
   },
   "wiki_management_home_page": "Wiki Management Home Page",
+  "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",
+    "scope_of_page_disclosure": "Scope of page disclosure",
+    "set_point": "Set point",
     "Guest Users Access": "Guest users access",
     "always_hidden": "Always hidden",
     "always_displayed": "Always displayed",
@@ -73,6 +79,13 @@
       "restricted": "Restricted (Requires approval by administrators)",
       "closed": "Closed (Invitation Only)"
     },
+    "share_link_management": "Share Link Management",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "expire": "Expiration",
+    "description": "Description",
     "share_link_rights": "Share link rights",
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",

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

@@ -78,7 +78,6 @@
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
-  "last_login": "Last login",
   "Share": "Share",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
@@ -129,8 +128,6 @@
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
-  "scope_of_page_disclosure": "Scope of page disclosure",
-  "set_point": "Set point",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -243,7 +240,6 @@
     "No_share_links":"No share links",
     "Share Link": "Share Link",
     "Page Path": "Page Path",
-    "share_link_notice":"remove all share links",
     "delete_all_share_links":"Delete all share links",
     "expire": "Expiration",
     "Days": "Days",

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

@@ -86,6 +86,13 @@
       "restricted": "制限 (登録完了には管理者の承認が必要)",
       "closed": "非公開 (登録には管理者による招待が必要)"
     },
+    "share_link_management": "共有リンク管理",
+    "share_link_notice":"共有リンクを全て削除します",
+    "delete_all_share_links":"全ての共有リンクを削除します",
+    "Share Link": "共有用リンク",
+    "Page Path": "ページパス",
+    "expire": "有効期限",
+    "description": "概要",
     "share_link_rights": "シェアリンクの権限",
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",

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

@@ -236,7 +236,6 @@
     "No_share_links":"共有リンクが存在しません",
     "Share Link": "共有用リンク",
     "Page Path": "ページパス",
-    "share_link_notice":"共有リンクを全て削除します",
     "delete_all_share_links":"全ての共有リンクを削除します",
     "expire": "有効期限",
     "Days": "日間",

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

@@ -9,6 +9,7 @@
   "Created": "创建",
   "Edit": "编辑",
   "Description": "描述",
+  "last_login": "上次登录",
   "wiki_management_home_page": "Wiki管理首页",
   "public": "公共",
   "anyone_with_the_link": "任何人",
@@ -87,6 +88,13 @@
 			"restricted": "受限(需要管理员批准)",
 			"closed": "已关闭(仅限邀请)"
 		},
+    "share_link_management": "Share Link Management",
+    "share_link_notice":"remove all share links",
+    "delete_all_share_links":"Delete all share links",
+    "Share Link": "Share Link",
+    "Page Path": "Page Path",
+    "expire": "Expiration",
+    "description": "Description",
     "share_link_rights": "分享链接权",
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",

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

@@ -73,7 +73,6 @@
   "username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
-  "last_login": "上次登录",
 	"Share": "分享",
   "Share Link": "分享链接",
 	"Markdown Link": "Markdown链接",
@@ -499,7 +498,7 @@
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
-		"give_user_admin": "Succeeded to give {{username}} admin",
+    "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
@@ -590,7 +589,6 @@
     "No_share_links":"No share links",
     "Share Link": "Share Link",
     "Page Path": "Page Path",
-    "share_link_notice":"remove all share links",
     "delete_all_share_links":"Delete all share links",
     "expire": "Expiration",
     "Days": "Days",

+ 0 - 91
packages/app/src/components/Admin/ManageExternalAccount.jsx

@@ -1,91 +0,0 @@
-import React, { Fragment } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { toastError } from '~/client/util/apiNotification';
-
-import PaginationWrapper from '../PaginationWrapper';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import ExternalAccountTable from './Users/ExternalAccountTable';
-
-
-class ManageExternalAccount extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.handleExternalAccountPage = this.handleExternalAccountPage.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.handleExternalAccountPage(1);
-  }
-
-  async handleExternalAccountPage(selectedPage) {
-    try {
-      await this.props.adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminExternalAccountsContainer } = this.props;
-    const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
-
-
-    const pager = (
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={this.handleExternalAccountPage}
-        totalItemsCount={totalAccounts}
-        pagingLimit={pagingLimit}
-        align="center"
-        size="sm"
-      />
-    );
-    return (
-      <Fragment>
-        <p>
-          <a className="btn btn-outline-secondary" href="/admin/users">
-            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
-            {t('admin:user_management.back_to_user_management')}
-          </a>
-        </p>
-
-        <h2>{t('admin:user_management.external_account_list')}</h2>
-        {(totalAccounts !== 0) ? (
-          <>
-            {pager}
-            <ExternalAccountTable />
-            {pager}
-          </>
-        )
-          : (
-            <>
-              {t('admin:user_management.external_account_none')}
-            </>
-          )}
-
-      </Fragment>
-    );
-  }
-
-}
-
-ManageExternalAccount.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
-};
-
-const ManageExternalAccountWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ManageExternalAccount t={t} {...props} />;
-};
-
-const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccountWrapperFC, [AdminExternalAccountsContainer]);
-
-export default ManageExternalAccountWrapper;

+ 79 - 0
packages/app/src/components/Admin/ManageExternalAccount.tsx

@@ -0,0 +1,79 @@
+import React, { useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import { toastError } from '~/client/util/apiNotification';
+
+import PaginationWrapper from '../PaginationWrapper';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import ExternalAccountTable from './Users/ExternalAccountTable';
+
+type ManageExternalAccountProps = {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer,
+}
+
+const ManageExternalAccount = (props: ManageExternalAccountProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { adminExternalAccountsContainer } = props;
+  const { activePage, totalAccounts, pagingLimit } = adminExternalAccountsContainer.state;
+
+  const externalAccountPageHandler = useCallback(async(selectedPage) => {
+    try {
+      await adminExternalAccountsContainer.retrieveExternalAccountsByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminExternalAccountsContainer]);
+
+  // for Next routing
+  useEffect(() => {
+    externalAccountPageHandler(1);
+  }, [externalAccountPageHandler]);
+
+  const pager = (
+    <PaginationWrapper
+      activePage={activePage}
+      changePage={externalAccountPageHandler}
+      totalItemsCount={totalAccounts}
+      pagingLimit={pagingLimit}
+      align="center"
+      size="sm"
+    />
+  );
+
+  return (
+    <>
+      <p>
+        <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>
+      <h2>{t('admin:user_management.external_account_list')}</h2>
+      {(totalAccounts !== 0) ? (
+        <>
+          {pager}
+          <ExternalAccountTable />
+          {pager}
+        </>
+      )
+        : (
+          <>
+            { t('admin:user_management.external_account_none') }
+          </>
+        )
+      }
+    </>
+  );
+};
+
+const ManageExternalAccountWrapper = withUnstatedContainers(ManageExternalAccount, [AdminExternalAccountsContainer]);
+
+export default ManageExternalAccountWrapper;

+ 4 - 4
packages/app/src/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -36,11 +36,11 @@ const DeleteAllShareLinksModal = React.memo((props) => {
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
         <span>
           <i className="icon-fw icon-fire"></i>
-          {t('share_links.delete_all_share_links')}
+          {t('security_settings.delete_all_share_links')}
         </span>
       </ModalHeader>
       <ModalBody>
-        { t('share_links.share_link_notice')}
+        { t('security_settings.share_link_notice')}
       </ModalBody>
       <ModalFooter>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
@@ -66,7 +66,7 @@ DeleteAllShareLinksModal.propTypes = {
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 const DeleteAllShareLinksModalWrapperFC = (props) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   return <DeleteAllShareLinksModal t={t} {...props} />;
 };

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

@@ -1,6 +1,7 @@
 import React, { useMemo, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { TabContent, TabPane } from 'reactstrap';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
@@ -95,9 +96,11 @@ const SecurityManagementContents = () => {
       <div className="mb-5">
         <h2 className="border-bottom">{t('security_settings.xss_prevent_setting')}</h2>
         <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>
 

+ 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;

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

@@ -0,0 +1,170 @@
+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,
+  pagingHandler: (page: number) => Promise<void>,
+  totalLinks: number,
+  limit: number,
+}
+
+type ShareLinkSettingProps = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer,
+}
+
+const Pager = (props: PagerProps) => {
+  const {
+    activePage, pagingHandler, totalLinks, limit,
+  } = props;
+
+  return (
+    <PaginationWrapper
+      activePage={activePage}
+      changePage={pagingHandler}
+      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('security_settings.delete_all_share_links')}
+        </button>
+        <h2 className="alert-anchor border-bottom">{t('security_settings.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}
+        pagingHandler={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;

+ 1 - 1
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -2,11 +2,11 @@ import React, {
   FC, useState, useEffect,
 } from 'react';
 
+import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import { TFunctionResult } from 'i18next';
 import { useTranslation } from 'next-i18next';
 
-import { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '~/interfaces/user';
 
 type Props = {
   headerLabel?: TFunctionResult,

+ 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;

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

@@ -1,5 +1,45 @@
+@use '~/styles/bootstrap/init' as bs;
+
 // styles for admin user search
-.search-clear :global {
-  top: 90px;
-  right: 4px;
+.search-typeahead :global {
+  position: relative;
+  width: 100%;
+  // corner radius
+  border-top-right-radius: bs.$border-radius;
+  border-bottom-right-radius: bs.$border-radius;
+  .rbt-input-main {
+    padding-right: 36px;
+  }
+  .search-clear {
+    position: absolute;
+    top: 12px;
+    right: 1px;
+    z-index: 3;
+    width: 24px;
+    height: 24px;
+    padding: 0;
+    line-height: 0;
+  }
+
+  .rbt-menu {
+    max-height: none !important;
+    margin-top: 3px;
+
+    li a span {
+      .page-path {
+        display: inline;
+        padding: 0 4px;
+        color: inherit;
+      }
+
+      .page-list-meta {
+        font-size: 0.9em;
+        color: bs.$gray-400;
+
+        > span {
+          margin-right: 0.3rem;
+        }
+      }
+    }
+  }
 }

+ 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 ${styles['search-typeahead']}`}>
+              <input
+                className="w-100"
+                type="text"
+                ref={inputRef}
+                onChange={changeSearchTextHandler}
+              />
+              {
+                adminUsersContainer.state.searchText.length > 0
+                  ? (
+                    <i
+                      className="icon-close 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;

+ 0 - 132
packages/app/src/components/Admin/Users/ExternalAccountTable.jsx

@@ -1,132 +0,0 @@
-import React, { Fragment } from 'react';
-
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class ExternalAccountTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-
-    };
-    this.removeExtenalAccount = this.removeExtenalAccount.bind(this);
-  }
-
-  // remove external-account
-  async removeExtenalAccount(externalAccountId) {
-    const { t } = this.props;
-
-    try {
-      const accountId = await this.props.adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
-      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  render() {
-    const { t, adminExternalAccountsContainer } = this.props;
-    return (
-      <Fragment>
-        <table className="table table-bordered table-user-list">
-          <thead>
-            <tr>
-              <th width="120px">{t('admin:user_management.authentication_provider')}</th>
-              <th><code>accountId</code></th>
-              <th>{t('admin:user_management.related_username')}<code>username</code></th>
-              <th>
-                {t('admin:user_management.password_setting')}
-                <div
-                  className="text-muted"
-                  data-toggle="popover"
-                  data-placement="top"
-                  data-trigger="hover focus"
-                  tabIndex="0"
-                  role="button"
-                  data-animation="false"
-                  data-html="true"
-                  data-content={t('admin:user_management.password_setting_help')}
-                >
-                  <small>
-                    <i className="icon-question" aria-hidden="true"></i>
-                  </small>
-                </div>
-              </th>
-              <th width="100px">{t('Created')}</th>
-              <th width="70px"></th>
-            </tr>
-          </thead>
-          <tbody>
-            {adminExternalAccountsContainer.state.externalAccounts.map((ea) => {
-              return (
-                <tr key={ea._id}>
-                  <td>{ea.providerType}</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>{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>
-                      <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={() => { return this.removeExtenalAccount(ea._id) }}>
-                          <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
-                        </button>
-                      </ul>
-                    </div>
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </Fragment>
-    );
-  }
-
-}
-
-ExternalAccountTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminExternalAccountsContainer: PropTypes.instanceOf(AdminExternalAccountsContainer).isRequired,
-};
-
-const ExternalAccountTableWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <ExternalAccountTable t={t} {...props} />;
-};
-
-const ExternalAccountTableWrapper = withUnstatedContainers(ExternalAccountTableWrapperFC, [AdminExternalAccountsContainer]);
-
-
-export default ExternalAccountTableWrapper;

+ 8 - 0
packages/app/src/components/Admin/Users/ExternalAccountTable.module.scss

@@ -0,0 +1,8 @@
+.ea-table :global {
+  thead th {
+    vertical-align: top;
+  }
+  td {
+    vertical-align: middle;
+  }
+}

+ 122 - 0
packages/app/src/components/Admin/Users/ExternalAccountTable.tsx

@@ -0,0 +1,122 @@
+import React, { useCallback } from 'react';
+
+import type { IAdminExternalAccount } from '@growi/core';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import styles from './ExternalAccountTable.module.scss';
+
+type ExternalAccountTableProps = {
+  adminExternalAccountsContainer: AdminExternalAccountsContainer,
+}
+
+const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element => {
+
+  const { t } = useTranslation('admin');
+
+  const { adminExternalAccountsContainer } = props;
+
+  const removeExtenalAccount = useCallback(async(externalAccountId) => {
+    try {
+      const accountId = await adminExternalAccountsContainer.removeExternalAccountById(externalAccountId);
+      toastSuccess(t('toaster.remove_external_user_success', { accountId }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminExternalAccountsContainer, t]);
+
+  return (
+    <div className="table-responsive text-nowrap">
+      <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')}
+                <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>
+                    <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>
+
+  );
+};
+
+const ExternalAccountTableWrapper = withUnstatedContainers(ExternalAccountTable, [AdminExternalAccountsContainer]);
+
+export default ExternalAccountTableWrapper;

+ 0 - 60
packages/app/src/components/Admin/Users/GiveAdminButton.jsx

@@ -1,60 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class GiveAdminButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickGiveAdminBtn = this.onClickGiveAdminBtn.bind(this);
-  }
-
-  async onClickGiveAdminBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.giveUserAdmin(this.props.user._id);
-      toastSuccess(t('toaster.give_user_admin', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickGiveAdminBtn() }}>
-        <i className="icon-fw icon-user-following"></i> {t('admin:user_management.user_table.give_admin_access')}
-      </button>
-    );
-  }
-
-}
-
-const GiveAdminButtonWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <GiveAdminButton t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GiveAdminButtonWrapper = withUnstatedContainers(GiveAdminButtonWrapperFC, [AdminUsersContainer]);
-
-GiveAdminButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default GiveAdminButtonWrapper;

+ 44 - 0
packages/app/src/components/Admin/Users/GiveAdminButton.tsx

@@ -0,0 +1,44 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type GiveAdminButtonProps = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { adminUsersContainer, user } = props;
+
+  const onClickGiveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.giveUserAdmin(user._id);
+      toastSuccess(t('toaster.give_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return (
+    <button className="dropdown-item" type="button" onClick={() => onClickGiveAdminBtnHandler()}>
+      <i className="icon-fw icon-user-following"></i> {t('admin:user_management.user_table.give_admin_access')}
+    </button>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GiveAdminButtonWrapper = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
+
+export default GiveAdminButtonWrapper;

+ 67 - 0
packages/app/src/components/Admin/Users/RemoveAdminButton.tsx

@@ -0,0 +1,67 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useCurrentUser } from '~/stores/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type RemoveAdminButtonProps = {
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}
+
+const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+  const { adminUsersContainer, user } = props;
+
+  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.removeUserAdmin(user._id);
+      toastSuccess(t('toaster.remove_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  const renderRemoveAdminBtn = () => {
+    return (
+      <button className="dropdown-item" type="button" onClick={() => onClickRemoveAdminBtnHandler()}>
+        <i className="icon-fw icon-user-unfollow"></i>{t('admin:user_management.user_table.remove_admin_access')}
+      </button>
+    );
+  };
+
+  const renderRemoveAdminAlert = () => {
+    return (
+      <div className="px-4">
+        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
+        <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+      </div>
+    );
+  };
+
+  if (currentUser == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      {user.username !== currentUser.username ? renderRemoveAdminBtn()
+        : renderRemoveAdminAlert()}
+    </>
+  );
+};
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AdminUsersContainer]);
+
+export default RemoveAdminButtonWrapper;

+ 1 - 1
packages/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx

@@ -1,10 +1,10 @@
 import React, { useCallback } from 'react';
 
+import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { IUserHasId } from '~/interfaces/user';
 import { useCurrentUser } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 9 - 13
packages/app/src/components/Admin/Users/SortIcons.jsx → packages/app/src/components/Admin/Users/SortIcons.tsx

@@ -1,31 +1,27 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
+type SortIconsProps = {
+  onClick: (sortOrder: string) => void,
+  isSelected: boolean,
+  isAsc: boolean,
+}
 
-const SortIcons = (props) => {
+export const SortIcons = (props: SortIconsProps): JSX.Element => {
 
-  const { isSelected, isAsc } = props;
+  const { onClick, isSelected, isAsc } = props;
 
   return (
     <div className="d-flex flex-column text-center">
       <a
         className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
         aria-hidden="true"
-        onClick={() => props.onClick('asc')}
+        onClick={() => onClick('asc')}
       />
       <a
         className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
         aria-hidden="true"
-        onClick={() => props.onClick('desc')}
+        onClick={() => onClick('desc')}
       />
     </div>
   );
 };
-
-SortIcons.propTypes = {
-  onClick: PropTypes.func.isRequired,
-  isSelected: PropTypes.bool.isRequired,
-  isAsc: PropTypes.bool.isRequired,
-};
-
-export default SortIcons;

+ 1 - 1
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -1,11 +1,11 @@
 import React, { useCallback } from 'react';
 
+import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '~/components/UnstatedUtils';
-import { IUserHasId } from '~/interfaces/user';
 import { useCurrentUser } from '~/stores/context';
 
 

+ 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} />
-                    </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} />
+                </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;

+ 24 - 27
packages/app/src/components/Navbar/AuthorInfo.jsx → packages/app/src/components/Navbar/AuthorInfo.tsx

@@ -1,18 +1,26 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { format } from 'date-fns';
-import { UserPicture } from '@growi/ui';
-import { pagePathUtils } from '@growi/core';
 
-const { userPageRoot } = pagePathUtils;
+import { pagePathUtils } from '@growi/core';
+import type { IUser } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import { format } from 'date-fns';
+import Link from 'next/link';
 
+export type AuthorInfoProps = {
+  date: Date,
+  user: IUser,
+  mode: 'create' | 'update',
+  locate: 'subnav' | 'footer',
+}
 
-const formatType = 'yyyy/MM/dd HH:mm';
-const AuthorInfo = (props) => {
+export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const {
-    mode, user, date, locate,
+    date, user, mode = 'create', locate = 'subnav',
   } = props;
 
+  const { userPageRoot } = pagePathUtils;
+  const formatType = 'yyyy/MM/dd HH:mm';
+
   const infoLabelForSubNav = mode === 'create'
     ? 'Created by'
     : 'Updated by';
@@ -23,16 +31,20 @@ const AuthorInfo = (props) => {
     ? 'Created at'
     : 'Last revision posted at';
   const userLabel = user != null
-    ? <a href={userPageRoot(user)}>{user.name}</a>
+    ? (
+      <Link href={userPageRoot(user)} prefetch={false}>
+        <a>{user.name}</a>
+      </Link>
+    )
     : <i>Unknown</i>;
 
   if (locate === 'footer') {
     try {
-      return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
+      return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm"/> {userLabel}</p>;
     }
     catch (err) {
       if (err instanceof RangeError) {
-        return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
+        return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm"/> {userLabel}</p>;
       }
       return <></>;
     }
@@ -50,7 +62,7 @@ const AuthorInfo = (props) => {
   return (
     <div className="d-flex align-items-center">
       <div className="mr-2">
-        <UserPicture user={user} size="sm" />
+        <UserPicture user={user} size="sm"/>
       </div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
@@ -61,18 +73,3 @@ const AuthorInfo = (props) => {
     </div>
   );
 };
-
-AuthorInfo.propTypes = {
-  date: PropTypes.instanceOf(Date),
-  user: PropTypes.object,
-  mode: PropTypes.oneOf(['create', 'update']),
-  locate: PropTypes.oneOf(['subnav', 'footer']),
-};
-
-AuthorInfo.defaultProps = {
-  mode: 'create',
-  locate: 'subnav',
-};
-
-
-export default AuthorInfo;

+ 4 - 3
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -37,8 +37,9 @@ import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { Skelton } from '../Skelton';
 
+import type { AuthorInfoProps } from './AuthorInfo';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
-import { SubNavButtonsProps } from './SubNavButtons';
+import type { SubNavButtonsProps } from './SubNavButtons';
 
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
@@ -57,7 +58,7 @@ const SubNavButtons = dynamic<SubNavButtonsProps>(
   () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
   { ssr: false, loading: () => <></> },
 );
-const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./AuthorInfo').then(mod => mod.AuthorInfo), {
   ssr: false,
   loading: AuthorInfoSkelton,
 });
@@ -377,7 +378,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
                 { currentPage != null
-                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} locate="subnav" />
+                  ? <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
                   : <AuthorInfoSkelton />
                 }
               </li>

+ 1 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -8,7 +8,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import styles from './DeleteAttachmentModal.module.scss';
 

+ 4 - 4
packages/app/src/components/PageComment.tsx

@@ -130,7 +130,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   const revisionId = getIdForRef(revision);
   const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
 
-  const generateCommentElement = (comment: ICommentHasId) => (
+  const commentElement = (comment: ICommentHasId) => (
     <Comment
       rendererOptions={rendererOptions}
       comment={comment}
@@ -143,7 +143,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
     />
   );
 
-  const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
+  const replyCommentsElement = (replyComments: ICommentHasIdList) => (
     <ReplyComments
       rendererOptions={rendererOptions}
       isReadOnly={isReadOnly}
@@ -172,8 +172,8 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
 
               return (
                 <div key={comment._id} className={commentThreadClasses}>
-                  {generateCommentElement(comment)}
-                  {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
+                  {commentElement(comment)}
+                  {hasReply && replyCommentsElement(allReplies[comment._id])}
                   {(!isReadOnly && !showEditorIds.has(comment._id)) && (
                     <div className="text-right">
                       <Button

+ 1 - 1
packages/app/src/components/PageComment/Comment.tsx

@@ -13,7 +13,7 @@ import { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import HistoryIcon from '../Icons/HistoryIcon';
 import RevisionRenderer from '../Page/RevisionRenderer';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import { CommentControl } from './CommentControl';
 import { CommentEditorProps } from './CommentEditor';

+ 1 - 1
packages/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -7,7 +7,7 @@ import {
 } from 'reactstrap';
 
 import { ICommentHasId } from '../../interfaces/comment';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import styles from './DeleteCommentModal.module.scss';
 

+ 3 - 2
packages/app/src/components/PageContentFooter.tsx

@@ -1,15 +1,16 @@
 import React from 'react';
 
-import { IPage, IUser } from '@growi/core';
+import type { IPage, IUser } from '@growi/core';
 import dynamic from 'next/dynamic';
 
 import { useSWRxCurrentPage } from '~/stores/page';
 
+import type { AuthorInfoProps } from './Navbar/AuthorInfo';
 import { Skelton } from './Skelton';
 
 import styles from './PageContentFooter.module.scss';
 
-const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'), {
+const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./Navbar/AuthorInfo').then(mod => mod.AuthorInfo), {
   ssr: false,
   loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} />,
 });

+ 1 - 1
packages/app/src/components/PageHistory/Revision.tsx

@@ -5,7 +5,7 @@ import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 
 import UserDate from '../User/UserDate';
-import Username from '../User/Username';
+import { Username } from '../User/Username';
 
 import styles from './Revision.module.scss';
 

+ 1 - 1
packages/app/src/components/PageStatusAlert.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 
 // import AppContainer from '~/client/services/AppContainer';
 // import PageContainer from '~/client/services/PageContainer';
-import Username from '~/components/User/Username';
+// import Username from '~/components/User/Username';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 

+ 5 - 5
packages/app/src/components/ShareLink/ShareLinkList.tsx

@@ -69,7 +69,7 @@ type Props = {
 
 const ShareLinkList = (props: Props): JSX.Element => {
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('admin');
 
   function renderShareLinks() {
     return (
@@ -96,10 +96,10 @@ const ShareLinkList = (props: Props): JSX.Element => {
       <table className="table table-bordered">
         <thead>
           <tr>
-            <th>{t('share_links.Share Link')}</th>
-            {props.isAdmin && <th>{t('share_links.Page Path')}</th>}
-            <th>{t('share_links.expire')}</th>
-            <th>{t('share_links.description')}</th>
+            <th>{t('security_settings.Share Link')}</th>
+            {props.isAdmin && <th>{t('security_settings.Page Path')}</th>}
+            <th>{t('security_settings.expire')}</th>
+            <th>{t('security_settings.description')}</th>
             <th></th>
           </tr>
         </thead>

+ 1 - 1
packages/app/src/components/User/UserInfo.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-import { IUserHasId } from '@growi/core';
+import type { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 
 import styles from './UserInfo.module.scss';

+ 0 - 30
packages/app/src/components/User/Username.jsx

@@ -1,30 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class Username extends React.Component {
-
-  renderForNull() {
-    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}`;
-
-    return (
-      <a href={href}>{name} (@{username})</a>
-    );
-  }
-
-}
-
-Username.propTypes = {
-  user: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), // Possibility of receiving a string of 'null'
-};

+ 26 - 0
packages/app/src/components/User/Username.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+
+import type { IUser } from '@growi/core';
+import Link from 'next/link';
+
+type UsernameProps = {
+ user?: IUser,
+}
+
+export const Username = (props: UsernameProps): JSX.Element => {
+  const { user } = props;
+
+  if (user == null) {
+    return <span>anyone</span>;
+  }
+
+  const name = user.name || '(no name)';
+  const username = user.username;
+  const href = `/user/${user.username}`;
+
+  return (
+    <Link href={href} prefetch={false}>
+      <a>{name} (@{username})</a>
+    </Link>
+  );
+};

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

@@ -39,3 +39,11 @@ export type IUserGroup = {
 export type IUserHasId = IUser & HasObjectId;
 export type IUserGroupHasId = IUserGroup & HasObjectId;
 export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;
+
+export type IAdminExternalAccount = {
+  _id: string,
+  providerType: string,
+  accountId: string,
+  user: IUser,
+  createdAt: Date,
+}