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

Merge remote-tracking branch 'origin/reactify-admin/external-account' into imprv/export-service

# Conflicts:
#	src/server/service/export.js
Yuki Takei 6 лет назад
Родитель
Сommit
b4c0b6752e
100 измененных файлов с 6477 добавлено и 2172 удалено
  1. 2 0
      README.md
  2. 0 0
      checkout
  3. 0 0
      master
  4. 2 0
      package.json
  5. 24 6
      resource/locales/en-US/translation.json
  6. 23 3
      resource/locales/ja/translation.json
  7. 68 22
      src/client/js/app.jsx
  8. 3 3
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  9. 51 0
      src/client/js/components/Admin/Customize/Customize.jsx
  10. 75 0
      src/client/js/components/Admin/FullTextSearchManagement/FullTextSearchPage.jsx
  11. 344 0
      src/client/js/components/Admin/Importer.jsx
  12. 115 0
      src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx
  13. 123 0
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  14. 96 0
      src/client/js/components/Admin/MarkdownSetting/WhiteListInput.jsx
  15. 117 0
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  16. 120 0
      src/client/js/components/Admin/UserGroup/UserGroupCreateForm.jsx
  17. 206 0
      src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  18. 186 0
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  19. 134 0
      src/client/js/components/Admin/UserGroup/UserGroupTable.jsx
  20. 51 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  21. 106 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  22. 87 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  23. 83 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  24. 43 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  25. 116 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  26. 57 0
      src/client/js/components/Admin/Users/GiveAdminButton.jsx
  27. 37 0
      src/client/js/components/Admin/Users/InviteUserControl.jsx
  28. 78 0
      src/client/js/components/Admin/Users/ManageExternalAccount.jsx
  29. 119 0
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  30. 81 0
      src/client/js/components/Admin/Users/RemoveAdminButton.jsx
  31. 57 0
      src/client/js/components/Admin/Users/StatusActivateButton.jsx
  32. 80 0
      src/client/js/components/Admin/Users/StatusSuspendedButton.jsx
  33. 222 0
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  34. 110 0
      src/client/js/components/Admin/Users/UserMenu.jsx
  35. 57 0
      src/client/js/components/Admin/Users/UserRemoveButton.jsx
  36. 127 0
      src/client/js/components/Admin/Users/UserTable.jsx
  37. 70 0
      src/client/js/components/Admin/Users/Users.jsx
  38. 0 260
      src/client/js/components/GroupDeleteModal/GroupDeleteModal.jsx
  39. 23 121
      src/client/js/components/MyDraftList/MyDraftList.jsx
  40. 174 0
      src/client/js/components/PaginationWrapper.jsx
  41. 19 118
      src/client/js/components/RecentCreated/RecentCreated.jsx
  42. 19 117
      src/client/js/components/TagsList.jsx
  43. 0 8
      src/client/js/legacy/crowi-admin.js
  44. 167 0
      src/client/js/services/AdminUsersContainer.js
  45. 49 0
      src/client/js/services/AppContainer.js
  46. 41 0
      src/client/js/services/MarkDownSettingContainer.js
  47. 135 0
      src/client/js/services/UserGroupDetailContainer.js
  48. 37 0
      src/client/js/util/apiNotification.js
  49. 20 0
      src/client/js/util/apiv3ErrorHandler.js
  50. 1 7
      src/server/crowi/express-init.js
  51. 8 0
      src/server/crowi/index.js
  52. 0 8
      src/server/form/admin/userInvite.js
  53. 0 3
      src/server/form/index.js
  54. 30 0
      src/server/middlewares/ApiV3FormValidator.js
  55. 21 0
      src/server/middlewares/index.js
  56. 13 0
      src/server/models/ErrorV3.js
  57. 3 0
      src/server/models/index.js
  58. 8 6
      src/server/models/page.js
  59. 8 4
      src/server/models/user-group.js
  60. 97 128
      src/server/models/user.js
  61. 58 313
      src/server/routes/admin.js
  62. 1 1
      src/server/routes/apiv3/import.js
  63. 14 0
      src/server/routes/apiv3/index.js
  64. 26 0
      src/server/routes/apiv3/markdown-setting.js
  65. 1 1
      src/server/routes/apiv3/mongo.js
  66. 36 0
      src/server/routes/apiv3/response.js
  67. 107 0
      src/server/routes/apiv3/statistics.js
  68. 127 0
      src/server/routes/apiv3/user-group-relation.js
  69. 567 0
      src/server/routes/apiv3/user-group.js
  70. 330 0
      src/server/routes/apiv3/users.js
  71. 2 1
      src/server/routes/attachment.js
  72. 0 2
      src/server/routes/comment.js
  73. 11 25
      src/server/routes/index.js
  74. 4 0
      src/server/service/app.js
  75. 25 1
      src/server/service/config-loader.js
  76. 14 2
      src/server/service/file-uploader/aws.js
  77. 109 0
      src/server/service/file-uploader/gcs.js
  78. 25 35
      src/server/service/file-uploader/gridfs.js
  79. 2 0
      src/server/service/file-uploader/index.js
  80. 7 2
      src/server/service/file-uploader/local.js
  81. 5 1
      src/server/service/file-uploader/none.js
  82. 34 15
      src/server/service/file-uploader/uploader.js
  83. 3 1
      src/server/service/growi-bridge.js
  84. 7 3
      src/server/service/import.js
  85. 18 0
      src/server/util/express-validator/sanitizer.js
  86. 2 0
      src/server/util/express-validator/validator.js
  87. 5 3
      src/server/util/middlewares.js
  88. 1 1
      src/server/util/swigFunctions.js
  89. 321 0
      src/server/views/admin/Users_reserve.html
  90. 5 2
      src/server/views/admin/customize.html
  91. 1 0
      src/server/views/admin/external-accounts.html
  92. 2 230
      src/server/views/admin/importer.html
  93. 1 1
      src/server/views/admin/markdown.html
  94. 7 114
      src/server/views/admin/search.html
  95. 5 176
      src/server/views/admin/user-group-detail.html
  96. 8 125
      src/server/views/admin/user-groups.html
  97. 9 287
      src/server/views/admin/users.html
  98. 1 1
      src/server/views/me/external-accounts.html
  99. 1 1
      src/server/views/widget/passport/ldap-association-tester.html
  100. 432 14
      yarn.lock

+ 2 - 0
README.md

@@ -169,7 +169,9 @@ Environment Variables
       * `local` : Server's Local file system (Setting-less)
       * `none` : Disable file uploading
     * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
+    * FILE_UPLOAD_TOTAL_LIMIT: Total capacity limit for uploads (bytes). default: `Infinity`
     * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
+      * MONGO_GRIDFS_TOTAL_LIMIT setting  takes precedence over FILE_UPLOAD_TOTAL_LIMIT.
     * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
     * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
     * FORCE_WIKI_MODE: Forces wiki mode. default: undefined



+ 2 - 0
package.json

@@ -68,6 +68,7 @@
       "mongoose: somehow GlobalNotificationSetting CRUD does not work with mongoose v5.6.0",
       "openid-client: Node.js 12 or higher is required for openid-client@3 and above."
     ],
+    "@google-cloud/storage": "^3.3.0",
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "async": "^3.0.1",
@@ -136,6 +137,7 @@
     "uglifycss": "^0.0.29",
     "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
+    "validator": "^11.1.0",
     "xss": "^1.0.6"
   },
   "devDependencies": {

+ 24 - 6
resource/locales/en-US/translation.json

@@ -21,6 +21,7 @@
   "New": "New",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",
+  "add": "Add",
   "Undo": "Undo",
   "Article": "Article",
   "Page": "Page",
@@ -30,7 +31,6 @@
   "status":"Status",
   "account_id": "Account Id",
 
-
   "Update": "Update",
   "Update Page": "Update Page",
   "Warning": "Warning",
@@ -50,6 +50,7 @@
   "History": "History",
   "Presentation Mode": "Presentation",
 
+  "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
   "Last_Login": "Last Login",
@@ -680,6 +681,7 @@
 
   "user_management": {
     "target_user": "Target User",
+    "new_password": "New Password",
     "invite_users": "Invite New Users",
     "emails": "Emails",
     "invite_thru_email": "Send Invitation Email",
@@ -699,6 +701,7 @@
     "unset": "No",
     "temporary_password": "The created user has a temporary password",
     "send_temporary_password": "Be sure to copy the temporary password ON THIS SCREEN and send it to the user.",
+    "password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
     "send_new_password": "Please send the new password to the user.",
     "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
     "reset_password": "Reset Password",
@@ -709,18 +712,26 @@
     "administrator_menu":"Administrator Menu",
     "cannot_remove":"You cannot remove yourself from administrator",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
-    "current_users": "Current users:"
+    "current_users": "Current users:",
+    "valid_email": "Valid email address is required",
+    "existing_email": "The following emails already exist",
+    "give_user_admin": "Give {{username}} admin success",
+    "remove_user_admin": "Remove {{username}} admin success",
+    "activate_user_success": "Activating {{username}} success",
+    "deactivate_user_success": "Deactivating {{username}} success",
+    "remove_user_success": "Removing {{username}} success"
   },
 
   "user_group_management": {
     "group_list": "Group List",
     "back_to_list": "Go Back to Group List",
+    "basic_info": "Basic Info",
+    "user_list": "User List",
     "create_group": "Create New Group",
     "group_example": "e.g. : Group1",
     "created_group": "Group was created",
     "add_user": "Add a User to the Created Group",
-    "deny_create_group": "You can't create a new group.",
-    "is_loading_data": "Loading data...",
+    "deny_create_group": "You can't create a new group with the current settings",
     "choose_action": "Choose an action for private pages",
     "delete_group": "Delete Group",
     "group_name": "Group Name",
@@ -731,12 +742,12 @@
     "select_group": "Select a group",
     "no_groups": "No groups to select",
     "no_pages": "There are no pages the group has view permission",
-    "how_to_add1": "Enter a username to add",
-    "how_to_add2": "Select a user from user list",
     "remove_from_group": "Remove this user"
   },
 
   "importer_management": {
+    "import_form_esa": "Import from esa.io",
+    "import_form_qiita": "import_from Qiita:Team",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from %s",
     "import_form_growi": "Import from GROWI",
@@ -764,6 +775,13 @@
     "Directory_hierarchy_tag": "Directory Hierarchy Tag"
   },
 
+  "full_text_search_management":{
+    "elasticsearch_management":"Elasticsearch Management",
+    "build_button":"Rebuild Index",
+    "rebuild_description_1":"Force rebuild index.",
+    "rebuild_description_2":"Click 'Build Now' to delete and create mapping file and add all pages.",
+    "rebuild_description_3":"This may take a while."
+  },
   "export_management": {
     "beta_warning": "This function is Beta.",
     "exporting_data_list": "Exporting Data List",

+ 23 - 3
resource/locales/ja/translation.json

@@ -21,6 +21,7 @@
   "New": "作成",
   "Shortcuts": "ショートカット",
   "eg": "例:",
+  "add": "追加",
   "Undo": "元に戻す",
   "Article": "記事",
   "Page": "ページ",
@@ -49,6 +50,7 @@
   "History": "更新履歴",
   "Presentation Mode": "プレゼンテーション",
 
+  "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
   "Last_Login": "最終ログイン",
@@ -663,6 +665,7 @@
 
   "user_management": {
     "target_user": "対象ユーザー",
+    "new_password": "新しいパスワード",
     "invite_users": "新規ユーザーの招待",
     "emails": "メールアドレス (複数行入力で複数人招待可能)",
     "invite_thru_email": "招待をメールで送信",
@@ -682,6 +685,7 @@
     "unset": "未設定",
     "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
     "send_temporary_password": "招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。",
+    "password_reset_message": "対象ユーザーに下記のパスワードを伝え、すぐに新しく別のパスワードを設定するよう伝えてください。",
     "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
     "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
     "reset_password": "パスワードの再発行",
@@ -692,12 +696,21 @@
     "administrator_menu": "管理者メニュー",
     "cannot_remove": "自分自身を管理者から外すことはできません",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
-    "current_users": "現在のユーザー数:"
+    "current_users": "現在のユーザー数:",
+    "valid_email": "メールアドレスを入力してください。",
+    "existing_email": "以下のEmailはすでに存在しています。",
+    "give_user_admin": "{{username}}を管理者に設定しました",
+    "remove_user_admin": "{{username}}を管理者から外しました",
+    "activate_user_success": "{{username}}を有効化しました",
+    "deactivate_user_success": "{{username}}を無効化しました",
+    "remove_user_success": "{{username}}を削除しました"
   },
 
   "user_group_management": {
     "group_list": "グループ一覧",
     "back_to_list": "グループ一覧に戻る",
+    "basic_info": "基本情報",
+    "user_list": "ユーザー一覧",
     "create_group": "新規グループの作成",
     "group_example": "例: Group1",
     "created_group": "グループを作成しました",
@@ -714,12 +727,12 @@
     "select_group": "グループを選択してください",
     "no_groups": "グループがありません",
     "no_pages": "グループが閲覧権限を保有するページはありません",
-    "how_to_add1": "ユーザー名を入力して追加",
-    "how_to_add2": "ユーザーを下のリストから選択",
     "remove_from_group": "グループから外す"
   },
 
   "importer_management": {
+    "import_form_esa": "esa.ioからインポート",
+    "import_form_qiita": "Qiita:Teamからインポート",
     "beta_warning": "この機能はベータ版です",
     "import_from": "%s からインポート",
     "import_form_growi": "GROWIからインポート",
@@ -747,6 +760,13 @@
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
 
+  "full_text_search_management":{
+    "elasticsearch_management":"Elasticsearch 管理",
+    "build_button":"インデックスのリビルド",
+    "rebuild_description_1":"Build Now ボタンを押すと全てのページのインデックスを削除し、作り直します。",
+    "rebuild_description_2":"この作業には数秒かかります。",
+    "rebuild_description_3":""
+  },
   "export_management": {
     "beta_warning": "この機能はベータ版です",
     "exporting_data_list": "エクスポート中のデータ",

+ 68 - 22
src/client/js/app.jsx

@@ -34,20 +34,29 @@ import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 
+import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
+import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
+import Users from './components/Admin/Users/Users';
+import ManageExternalAccount from './components/Admin/Users/ManageExternalAccount';
+import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
+import Customize from './components/Admin/Customize/Customize';
+import Importer from './components/Admin/Importer';
+import FullTextSearchManagement from './components/Admin/FullTextSearchManagement/FullTextSearchPage';
 import ExportPage from './components/Admin/Export/ExportPage';
-import GrowiZipImportSection from './components/Admin/Import/GrowiZipImportSection';
-import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 
 import AppContainer from './services/AppContainer';
 import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
+import UserGroupDetailContainer from './services/UserGroupDetailContainer';
+import AdminUsersContainer from './services/AdminUsersContainer';
 import WebsocketContainer from './services/WebsocketContainer';
+import MarkDownSettingContainer from './services/MarkDownSettingContainer';
 
 const logger = loggerFactory('growi:app');
 
@@ -100,7 +109,12 @@ let componentMappings = {
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
 
+  'admin-full-text-search-management': <FullTextSearchManagement />,
+  'admin-customize': <Customize />,
+  'admin-external-account-setting': <ManageExternalAccount />,
+
   'staff-credit': <StaffCredit />,
+  'admin-importer': <Importer />,
 };
 
 // additional definitions if data exists
@@ -147,6 +161,45 @@ Object.keys(componentMappings).forEach((key) => {
 });
 
 // render for admin
+const adminUsersElem = document.getElementById('admin-user-page');
+if (adminUsersElem != null) {
+  const adminUsersContainer = new AdminUsersContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[injectableContainers, adminUsersContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <Users />
+      </I18nextProvider>
+    </Provider>,
+    adminUsersElem,
+  );
+}
+
+const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
+if (adminUserGroupDetailElem != null) {
+  const userGroupDetailContainer = new UserGroupDetailContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[userGroupDetailContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <UserGroupDetailPage />
+      </I18nextProvider>
+    </Provider>,
+    adminUserGroupDetailElem,
+  );
+}
+
+const adminMarkDownSettingElem = document.getElementById('admin-markdown-setting');
+if (adminMarkDownSettingElem != null) {
+  const markDownSettingContainer = new MarkDownSettingContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[injectableContainers, markDownSettingContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <MarkdownSetting />
+      </I18nextProvider>
+    </Provider>,
+    adminMarkDownSettingElem,
+  );
+}
+
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {
   // get input[type=hidden] element
@@ -177,42 +230,35 @@ if (customHeaderEditorElem != null) {
     customHeaderEditorElem,
   );
 }
-const adminGrantSelectorElem = document.getElementById('admin-delete-user-group-modal');
-if (adminGrantSelectorElem != null) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <GroupDeleteModal
-        crowi={appContainer}
-      />
-    </I18nextProvider>,
-    adminGrantSelectorElem,
-  );
-}
 
-const adminExportPageElem = document.getElementById('admin-export-page');
-if (adminExportPageElem != null) {
+const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
+if (adminUserGroupPageElem != null) {
+  const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
+
   ReactDOM.render(
     <Provider inject={[websocketContainer]}>
       <I18nextProvider i18n={i18n}>
-        <ExportPage
+        <UserGroupPage
           crowi={appContainer}
+          isAclEnabled={isAclEnabled}
         />
       </I18nextProvider>
     </Provider>,
-    adminExportPageElem,
+    adminUserGroupPageElem,
   );
 }
 
-// TODO: move to /imponents/Admin/Importer.jsx
-const growiImportElem = document.getElementById('growi-import');
-if (growiImportElem != null) {
+const adminExportPageElem = document.getElementById('admin-export-page');
+if (adminExportPageElem != null) {
   ReactDOM.render(
     <Provider inject={[]}>
       <I18nextProvider i18n={i18n}>
-        <GrowiZipImportSection />
+        <ExportPage
+          crowi={appContainer}
+        />
       </I18nextProvider>
     </Provider>,
-    growiImportElem,
+    adminExportPageElem,
   );
 }
 

+ 3 - 3
src/client/js/components/Admin/AdminRebuildSearch.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { createSubscribedElement } from '../UnstatedUtils';
-import WebsocketContainer from '../../services/AppContainer';
+import WebsocketContainer from '../../services/WebsocketContainer';
 
 class AdminRebuildSearch extends React.Component {
 
@@ -18,7 +18,7 @@ class AdminRebuildSearch extends React.Component {
   }
 
   componentDidMount() {
-    const socket = this.props.webspcketContainer.getWebSocket();
+    const socket = this.props.websocketContainer.getWebSocket();
 
     socket.on('admin:addPageProgress', (data) => {
       const newStates = Object.assign(data, { isCompleted: false });
@@ -76,7 +76,7 @@ const AdminRebuildSearchWrapper = (props) => {
 };
 
 AdminRebuildSearch.propTypes = {
-  webspcketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
 };
 
 export default AdminRebuildSearchWrapper;

+ 51 - 0
src/client/js/components/Admin/Customize/Customize.jsx

@@ -0,0 +1,51 @@
+
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import AppContainer from '../../../services/AppContainer';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+class Customize extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        {/* fieldset + legend ではなく、row + header + フォームコンポーネントに書き換える(GC244着手時に対応) */}
+        <fieldset>
+          <legend>{t('customize_page.Layout')}</legend>
+          {/* レイアウトフォームの react componentをここで呼ぶ(GW-244) */}
+          <legend>{t('customize_page.Theme')}</legend>
+          {/* テーマフォームの react componentをここで呼ぶ(GW-245) */}
+          <legend>{t('customize_page.Behavior')}</legend>
+          {/* 挙動フォームの react componentをここで呼ぶ(GW-246) */}
+          <legend>{t('customize_page.Function')}</legend>
+          {/* 機能フォームの react componentをここで呼ぶ(GW-276) */}
+          <legend>{t('customize_page.Code Highlight')}</legend>
+          {/* コードハイライトフォームの react componentをここで呼ぶ(GW-277) */}
+          <legend>{t('customize_page.custom_title')}</legend>
+          {/* カスタムタイトルフォームの react componentをここで呼ぶ(GW-278) */}
+          <legend>{t('customize_page.Custom CSS')}</legend>
+          {/* カスタムCSSフォームの react componentをここで呼ぶ(GW-279) */}
+          <legend>{t('customize_page.Custom script')}</legend>
+          {/* カスタムスクリプトフォームの react componentをここで呼ぶ(GW-280) */}
+        </fieldset>
+      </Fragment>
+    );
+  }
+
+}
+
+const CustomizeWrapper = (props) => {
+  return createSubscribedElement(Customize, props, [AppContainer]);
+};
+
+Customize.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeWrapper);

+ 75 - 0
src/client/js/components/Admin/FullTextSearchManagement/FullTextSearchPage.jsx

@@ -0,0 +1,75 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import AdminRebuildSearch from '../AdminRebuildSearch';
+import AppContainer from '../../../services/AppContainer';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+
+class FullTextSearchManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.buildIndex = this.buildIndex.bind(this);
+  }
+
+  async buildIndex() {
+
+    const { appContainer } = this.props;
+    const pageId = this.pageId;
+
+    try {
+      const res = await appContainer.apiPost('/admin/search/build', { page_id: pageId });
+      if (!res.ok) {
+        throw new Error(res.message);
+      }
+      else {
+        toastSuccess('Building request is successfully posted.');
+      }
+    }
+    catch (e) {
+      toastError(e, (new Error('エラーが発生しました')));
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <fieldset>
+          <legend> { t('full_text_search_management.elasticsearch_management') } </legend>
+          <div className="form-group form-horizontal">
+            <div className="col-xs-3 control-label"></div>
+            <div className="col-xs-7">
+              <button type="submit" className="btn btn-inverse" onClick={this.buildIndex}>{ t('full_text_search_management.build_button') }</button>
+              <p className="help-block">
+                { t('full_text_search_management.rebuild_description_1') }<br />
+                { t('full_text_search_management.rebuild_description_2') }<br />
+                { t('full_text_search_management.rebuild_description_3') }<br />
+              </p>
+            </div>
+          </div>
+        </fieldset>
+
+        <AdminRebuildSearch />
+      </Fragment>
+    );
+  }
+
+}
+
+const FullTextSearchManagementWrapper = (props) => {
+  return createSubscribedElement(FullTextSearchManagement, props, [AppContainer]);
+};
+
+FullTextSearchManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(FullTextSearchManagementWrapper);

+ 344 - 0
src/client/js/components/Admin/Importer.jsx

@@ -0,0 +1,344 @@
+import React, { Fragment } from 'react';
+import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import { toastSuccess, toastError } from '../../util/apiNotification';
+
+import AppContainer from '../../services/AppContainer';
+
+import GrowiZipImportSection from './Import/GrowiZipImportSection';
+
+const logger = loggerFactory('growi:importer');
+
+class Importer extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      esaTeamName: '',
+      esaAccessToken: '',
+      qiitaTeamName: '',
+      qiitaAccessToken: '',
+    };
+    this.esaHandleSubmit = this.esaHandleSubmit.bind(this);
+    this.esaHandleSubmitTest = this.esaHandleSubmitTest.bind(this);
+    this.esaHandleSubmitUpdate = this.esaHandleSubmitUpdate.bind(this);
+    this.qiitaHandleSubmit = this.qiitaHandleSubmit.bind(this);
+    this.qiitaHandleSubmitTest = this.qiitaHandleSubmitTest.bind(this);
+    this.qiitaHandleSubmitUpdate = this.qiitaHandleSubmitUpdate.bind(this);
+    this.handleInputValue = this.handleInputValue.bind(this);
+  }
+
+  handleInputValue(event) {
+    this.setState({
+      [event.target.name]: event.target.value,
+    });
+  }
+
+  async esaHandleSubmit() {
+    try {
+      const params = {
+        'importer:esa:team_name': this.state.esaTeamName,
+        'importer:esa:access_token': this.state.esaAccessToken,
+      };
+      await this.props.appContainer.apiPost('/admin/import/esa', params);
+      toastSuccess('Import posts from esa success.');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Error occurred in importing pages from esa.io');
+    }
+  }
+
+  async esaHandleSubmitTest() {
+    try {
+      const params = {
+        'importer:esa:team_name': this.state.esaTeamName,
+        'importer:esa:access_token': this.state.esaAccessToken,
+      };
+      await this.props.appContainer.apiPost('/admin/import/testEsaAPI', params);
+      toastSuccess('Test connection to esa success.');
+    }
+    catch (error) {
+      toastError(error, 'Test connection to esa failed.');
+    }
+  }
+
+  async esaHandleSubmitUpdate() {
+    const params = {
+      'importer:esa:team_name': this.state.esaTeamName,
+      'importer:esa:access_token': this.state.esaAccessToken,
+    };
+    try {
+      await this.props.appContainer.apiPost('/admin/settings/importerEsa', params);
+      toastSuccess('Updated');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Errors');
+    }
+  }
+
+  async qiitaHandleSubmit() {
+    try {
+      const params = {
+        'importer:qiita:team_name': this.state.qiitaTeamName,
+        'importer:qiita:access_token': this.state.qiitaAccessToken,
+      };
+      await this.props.appContainer.apiPost('/admin/import/qiita', params);
+      toastSuccess('Import posts from qiita:team success.');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Error occurred in importing pages from qiita:team');
+    }
+  }
+
+
+  async qiitaHandleSubmitTest() {
+    try {
+      const params = {
+        'importer:qiita:team_name': this.state.qiitaTeamName,
+        'importer:qiita:access_token': this.state.qiitaAccessToken,
+      };
+      await this.props.appContainer.apiPost('/admin/import/testQiitaAPI', params);
+      toastSuccess('Test connection to qiita:team success.');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Test connection to qiita:team failed.');
+    }
+  }
+
+  async qiitaHandleSubmitUpdate() {
+    const params = {
+      'importer:qiita:team_name': this.state.qiitaTeamName,
+      'importer:qiita:access_token': this.state.qiitaAccessToken,
+    };
+    try {
+      await this.props.appContainer.apiPost('/admin/settings/importerQiita', params);
+      toastSuccess('Updated');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Errors');
+    }
+  }
+
+  render() {
+    const {
+      esaTeamName, esaAccessToken, qiitaTeamName, qiitaAccessToken,
+    } = this.state;
+    const { t } = this.props;
+    return (
+      <Fragment>
+        <GrowiZipImportSection />
+
+        <form
+          className="form-horizontal"
+          id="importerSettingFormEsa"
+          role="form"
+        >
+          <fieldset>
+            <legend>{ t('importer_management.import_form_esa') }</legend>
+            <table className="table table-bordered table-mapping">
+              <thead>
+                <tr>
+                  <th width="45%">esa.io</th>
+                  <th width="10%"></th>
+                  <th>GROWI</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <th>{ t('Article') }</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{ t('Page') }</th>
+                </tr>
+                <tr>
+                  <th>{ t('Category') }</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{ t('Page Path') }</th>
+                </tr>
+                <tr>
+                  <th>{ t('User') }</th>
+                  <th></th>
+                  <th>(TBD)</th>
+                </tr>
+              </tbody>
+            </table>
+
+            <div className="well well-sm mb-0 small">
+              <ul>
+                <li>{ t('importer_management.page_skip') }</li>
+              </ul>
+            </div>
+
+            <div className="form-group">
+              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+            </div>
+
+            <div className="form-group">
+              <label htmlFor="settingForm[importer:esa:team_name]" className="col-xs-3 control-label">
+                { t('importer_management.esa_settings.team_name') }
+              </label>
+              <div className="col-xs-6">
+                <input className="form-control" type="text" name="esaTeamName" value={esaTeamName} onChange={this.handleInputValue} />
+              </div>
+
+            </div>
+
+            <div className="form-group">
+              <label htmlFor="settingForm[importer:esa:access_token]" className="col-xs-3 control-label">
+                { t('importer_management.esa_settings.access_token') }
+              </label>
+              <div className="col-xs-6">
+                <input className="form-control" type="password" name="esaAccessToken" value={esaAccessToken} onChange={this.handleInputValue} />
+              </div>
+            </div>
+
+            <div className="form-group">
+              <div className="col-xs-offset-3 col-xs-6">
+                <input
+                  id="testConnectionToEsa"
+                  type="button"
+                  className="btn btn-primary btn-esa"
+                  name="Esa"
+                  onClick={this.esaHandleSubmit}
+                  value={t('importer_management.import')}
+                />
+                <input type="button" className="btn btn-secondary" onClick={this.esaHandleSubmitUpdate} value={t('Update')} />
+                <span className="col-xs-offset-1">
+                  <input
+                    name="Esa"
+                    type="button"
+                    id="importFromEsa"
+                    className="btn btn-default btn-esa"
+                    onClick={this.esaHandleSubmitTest}
+                    value={t('importer_management.esa_settings.test_connection')}
+                  />
+                </span>
+
+              </div>
+            </div>
+          </fieldset>
+        </form>
+
+        <form
+          className="form-horizontal mt-5"
+          id="importerSettingFormQiita"
+          role="form"
+        >
+          <fieldset>
+            <legend>{ t('importer_management.import_form_qiita', 'Qiita:Team') }</legend>
+            <table className="table table-bordered table-mapping">
+              <thead>
+                <tr>
+                  <th width="45%">Qiita:Team</th>
+                  <th width="10%"></th>
+                  <th>GROWI</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <th>{ t('Article') }</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{ t('Page') }</th>
+                </tr>
+                <tr>
+                  <th>{ t('Tag')}</th>
+                  <th></th>
+                  <th>-</th>
+                </tr>
+                <tr>
+                  <th>{ t('importer_management.Directory_hierarchy_tag') }</th>
+                  <th></th>
+                  <th>(TBD)</th>
+                </tr>
+                <tr>
+                  <th>{ t('User') }</th>
+                  <th></th>
+                  <th>(TBD)</th>
+                </tr>
+              </tbody>
+            </table>
+            <div className="well well-sm mb-0 small">
+              <ul>
+                <li>{ t('importer_management.page_skip') }</li>
+              </ul>
+            </div>
+
+            <div className="form-group">
+              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+            </div>
+            <div className="form-group">
+              <label htmlFor="settingForm[importer:qiita:team_name]" className="col-xs-3 control-label">
+                { t('importer_management.qiita_settings.team_name') }
+              </label>
+              <div className="col-xs-6">
+                <input className="form-control" type="text" name="qiitaTeamName" value={qiitaTeamName} onChange={this.handleInputValue} />
+              </div>
+            </div>
+
+            <div className="form-group">
+              <label htmlFor="settingForm[importer:qiita:access_token]" className="col-xs-3 control-label">
+                { t('importer_management.qiita_settings.access_token') }
+              </label>
+              <div className="col-xs-6">
+                <input className="form-control" type="password" name="qiitaAccessToken" value={qiitaAccessToken} onChange={this.handleInputValue} />
+              </div>
+            </div>
+
+
+            <div className="form-group">
+              <div className="col-xs-offset-3 col-xs-6">
+                <input
+                  id="testConnectionToQiita"
+                  type="button"
+                  className="btn btn-primary btn-qiita"
+                  name="Qiita"
+                  onClick={this.qiitaHandleSubmit}
+                  value={t('importer_management.import')}
+                />
+                <input type="button" className="btn btn-secondary" onClick={this.qiitaHandleSubmitUpdate} value={t('Update')} />
+                <span className="col-xs-offset-1">
+                  <input
+                    name="Qiita"
+                    type="button"
+                    id="importFromQiita"
+                    className="btn btn-default btn-qiita"
+                    onClick={this.qiitaHandleSubmitTest}
+                    value={t('importer_management.qiita_settings.test_connection')}
+                  />
+                </span>
+
+              </div>
+            </div>
+
+
+          </fieldset>
+
+
+        </form>
+      </Fragment>
+
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ImporterWrapper = (props) => {
+  return createSubscribedElement(Importer, props, [AppContainer]);
+};
+
+Importer.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(ImporterWrapper);

+ 115 - 0
src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx

@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+const logger = loggerFactory('growi:importer');
+
+class LineBreakSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { appContainer } = this.props;
+
+    this.state = {
+      isEnabledLinebreaks: appContainer.config.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: appContainer.config.isEnabledLinebreaksInComments,
+    };
+    this.onChangeEnableLineBreaks = this.onChangeEnableLineBreaks.bind(this);
+    this.onChangeEnableLineBreaksInComments = this.onChangeEnableLineBreaksInComments.bind(this);
+    this.changeLineBreakSettings = this.changeLineBreakSettings.bind(this);
+  }
+
+
+  onChangeEnableLineBreaks() {
+    this.setState({ isEnabledLinebreaks: !this.state.isEnabledLinebreaks });
+  }
+
+  onChangeEnableLineBreaksInComments() {
+    this.setState({ isEnabledLinebreaksInComments: !this.state.isEnabledLinebreaksInComments });
+  }
+
+  async changeLineBreakSettings() {
+    const { appContainer } = this.props;
+    const params = {
+      isEnabledLinebreaks: this.state.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
+    };
+    try {
+      await appContainer.apiPost('/admin/markdown/lineBreaksSetting', { params });
+      toastSuccess('Success change line braek setting');
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <React.Fragment>
+        <div className="row my-3">
+          <div className="form-group">
+            <legend>{ t('markdown_setting.line_break_setting') }</legend>
+            <p className="well">{ t('markdown_setting.line_break_setting_desc') }</p>
+            <fieldset className="row">
+              <div className="form-group">
+                <div className="col-xs-4 text-right">
+                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaks}>
+                    <input type="checkbox" name="isEnabledLinebreaks" checked={this.state.isEnabledLinebreaks} />
+                    <label>
+                      { t('markdown_setting.Enable Line Break') }
+                    </label>
+                    <p className="help-block">{ t('markdown_setting.Enable Line Break desc') }</p>
+                  </div>
+                </div>
+              </div>
+            </fieldset>
+            <fieldset className="row">
+              <div className="form-group my-3">
+                <div className="col-xs-4 text-right">
+                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaksInComments}>
+                    <input type="checkbox" name="isEnabledLinebreaksInComments" checked={this.state.isEnabledLinebreaksInComments} />
+                    <label>
+                      { t('markdown_setting.Enable Line Break for comment') }
+                    </label>
+                    <p className="help-block">{ t('markdown_setting.Enable Line Break for comment desc') }</p>
+                  </div>
+                </div>
+              </div>
+            </fieldset>
+          </div>
+          <div className="form-group my-3">
+            <div className="col-xs-offset-4 col-xs-5">
+              <button type="submit" className="btn btn-primary" onClick={this.changeLineBreakSettings}>{ t('Update') }</button>
+            </div>
+          </div>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const LineBreakSettingWrapper = (props) => {
+  return createSubscribedElement(LineBreakSetting, props, [AppContainer]);
+};
+
+LineBreakSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(LineBreakSettingWrapper);

+ 123 - 0
src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx

@@ -0,0 +1,123 @@
+/* eslint-disable max-len */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import LineBreakSetting from './LineBreakSetting';
+import XssForm from './XssForm';
+
+class MarkdownSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      // TODO GW-220 get correct BreakOption value
+      pageBreakOption: 1,
+      // TODO GW-258 get correct custom regular expression
+      customRegularExpression: '',
+    };
+
+    this.handleInputChange = this.handleInputChange.bind(this);
+  }
+
+  // TODO Delete after component split
+  handleInputChange(e) {
+    const target = e.target;
+    const value = target.type === 'checkbox' ? target.checked : target.value;
+    const name = target.name;
+
+    this.setState({ [name]: value });
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      // TODO GW-322 adjust layout
+      <React.Fragment>
+        <div>
+          {/* Line Break Setting */}
+          <LineBreakSetting />
+        </div>
+
+        <div className="row my-3">
+          <div className="form-group">
+            <legend>{ t('markdown_setting.presentation_setting') }</legend>
+            <p className="well">{ t('markdown_setting.presentation_setting_desc') }</p>
+          </div>
+          <fieldset className="form-group row my-2">
+
+            <label className="col-xs-3 control-label text-right">
+              { t('markdown_setting.Page break setting') }
+            </label>
+
+            <div className="col-xs-3 radio radio-primary">
+              <input type="radio" id="pageBreakOption1" name="pageBreakOption" value="1" checked={this.state.pageBreakOption === 1} onChange={this.handleInputChange} />
+              <label htmlFor="pageBreakOption1">
+                <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
+                <p className="mt-3">
+                  { t('markdown_setting.Preset one separator desc') }
+                  <pre><code>{ t('markdown_setting.Preset one separator value') }</code></pre>
+                </p>
+              </label>
+            </div>
+
+            <div className="col-xs-3 radio radio-primary mt-3">
+              <input type="radio" id="pageBreakOption2" name="pageBreakOption" value="2" checked={this.state.pageBreakOption === 2} onChange={this.handleInputChange} />
+              <label htmlFor="pageBreakOption2">
+                <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
+                <p className="mt-3">
+                  { t('markdown_setting.Preset two separator desc') }
+                  <pre><code>{ t('markdown_setting.Preset two separator value') }</code></pre>
+                </p>
+              </label>
+            </div>
+
+            <div className="col-xs-3 radio radio-primary mt-3">
+              <input type="radio" id="pageBreakOption3" name="pageBreakOption" value="3" checked={this.state.pageBreakOption === 3} onChange={this.handleInputChange} />
+              <label htmlFor="pageBreakOption3">
+                <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
+                <p className="mt-3">
+                  { t('markdown_setting.Custom separator desc') }
+                  <div>
+                    <input className="form-control" name="customRegularExpression" value={this.state.customRegularExpression} onChange={this.handleInputChange} />
+                  </div>
+                </p>
+              </label>
+            </div>
+
+            <div className="form-group my-3">
+              <div className="col-xs-offset-4 col-xs-5">
+                <button type="submit" className="btn btn-primary">{ t('Update') }</button>
+              </div>
+            </div>
+
+          </fieldset>
+        </div>
+        {/* XSS Setting */}
+        <div className="row my-3">
+          <h2>{ t('markdown_setting.XSS_setting') }</h2>
+          <p className="well">{ t('markdown_setting.XSS_setting_desc') }</p>
+          <XssForm />
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+const MarkdownSettingWrapper = (props) => {
+  return createSubscribedElement(MarkdownSetting, props, [AppContainer]);
+};
+
+MarkdownSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+};
+
+export default withTranslation()(MarkdownSettingWrapper);

+ 96 - 0
src/client/js/components/Admin/MarkdownSetting/WhiteListInput.jsx

@@ -0,0 +1,96 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { tags, attrs } from '../../../../../lib/service/xss/recommended-whitelist';
+
+import AppContainer from '../../../services/AppContainer';
+import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+
+class WhiteListInput extends React.Component {
+
+  renderRecommendBtn() {
+    const { t } = this.props;
+
+    return (
+      <p id="btn-import-tags" className="btn btn-xs btn-primary">
+        { t('markdown_setting.import_recommended', 'tags') }
+      </p>
+    );
+  }
+
+  renderTagValue() {
+    const { customizable, markDownSettingContainer } = this.props;
+
+    if (customizable) {
+      return markDownSettingContainer.state.tagWhiteList;
+    }
+
+    return tags;
+  }
+
+  renderAttrValue() {
+    const { customizable, markDownSettingContainer } = this.props;
+
+    if (customizable) {
+      return markDownSettingContainer.state.attrWhiteList;
+    }
+
+    return attrs;
+  }
+
+  render() {
+    const { t, customizable, markDownSettingContainer } = this.props;
+
+    return (
+      <>
+        <div className="m-t-15">
+          <div className="d-flex justify-content-between">
+            { t('markdown_setting.Tag names') }
+            {customizable && this.renderRecommendBtn()}
+          </div>
+          <textarea
+            className="form-control xss-list"
+            name="recommendedTags"
+            rows="6"
+            cols="40"
+            readOnly={!customizable}
+            value={this.renderTagValue()}
+            onChange={(e) => { markDownSettingContainer.setState({ tagWhiteList: e.target.value }) }}
+          />
+        </div>
+        <div className="m-t-15">
+          <div className="d-flex justify-content-between">
+            { t('markdown_setting.Tag attributes') }
+            {customizable && this.renderRecommendBtn()}
+          </div>
+          <textarea
+            className="form-control xss-list"
+            name="recommendedAttrs"
+            rows="6"
+            cols="40"
+            readOnly={!customizable}
+            value={this.renderAttrValue()}
+            onChange={(e) => { markDownSettingContainer.setState({ attrWhiteList: e.target.value }) }}
+          />
+        </div>
+      </>
+    );
+  }
+
+}
+
+const WhiteListWrapper = (props) => {
+  return createSubscribedElement(WhiteListInput, props, [AppContainer, MarkDownSettingContainer]);
+};
+
+WhiteListInput.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+
+  customizable: PropTypes.bool.isRequired,
+};
+
+export default withTranslation()(WhiteListWrapper);

+ 117 - 0
src/client/js/components/Admin/MarkdownSetting/XssForm.jsx

@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+
+import WhiteListInput from './WhiteListInput';
+
+class XssForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    // TODO GW-303 create apiV3 of update setting
+  }
+
+  xssOptions() {
+    const { t, markDownSettingContainer } = this.props;
+    const { xssOption } = markDownSettingContainer.state;
+
+    return (
+      <fieldset className="form-group col-xs-12 my-3">
+        <div className="col-xs-4 radio radio-primary">
+          <input
+            type="radio"
+            id="xssOption1"
+            name="XssOption"
+            checked={xssOption === 1}
+            onChange={() => { markDownSettingContainer.setState({ xssOption: 1 }) }}
+          />
+          <label htmlFor="xssOption1">
+            <p className="font-weight-bold">{ t('markdown_setting.Ignore all tags') }</p>
+            <div className="m-t-15">
+              { t('markdown_setting.Ignore all tags desc') }
+            </div>
+          </label>
+        </div>
+
+        <div className="col-xs-4 radio radio-primary">
+          <input
+            type="radio"
+            id="xssOption2"
+            name="XssOption"
+            checked={xssOption === 2}
+            onChange={() => { markDownSettingContainer.setState({ xssOption: 2 }) }}
+          />
+          <label htmlFor="xssOption2">
+            <p className="font-weight-bold">{ t('markdown_setting.Recommended setting') }</p>
+            <WhiteListInput customizable={false} />
+          </label>
+        </div>
+
+        <div className="col-xs-4 radio radio-primary">
+          <input
+            type="radio"
+            id="xssOption3"
+            name="XssOption"
+            checked={xssOption === 3}
+            onChange={() => { markDownSettingContainer.setState({ xssOption: 3 }) }}
+          />
+          <label htmlFor="xssOption3">
+            <p className="font-weight-bold">{ t('markdown_setting.Custom Whitelist') }</p>
+            <WhiteListInput customizable />
+          </label>
+        </div>
+      </fieldset>
+    );
+  }
+
+  render() {
+    const { t, markDownSettingContainer } = this.props;
+    const { isEnabledXss } = markDownSettingContainer.state;
+
+    return (
+      <React.Fragment>
+        <form className="row">
+          <div className="form-group">
+            <div className="col-xs-4 text-right">
+              <div className="checkbox checkbox-success" onChange={markDownSettingContainer.switchEnableXss}>
+                <input type="checkbox" id="XssEnable" className="form-check-input" name="isEnabledXss" checked={isEnabledXss} />
+                <label htmlFor="XssEnable">
+                  { t('markdown_setting.Enable XSS prevention') }
+                </label>
+              </div>
+            </div>
+            {isEnabledXss && this.xssOptions()}
+          </div>
+          <div className="form-group my-3">
+            <div className="col-xs-offset-4 col-xs-5">
+              <div className="btn btn-primary" onClick={this.onClickSubmit}>{ t('Update') }</div>
+            </div>
+          </div>
+        </form>
+      </React.Fragment>
+    );
+  }
+
+}
+
+const XssFormWrapper = (props) => {
+  return createSubscribedElement(XssForm, props, [AppContainer, MarkDownSettingContainer]);
+};
+
+XssForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+};
+
+export default withTranslation()(XssFormWrapper);

+ 120 - 0
src/client/js/components/Admin/UserGroup/UserGroupCreateForm.jsx

@@ -0,0 +1,120 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupCreateForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: '',
+    };
+
+    this.xss = window.xss;
+
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  handleChange(event) {
+    const target = event.target;
+    const value = target.type === 'checkbox' ? target.checked : target.value;
+    const name = target.name;
+
+    this.setState({
+      [name]: value,
+    });
+  }
+
+  async handleSubmit(e) {
+    e.preventDefault();
+
+    try {
+      const res = await this.props.appContainer.apiv3.post('/user-groups', {
+        name: this.state.name,
+      });
+
+      const userGroup = res.data.userGroup;
+      const userGroupId = userGroup._id;
+
+      const res2 = await this.props.appContainer.apiv3.get(`/user-groups/${userGroupId}/users`);
+
+      const { users } = res2.data;
+
+      this.props.onCreate(userGroup, users);
+
+      this.setState({ name: '' });
+
+      toastSuccess(`Created a user group "${this.xss.process(userGroup.name)}"`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  validateForm() {
+    return this.state.name !== '';
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div>
+        <p>
+          {this.props.isAclEnabled
+            ? (
+              <button type="button" data-toggle="collapse" className="btn btn-default" href="#createGroupForm">
+                { t('user_group_management.create_group') }
+              </button>
+            )
+            : (
+              t('user_group_management.deny_create_group')
+            )
+          }
+        </p>
+        <form onSubmit={this.handleSubmit}>
+          <div id="createGroupForm" className="collapse">
+            <div className="form-group">
+              <label htmlFor="name">{ t('user_group_management.group_name') }</label>
+              <textarea
+                id="name"
+                name="name"
+                className="form-control"
+                placeholder={t('user_group_management.group_example')}
+                value={this.state.name}
+                onChange={this.handleChange}
+              >
+              </textarea>
+            </div>
+            <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{ t('Create') }</button>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupCreateFormWrapper = (props) => {
+  return createSubscribedElement(UserGroupCreateForm, props, [AppContainer]);
+};
+
+UserGroupCreateForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isAclEnabled: PropTypes.bool,
+  onCreate: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(UserGroupCreateFormWrapper);

+ 206 - 0
src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx

@@ -0,0 +1,206 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+/**
+ * Delete User Group Select component
+ *
+ * @export
+ * @class GrantSelector
+ * @extends {React.Component}
+ */
+class UserGroupDeleteModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { t } = this.props;
+
+    // actionName master constants
+    this.actionForPages = {
+      public: 'public',
+      delete: 'delete',
+      transfer: 'transfer',
+    };
+
+    this.availableOptions = [
+      {
+        id: 1, actionForPages: this.actionForPages.public, iconClass: 'icon-people', styleClass: '', label: t('user_group_management.publish_pages'),
+      },
+      {
+        id: 2, actionForPages: this.actionForPages.delete, iconClass: 'icon-trash', styleClass: 'text-danger', label: t('user_group_management.delete_pages'),
+      },
+      {
+        id: 3, actionForPages: this.actionForPages.transfer, iconClass: 'icon-options', styleClass: '', label: t('user_group_management.transfer_pages'),
+      },
+    ];
+
+    this.initialState = {
+      actionName: '',
+      transferToUserGroupId: '',
+    };
+
+    this.state = this.initialState;
+
+    this.xss = window.xss;
+
+    this.onHide = this.onHide.bind(this);
+    this.handleActionChange = this.handleActionChange.bind(this);
+    this.handleGroupChange = this.handleGroupChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
+    this.renderGroupSelector = this.renderGroupSelector.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  onHide() {
+    this.setState(this.initialState);
+    this.props.onHide();
+  }
+
+  handleActionChange(e) {
+    const actionName = e.target.value;
+    this.setState({ actionName });
+  }
+
+  handleGroupChange(e) {
+    const transferToUserGroupId = e.target.value;
+    this.setState({ transferToUserGroupId });
+  }
+
+  handleSubmit(e) {
+    e.preventDefault();
+
+    this.props.onDelete({
+      deleteGroupId: this.props.deleteUserGroup._id,
+      actionName: this.state.actionName,
+      transferToUserGroupId: this.state.transferToUserGroupId,
+    });
+  }
+
+  renderPageActionSelector() {
+    const { t } = this.props;
+
+    const optoins = this.availableOptions.map((opt) => {
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
+      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
+    });
+
+    return (
+      <select
+        name="actionName"
+        className="form-control"
+        placeholder="select"
+        value={this.state.actionName}
+        onChange={this.handleActionChange}
+      >
+        <option value="" disabled>{t('user_group_management.choose_action')}</option>
+        {optoins}
+      </select>
+    );
+  }
+
+  renderGroupSelector() {
+    const { t } = this.props;
+
+    const groups = this.props.userGroups.filter((group) => {
+      return group._id !== this.props.deleteUserGroup._id;
+    });
+
+    const options = groups.map((group) => {
+      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.xss.process(group.name)}`;
+      return <option key={group._id} value={group._id} data-content={dataContent}>{this.xss.process(group.name)}</option>;
+    });
+
+    const defaultOptionText = groups.length === 0 ? t('user_group_management.no_groups') : t('user_group_management.select_group');
+
+    return (
+      <select
+        name="transferToUserGroupId"
+        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
+        value={this.state.transferToUserGroupId}
+        onChange={this.handleGroupChange}
+      >
+        <option value="" disabled>{defaultOptionText}</option>
+        {options}
+      </select>
+    );
+  }
+
+  validateForm() {
+    let isValid = true;
+
+    if (this.state.actionName === '') {
+      isValid = false;
+    }
+    else if (this.state.actionName === this.actionForPages.transfer) {
+      isValid = this.state.transferToUserGroupId !== '';
+    }
+
+    return isValid;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isShow} onHide={this.onHide}>
+        <Modal.Header className="modal-header bg-danger" closeButton>
+          <Modal.Title>
+            <i className="icon icon-fire"></i> {t('user_group_management.delete_group')}
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <div>
+            <span className="font-weight-bold">{t('user_group_management.group_name')}</span> : &quot;{this.props.deleteUserGroup.name}&quot;
+          </div>
+          <div className="text-danger mt-5">
+            {t('user_group_management.group_and_pages_not_retrievable')}
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <form className="d-flex justify-content-between" onSubmit={this.handleSubmit}>
+            <div className="d-flex">
+              {this.renderPageActionSelector()}
+              {this.renderGroupSelector()}
+            </div>
+            <button type="submit" value="" className="btn btn-sm btn-danger" disabled={!this.validateForm()}>
+              <i className="icon icon-fire"></i> {t('Delete')}
+            </button>
+          </form>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDeleteModalWrapper = (props) => {
+  return createSubscribedElement(UserGroupDeleteModal, props, [AppContainer]);
+};
+
+UserGroupDeleteModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
+  deleteUserGroup: PropTypes.object,
+  onDelete: PropTypes.func.isRequired,
+  isShow: PropTypes.bool.isRequired,
+  onShow: PropTypes.func.isRequired,
+  onHide: PropTypes.func.isRequired,
+};
+
+UserGroupDeleteModal.defaultProps = {
+  deleteUserGroup: {},
+};
+
+export default withTranslation()(UserGroupDeleteModalWrapper);

+ 186 - 0
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -0,0 +1,186 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+import PaginationWrapper from '../../PaginationWrapper';
+import UserGroupTable from './UserGroupTable';
+import UserGroupCreateForm from './UserGroupCreateForm';
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupPage extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      userGroups: [],
+      userGroupRelations: {},
+      selectedUserGroup: undefined, // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+      isDeleteModalShow: false,
+      activePage: 1,
+      totalUserGroups: 0,
+      pagingLimit: Infinity,
+    };
+
+    this.xss = window.xss;
+
+    this.handlePage = this.handlePage.bind(this);
+    this.showDeleteModal = this.showDeleteModal.bind(this);
+    this.hideDeleteModal = this.hideDeleteModal.bind(this);
+    this.addUserGroup = this.addUserGroup.bind(this);
+    this.deleteUserGroupById = this.deleteUserGroupById.bind(this);
+  }
+
+  async componentDidMount() {
+    await this.syncUserGroupAndRelations();
+  }
+
+  async showDeleteModal(group) {
+    try {
+      await this.syncUserGroupAndRelations();
+
+      this.setState({
+        selectedUserGroup: group,
+        isDeleteModalShow: true,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  hideDeleteModal() {
+    this.setState({
+      selectedUserGroup: undefined,
+      isDeleteModalShow: false,
+    });
+  }
+
+  addUserGroup(userGroup, users) {
+    this.setState((prevState) => {
+      const userGroupRelations = Object.assign(prevState.userGroupRelations, {
+        [userGroup._id]: users,
+      });
+
+      return {
+        userGroups: [...prevState.userGroups, userGroup],
+        userGroupRelations,
+      };
+    });
+  }
+
+  async deleteUserGroupById({ deleteGroupId, actionName, transferToUserGroupId }) {
+    try {
+      const res = await this.props.appContainer.apiv3.delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      this.setState((prevState) => {
+        const userGroups = prevState.userGroups.filter((userGroup) => {
+          return userGroup._id !== deleteGroupId;
+        });
+
+        delete prevState.userGroupRelations[deleteGroupId];
+
+        return {
+          userGroups,
+          userGroupRelations: prevState.userGroupRelations,
+          selectedUserGroup: undefined,
+          isDeleteModalShow: false,
+        };
+      });
+
+      toastSuccess(`Deleted a group "${this.xss.process(res.data.userGroup.name)}"`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the group'));
+    }
+  }
+
+  async handlePage(selectedPage) {
+    await this.setState({ activePage: selectedPage });
+    await this.syncUserGroupAndRelations();
+  }
+
+  async syncUserGroupAndRelations() {
+    let userGroups = [];
+    let userGroupRelations = {};
+    let totalUserGroups = 0;
+    let pagingLimit = Infinity;
+
+    try {
+      const params = { page: this.state.activePage };
+      const responses = await Promise.all([
+        this.props.appContainer.apiv3.get('/user-groups', params),
+        this.props.appContainer.apiv3.get('/user-group-relations', params),
+      ]);
+
+      const [userGroupsRes, userGroupRelationsRes] = responses;
+      userGroups = userGroupsRes.data.userGroups;
+      totalUserGroups = userGroupsRes.data.totalUserGroups;
+      pagingLimit = userGroupsRes.data.pagingLimit;
+      userGroupRelations = userGroupRelationsRes.data.userGroupRelations;
+
+      this.setState({
+        userGroups,
+        userGroupRelations,
+        totalUserGroups,
+        pagingLimit,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    return (
+      <Fragment>
+        <UserGroupCreateForm
+          isAclEnabled={this.props.isAclEnabled}
+          onCreate={this.addUserGroup}
+        />
+        <UserGroupTable
+          userGroups={this.state.userGroups}
+          isAclEnabled={this.props.isAclEnabled}
+          onDelete={this.showDeleteModal}
+          userGroupRelations={this.state.userGroupRelations}
+        />
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalUserGroups}
+          pagingLimit={this.state.pagingLimit}
+        />
+        <UserGroupDeleteModal
+          userGroups={this.state.userGroups}
+          deleteUserGroup={this.state.selectedUserGroup}
+          onDelete={this.deleteUserGroupById}
+          isShow={this.state.isDeleteModalShow}
+          onShow={this.showDeleteModal}
+          onHide={this.hideDeleteModal}
+        />
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupPageWrapper = (props) => {
+  return createSubscribedElement(UserGroupPage, props, [AppContainer]);
+};
+
+UserGroupPage.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isAclEnabled: PropTypes.bool,
+};
+
+export default UserGroupPageWrapper;

+ 134 - 0
src/client/js/components/Admin/UserGroup/UserGroupTable.jsx

@@ -0,0 +1,134 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class UserGroupTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.xss = window.xss;
+
+    this.state = {
+      userGroups: this.props.userGroups,
+      userGroupRelations: this.props.userGroupRelations,
+    };
+
+    this.onDelete = this.onDelete.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.setState({
+      userGroups: nextProps.userGroups,
+      userGroupRelations: nextProps.userGroupRelations,
+    });
+  }
+
+  onDelete(e) {
+    const { target } = e;
+    const groupId = target.getAttribute('data-user-group-id');
+    const group = this.state.userGroups.find((group) => {
+      return group._id === groupId;
+    });
+
+    this.props.onDelete(group);
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <h2>{t('user_group_management.group_list')}</h2>
+
+        <table className="table table-bordered table-user-list">
+          <thead>
+            <tr>
+              <th>{ t('Name') }</th>
+              <th>{ t('User') }</th>
+              <th width="100px">{ t('Created') }</th>
+              <th width="70px"></th>
+            </tr>
+          </thead>
+          <tbody>
+            {this.state.userGroups.map((group) => {
+              return (
+                <tr key={group._id}>
+                  {this.props.isAclEnabled
+                    ? (
+                      <td><a href={`/admin/user-group-detail/${group._id}`}>{this.xss.process(group.name)}</a></td>
+                    )
+                    : (
+                      <td>{this.xss.process(group.name)}</td>
+                    )
+                  }
+                  <td>
+                    <ul className="list-inline">
+                      {this.state.userGroupRelations[group._id].map((user) => {
+                        return <li key={user._id} className="list-inline-item badge badge-primary">{this.xss.process(user.username)}</li>;
+                      })}
+                    </ul>
+                  </td>
+                  <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
+                  {this.props.isAclEnabled
+                    ? (
+                      <td>
+                        <div className="btn-group admin-group-menu">
+                          <button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                            <i className="icon-settings"></i> <span className="caret"></span>
+                          </button>
+                          <ul className="dropdown-menu" role="menu">
+                            <li>
+                              <a href={`/admin/user-group-detail/${group._id}`}>
+                                <i className="icon-fw icon-note"></i> { t('Edit') }
+                              </a>
+                            </li>
+
+                            <li>
+                              <a href="#" onClick={this.onDelete} data-user-group-id={group._id}>
+                                <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+                              </a>
+                            </li>
+
+                          </ul>
+                        </div>
+                      </td>
+                    )
+                    : (
+                      <td></td>
+                    )
+                  }
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupTableWrapper = (props) => {
+  return createSubscribedElement(UserGroupTable, props, [AppContainer]);
+};
+
+
+UserGroupTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
+  userGroupRelations: PropTypes.object.isRequired,
+  isAclEnabled: PropTypes.bool,
+  onDelete: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(UserGroupTableWrapper);

+ 51 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx

@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import UserGroupEditForm from './UserGroupEditForm';
+import UserGroupUserTable from './UserGroupUserTable';
+import UserGroupUserModal from './UserGroupUserModal';
+import UserGroupPageList from './UserGroupPageList';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class UserGroupDetailPage extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div>
+        <a href="/admin/user-groups" className="btn btn-default">
+          <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+          {t('user_group_management.back_to_list')}
+        </a>
+        <div className="m-t-20 form-box">
+          <UserGroupEditForm />
+        </div>
+        <legend className="m-t-20">{ t('user_group_management.user_list') }</legend>
+        <UserGroupUserTable />
+        <UserGroupUserModal />
+        <legend className="m-t-20">{ t('Page') }</legend>
+        <div className="page-list">
+          <UserGroupPageList />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+UserGroupDetailPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDetailPageWrapper = (props) => {
+  return createSubscribedElement(UserGroupDetailPage, props, [AppContainer]);
+};
+
+export default withTranslation()(UserGroupDetailPageWrapper);

+ 106 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx

@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupEditForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: props.userGroupDetailContainer.state.userGroup.name,
+      nameCache: props.userGroupDetailContainer.state.userGroup.name, // cache for name. update every submit
+    };
+
+    this.xss = window.xss;
+
+    this.changeUserGroupName = this.changeUserGroupName.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeUserGroupName(event) {
+    this.setState({
+      name: event.target.value,
+    });
+  }
+
+  async handleSubmit(e) {
+    e.preventDefault();
+
+    try {
+      const res = await this.props.userGroupDetailContainer.updateUserGroup({
+        name: this.state.name,
+      });
+
+      toastSuccess(`Updated the group name to "${this.xss.process(res.data.userGroup.name)}"`);
+      this.setState({ nameCache: this.state.name });
+    }
+    catch (err) {
+      toastError(new Error('Unable to update the group name'));
+    }
+  }
+
+  validateForm() {
+    return (
+      this.state.name !== this.state.nameCache
+      && this.state.name !== ''
+    );
+  }
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <form className="form-horizontal" onSubmit={this.handleSubmit}>
+        <fieldset>
+          <legend>{ t('user_group_management.basic_info') }</legend>
+          <div className="form-group">
+            <label htmlFor="name" className="col-sm-2 control-label">{ t('Name') }</label>
+            <div className="col-sm-4">
+              <input className="form-control" type="text" name="name" value={this.state.name} onChange={this.changeUserGroupName} />
+            </div>
+          </div>
+          <div className="form-group">
+            <label className="col-sm-2 control-label">{ t('Created') }</label>
+            <div className="col-sm-4">
+              <input
+                type="text"
+                className="form-control"
+                value={dateFnsFormat(new Date(userGroupDetailContainer.state.userGroup.createdAt), 'yyyy-MM-dd')}
+                disabled
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-sm-offset-2 col-sm-10">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{ t('Update') }</button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    );
+  }
+
+}
+
+UserGroupEditForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupEditFormWrapper = (props) => {
+  return createSubscribedElement(UserGroupEditForm, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupEditFormWrapper);

+ 87 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -0,0 +1,87 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import Page from '../../PageList/Page';
+import PaginationWrapper from '../../PaginationWrapper';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastError } from '../../../util/apiNotification';
+
+class UserGroupPageList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      currentPages: [],
+      activePage: 1,
+      total: 0,
+      pagingLimit: 10,
+    };
+
+    this.handlePageChange = this.handlePageChange.bind(this);
+  }
+
+  async componentDidMount() {
+    await this.handlePageChange(this.state.activePage);
+  }
+
+  async handlePageChange(pageNum) {
+    const limit = this.state.pagingLimit;
+    const offset = (pageNum - 1) * limit;
+
+    try {
+      const res = await this.props.appContainer.apiv3.get(`/user-groups/${this.props.userGroupDetailContainer.state.userGroup._id}/pages`, {
+        limit,
+        offset,
+      });
+      const { total, pages } = res.data;
+
+      this.setState({
+        total,
+        activePage: pageNum,
+        currentPages: pages,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <Fragment>
+        <ul className="page-list-ul page-list-ul-flat">
+          {this.state.currentPages.map((page) => { return <Page key={page._id} page={page} /> })}
+        </ul>
+        {userGroupDetailContainer.state.relatedPages.length === 0 ? <p>{ t('user_group_management.no_pages') }</p> : null}
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePageChange}
+          totalItemsCount={this.state.total}
+          pagingLimit={this.state.pagingLimit}
+        />
+      </Fragment>
+    );
+  }
+
+}
+
+UserGroupPageList.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupPageListWrapper = (props) => {
+  return createSubscribedElement(UserGroupPageList, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupPageListWrapper);

+ 83 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -0,0 +1,83 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupUserFormByInput extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      username: '',
+    };
+
+    this.xss = window.xss;
+
+    this.changeUsername = this.changeUsername.bind(this);
+    this.addUserBySubmit = this.addUserBySubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeUsername(e) {
+    this.setState({ username: e.target.value });
+  }
+
+  async addUserBySubmit(e) {
+    e.preventDefault();
+    const { username } = this.state;
+
+    try {
+      await this.props.userGroupDetailContainer.addUserByUsername(username);
+      toastSuccess(`Added "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
+      this.setState({ username: '' });
+    }
+    catch (err) {
+      toastError(new Error(`Unable to add "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
+    }
+  }
+
+  validateForm() {
+    return this.state.username !== '';
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-inline" onSubmit={this.addUserBySubmit}>
+        <div className="form-group">
+          <input
+            type="text"
+            name="username"
+            className="form-control input-sm"
+            placeholder={t('username')}
+            value={this.state.username}
+            onChange={this.changeUsername}
+          />
+        </div>
+        <button type="submit" className="btn btn-sm btn-success" disabled={!this.validateForm()}>{ t('add') }</button>
+      </form>
+    );
+  }
+
+}
+
+UserGroupUserFormByInput.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserFormByInputWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserFormByInput, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserFormByInputWrapper);

+ 43 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+
+import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+
+class UserGroupUserModal extends React.Component {
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <Modal show={userGroupDetailContainer.state.isUserGroupUserModalOpen} onHide={userGroupDetailContainer.closeUserGroupUserModal}>
+        <Modal.Header closeButton>
+          <Modal.Title>{ t('user_group_management.add_user') }</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <UserGroupUserFormByInput />
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
+}
+
+UserGroupUserModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserModalWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserModal, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserModalWrapper);

+ 116 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import UserPicture from '../../User/UserPicture';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupUserTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.xss = window.xss;
+
+    this.removeUser = this.removeUser.bind(this);
+  }
+
+  async removeUser(username) {
+    try {
+      await this.props.userGroupDetailContainer.removeUserByUsername(username);
+      toastSuccess(`Removed "${this.xss.process(username)}" from "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
+    }
+    catch (err) {
+      // eslint-disable-next-line max-len
+      toastError(new Error(`Unable to remove "${this.xss.process(username)}" from "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
+    }
+  }
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <table className="table table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="100px">#</th>
+            <th>
+              { t('username') }
+            </th>
+            <th>{ t('Name') }</th>
+            <th width="100px">{ t('Created') }</th>
+            <th width="160px">{ t('Last_Login')}</th>
+            <th width="70px"></th>
+          </tr>
+        </thead>
+        <tbody>
+          {userGroupDetailContainer.state.userGroupRelations.map((sRelation) => {
+              const { relatedUser } = sRelation;
+
+              return (
+                <tr key={sRelation._id}>
+                  <td>
+                    <UserPicture user={relatedUser} className="picture img-circle" />
+                  </td>
+                  <td>
+                    <strong>{relatedUser.username}</strong>
+                  </td>
+                  <td>{relatedUser.name}</td>
+                  <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
+                  <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
+                  <td>
+                    <div className="btn-group admin-user-menu">
+                      <button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                        <i className="icon-settings"></i> <span className="caret"></span>
+                      </button>
+                      <ul className="dropdown-menu" role="menu">
+                        <li>
+                          <a onClick={() => { return this.removeUser(relatedUser.username) }}>
+                            <i className="icon-fw icon-user-unfollow"></i> { t('user_group_management.remove_from_group')}
+                          </a>
+                        </li>
+                      </ul>
+                    </div>
+                  </td>
+                </tr>
+              );
+            })}
+
+          <tr>
+            <td></td>
+            <td className="text-center">
+              <button className="btn btn-default" type="button" onClick={userGroupDetailContainer.openUserGroupUserModal}>
+                <i className="ti-plus"></i>
+              </button>
+            </td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+          </tr>
+
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+UserGroupUserTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserTableWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserTable, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserTableWrapper);

+ 57 - 0
src/client/js/components/Admin/Users/GiveAdminButton.jsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+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('user_management.give_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <a className="px-4" onClick={() => { this.onClickGiveAdminBtn() }}>
+        <i className="icon-fw icon-user-following"></i> { t('user_management.give_admin_access') }
+      </a>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const GiveAdminButtonWrapper = (props) => {
+  return createSubscribedElement(GiveAdminButton, props, [AppContainer, AdminUsersContainer]);
+};
+
+GiveAdminButton.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(GiveAdminButtonWrapper);

+ 37 - 0
src/client/js/components/Admin/Users/InviteUserControl.jsx

@@ -0,0 +1,37 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import UserInviteModal from './UserInviteModal';
+
+class InviteUserControl extends React.Component {
+
+  render() {
+    const { t, adminUsersContainer } = this.props;
+
+    return (
+      <Fragment>
+        <button type="button" className="btn btn-default" onClick={adminUsersContainer.toggleUserInviteModal}>
+          { t('user_management.invite_users') }
+        </button>
+        <UserInviteModal />
+      </Fragment>
+    );
+  }
+
+}
+
+const InviteUserControlWrapper = (props) => {
+  return createSubscribedElement(InviteUserControl, props, [AppContainer, AdminUsersContainer]);
+};
+
+InviteUserControl.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+};
+
+export default withTranslation()(InviteUserControlWrapper);

+ 78 - 0
src/client/js/components/Admin/Users/ManageExternalAccount.jsx

@@ -0,0 +1,78 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+
+class ManageExternalAccount extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+    };
+  }
+
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <p>
+          <a className="btn btn-default" href="/admin/users">
+            <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+            { t('user_management.back_to_user_management') }
+          </a>
+        </p>
+
+        <h2>{ t('user_management.external_account_list') }</h2>
+
+        <table className="table table-bordered table-user-list">
+          <thead>
+            <tr>
+              <th width="120px">{ t('user_management.authentication_provider') }</th>
+              <th><code>accountId</code></th>
+              <th>{ t('user_management.related_username', 'username') }</th>
+              <th>
+                { t('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="<small>{{ t('user_management.password_setting_help') }}</small>"
+                >
+                  <small>
+                    <i className="icon-question" aria-hidden="true"></i>
+                  </small>
+                </div>
+              </th>
+              <th width="100px">{ t('Created') }</th>
+              <th width="70px"></th>
+            </tr>
+          </thead>
+          {/* TODO GW-328 */}
+        </table>
+      </Fragment>
+    );
+  }
+
+}
+
+const ManageExternalAccountWrapper = (props) => {
+  return createSubscribedElement(ManageExternalAccount, props, [AppContainer]);
+};
+
+ManageExternalAccount.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(ManageExternalAccountWrapper);

+ 119 - 0
src/client/js/components/Admin/Users/PasswordResetModal.jsx

@@ -0,0 +1,119 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+import { toastError } from '../../../util/apiNotification';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+class PasswordResetModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      temporaryPassword: [],
+      isPasswordResetDone: false,
+    };
+
+    this.resetPassword = this.resetPassword.bind(this);
+  }
+
+  async resetPassword() {
+    const { appContainer, adminUsersContainer } = this.props;
+    const user = adminUsersContainer.state.userForPasswordResetModal;
+
+    const res = await appContainer.apiPost('/admin/users.resetPassword', { user_id: user._id });
+    if (res.ok) {
+      this.setState({ temporaryPassword: res.newPassword, isPasswordResetDone: true });
+    }
+    else {
+      toastError('Failed to reset password');
+    }
+  }
+
+  renderModalBodyBeforeReset() {
+    const { t, adminUsersContainer } = this.props;
+    const user = adminUsersContainer.state.userForPasswordResetModal;
+
+    return (
+      <div>
+        <p className="alert alert-danger">{ t('user_management.password_reset_message') }</p>
+        <p>
+          { t('user_management.target_user') }: <code>{ user.email }</code>
+        </p>
+        <p>
+          { t('user_management.new_password') }: <code>{ this.state.temporaryPassword }</code>
+        </p>
+      </div>
+    );
+  }
+
+  returnModalBodyAfterReset() {
+    const { t, adminUsersContainer } = this.props;
+    const user = adminUsersContainer.state.userForPasswordResetModal;
+
+    return (
+      <div>
+        <p>
+          { t('user_management.password_never_seen') }<br />
+          <span className="text-danger">{ t('user_management.send_new_password') }</span>
+        </p>
+        <p>
+          { t('user_management.target_user') }: <code>{ user.email }</code>
+        </p>
+        <button type="submit" className="btn btn-primary" onClick={this.resetPassword}>
+          { t('user_management.reset_password')}
+        </button>
+      </div>
+    );
+  }
+
+  returnModalFooter() {
+    return (
+      <div>
+        <button type="submit" className="btn btn-primary" onClick={this.props.adminUsersContainer.hidePasswordResetModal}>OK</button>
+      </div>
+    );
+  }
+
+
+  render() {
+    const { t, adminUsersContainer } = this.props;
+
+    return (
+      <Modal show={adminUsersContainer.state.isPasswordResetModalShown} onHide={adminUsersContainer.hidePasswordResetModal}>
+        <Modal.Header className="modal-header" closeButton>
+          <Modal.Title>
+            { t('user_management.reset_password') }
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          {this.state.isPasswordResetDone ? this.renderModalBodyBeforeReset() : this.returnModalBodyAfterReset()}
+        </Modal.Body>
+        <Modal.Footer>
+          {this.state.isPasswordResetDone && this.returnModalFooter()}
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const PasswordResetModalWrapper = (props) => {
+  return createSubscribedElement(PasswordResetModal, props, [AppContainer, AdminUsersContainer]);
+};
+
+PasswordResetModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+};
+
+export default withTranslation()(PasswordResetModalWrapper);

+ 81 - 0
src/client/js/components/Admin/Users/RemoveAdminButton.jsx

@@ -0,0 +1,81 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+class RemoveAdminButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickRemoveAdminBtn = this.onClickRemoveAdminBtn.bind(this);
+  }
+
+  async onClickRemoveAdminBtn() {
+    const { t } = this.props;
+
+    try {
+      const username = await this.props.adminUsersContainer.removeUserAdmin(this.props.user._id);
+      toastSuccess(t('user_management.remove_user_admin', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+
+  renderRemoveAdminBtn() {
+    const { t } = this.props;
+
+    return (
+      <a className="px-4" onClick={() => { this.onClickRemoveAdminBtn() }}>
+        <i className="icon-fw icon-user-unfollow"></i> { t('user_management.remove_admin_access') }
+      </a>
+    );
+  }
+
+  renderRemoveAdminAlert() {
+    const { t } = this.props;
+
+    return (
+      <div className="px-4">
+        <i className="icon-fw icon-user-unfollow mb-2"></i>{ t('user_management.remove_admin_access') }
+        <p className="alert alert-danger">{ t('user_management.cannot_remove') }</p>
+      </div>
+    );
+  }
+
+  render() {
+    const { user } = this.props;
+    const me = this.props.appContainer.me;
+
+    return (
+      <Fragment>
+        {user.username !== me ? this.renderRemoveAdminBtn()
+          : this.renderRemoveAdminAlert()}
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+* Wrapper component for using unstated
+*/
+const RemoveAdminButtonWrapper = (props) => {
+  return createSubscribedElement(RemoveAdminButton, props, [AppContainer, AdminUsersContainer]);
+};
+
+RemoveAdminButton.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(RemoveAdminButtonWrapper);

+ 57 - 0
src/client/js/components/Admin/Users/StatusActivateButton.jsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class StatusActivateButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickAcceptBtn = this.onClickAcceptBtn.bind(this);
+  }
+
+  async onClickAcceptBtn() {
+    const { t } = this.props;
+
+    try {
+      const username = await this.props.adminUsersContainer.activateUser(this.props.user._id);
+      toastSuccess(t('user_management.activate_user_success', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <a className="px-4" onClick={() => { this.onClickAcceptBtn() }}>
+        <i className="icon-fw icon-user-following"></i> { t('user_management.accept') }
+      </a>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusActivateFormWrapper = (props) => {
+  return createSubscribedElement(StatusActivateButton, props, [AppContainer, AdminUsersContainer]);
+};
+
+StatusActivateButton.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(StatusActivateFormWrapper);

+ 80 - 0
src/client/js/components/Admin/Users/StatusSuspendedButton.jsx

@@ -0,0 +1,80 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+class StatusSuspendedButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickDeactiveBtn = this.onClickDeactiveBtn.bind(this);
+  }
+
+  async onClickDeactiveBtn() {
+    const { t } = this.props;
+
+    try {
+      const username = await this.props.adminUsersContainer.deactivateUser(this.props.user._id);
+      toastSuccess(t('user_management.deactivate_user_success', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  renderSuspendedBtn() {
+    const { t } = this.props;
+
+    return (
+      <a className="px-4" onClick={() => { this.onClickDeactiveBtn() }}>
+        <i className="icon-fw icon-ban"></i> { t('user_management.deactivate_account') }
+      </a>
+    );
+  }
+
+  renderSuspendedAlert() {
+    const { t } = this.props;
+
+    return (
+      <div className="px-4">
+        <i className="icon-fw icon-ban mb-2"></i>{ t('user_management.deactivate_account') }
+        <p className="alert alert-danger">{ t('user_management.your_own') }</p>
+      </div>
+    );
+  }
+
+  render() {
+    const { user } = this.props;
+    const me = this.props.appContainer.me;
+
+    return (
+      <Fragment>
+        {user.username !== me ? this.renderSuspendedBtn()
+          : this.renderSuspendedAlert()}
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const StatusSuspendedFormWrapper = (props) => {
+  return createSubscribedElement(StatusSuspendedButton, props, [AppContainer, AdminUsersContainer]);
+};
+
+StatusSuspendedButton.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(StatusSuspendedFormWrapper);

+ 222 - 0
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -0,0 +1,222 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+
+import Button from 'react-bootstrap/es/Button';
+import Modal from 'react-bootstrap/es/Modal';
+
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+class UserInviteModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      emailInputValue: '',
+      sendEmail: false,
+      invitedEmailList: null,
+    };
+
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.handleInput = this.handleInput.bind(this);
+    this.handleCheckBox = this.handleCheckBox.bind(this);
+    this.onToggleModal = this.onToggleModal.bind(this);
+  }
+
+  onToggleModal() {
+    this.props.adminUsersContainer.toggleUserInviteModal();
+    this.setState({ invitedEmailList: null });
+  }
+
+  showToaster() {
+    toastSuccess('Copied Mail and Password');
+  }
+
+  renderModalBody() {
+    const { t } = this.props;
+
+    return (
+      <>
+        <label> { t('user_management.emails') }</label>
+        <textarea
+          className="form-control"
+          placeholder="e.g. user@growi.org"
+          style={{ height: '200px' }}
+          value={this.state.emailInputValue}
+          onChange={this.handleInput}
+        />
+        {!this.validEmail() && <p className="m-2 text-danger">{ t('user_management.valid_email') }</p>}
+      </>
+    );
+  }
+
+  renderCreatedModalBody() {
+    const { t } = this.props;
+    const { invitedEmailList } = this.state;
+
+    return (
+      <>
+        <p>{t('user_management.temporary_password')}</p>
+        <p>{t('user_management.send_new_password')}</p>
+        {invitedEmailList.createdUserList.length > 0 && this.renderCreatedEmail(invitedEmailList.createdUserList)}
+        {invitedEmailList.existingEmailList.length > 0 && this.renderExistingEmail(invitedEmailList.existingEmailList)}
+      </>
+    );
+  }
+
+  renderModalFooter() {
+    const { t } = this.props;
+
+    return (
+      <>
+        <div className="checkbox checkbox-success text-left" onChange={this.handleCheckBox} style={{ flex: 1 }}>
+          <input type="checkbox" id="sendEmail" className="form-check-input" name="sendEmail" defaultChecked={this.state.sendEmail} />
+          <label htmlFor="sendEmail">
+            { t('user_management.invite_thru_email') }
+          </label>
+        </div>
+        <div>
+          <Button bsStyle="danger" className="fcbtn btn btn-xs btn-danger btn-outline btn-rounded" onClick={this.onToggleModal}>
+          Cancel
+          </Button>
+          <Button
+            bsStyle="primary"
+            className="fcbtn btn btn-primary btn-outline btn-rounded btn-1b"
+            onClick={this.handleSubmit}
+            disabled={!this.validEmail()}
+          >
+          Done
+          </Button>
+        </div>
+      </>
+    );
+  }
+
+  renderCreatedModalFooter() {
+    const { t } = this.props;
+
+    return (
+      <>
+        <label className="mr-3 text-left text-danger" style={{ flex: 1 }}>
+          {t('user_management.send_temporary_password')}
+        </label>
+        <Button
+          bsStyle="primary"
+          className="fcbtn btn btn-primary btn-outline btn-rounded"
+          onClick={this.onToggleModal}
+        >
+          Close
+        </Button>
+      </>
+    );
+  }
+
+  renderCreatedEmail(userList) {
+    return (
+      <ul>
+        {userList.map((user) => {
+          const copyText = `Email:${user.email} Password:${user.password} `;
+          return (
+            <CopyToClipboard key={user.email} text={copyText} onCopy={this.showToaster}>
+              <li key={user.email} className="btn">Email: <strong className="mr-3">{user.email}</strong> Password: <strong>{user.password}</strong></li>
+            </CopyToClipboard>
+          );
+        })}
+      </ul>
+    );
+  }
+
+  renderExistingEmail(emailList) {
+    const { t } = this.props;
+
+    return (
+      <>
+        <p className="text-warning">{ t('user_management.existing_email') }</p>
+        <ul>
+          {emailList.map((user) => {
+            return (
+              <li key={user}><strong>{user}</strong></li>
+            );
+          })}
+        </ul>
+      </>
+    );
+  }
+
+  validEmail() {
+    return this.state.emailInputValue.match(/.+@.+\..+/) != null;
+  }
+
+  async handleSubmit() {
+    const { adminUsersContainer } = this.props;
+
+    const array = this.state.emailInputValue.split('\n');
+    const emailList = array.filter((element) => { return element.match(/.+@.+\..+/) });
+    const shapedEmailList = emailList.map((email) => { return email.trim() });
+
+    try {
+      const emailList = await adminUsersContainer.createUserInvited(shapedEmailList, this.state.sendEmail);
+      this.setState({ emailInputValue: '' });
+      this.setState({ invitedEmailList: emailList });
+      toastSuccess('Inviting user success');
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  handleInput(event) {
+    this.setState({ emailInputValue: event.target.value });
+  }
+
+  handleCheckBox() {
+    this.setState({ sendEmail: !this.state.sendEmail });
+  }
+
+  render() {
+    const { t, adminUsersContainer } = this.props;
+    const { invitedEmailList } = this.state;
+
+    return (
+      <Modal show={adminUsersContainer.state.isUserInviteModalShown} onHide={this.onToggleModal}>
+        <Modal.Header className="modal-header" closeButton>
+          <Modal.Title>
+            { t('user_management.invite_users') }
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          {invitedEmailList == null ? this.renderModalBody()
+           : this.renderCreatedModalBody()}
+        </Modal.Body>
+        <Modal.Footer className="d-flex">
+          {invitedEmailList == null ? this.renderModalFooter()
+           : this.renderCreatedModalFooter()}
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserInviteModalWrapper = (props) => {
+  return createSubscribedElement(UserInviteModal, props, [AppContainer, AdminUsersContainer]);
+};
+
+
+UserInviteModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+};
+
+export default withTranslation()(UserInviteModalWrapper);

+ 110 - 0
src/client/js/components/Admin/Users/UserMenu.jsx

@@ -0,0 +1,110 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import StatusActivateButton from './StatusActivateButton';
+import StatusSuspendedButton from './StatusSuspendedButton';
+import RemoveUserButton from './UserRemoveButton';
+import RemoveAdminButton from './RemoveAdminButton';
+import GiveAdminButton from './GiveAdminButton';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+class UserMenu extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.onPasswordResetClicked = this.onPasswordResetClicked.bind(this);
+  }
+
+  onPasswordResetClicked() {
+    this.props.adminUsersContainer.showPasswordResetModal(this.props.user);
+  }
+
+  renderEditMenu() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <li className="dropdown-header">{ t('user_management.edit_menu') }</li>
+        <li onClick={this.onPasswordResetClicked}>
+          <a>
+            <i className="icon-fw icon-key"></i>{ t('user_management.reset_password') }
+          </a>
+        </li>
+      </Fragment>
+    );
+  }
+
+  renderStatusMenu() {
+    const { t, user } = this.props;
+
+    return (
+      <Fragment>
+        <li className="divider"></li>
+        <li className="dropdown-header">{ t('status') }</li>
+        <li>
+          {(user.status === 1 || user.status === 3) && <StatusActivateButton user={user} />}
+          {user.status === 2 && <StatusSuspendedButton user={user} />}
+          {(user.status === 1 || user.status === 3 || user.status === 5) && <RemoveUserButton user={user} />}
+        </li>
+      </Fragment>
+    );
+  }
+
+  renderAdminMenu() {
+    const { t, user } = this.props;
+
+    return (
+      <Fragment>
+        <li className="divider pl-0"></li>
+        <li className="dropdown-header">{ t('user_management.administrator_menu') }</li>
+        <li>
+          {user.admin === true && <RemoveAdminButton user={user} />}
+          {user.admin === false && <GiveAdminButton user={user} />}
+        </li>
+      </Fragment>
+    );
+  }
+
+  render() {
+    const { user } = this.props;
+
+    return (
+      <Fragment>
+        <div className="btn-group admin-user-menu">
+          <button type="button" className="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown">
+            <i className="icon-settings"></i> <span className="caret"></span>
+          </button>
+          <ul className="dropdown-menu" role="menu">
+            {this.renderEditMenu()}
+            {user.status !== 4 && this.renderStatusMenu()}
+            {user.status === 2 && this.renderAdminMenu()}
+          </ul>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+const UserMenuWrapper = (props) => {
+  return createSubscribedElement(UserMenu, props, [AppContainer, AdminUsersContainer]);
+};
+
+UserMenu.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(UserMenuWrapper);

+ 57 - 0
src/client/js/components/Admin/Users/UserRemoveButton.jsx

@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserRemoveButton extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickDeleteBtn = this.onClickDeleteBtn.bind(this);
+  }
+
+  async onClickDeleteBtn() {
+    const { t } = this.props;
+
+    try {
+      const username = await this.props.adminUsersContainer.removeUser(this.props.user._id);
+      toastSuccess(t('user_management.remove_user_success', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <a className="px-4" onClick={() => { this.onClickDeleteBtn() }}>
+        <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+      </a>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserRemoveButtonWrapper = (props) => {
+  return createSubscribedElement(UserRemoveButton, props, [AppContainer, AdminUsersContainer]);
+};
+
+UserRemoveButton.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+  user: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(UserRemoveButtonWrapper);

+ 127 - 0
src/client/js/components/Admin/Users/UserTable.jsx

@@ -0,0 +1,127 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import UserPicture from '../../User/UserPicture';
+import UserMenu from './UserMenu';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+class UserTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+
+    };
+
+    this.getUserStatusLabel = this.getUserStatusLabel.bind(this);
+  }
+
+  /**
+   * user.statusをみてステータスのラベルを返す
+   * @param {string} userStatus
+   * @return ステータスラベル
+   */
+  getUserStatusLabel(userStatus) {
+    let additionalClassName;
+    let text;
+
+    switch (userStatus) {
+      case 1:
+        additionalClassName = 'label-info';
+        text = 'Approval Pending';
+        break;
+      case 2:
+        additionalClassName = 'label-success';
+        text = 'Active';
+        break;
+      case 3:
+        additionalClassName = 'label-warning';
+        text = 'Suspended';
+        break;
+      case 4:
+        additionalClassName = 'label-danger';
+        text = 'Deleted';
+        break;
+      case 5:
+        additionalClassName = 'label-info';
+        text = 'Invited';
+        break;
+    }
+
+    return (
+      <span className={`label ${additionalClassName}`}>
+        {text}
+      </span>
+    );
+  }
+
+  render() {
+    const { t, adminUsersContainer } = this.props;
+
+    return (
+      <Fragment>
+        <h2>{ t('User_Management') }</h2>
+
+        <table className="table table-default table-bordered table-user-list">
+          <thead>
+            <tr>
+              <th width="100px">#</th>
+              <th>{ t('status') }</th>
+              <th><code>{ t('User') }</code></th>
+              <th>{ t('Name') }</th>
+              <th>{ t('Email') }</th>
+              <th width="100px">{ t('Created') }</th>
+              <th width="150px">{ t('Last_Login') }</th>
+              <th width="70px"></th>
+            </tr>
+          </thead>
+          <tbody>
+            {adminUsersContainer.state.users.map((user) => {
+              return (
+                <tr key={user._id}>
+                  <td>
+                    <UserPicture user={user} className="picture img-circle" />
+                    {user.admin && <span className="label label-inverse label-admin ml-2">{ t('administrator') }</span>}
+                  </td>
+                  <td>{this.getUserStatusLabel(user.status)}</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>
+      </Fragment>
+    );
+  }
+
+}
+
+const UserTableWrapper = (props) => {
+  return createSubscribedElement(UserTable, props, [AppContainer, AdminUsersContainer]);
+};
+
+UserTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+};
+
+export default withTranslation()(UserTableWrapper);

+ 70 - 0
src/client/js/components/Admin/Users/Users.jsx

@@ -0,0 +1,70 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import PasswordResetModal from './PasswordResetModal';
+import PaginationWrapper from '../../PaginationWrapper';
+import InviteUserControl from './InviteUserControl';
+import UserTable from './UserTable';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminUsersContainer from '../../../services/AdminUsersContainer';
+
+class UserPage extends React.Component {
+
+  constructor(props) {
+    super();
+
+    this.handlePage = this.handlePage.bind(this);
+  }
+
+  async handlePage(selectedPage) {
+    try {
+      await this.props.adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminUsersContainer } = this.props;
+
+    return (
+      <Fragment>
+        {adminUsersContainer.state.userForPasswordResetModal && <PasswordResetModal />}
+        <p>
+          <InviteUserControl />
+          <a className="btn btn-default btn-outline ml-2" href="/admin/users/external-accounts">
+            <i className="icon-user-follow" aria-hidden="true"></i>
+            { t('user_management.external_account') }
+          </a>
+        </p>
+        <UserTable />
+        <PaginationWrapper
+          activePage={adminUsersContainer.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={adminUsersContainer.state.totalUsers}
+          pagingLimit={adminUsersContainer.state.pagingLimit}
+        />
+      </Fragment>
+    );
+  }
+
+}
+
+const UserPageWrapper = (props) => {
+  return createSubscribedElement(UserPage, props, [AppContainer, AdminUsersContainer]);
+};
+
+UserPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+
+};
+
+export default withTranslation()(UserPageWrapper);

+ 0 - 260
src/client/js/components/GroupDeleteModal/GroupDeleteModal.jsx

@@ -1,260 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import * as toastr from 'toastr';
-
-/**
- * Delete User Group Select component
- *
- * @export
- * @class GrantSelector
- * @extends {React.Component}
- */
-class GroupDeleteModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { t } = this.props;
-
-    // actionName master constants
-    this.actionForPages = {
-      public: 'public',
-      delete: 'delete',
-      transfer: 'transfer',
-    };
-
-    this.availableOptions = [
-      {
-        id: 1, actionForPages: this.actionForPages.public, iconClass: 'icon-people', styleClass: '', label: t('user_group_management.publish_pages'),
-      },
-      {
-        id: 2, actionForPages: this.actionForPages.delete, iconClass: 'icon-trash', styleClass: 'text-danger', label: t('user_group_management.delete_pages'),
-      },
-      {
-        id: 3, actionForPages: this.actionForPages.transfer, iconClass: 'icon-options', styleClass: '', label: t('user_group_management.transfer_pages'),
-      },
-    ];
-
-    this.initialState = {
-      deleteGroupId: '',
-      deleteGroupName: '',
-      groups: [],
-      actionName: '',
-      selectedGroupId: '',
-      isFetching: false,
-    };
-
-    this.state = this.initialState;
-
-    // logger
-    this.logger = require('@alias/logger')('growi:GroupDeleteModal:GroupDeleteModal');
-
-    // retrieve xss library from window
-    this.xss = window.xss;
-
-    this.getGroupName = this.getGroupName.bind(this);
-    this.changeActionHandler = this.changeActionHandler.bind(this);
-    this.changeGroupHandler = this.changeGroupHandler.bind(this);
-    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
-    this.renderGroupSelector = this.renderGroupSelector.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  componentDidMount() {
-    // bootstrap and this jQuery opens/hides the modal.
-    // let React handle it in the future.
-    $('#admin-delete-user-group-modal').on('show.bs.modal', async(button) => {
-      this.setState({ isFetching: true });
-
-      const groups = await this.fetchAllGroups();
-
-      const data = $(button.relatedTarget);
-      const deleteGroupId = data.data('user-group-id');
-      const deleteGroupName = data.data('user-group-name');
-
-      this.setState({
-        groups,
-        deleteGroupId,
-        deleteGroupName,
-        isFetching: false,
-      });
-    });
-
-    $('#admin-delete-user-group-modal').on('hide.bs.modal', (button) => {
-      this.setState(this.initialState);
-    });
-  }
-
-  getGroupName(group) {
-    return this.xss.process(group.name);
-  }
-
-  async fetchAllGroups() {
-    let groups = [];
-
-    try {
-      const res = await this.props.crowi.apiGet('/admin/user-groups');
-      if (res.ok) {
-        groups = res.userGroups;
-      }
-      else {
-        throw new Error('Unable to fetch groups from server');
-      }
-    }
-    catch (err) {
-      this.handleError(err);
-    }
-
-    return groups;
-  }
-
-  handleError(err) {
-    this.logger.error(err);
-    toastr.error(err, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  changeActionHandler(e) {
-    const actionName = e.target.value;
-    this.setState({ actionName });
-  }
-
-  changeGroupHandler(e) {
-    const selectedGroupId = e.target.value;
-    this.setState({ selectedGroupId });
-  }
-
-  renderPageActionSelector() {
-    const { t } = this.props;
-
-    const optoins = this.availableOptions.map((opt) => {
-      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
-      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
-    });
-
-    return (
-      <select
-        name="actionName"
-        className="form-control"
-        placeholder="select"
-        value={this.state.actionName}
-        onChange={this.changeActionHandler}
-      >
-        <option value="" disabled>{t('user_group_management.choose_action')}</option>
-        {optoins}
-      </select>
-    );
-  }
-
-  renderGroupSelector() {
-    const { t } = this.props;
-
-    const groups = this.state.groups.filter((group) => {
-      return group._id !== this.state.deleteGroupId;
-    });
-
-    const options = groups.map((group) => {
-      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.getGroupName(group)}`;
-      return <option key={group._id} value={group._id} data-content={dataContent}>{this.getGroupName(group)}</option>;
-    });
-
-    const defaultOptionText = groups.length === 0 ? t('user_group_management.no_groups') : t('user_group_management.select_group');
-
-    return (
-      <select
-        name="selectedGroupId"
-        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
-        value={this.state.selectedGroupId}
-        onChange={this.changeGroupHandler}
-      >
-        <option value="" disabled>{defaultOptionText}</option>
-        {options}
-      </select>
-    );
-  }
-
-  validateForm() {
-    let isValid = true;
-
-    if (this.state.actionName === '') {
-      isValid = false;
-    }
-    else if (this.state.actionName === this.actionForPages.transfer) {
-      isValid = this.state.selectedGroupId !== '';
-    }
-
-    return isValid;
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="modal-dialog">
-        <div className="modal-content">
-          <div className="modal-header bg-danger">
-            <button type="button" className="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-            <div className="modal-title">
-              <i className="icon icon-fire"></i> {t('user_group_management.delete_group')}
-            </div>
-          </div>
-
-          <div className="modal-body">
-            <div>
-              <span className="font-weight-bold">{t('user_group_management.group_name')}</span> : &quot;{this.state.deleteGroupName}&quot;
-            </div>
-            {this.state.isFetching
-              ? (
-                <div className="mt-5">
-                  {t('user_group_management.is_loading_data')}
-                </div>
-              )
-              : (
-                <div className="text-danger mt-5">
-                  {t('user_group_management.group_and_pages_not_retrievable')}
-                </div>
-              )
-            }
-          </div>
-
-          {this.state.isFetching
-            ? (
-              null
-            )
-            : (
-              <div className="modal-footer">
-                <form action="/admin/user-group.remove" method="post" id="admin-user-groups-delete" className="d-flex justify-content-between">
-                  <div className="d-flex">
-                    {this.renderPageActionSelector()}
-                    {this.renderGroupSelector()}
-                  </div>
-                  <input type="hidden" id="deleteGroupId" name="deleteGroupId" value={this.state.deleteGroupId} onChange={() => {}} />
-                  <input type="hidden" name="_csrf" defaultValue={this.props.crowi.csrfToken} />
-                  <button type="submit" value="" className="btn btn-sm btn-danger" disabled={!this.validateForm()}>
-                    <i className="icon icon-fire"></i> {t('Delete')}
-                  </button>
-                </form>
-              </div>
-            )
-          }
-        </div>
-      </div>
-    );
-  }
-
-}
-
-GroupDeleteModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(GroupDeleteModal);

+ 23 - 121
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -3,13 +3,13 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
-import Pagination from 'react-bootstrap/lib/Pagination';
-
 import { createSubscribedElement } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
 import EditorContainer from '../../services/EditorContainer';
 
+import PaginationWrapper from '../PaginationWrapper';
+
 import Draft from './Draft';
 
 class MyDraftList extends React.Component {
@@ -21,14 +21,15 @@ class MyDraftList extends React.Component {
       drafts: [],
       currentDrafts: [],
       activePage: 1,
-      paginationNumbers: {},
+      totalDrafts: 0,
+      pagingLimit: Infinity,
     };
 
+    this.handlePage = this.handlePage.bind(this);
     this.getDraftsFromLocalStorage = this.getDraftsFromLocalStorage.bind(this);
     this.getCurrentDrafts = this.getCurrentDrafts.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
     this.clearAllDrafts = this.clearAllDrafts.bind(this);
-    this.calculatePagination = this.calculatePagination.bind(this);
   }
 
   async componentWillMount() {
@@ -36,6 +37,11 @@ class MyDraftList extends React.Component {
     this.getCurrentDrafts(1);
   }
 
+  async handlePage(selectedPage) {
+    await this.getDraftsFromLocalStorage();
+    await this.getCurrentDrafts(selectedPage);
+  }
+
   async getDraftsFromLocalStorage() {
     const draftsAsObj = this.props.editorContainer.drafts;
 
@@ -53,7 +59,7 @@ class MyDraftList extends React.Component {
       };
     });
 
-    this.setState({ drafts });
+    this.setState({ drafts, totalDrafts: drafts.length });
   }
 
   getCurrentDrafts(selectPageNumber) {
@@ -61,16 +67,16 @@ class MyDraftList extends React.Component {
 
     const limit = appContainer.getConfig().recentCreatedLimit;
 
-    const totalCount = this.state.drafts.length;
+    const totalDrafts = this.state.drafts.length;
     const activePage = selectPageNumber;
-    const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
 
     const currentDrafts = this.state.drafts.slice((activePage - 1) * limit, activePage * limit);
 
     this.setState({
       currentDrafts,
       activePage,
-      paginationNumbers,
+      totalDrafts,
+      pagingLimit: limit,
     });
   }
 
@@ -112,125 +118,16 @@ class MyDraftList extends React.Component {
       drafts: [],
       currentDrafts: [],
       activePage: 1,
-      paginationNumbers: {},
+      totalDrafts: 0,
+      pagingLimit: Infinity,
     });
   }
 
-  calculatePagination(limit, totalCount, activePage) {
-    // calc totalPageNumber
-    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
-
-    let paginationStart = activePage - 2;
-    let maxViewPageNum = activePage + 2;
-    // pagiNation Number area size = 5 , pageNuber calculate in here
-    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
-    if (paginationStart < 1) {
-      const diff = 1 - paginationStart;
-      paginationStart += diff;
-      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
-    }
-    if (maxViewPageNum > totalPage) {
-      const diff = maxViewPageNum - totalPage;
-      maxViewPageNum -= diff;
-      paginationStart = Math.max(1, paginationStart - diff);
-    }
-
-    return {
-      totalPage,
-      paginationStart,
-      maxViewPageNum,
-    };
-  }
-
-  /**
-   * generate Elements of Pagination First Prev
-   * ex.  <<   <   1  2  3  >  >>
-   * this function set << & <
-   */
-  generateFirstPrev(activePage) {
-    const paginationItems = [];
-    if (activePage !== 1) {
-      paginationItems.push(
-        <Pagination.First key="first" onClick={() => { return this.getCurrentDrafts(1) }} />,
-      );
-      paginationItems.push(
-        <Pagination.Prev key="prev" onClick={() => { return this.getCurrentDrafts(this.state.activePage - 1) }} />,
-      );
-    }
-    else {
-      paginationItems.push(
-        <Pagination.First key="first" disabled />,
-      );
-      paginationItems.push(
-        <Pagination.Prev key="prev" disabled />,
-      );
-
-    }
-    return paginationItems;
-  }
-
-  /**
-   * generate Elements of Pagination First Prev
-   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
-   * this function set  numbers
-   */
-  generatePaginations(activePage, paginationStart, maxViewPageNum) {
-    const paginationItems = [];
-    for (let number = paginationStart; number <= maxViewPageNum; number++) {
-      paginationItems.push(
-        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getCurrentDrafts(number) }}>{number}</Pagination.Item>,
-      );
-    }
-    return paginationItems;
-  }
-
-  /**
-   * generate Elements of Pagination First Prev
-   * ex.  <<   <   1  2  3  >  >>
-   * this function set > & >>
-   */
-  generateNextLast(activePage, totalPage) {
-    const paginationItems = [];
-    if (totalPage !== activePage) {
-      paginationItems.push(
-        <Pagination.Next key="next" onClick={() => { return this.getCurrentDrafts(this.state.activePage + 1) }} />,
-      );
-      paginationItems.push(
-        <Pagination.Last key="last" onClick={() => { return this.getCurrentDrafts(totalPage) }} />,
-      );
-    }
-    else {
-      paginationItems.push(
-        <Pagination.Next key="next" disabled />,
-      );
-      paginationItems.push(
-        <Pagination.Last key="last" disabled />,
-      );
-
-    }
-    return paginationItems;
-
-  }
-
   render() {
     const { t } = this.props;
 
     const draftList = this.generateDraftList(this.state.currentDrafts);
-
-    const paginationItems = [];
-
-    const totalCount = this.state.drafts.length;
-
-    const activePage = this.state.activePage;
-    const totalPage = this.state.paginationNumbers.totalPage;
-    const paginationStart = this.state.paginationNumbers.paginationStart;
-    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
-    const firstPrevItems = this.generateFirstPrev(activePage);
-    paginationItems.push(firstPrevItems);
-    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
-    paginationItems.push(paginations);
-    const nextLastItems = this.generateNextLast(activePage, totalPage);
-    paginationItems.push(nextLastItems);
+    const totalCount = this.state.totalDrafts;
 
     return (
       <div className="page-list-container-create">
@@ -254,7 +151,12 @@ class MyDraftList extends React.Component {
             <div className="tab-pane m-t-30 accordion" id="draft-list">
               {draftList}
             </div>
-            <Pagination bsSize="small">{paginationItems}</Pagination>
+            <PaginationWrapper
+              activePage={this.state.activePage}
+              changePage={this.handlePage}
+              totalItemsCount={this.state.totalDrafts}
+              pagingLimit={this.state.pagingLimit}
+            />
           </React.Fragment>
         ) }
 

+ 174 - 0
src/client/js/components/PaginationWrapper.jsx

@@ -0,0 +1,174 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import Pagination from 'react-bootstrap/lib/Pagination';
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
+class PaginationWrapper extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      totalItemsCount: 0,
+      activePage: 1,
+      paginationNumbers: {},
+      limit: Infinity,
+    };
+
+    this.calculatePagination = this.calculatePagination.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.setState({
+      activePage: nextProps.activePage,
+      totalItemsCount: nextProps.totalItemsCount,
+      limit: nextProps.pagingLimit,
+    }, () => {
+      const activePage = this.state.activePage;
+      const totalCount = this.state.totalItemsCount;
+      const limit = this.state.limit;
+      const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
+      this.setState({ paginationNumbers });
+    });
+  }
+
+  calculatePagination(limit, totalCount, activePage) {
+    // calc totalPageNumber
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+
+    let paginationStart = activePage - 2;
+    let maxViewPageNum = activePage + 2;
+    // if pagiNation Number area size = 5 , pageNumber is calculated here
+    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
+    if (paginationStart < 1) {
+      const diff = 1 - paginationStart;
+      paginationStart += diff;
+      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
+    }
+    if (maxViewPageNum > totalPage) {
+      const diff = maxViewPageNum - totalPage;
+      maxViewPageNum -= diff;
+      paginationStart = Math.max(1, paginationStart - diff);
+    }
+
+    return {
+      totalPage,
+      paginationStart,
+      maxViewPageNum,
+    };
+  }
+
+  /**
+    * generate Elements of Pagination First Prev
+    * ex.  <<   <   1  2  3  >  >>
+    * this function set << & <
+    */
+  generateFirstPrev(activePage) {
+    const paginationItems = [];
+    if (activePage !== 1) {
+      paginationItems.push(
+        <Pagination.First key="first" onClick={() => { return this.props.changePage(1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" onClick={() => { return this.props.changePage(activePage - 1) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.First key="first" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
+   * this function set  numbers
+   */
+  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+    const paginationItems = [];
+    for (let number = paginationStart; number <= maxViewPageNum; number++) {
+      paginationItems.push(
+        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.props.changePage(number) }}>{number}</Pagination.Item>,
+      );
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set > & >>
+   */
+  generateNextLast(activePage, totalPage) {
+    const paginationItems = [];
+    if (totalPage !== activePage) {
+      paginationItems.push(
+        <Pagination.Next key="next" onClick={() => { return this.props.changePage(activePage + 1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" onClick={() => { return this.props.changePage(totalPage) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.Next key="next" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" disabled />,
+      );
+
+    }
+    return paginationItems;
+
+  }
+
+  render() {
+    const paginationItems = [];
+
+    const activePage = this.state.activePage;
+    const totalPage = this.state.paginationNumbers.totalPage;
+    const paginationStart = this.state.paginationNumbers.paginationStart;
+    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
+    const firstPrevItems = this.generateFirstPrev(activePage);
+    paginationItems.push(firstPrevItems);
+    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
+    paginationItems.push(paginations);
+    const nextLastItems = this.generateNextLast(activePage, totalPage);
+    paginationItems.push(nextLastItems);
+
+    return (
+      <React.Fragment>
+        <div>
+          <Pagination bsSize="small">{paginationItems}</Pagination>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+
+}
+
+const PaginationWrappered = (props) => {
+  return createSubscribedElement(PaginationWrapper, props, [AppContainer]);
+};
+
+PaginationWrapper.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  activePage: PropTypes.number.isRequired,
+  changePage: PropTypes.func.isRequired,
+  totalItemsCount: PropTypes.number.isRequired,
+  pagingLimit: PropTypes.number.isRequired,
+};
+
+export default withTranslation()(PaginationWrappered);

+ 19 - 118
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -1,12 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import Pagination from 'react-bootstrap/lib/Pagination';
-
 import { createSubscribedElement } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
 
+import PaginationWrapper from '../PaginationWrapper';
+
 import Page from '../PageList/Page';
 
 class RecentCreated extends React.Component {
@@ -17,9 +17,11 @@ class RecentCreated extends React.Component {
     this.state = {
       pages: [],
       activePage: 1,
-      paginationNumbers: {},
+      totalPages: 0,
+      pagingLimit: Infinity,
     };
-    this.calculatePagination = this.calculatePagination.bind(this);
+
+    this.handlePage = this.handlePage.bind(this);
   }
 
 
@@ -27,6 +29,10 @@ class RecentCreated extends React.Component {
     this.getRecentCreatedList(1);
   }
 
+  async handlePage(selectedPage) {
+    await this.getRecentCreatedList(selectedPage);
+  }
+
   getRecentCreatedList(selectPageNumber) {
     const { appContainer, pageContainer } = this.props;
     const { pageId } = pageContainer.state;
@@ -40,45 +46,18 @@ class RecentCreated extends React.Component {
       page_id: pageId, user: userId, limit, offset,
     })
       .then((res) => {
-        const totalCount = res.totalCount;
+        const totalPages = res.totalCount;
         const pages = res.pages;
         const activePage = selectPageNumber;
-        // pagiNation calculate function call
-        const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
         this.setState({
           pages,
           activePage,
-          paginationNumbers,
+          totalPages,
+          pagingLimit: limit,
         });
       });
   }
 
-  calculatePagination(limit, totalCount, activePage) {
-    // calc totalPageNumber
-    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
-
-    let paginationStart = activePage - 2;
-    let maxViewPageNum = activePage + 2;
-    // pagiNation Number area size = 5 , pageNuber calculate in here
-    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
-    if (paginationStart < 1) {
-      const diff = 1 - paginationStart;
-      paginationStart += diff;
-      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
-    }
-    if (maxViewPageNum > totalPage) {
-      const diff = maxViewPageNum - totalPage;
-      maxViewPageNum -= diff;
-      paginationStart = Math.max(1, paginationStart - diff);
-    }
-
-    return {
-      totalPage,
-      paginationStart,
-      maxViewPageNum,
-    };
-  }
-
   /**
    * generate Elements of Page
    *
@@ -92,98 +71,20 @@ class RecentCreated extends React.Component {
 
   }
 
-  /**
-   * generate Elements of Pagination First Prev
-   * ex.  <<   <   1  2  3  >  >>
-   * this function set << & <
-   */
-  generateFirstPrev(activePage) {
-    const paginationItems = [];
-    if (activePage !== 1) {
-      paginationItems.push(
-        <Pagination.First key="first" onClick={() => { return this.getRecentCreatedList(1) }} />,
-      );
-      paginationItems.push(
-        <Pagination.Prev key="prev" onClick={() => { return this.getRecentCreatedList(this.state.activePage - 1) }} />,
-      );
-    }
-    else {
-      paginationItems.push(
-        <Pagination.First key="first" disabled />,
-      );
-      paginationItems.push(
-        <Pagination.Prev key="prev" disabled />,
-      );
-
-    }
-    return paginationItems;
-  }
-
-  /**
-   * generate Elements of Pagination First Prev
-   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
-   * this function set  numbers
-   */
-  generatePaginations(activePage, paginationStart, maxViewPageNum) {
-    const paginationItems = [];
-    for (let number = paginationStart; number <= maxViewPageNum; number++) {
-      paginationItems.push(
-        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getRecentCreatedList(number) }}>{number}</Pagination.Item>,
-      );
-    }
-    return paginationItems;
-  }
-
-  /**
-   * generate Elements of Pagination First Prev
-   * ex.  <<   <   1  2  3  >  >>
-   * this function set > & >>
-   */
-  generateNextLast(activePage, totalPage) {
-    const paginationItems = [];
-    if (totalPage !== activePage) {
-      paginationItems.push(
-        <Pagination.Next key="next" onClick={() => { return this.getRecentCreatedList(this.state.activePage + 1) }} />,
-      );
-      paginationItems.push(
-        <Pagination.Last key="last" onClick={() => { return this.getRecentCreatedList(totalPage) }} />,
-      );
-    }
-    else {
-      paginationItems.push(
-        <Pagination.Next key="next" disabled />,
-      );
-      paginationItems.push(
-        <Pagination.Last key="last" disabled />,
-      );
-
-    }
-    return paginationItems;
-
-  }
-
   render() {
     const pageList = this.generatePageList(this.state.pages);
 
-    const paginationItems = [];
-
-    const activePage = this.state.activePage;
-    const totalPage = this.state.paginationNumbers.totalPage;
-    const paginationStart = this.state.paginationNumbers.paginationStart;
-    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
-    const firstPrevItems = this.generateFirstPrev(activePage);
-    paginationItems.push(firstPrevItems);
-    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
-    paginationItems.push(paginations);
-    const nextLastItems = this.generateNextLast(activePage, totalPage);
-    paginationItems.push(nextLastItems);
-
     return (
       <div className="page-list-container-create">
         <ul className="page-list-ul page-list-ul-flat">
           {pageList}
         </ul>
-        <Pagination bsSize="small">{paginationItems}</Pagination>
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalPages}
+          pagingLimit={this.state.pagingLimit}
+        />
       </div>
     );
   }

+ 19 - 117
src/client/js/components/TagsList.jsx

@@ -2,7 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
-import Pagination from 'react-bootstrap/lib/Pagination';
+
+import PaginationWrapper from './PaginationWrapper';
 
 class TagsList extends React.Component {
 
@@ -12,59 +13,38 @@ class TagsList extends React.Component {
     this.state = {
       tagData: [],
       activePage: 1,
-      paginationNumbers: {},
+      totalTags: 0,
+      pagingLimit: 10,
     };
 
-    this.calculatePagination = this.calculatePagination.bind(this);
+    this.handlePage = this.handlePage.bind(this);
+    this.getTagList = this.getTagList.bind(this);
   }
 
   async componentWillMount() {
     await this.getTagList(1);
   }
 
+  async handlePage(selectedPage) {
+    await this.getTagList(selectedPage);
+  }
+
   async getTagList(selectPageNumber) {
-    const limit = 10;
+    const limit = this.state.pagingLimit;
     const offset = (selectPageNumber - 1) * limit;
     const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
 
-    const totalCount = res.totalCount;
+    const totalTags = res.totalCount;
     const tagData = res.data;
     const activePage = selectPageNumber;
-    const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
 
     this.setState({
       tagData,
       activePage,
-      paginationNumbers,
+      totalTags,
     });
   }
 
-  calculatePagination(limit, totalCount, activePage) {
-    // calc totalPageNumber
-    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
-
-    let paginationStart = activePage - 2;
-    let maxViewPageNum = activePage + 2;
-    // pagination Number area size = 5 , pageNumber calculate in here
-    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
-    if (paginationStart < 1) {
-      const diff = 1 - paginationStart;
-      paginationStart += diff;
-      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
-    }
-    if (maxViewPageNum > totalPage) {
-      const diff = maxViewPageNum - totalPage;
-      maxViewPageNum -= diff;
-      paginationStart = Math.max(1, paginationStart - diff);
-    }
-
-    return {
-      totalPage,
-      paginationStart,
-      maxViewPageNum,
-    };
-  }
-
   /**
    * generate Elements of Tag
    *
@@ -82,93 +62,10 @@ class TagsList extends React.Component {
     });
   }
 
-  /**
-   * generate Elements of Pagination First Prev
-   * ex.  <<   <   1  2  3  >  >>
-   * this function set << & <
-   */
-  generateFirstPrev(activePage) {
-    const paginationItems = [];
-    if (activePage !== 1) {
-      paginationItems.push(
-        <Pagination.First key="first" onClick={() => { return this.getTagList(1) }} />,
-      );
-      paginationItems.push(
-        <Pagination.Prev key="prev" onClick={() => { return this.getTagList(this.state.activePage - 1) }} />,
-      );
-    }
-    else {
-      paginationItems.push(
-        <Pagination.First key="first" disabled />,
-      );
-      paginationItems.push(
-        <Pagination.Prev key="prev" disabled />,
-      );
-
-    }
-    return paginationItems;
-  }
-
-  /**
-   * generate Elements of Pagination First Prev
-   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
-   * this function set  numbers
-   */
-  generatePaginations(activePage, paginationStart, maxViewPageNum) {
-    const paginationItems = [];
-    for (let number = paginationStart; number <= maxViewPageNum; number++) {
-      paginationItems.push(
-        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getTagList(number) }}>{number}</Pagination.Item>,
-      );
-    }
-    return paginationItems;
-  }
-
-  /**
-   * generate Elements of Pagination First Prev
-   * ex.  <<   <   1  2  3  >  >>
-   * this function set > & >>
-   */
-  generateNextLast(activePage, totalPage) {
-    const paginationItems = [];
-    if (totalPage !== activePage) {
-      paginationItems.push(
-        <Pagination.Next key="next" onClick={() => { return this.getTagList(this.state.activePage + 1) }} />,
-      );
-      paginationItems.push(
-        <Pagination.Last key="last" onClick={() => { return this.getTagList(totalPage) }} />,
-      );
-    }
-    else {
-      paginationItems.push(
-        <Pagination.Next key="next" disabled />,
-      );
-      paginationItems.push(
-        <Pagination.Last key="last" disabled />,
-      );
-
-    }
-    return paginationItems;
-  }
-
   render() {
     const { t } = this.props;
     const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
 
-    const paginationItems = [];
-
-    const activePage = this.state.activePage;
-    const totalPage = this.state.paginationNumbers.totalPage;
-    const paginationStart = this.state.paginationNumbers.paginationStart;
-    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
-    const firstPrevItems = this.generateFirstPrev(activePage);
-    paginationItems.push(firstPrevItems);
-    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
-    paginationItems.push(paginations);
-    const nextLastItems = this.generateNextLast(activePage, totalPage);
-    paginationItems.push(nextLastItems);
-    const pagination = this.state.tagData.length ? <Pagination>{paginationItems}</Pagination> : null;
-
     return (
       <div className="text-center">
         <div className="tag-list">
@@ -178,7 +75,12 @@ class TagsList extends React.Component {
           {messageForNoTag}
         </div>
         <div className="tag-list-pagination">
-          {pagination}
+          <PaginationWrapper
+            activePage={this.state.activePage}
+            changePage={this.handlePage}
+            totalItemsCount={this.state.totalTags}
+            pagingLimit={this.state.pagingLimit}
+          />
         </div>
       </div>
     );

+ 0 - 8
src/client/js/legacy/crowi-admin.js

@@ -61,14 +61,6 @@ $(() => {
     return false;
   });
 
-  $('form#user-group-relation-create').on('submit', function(e) {
-    $.post('/admin/user-group-relation/create', $(this).serialize(), (res) => {
-      $('#admin-add-user-group-relation-modal').modal('hide');
-      return;
-    });
-  });
-
-
   $('#pictureUploadForm input[name=userGroupPicture]').on('change', function() {
     const $form = $('#pictureUploadForm');
     const fd = new FormData($form[0]);

+ 167 - 0
src/client/js/services/AdminUsersContainer.js

@@ -0,0 +1,167 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:UserGroupDetailContainer');
+
+/**
+ * Service container for admin users page (Users.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminUsersContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      users: JSON.parse(document.getElementById('admin-user-page').getAttribute('users')) || [],
+      isPasswordResetModalShown: false,
+      isUserInviteModalShown: false,
+      userForPasswordResetModal: null,
+      totalUsers: 0,
+      activePage: 1,
+      pagingLimit: Infinity,
+    };
+
+    this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
+    this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
+    this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminUsersContainer';
+  }
+
+  /**
+   * syncUsers of selectedPage
+   * @memberOf AdminUsersContainer
+   * @param {number} selectedPage
+   */
+  async retrieveUsersByPagingNum(selectedPage) {
+
+    const params = { page: selectedPage };
+    const response = await this.appContainer.apiv3.get('/users', params);
+
+    const users = response.data.users;
+    const totalUsers = response.data.totalUsers;
+    const pagingLimit = response.data.pagingLimit;
+
+    this.setState({
+      users,
+      totalUsers,
+      pagingLimit,
+      activePage: selectedPage,
+    });
+
+  }
+
+  /**
+   * create user invited
+   * @memberOf AdminUsersContainer
+   * @param {object} shapedEmailList
+   * @param {bool} sendEmail
+   */
+  async createUserInvited(shapedEmailList, sendEmail) {
+    const response = await this.appContainer.apiv3.post('/users/invite', {
+      shapedEmailList,
+      sendEmail,
+    });
+    const { emailList } = response.data;
+    return emailList;
+  }
+
+  /**
+   * open reset password modal, and props user
+   * @memberOf AdminUsersContainer
+   * @param {object} user
+   */
+  async showPasswordResetModal(user) {
+    await this.setState({
+      isPasswordResetModalShown: true,
+      userForPasswordResetModal: user,
+    });
+  }
+
+  /**
+   * close reset password modal
+   * @memberOf AdminUsersContainer
+   */
+  async hidePasswordResetModal() {
+    await this.setState({ isPasswordResetModalShown: false });
+  }
+
+  /**
+   * toggle user invite modal
+   * @memberOf AdminUsersContainer
+   */
+  async toggleUserInviteModal() {
+    await this.setState({ isUserInviteModalShown: !this.state.isUserInviteModalShown });
+  }
+
+  /**
+   * Give user admin
+   * @memberOf AdminUsersContainer
+   * @param {string} userId
+   * @return {string} username
+   */
+  async giveUserAdmin(userId) {
+    const response = await this.appContainer.apiv3.put(`/users/${userId}/giveAdmin`);
+    const { username } = response.data.userData;
+    return username;
+  }
+
+  /**
+   * Remove user admin
+   * @memberOf AdminUsersContainer
+   * @param {string} userId
+   * @return {string} username
+   */
+  async removeUserAdmin(userId) {
+    const response = await this.appContainer.apiv3.put(`/users/${userId}/removeAdmin`);
+    const { username } = response.data.userData;
+    return username;
+  }
+
+  /**
+   * Activate user
+   * @memberOf AdminUsersContainer
+   * @param {string} userId
+   * @return {string} username
+   */
+  async activateUser(userId) {
+    const response = await this.appContainer.apiv3.put(`/users/${userId}/activate`);
+    const { username } = response.data.userData;
+    return username;
+  }
+
+  /**
+   * Deactivate user
+   * @memberOf AdminUsersContainer
+   * @param {string} userId
+   * @return {string} username
+   */
+  async deactivateUser(userId) {
+    const response = await this.appContainer.apiv3.put(`/users/${userId}/deactivate`);
+    const { username } = response.data.userData;
+    return username;
+  }
+
+  /**
+   * remove user
+   * @memberOf AdminUsersContainer
+   * @param {string} userId
+   * @return {string} username
+   */
+  async removeUser(userId) {
+    const response = await this.appContainer.apiv3.delete(`/users/${userId}/remove`);
+    const { username } = response.data.userData;
+    return username;
+  }
+
+}

+ 49 - 0
src/client/js/services/AppContainer.js

@@ -1,6 +1,7 @@
 import { Container } from 'unstated';
 
 import axios from 'axios';
+import urljoin from 'url-join';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
@@ -13,6 +14,7 @@ import {
 } from '../util/interceptor/detach-code-blocks';
 
 import i18nFactory from '../util/i18n';
+import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
 
 /**
  * Service container related to options for Application
@@ -69,6 +71,14 @@ export default class AppContainer extends Container {
     this.apiPost = this.apiPost.bind(this);
     this.apiDelete = this.apiDelete.bind(this);
     this.apiRequest = this.apiRequest.bind(this);
+
+    this.apiv3Root = '/_api/v3';
+    this.apiv3 = {
+      get: this.apiv3Get.bind(this),
+      post: this.apiv3Post.bind(this),
+      put: this.apiv3Put.bind(this),
+      delete: this.apiv3Delete.bind(this),
+    };
   }
 
   /**
@@ -307,4 +317,43 @@ export default class AppContainer extends Container {
     throw new Error(res.data.error);
   }
 
+  async apiv3Request(method, path, params) {
+    try {
+      const res = await axios[method](urljoin(this.apiv3Root, path), params);
+      return res.data;
+    }
+    catch (err) {
+      const errors = apiv3ErrorHandler(err);
+      throw errors;
+    }
+  }
+
+  async apiv3Get(path, params) {
+    return this.apiv3Request('get', path, { params });
+  }
+
+  async apiv3Post(path, params = {}) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('post', path, params);
+  }
+
+  async apiv3Put(path, params = {}) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('put', path, params);
+  }
+
+  async apiv3Delete(path, params = {}) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('delete', path, { params });
+  }
+
 }

+ 41 - 0
src/client/js/services/MarkDownSettingContainer.js

@@ -0,0 +1,41 @@
+import { Container } from 'unstated';
+
+/**
+ * Service container for admin markdown setting page (MarkDownSetting.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class MarkDownSettingContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      isEnabledXss: (appContainer.config.xssOption != null),
+      xssOption: appContainer.config.xssOption,
+      tagWhiteList: appContainer.config.tagWhiteList || '',
+      attrWhiteList: appContainer.config.attrWhiteList || '',
+    };
+
+    this.switchEnableXss = this.switchEnableXss.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'MarkDownSettingContainer';
+  }
+
+  /**
+   * Switch enableXss
+   */
+  switchEnableXss() {
+    if (this.state.isEnabledXss) {
+      this.setState({ xssOption: null });
+    }
+    this.setState({ isEnabledXss: !this.state.isEnabledXss });
+  }
+
+}

+ 135 - 0
src/client/js/services/UserGroupDetailContainer.js

@@ -0,0 +1,135 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:UserGroupDetailContainer');
+
+/**
+ * Service container for admin user group detail page (UserGroupDetailPage.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class UserGroupDetailContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO: [SPA] get userGroup from props
+      userGroup: JSON.parse(document.getElementById('admin-user-group-detail').getAttribute('data-user-group')),
+      userGroupRelations: [],
+      relatedPages: [],
+      isUserGroupUserModalOpen: false,
+    };
+
+    this.init();
+
+    this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
+    this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
+    this.addUserByUsername = this.addUserByUsername.bind(this);
+    this.removeUserByUsername = this.removeUserByUsername.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'UserGroupDetailContainer';
+  }
+
+  /**
+   * retrieve user group data
+   */
+  async init() {
+    try {
+      const [
+        userGroupRelations,
+        relatedPages,
+      ] = await Promise.all([
+        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
+        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
+      ]);
+
+      await this.setState({
+        userGroupRelations,
+        relatedPages,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+  /**
+   * update user group
+   *
+   * @memberOf UserGroupDetailContainer
+   * @param {object} param update param for user group
+   * @return {object} response object
+   */
+  async updateUserGroup(param) {
+    const res = await this.appContainer.apiv3.put(`/user-groups/${this.state.userGroup._id}`, param);
+    const { userGroup } = res.data;
+
+    await this.setState({ userGroup });
+
+    return res;
+  }
+
+  /**
+   * open a modal
+   *
+   * @memberOf UserGroupDetailContainer
+   */
+  async openUserGroupUserModal() {
+    await this.setState({ isUserGroupUserModalOpen: true });
+  }
+
+  /**
+   * close a modal
+   *
+   * @memberOf UserGroupDetailContainer
+   */
+  async closeUserGroupUserModal() {
+    await this.setState({ isUserGroupUserModalOpen: false });
+  }
+
+  /**
+   * update user group
+   *
+   * @memberOf UserGroupDetailContainer
+   * @param {string} username username of the user to be added to the group
+   */
+  async addUserByUsername(username) {
+    const res = await this.appContainer.apiv3.post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const { userGroupRelation } = res.data;
+
+    this.setState((prevState) => {
+      return {
+        userGroupRelations: [...prevState.userGroupRelations, userGroupRelation],
+      };
+    });
+  }
+
+  /**
+   * update user group
+   *
+   * @memberOf UserGroupDetailContainer
+   * @param {string} username username of the user to be removed from the group
+   */
+  async removeUserByUsername(username) {
+    const res = await this.appContainer.apiv3.delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+
+    this.setState((prevState) => {
+      return {
+        userGroupRelations: prevState.userGroupRelations.filter((u) => { return u._id !== res.data.userGroupRelation._id }),
+      };
+    });
+  }
+
+}

+ 37 - 0
src/client/js/util/apiNotification.js

@@ -0,0 +1,37 @@
+// show API error/sucess toastr
+
+import * as toastr from 'toastr';
+import toArrayIfNot from '../../../lib/util/toArrayIfNot';
+
+const toastrOption = {
+  error: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+  success: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+};
+
+// accepts both a single error and an array of errors
+export const toastError = (err, header = 'Error', option = toastrOption.error) => {
+  const errs = toArrayIfNot(err);
+
+  for (const err of errs) {
+    toastr.error(err.message, header, option);
+  }
+};
+
+// only accepts a single item
+export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
+  toastr.success(body, header, option);
+};

+ 20 - 0
src/client/js/util/apiv3ErrorHandler.js

@@ -0,0 +1,20 @@
+// API v3 sends an array of errors in res.data.errors.
+// API v3 errors need to extracted from an error object in order to properly handle them.
+
+import toArrayIfNot from '../../../lib/util/toArrayIfNot';
+
+const logger = require('@alias/logger')('growi:apiv3');
+
+const apiv3ErrorHandler = (_err, header = 'Error') => {
+  // extract api errors from general 400 err
+  const err = _err.response ? _err.response.data.errors : _err;
+  const errs = toArrayIfNot(err);
+
+  for (const err of errs) {
+    logger.error(err.message);
+  }
+
+  return errs;
+};
+
+export default apiv3ErrorHandler;

+ 1 - 7
src/server/crowi/express-init.js

@@ -24,10 +24,6 @@ module.exports = function(crowi, app) {
 
   const env = crowi.node_env;
 
-  // New type config API
-  const configManager = crowi.configManager;
-  const getConfig = configManager.getConfig;
-
   const User = crowi.model('User');
   const lngDetector = new i18nMiddleware.LanguageDetector();
   lngDetector.addDetector(i18nUserSettingDetector);
@@ -56,13 +52,12 @@ module.exports = function(crowi, app) {
 
   app.use((req, res, next) => {
     const now = new Date();
-    const tzoffset = -(getConfig('crowi', 'app:timezone') || 9) * 60;
     // for datez
 
     const Page = crowi.model('Page');
     const User = crowi.model('User');
     const Config = crowi.model('Config');
-    app.set('tzoffset', tzoffset);
+    app.set('tzoffset', crowi.appService.getTzoffset());
 
     req.csrfToken = null;
 
@@ -70,7 +65,6 @@ module.exports = function(crowi, app) {
     res.locals.baseUrl = crowi.appService.getSiteUrl();
     res.locals.env = env;
     res.locals.now = now;
-    res.locals.tzoffset = tzoffset;
     res.locals.consts = {
       pageGrants: Page.getGrantLabels(),
       userStatus: User.getUserStatusLabels(),

+ 8 - 0
src/server/crowi/index.js

@@ -14,6 +14,7 @@ const sep = path.sep;
 const mongoose = require('mongoose');
 
 const models = require('../models');
+const initMiddlewares = require('../middlewares');
 
 const PluginService = require('../plugins/plugin.service');
 
@@ -56,6 +57,7 @@ function Crowi(rootdir) {
   this.tokens = null;
 
   this.models = {};
+  this.middlewares = {};
 
   this.env = process.env;
   this.node_env = this.env.NODE_ENV || 'development';
@@ -83,6 +85,7 @@ function getMongoUrl(env) {
 Crowi.prototype.init = async function() {
   await this.setupDatabase();
   await this.setupModels();
+  await this.setupMiddlewares();
   await this.setupSessionConfig();
   await this.setupConfigManager();
 
@@ -259,6 +262,11 @@ Crowi.prototype.setupModels = async function() {
   });
 };
 
+Crowi.prototype.setupMiddlewares = async function() {
+  // const self = this;
+  this.middlewares = await initMiddlewares(this);
+};
+
 Crowi.prototype.getIo = function() {
   return this.io;
 };

+ 0 - 8
src/server/form/admin/userInvite.js

@@ -1,8 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('inviteForm[emailList]', '招待メールアドレス').trim().required(),
-  field('inviteForm[sendEmail]').trim(),
-);

+ 0 - 3
src/server/form/index.js

@@ -15,8 +15,6 @@ module.exports = {
     siteUrl: require('./admin/siteUrl'),
     mail: require('./admin/mail'),
     aws: require('./admin/aws'),
-    importerEsa: require('./admin/importerEsa'),
-    importerQiita: require('./admin/importerQiita'),
     plugin: require('./admin/plugin'),
     securityGeneral: require('./admin/securityGeneral'),
     securityPassportLocal: require('./admin/securityPassportLocal'),
@@ -39,7 +37,6 @@ module.exports = {
     customlayout: require('./admin/customlayout'),
     customfeatures: require('./admin/customfeatures'),
     customhighlightJsStyle: require('./admin/customhighlightJsStyle'),
-    userInvite: require('./admin/userInvite'),
     slackIwhSetting: require('./admin/slackIwhSetting'),
     slackSetting: require('./admin/slackSetting'),
     userGroupCreate: require('./admin/userGroupCreate'),

+ 30 - 0
src/server/middlewares/ApiV3FormValidator.js

@@ -0,0 +1,30 @@
+const logger = require('@alias/logger')('growi:middlewares:ApiV3FormValidator');
+const { validationResult } = require('express-validator/check');
+
+class ApiV3FormValidator {
+
+  constructor(crowi) {
+    const { ErrorV3 } = crowi.models;
+
+    return (req, res, next) => {
+      logger.debug('req.query', req.query);
+      logger.debug('req.params', req.params);
+      logger.debug('req.body', req.body);
+
+      const errObjArray = validationResult(req);
+      if (errObjArray.isEmpty()) {
+        return next();
+      }
+
+      const errs = errObjArray.array().map((err) => {
+        logger.error(`${err.location}.${err.param}: ${err.value} - ${err.msg}`);
+        return new ErrorV3(`${err.param}: ${err.msg}`, 'validation_failed');
+      });
+
+      return res.apiv3Err(errs);
+    };
+  }
+
+}
+
+module.exports = ApiV3FormValidator;

+ 21 - 0
src/server/middlewares/index.js

@@ -0,0 +1,21 @@
+const fs = require('fs');
+const path = require('path');
+
+const initMiddlewares = (crowi) => {
+  const basename = path.basename(__filename);
+  const middlewares = {};
+
+  fs
+    .readdirSync(__dirname)
+    .filter((file) => {
+      return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
+    })
+    .forEach((file) => {
+      const Middleware = require(path.join(__dirname, file));
+      middlewares[file.slice(0, -3)] = new Middleware(crowi);
+    });
+
+  return middlewares;
+};
+
+module.exports = initMiddlewares;

+ 13 - 0
src/server/models/ErrorV3.js

@@ -0,0 +1,13 @@
+class ErrorV3 extends Error {
+
+  constructor(message = '', code = '') {
+    super(); // do not provide message to the super constructor
+    this.message = message;
+    this.code = code;
+  }
+
+}
+
+module.exports = function(crowi) {
+  return ErrorV3;
+};

+ 3 - 0
src/server/models/index.js

@@ -15,4 +15,7 @@ module.exports = {
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
+
+  // non-persistent models
+  ErrorV3: require('./ErrorV3'),
 };

+ 8 - 6
src/server/models/page.js

@@ -7,6 +7,7 @@ const debug = require('debug')('growi:models:page');
 const nodePath = require('path');
 const urljoin = require('url-join');
 const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const { pathUtils } = require('growi-commons');
@@ -67,6 +68,7 @@ const pageSchema = new mongoose.Schema({
   toObject: { getters: true },
 });
 // apply plugins
+pageSchema.plugin(mongoosePaginate);
 pageSchema.plugin(uniqueValidator);
 
 
@@ -1281,7 +1283,7 @@ module.exports = function(crowi) {
     return pageData;
   };
 
-  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, selectedGroupId) {
+  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {
     const Page = mongoose.model('Page');
 
     const pages = await this.find({ grantedGroup: deletedGroup });
@@ -1299,7 +1301,7 @@ module.exports = function(crowi) {
         break;
       case 'transfer':
         await Promise.all(pages.map((page) => {
-          return Page.transferPageToGroup(page, selectedGroupId);
+          return Page.transferPageToGroup(page, transferToUserGroupId);
         }));
         break;
       default:
@@ -1313,17 +1315,17 @@ module.exports = function(crowi) {
     await page.save();
   };
 
-  pageSchema.statics.transferPageToGroup = async function(page, selectedGroupId) {
+  pageSchema.statics.transferPageToGroup = async function(page, transferToUserGroupId) {
     const UserGroup = mongoose.model('UserGroup');
 
     // check page existence
-    const isExist = await UserGroup.count({ _id: selectedGroupId }) > 0;
+    const isExist = await UserGroup.count({ _id: transferToUserGroupId }) > 0;
     if (isExist) {
-      page.grantedGroup = selectedGroupId;
+      page.grantedGroup = transferToUserGroupId;
       await page.save();
     }
     else {
-      throw new Error('Cannot find the group to which private pages belong to. _id: ', selectedGroupId);
+      throw new Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroupId);
     }
   };
 

+ 8 - 4
src/server/models/user-group.js

@@ -90,7 +90,7 @@ class UserGroup {
   }
 
   // グループの完全削除
-  static async removeCompletelyById(deleteGroupId, action, selectedGroupId) {
+  static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
     const UserGroupRelation = mongoose.model('UserGroupRelation');
     const Page = mongoose.model('Page');
 
@@ -102,22 +102,26 @@ class UserGroup {
 
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
-      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, selectedGroupId),
+      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
     ]);
 
     return deletedGroup;
   }
 
+  static countUserGroups() {
+    return this.estimatedDocumentCount();
+  }
+
   // グループ生成(名前が要る)
   static createGroupByName(name) {
     return this.create({ name });
   }
 
   // グループ名の更新
-  updateName(name) {
+  async updateName(name) {
     // 名前を設定して更新
     this.name = name;
-    return this.save();
+    await this.save();
   }
 
 }

+ 97 - 128
src/server/models/user.js

@@ -2,14 +2,13 @@
 
 const debug = require('debug')('growi:models:user');
 const logger = require('@alias/logger')('growi:models:user');
-const path = require('path');
 const mongoose = require('mongoose');
+const path = require('path');
 const uniqueValidator = require('mongoose-unique-validator');
 const mongoosePaginate = require('mongoose-paginate');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const crypto = require('crypto');
-const async = require('async');
 
 module.exports = function(crowi) {
   const STATUS_REGISTERED = 1;
@@ -17,7 +16,7 @@ module.exports = function(crowi) {
   const STATUS_SUSPENDED = 3;
   const STATUS_DELETED = 4;
   const STATUS_INVITED = 5;
-  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction status lang createdAt admin';
+  const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction status lang createdAt lastLoginAt admin';
   const IMAGE_POPULATION = { path: 'imageAttachment', select: 'filePathProxied' };
 
   const LANG_EN = 'en';
@@ -286,20 +285,16 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.methods.removeFromAdmin = function(callback) {
+  userSchema.methods.removeFromAdmin = async function() {
     debug('Remove from admin', this);
     this.admin = 0;
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
+    return this.save();
   };
 
-  userSchema.methods.makeAdmin = function(callback) {
+  userSchema.methods.makeAdmin = async function() {
     debug('Admin', this);
     this.admin = 1;
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
+    return this.save();
   };
 
   userSchema.methods.asyncMakeAdmin = async function(callback) {
@@ -307,16 +302,14 @@ module.exports = function(crowi) {
     return this.save();
   };
 
-  userSchema.methods.statusActivate = function(callback) {
+  userSchema.methods.statusActivate = async function() {
     debug('Activate User', this);
     this.status = STATUS_ACTIVE;
-    this.save((err, userData) => {
-      userEvent.emit('activated', userData);
-      return callback(err, userData);
-    });
+    const userData = await this.save();
+    return userEvent.emit('activated', userData);
   };
 
-  userSchema.methods.statusSuspend = function(callback) {
+  userSchema.methods.statusSuspend = async function() {
     debug('Suspend User', this);
     this.status = STATUS_SUSPENDED;
     if (this.email === undefined || this.email === null) { // migrate old data
@@ -328,12 +321,10 @@ module.exports = function(crowi) {
     if (this.username === undefined || this.usename === null) { // migrate old data
       this.username = '-';
     }
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
+    return this.save();
   };
 
-  userSchema.methods.statusDelete = function(callback) {
+  userSchema.methods.statusDelete = async function() {
     debug('Delete User', this);
 
     const now = new Date();
@@ -347,9 +338,7 @@ module.exports = function(crowi) {
     this.googleId = null;
     this.isGravatarEnabled = false;
     this.image = null;
-    this.save((err, userData) => {
-      return callback(err, userData);
-    });
+    return this.save();
   };
 
   userSchema.methods.updateGoogleId = function(googleId, callback) {
@@ -612,123 +601,103 @@ module.exports = function(crowi) {
     return newPassword;
   };
 
-  userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {
-    validateCrowi();
-
+  userSchema.statics.createUserByEmail = async function(email) {
     const configManager = crowi.configManager;
 
     const User = this;
-    const createdUserList = [];
-    const mailer = crowi.getMailer();
+    const newUser = new User();
 
-    if (!Array.isArray(emailList)) {
-      debug('emailList is not array');
+    /* eslint-disable newline-per-chained-call */
+    const tmpUsername = `temp_${Math.random().toString(36).slice(-16)}`;
+    const password = Math.random().toString(36).slice(-16);
+    /* eslint-enable newline-per-chained-call */
+
+    newUser.username = tmpUsername;
+    newUser.email = email;
+    newUser.setPassword(password);
+    newUser.createdAt = Date.now();
+    newUser.status = STATUS_INVITED;
+
+    const globalLang = configManager.getConfig('crowi', 'app:globalLang');
+    if (globalLang != null) {
+      newUser.lang = globalLang;
     }
 
-    async.each(
-      emailList,
-      (email, next) => {
-        const newUser = new User();
-        let tmpUsername;
-        let password;
-
-        // eslint-disable-next-line no-param-reassign
-        email = email.trim();
-
-        // email check
-        // TODO: 削除済みはチェック対象から外そう〜
-        User.findOne({ email }, (err, userData) => {
-          // The user is exists
-          if (userData) {
-            createdUserList.push({
-              email,
-              password: null,
-              user: null,
-            });
-
-            return next();
-          }
+    try {
+      const newUserData = await newUser.save();
+      return {
+        email,
+        password,
+        user: newUserData,
+      };
+    }
+    catch (err) {
+      return {
+        email,
+      };
+    }
+  };
 
-          /* eslint-disable newline-per-chained-call */
-          tmpUsername = `temp_${Math.random().toString(36).slice(-16)}`;
-          password = Math.random().toString(36).slice(-16);
-          /* eslint-enable newline-per-chained-call */
+  userSchema.statics.createUsersByEmailList = async function(emailList) {
+    const User = this;
 
-          newUser.username = tmpUsername;
-          newUser.email = email;
-          newUser.setPassword(password);
-          newUser.createdAt = Date.now();
-          newUser.status = STATUS_INVITED;
+    // check exists and get list of tyr to create
+    const existingUserList = await User.find({ email: { $in: emailList }, userStatus: { $ne: STATUS_DELETED } });
+    const existingEmailList = existingUserList.map((user) => { return user.email });
+    const creationEmailList = emailList.filter((email) => { return existingEmailList.indexOf(email) === -1 });
 
-          const globalLang = configManager.getConfig('crowi', 'app:globalLang');
-          if (globalLang != null) {
-            newUser.lang = globalLang;
-          }
+    const createdUserList = [];
+    await Promise.all(creationEmailList.map(async(email) => {
+      const createdEmail = await this.createUserByEmail(email);
+      createdUserList.push(createdEmail);
+    }));
+
+    return { existingEmailList, createdUserList };
+  };
+
+  userSchema.statics.sendEmailbyUserList = async function(userList) {
+    const mailer = crowi.getMailer();
+    const appTitle = crowi.appService.getAppTitle();
+
+    await Promise.all(userList.map(async(user) => {
+      if (user.password == null) {
+        return;
+      }
 
-          newUser.save((err, userData) => {
-            if (err) {
-              createdUserList.push({
-                email,
-                password: null,
-                user: null,
-              });
-              debug('save failed!! ', err);
-            }
-            else {
-              createdUserList.push({
-                email,
-                password,
-                user: userData,
-              });
-              debug('saved!', email);
-            }
-
-            next();
-          });
+      try {
+        return mailer.send({
+          to: user.email,
+          subject: `Invitation to ${appTitle}`,
+          template: path.join(crowi.localeDir, 'en-US/admin/userInvitation.txt'),
+          vars: {
+            email: user.email,
+            password: user.password,
+            url: crowi.appService.getSiteUrl(),
+            appTitle,
+          },
         });
-      },
-      (err) => {
-        if (err) {
-          debug('error occured while iterate email list');
-        }
+      }
+      catch (err) {
+        return debug('fail to send email: ', err);
+      }
+    }));
 
-        if (toSendEmail) {
-          // TODO: メール送信部分のロジックをサービス化する
-          async.each(
-            createdUserList,
-            (user, next) => {
-              if (user.password === null) {
-                return next();
-              }
-
-              const appTitle = crowi.appService.getAppTitle();
-
-              mailer.send({
-                to: user.email,
-                subject: `Invitation to ${appTitle}`,
-                template: path.join(crowi.localeDir, 'en-US/admin/userInvitation.txt'),
-                vars: {
-                  email: user.email,
-                  password: user.password,
-                  url: crowi.appService.getSiteUrl(),
-                  appTitle,
-                },
-              },
-              (err, s) => {
-                debug('completed to send email: ', err, s);
-                next();
-              });
-            },
-            (err) => {
-              debug('Sending invitation email completed.', err);
-            },
-          );
-        }
+  };
 
-        debug('createdUserList!!! ', createdUserList);
-        return callback(null, createdUserList);
-      },
-    );
+  userSchema.statics.createUsersByInvitation = async function(emailList, toSendEmail) {
+    validateCrowi();
+
+    if (!Array.isArray(emailList)) {
+      debug('emailList is not array');
+    }
+
+    const afterWorkEmailList = await this.createUsersByEmailList(emailList);
+
+    if (toSendEmail) {
+      await this.sendEmailbyUserList(afterWorkEmailList.createdUserList);
+    }
+
+    return afterWorkEmailList;
   };
 
   userSchema.statics.createUserByEmailAndPasswordAndStatus = async function(name, username, email, password, lang, status, callback) {

+ 58 - 313
src/server/routes/admin.js

@@ -4,7 +4,6 @@ module.exports = function(crowi, app) {
   const logger = require('@alias/logger')('growi:routes:admin');
 
   const models = crowi.models;
-  const Page = models.Page;
   const User = models.User;
   const ExternalAccount = models.ExternalAccount;
   const UserGroup = models.UserGroup;
@@ -32,6 +31,10 @@ module.exports = function(crowi, app) {
   const MAX_PAGE_LIST = 50;
   const actions = {};
 
+  const { check } = require('express-validator/check');
+
+  const api = {};
+
   function createPager(total, limit, page, pagesCount, maxPageList) {
     const pager = {
       page,
@@ -128,6 +131,7 @@ module.exports = function(crowi, app) {
 
   // app.post('/admin/markdown/lineBreaksSetting' , admin.markdown.lineBreaksSetting);
   actions.markdown.lineBreaksSetting = async function(req, res) {
+
     const markdownSetting = req.form.markdownSetting;
 
     if (req.form.isValid) {
@@ -137,8 +141,8 @@ module.exports = function(crowi, app) {
     else {
       req.flash('errorMessage', req.form.errors);
     }
-
     return res.redirect('/admin/markdown');
+
   };
 
   // app.post('/admin/markdown/presentationSetting' , admin.markdown.presentationSetting);
@@ -456,135 +460,6 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.user.invite = function(req, res) {
-    const form = req.form.inviteForm;
-    const toSendEmail = form.sendEmail || false;
-    if (req.form.isValid) {
-      User.createUsersByInvitation(form.emailList.split('\n'), toSendEmail, (err, userList) => {
-        if (err) {
-          req.flash('errorMessage', req.form.errors.join('\n'));
-        }
-        else {
-          req.flash('createdUser', userList);
-        }
-        return res.redirect('/admin/users');
-      });
-    }
-    else {
-      req.flash('errorMessage', req.form.errors.join('\n'));
-      return res.redirect('/admin/users');
-    }
-  };
-
-  actions.user.makeAdmin = function(req, res) {
-    const id = req.params.id;
-    User.findById(id, (err, userData) => {
-      userData.makeAdmin((err, userData) => {
-        if (err === null) {
-          req.flash('successMessage', `${userData.name}さんのアカウントを管理者に設定しました。`);
-        }
-        else {
-          req.flash('errorMessage', '更新に失敗しました。');
-          debug(err, userData);
-        }
-        return res.redirect('/admin/users');
-      });
-    });
-  };
-
-  actions.user.removeFromAdmin = function(req, res) {
-    const id = req.params.id;
-    User.findById(id, (err, userData) => {
-      userData.removeFromAdmin((err, userData) => {
-        if (err === null) {
-          req.flash('successMessage', `${userData.name}さんのアカウントを管理者から外しました。`);
-        }
-        else {
-          req.flash('errorMessage', '更新に失敗しました。');
-          debug(err, userData);
-        }
-        return res.redirect('/admin/users');
-      });
-    });
-  };
-
-  actions.user.activate = async function(req, res) {
-    // check user upper limit
-    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-    if (isUserCountExceedsUpperLimit) {
-      req.flash('errorMessage', 'ユーザーが上限に達したため有効化できません。');
-      return res.redirect('/admin/users');
-    }
-
-    const id = req.params.id;
-    User.findById(id, (err, userData) => {
-      userData.statusActivate((err, userData) => {
-        if (err === null) {
-          req.flash('successMessage', `${userData.name}さんのアカウントを有効化しました`);
-        }
-        else {
-          req.flash('errorMessage', '更新に失敗しました。');
-          debug(err, userData);
-        }
-        return res.redirect('/admin/users');
-      });
-    });
-  };
-
-  actions.user.suspend = function(req, res) {
-    const id = req.params.id;
-
-    User.findById(id, (err, userData) => {
-      userData.statusSuspend((err, userData) => {
-        if (err === null) {
-          req.flash('successMessage', `${userData.name}さんのアカウントを利用停止にしました`);
-        }
-        else {
-          req.flash('errorMessage', '更新に失敗しました。');
-          debug(err, userData);
-        }
-        return res.redirect('/admin/users');
-      });
-    });
-  };
-
-  actions.user.remove = function(req, res) {
-    const id = req.params.id;
-    let username = '';
-
-    return new Promise((resolve, reject) => {
-      User.findById(id, (err, userData) => {
-        username = userData.username;
-        return resolve(userData);
-      });
-    })
-      .then((userData) => {
-        return new Promise((resolve, reject) => {
-          userData.statusDelete((err, userData) => {
-            if (err) {
-              reject(err);
-            }
-            resolve(userData);
-          });
-        });
-      })
-      .then((userData) => {
-      // remove all External Accounts
-        return ExternalAccount.remove({ user: userData }).then(() => { return userData });
-      })
-      .then((userData) => {
-        return Page.removeByPath(`/user/${username}`).then(() => { return userData });
-      })
-      .then((userData) => {
-        req.flash('successMessage', `${username} さんのアカウントを削除しました`);
-        return res.redirect('/admin/users');
-      })
-      .catch((err) => {
-        req.flash('errorMessage', '削除に失敗しました。');
-        return res.redirect('/admin/users');
-      });
-  };
-
   // これやったときの relation の挙動未確認
   actions.user.removeCompletely = function(req, res) {
     // ユーザーの物理削除
@@ -677,7 +552,12 @@ module.exports = function(crowi, app) {
           return new Promise((resolve, reject) => {
             UserGroupRelation.findAllRelationForUserGroup(userGroup)
               .then((relations) => {
-                return resolve([userGroup, relations]);
+                return resolve({
+                  id: userGroup._id,
+                  relatedUsers: relations.map((relation) => {
+                    return relation.relatedUser;
+                  }),
+                });
               });
           });
         });
@@ -686,7 +566,9 @@ module.exports = function(crowi, app) {
         return Promise.all(allRelationsPromise);
       })
       .then((relations) => {
-        renderVar.userGroupRelations = new Map(relations);
+        for (const relation of relations) {
+          renderVar.userGroupRelations[relation.id] = relation.relatedUsers;
+        }
         debug('in findUserGroupsWithPagination findAllRelationForUserGroupResult', renderVar.userGroupRelations);
         return res.render('admin/user-groups', renderVar);
       })
@@ -699,176 +581,46 @@ module.exports = function(crowi, app) {
   // グループ詳細
   actions.userGroup.detail = async function(req, res) {
     const userGroupId = req.params.id;
-    const renderVar = {
-      userGroup: null,
-      userGroupRelations: [],
-      notRelatedusers: [],
-      relatedPages: [],
-    };
-
     const userGroup = await UserGroup.findOne({ _id: userGroupId });
 
     if (userGroup == null) {
       logger.error('no userGroup is exists. ', userGroupId);
-      req.flash('errorMessage', 'グループがありません');
       return res.redirect('/admin/user-groups');
     }
-    renderVar.userGroup = userGroup;
-
-    const resolves = await Promise.all([
-      // get all user and group relations
-      UserGroupRelation.findAllRelationForUserGroup(userGroup),
-      // get all not related users for group
-      UserGroupRelation.findUserByNotRelatedGroup(userGroup),
-      // get all related pages
-      Page.find({ grant: Page.GRANT_USER_GROUP, grantedGroup: { $in: [userGroup] } }),
-    ]);
-    renderVar.userGroupRelations = resolves[0];
-    renderVar.notRelatedusers = resolves[1];
-    renderVar.relatedPages = resolves[2];
-
-    return res.render('admin/user-group-detail', renderVar);
-  };
 
-  // グループの生成
-  actions.userGroup.create = function(req, res) {
-    const form = req.form.createGroupForm;
-    if (req.form.isValid) {
-      const userGroupName = crowi.xss.process(form.userGroupName);
-
-      UserGroup.createGroupByName(userGroupName)
-        .then((newUserGroup) => {
-          req.flash('successMessage', newUserGroup.name);
-          req.flash('createdUserGroup', newUserGroup);
-          return res.redirect('/admin/user-groups');
-        })
-        .catch((err) => {
-          debug('create userGroup error:', err);
-          req.flash('errorMessage', '同じグループ名が既に存在します。');
-        });
-    }
-    else {
-      req.flash('errorMessage', req.form.errors.join('\n'));
-      return res.redirect('/admin/user-groups');
-    }
-  };
-
-  //
-  actions.userGroup.update = function(req, res) {
-    const userGroupId = req.params.userGroupId;
-    const name = crowi.xss.process(req.body.name);
-
-    UserGroup.findById(userGroupId)
-      .then((userGroupData) => {
-        if (userGroupData == null) {
-          req.flash('errorMessage', 'グループの検索に失敗しました。');
-          return new Promise();
-        }
-
-        // 名前存在チェック
-        return UserGroup.isRegisterableName(name)
-          .then((isRegisterableName) => {
-          // 既に存在するグループ名に更新しようとした場合はエラー
-            if (!isRegisterableName) {
-              req.flash('errorMessage', 'グループ名が既に存在します。');
-            }
-            else {
-              return userGroupData.updateName(name)
-                .then(() => {
-                  req.flash('successMessage', 'グループ名を更新しました。');
-                })
-                .catch((err) => {
-                  req.flash('errorMessage', 'グループ名の更新に失敗しました。');
-                });
-            }
-          });
-      })
-      .then(() => {
-        return res.redirect(`/admin/user-group-detail/${userGroupId}`);
-      });
-  };
-
-
-  // app.post('/_api/admin/user-group/delete' , admin.userGroup.removeCompletely);
-  actions.userGroup.removeCompletely = async(req, res) => {
-    const { deleteGroupId, actionName, selectedGroupId } = req.body;
-
-    try {
-      await UserGroup.removeCompletelyById(deleteGroupId, actionName, selectedGroupId);
-      req.flash('successMessage', '削除しました');
-    }
-    catch (err) {
-      debug('Error while removing userGroup.', err, deleteGroupId);
-      req.flash('errorMessage', '完全な削除に失敗しました。');
-    }
-
-    return res.redirect('/admin/user-groups');
-  };
-
-  actions.userGroupRelation = {};
-  actions.userGroupRelation.index = function(req, res) {
-
-  };
-
-  actions.userGroupRelation.create = function(req, res) {
-    const User = crowi.model('User');
-    const UserGroup = crowi.model('UserGroup');
-    const UserGroupRelation = crowi.model('UserGroupRelation');
-
-    // req params
-    const userName = req.body.user_name;
-    const userGroupId = req.body.user_group_id;
-
-    let user = null;
-    let userGroup = null;
-
-    Promise.all([
-      // ユーザグループをIDで検索
-      UserGroup.findById(userGroupId),
-      // ユーザを名前で検索
-      User.findUserByUsername(userName),
-    ])
-      .then((resolves) => {
-        userGroup = resolves[0];
-        user = resolves[1];
-        // Relation を作成
-        UserGroupRelation.createRelation(userGroup, user);
-      })
-      .then((result) => {
-        return res.redirect(`/admin/user-group-detail/${userGroup.id}`);
-      })
-      .catch((err) => {
-        debug('Error on create user-group relation', err);
-        req.flash('errorMessage', 'Error on create user-group relation');
-        return res.redirect(`/admin/user-group-detail/${userGroup.id}`);
-      });
-  };
-
-  actions.userGroupRelation.remove = function(req, res) {
-    const UserGroupRelation = crowi.model('UserGroupRelation');
-    const userGroupId = req.params.id;
-    const relationId = req.params.relationId;
-
-    UserGroupRelation.removeById(relationId)
-      .then(() => {
-        return res.redirect(`/admin/user-group-detail/${userGroupId}`);
-      })
-      .catch((err) => {
-        debug('Error on remove user-group-relation', err);
-        req.flash('errorMessage', 'グループのユーザ削除に失敗しました。');
-      });
+    return res.render('admin/user-group-detail', { userGroup });
   };
 
   // Importer management
   actions.importer = {};
+  actions.importer.api = api;
+  api.validators = {};
+  api.validators.importer = {};
+
   actions.importer.index = function(req, res) {
     const settingForm = configManager.getConfigByPrefix('crowi', 'importer:');
-
     return res.render('admin/importer', {
       settingForm,
     });
   };
 
+  api.validators.importer.esa = function() {
+    const validator = [
+      check('importer:esa:team_name').not().isEmpty().withMessage('Error. Empty esa:team_name'),
+      check('importer:esa:access_token').not().isEmpty().withMessage('Error. Empty esa:access_token'),
+    ];
+    return validator;
+  };
+
+  api.validators.importer.qiita = function() {
+    const validator = [
+      check('importer:qiita:team_name').not().isEmpty().withMessage('Error. Empty qiita:team_name'),
+      check('importer:qiita:access_token').not().isEmpty().withMessage('Error. Empty qiita:access_token'),
+    ];
+    return validator;
+  };
+
+
   // Export management
   actions.export = {};
   actions.export.index = (req, res) => {
@@ -1272,16 +1024,17 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   actions.api.importerSettingEsa = async(req, res) => {
-    const form = req.form.settingForm;
+    const form = req.body;
 
-    if (!req.form.isValid) {
-      return res.json({ status: false, message: req.form.errors.join('\n') });
+    const { validationResult } = require('express-validator');
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      return res.json(ApiResponse.error('esa.io form is blank'));
     }
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeEsaClient(); // let it run in the back aftert res
-
-    return res.json({ status: true });
+    return res.json(ApiResponse.success());
   };
 
   /**
@@ -1291,16 +1044,19 @@ module.exports = function(crowi, app) {
    * @param {*} res
    */
   actions.api.importerSettingQiita = async(req, res) => {
-    const form = req.form.settingForm;
+    const form = req.body;
 
-    if (!req.form.isValid) {
-      return res.json({ status: false, message: req.form.errors.join('\n') });
+    const { validationResult } = require('express-validator');
+    const errors = validationResult(req);
+    if (!errors.isEmpty()) {
+      console.log('validator', errors);
+      return res.json(ApiResponse.error('Qiita form is blank'));
     }
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeQiitaClient(); // let it run in the back aftert res
 
-    return res.json({ status: true });
+    return res.json(ApiResponse.success());
   };
 
   /**
@@ -1321,9 +1077,9 @@ module.exports = function(crowi, app) {
     }
 
     if (errors.length > 0) {
-      return res.json({ status: false, message: `<br> - ${errors.join('<br> - ')}` });
+      return res.json(ApiResponse.error(`<br> - ${errors.join('<br> - ')}`));
     }
-    return res.json({ status: true });
+    return res.json(ApiResponse.success());
   };
 
   /**
@@ -1344,9 +1100,9 @@ module.exports = function(crowi, app) {
     }
 
     if (errors.length > 0) {
-      return res.json({ status: false, message: `<br> - ${errors.join('<br> - ')}` });
+      return res.json(ApiResponse.error(`<br> - ${errors.join('<br> - ')}`));
     }
-    return res.json({ status: true });
+    return res.json(ApiResponse.success());
   };
 
   /**
@@ -1358,10 +1114,10 @@ module.exports = function(crowi, app) {
   actions.api.testEsaAPI = async(req, res) => {
     try {
       await importer.testConnectionToEsa();
-      return res.json({ status: true });
+      return res.json(ApiResponse.success());
     }
     catch (err) {
-      return res.json({ status: false, message: `${err}` });
+      return res.json(ApiResponse.error(err));
     }
   };
 
@@ -1374,10 +1130,10 @@ module.exports = function(crowi, app) {
   actions.api.testQiitaAPI = async(req, res) => {
     try {
       await importer.testConnectionToQiita();
-      return res.json({ status: true });
+      return res.json(ApiResponse.success());
     }
     catch (err) {
-      return res.json({ status: false, message: `${err}` });
+      return res.json(ApiResponse.error(err));
     }
   };
 
@@ -1400,17 +1156,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
   };
 
-  actions.api.userGroups = async(req, res) => {
-    try {
-      const userGroups = await UserGroup.find();
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.json(ApiResponse.error('Error'));
-    }
-  };
-
   function validateMailSetting(req, form, callback) {
     const mailer = crowi.mailer;
     const option = {

+ 1 - 1
src/server/routes/apiv3/import.js

@@ -53,7 +53,7 @@ module.exports = (crowi) => {
    * @return {object} document to be persisted
    */
   const overwriteParamsFn = async(Model, schema, req) => {
-    const { collectionName } = Model.collection;
+    const collectionName = Model.collection.name;
 
     /* eslint-disable no-case-declarations */
     switch (Model.collection.collectionName) {

+ 14 - 0
src/server/routes/apiv3/index.js

@@ -7,13 +7,27 @@ const express = require('express');
 const router = express.Router();
 
 module.exports = (crowi) => {
+
+  // add custom functions to express response
+  require('./response')(express, crowi);
+
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
+  router.use('/markdown-setting', require('./markdown-setting')(crowi));
+
+  router.use('/users', require('./users')(crowi));
+
+  router.use('/user-groups', require('./user-group')(crowi));
+
+  router.use('/user-group-relations', require('./user-group-relation')(crowi));
+
   router.use('/mongo', require('./mongo')(crowi));
 
   router.use('/export', require('./export')(crowi));
 
   router.use('/import', require('./import')(crowi));
 
+  router.use('/statistics', require('./statistics')(crowi));
+
   return router;
 };

+ 26 - 0
src/server/routes/apiv3/markdown-setting.js

@@ -0,0 +1,26 @@
+/* eslint-disable no-unused-vars */
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:user-group');
+
+const express = require('express');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: MarkDownSetting
+ */
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+
+  const {
+    ErrorV3,
+    Config,
+  } = crowi.models;
+
+  return router;
+};

+ 1 - 1
src/server/routes/apiv3/mongo.js

@@ -33,7 +33,7 @@ module.exports = (crowi) => {
    *                      type: string
    */
   router.get('/collections', async(req, res) => {
-    const collections = Object.values(crowi.models).map(model => model.collection.collectionName);
+    const collections = Object.values(crowi.models).map(model => model.collection.name);
 
     // TODO: use res.apiv3
     return res.json({

+ 36 - 0
src/server/routes/apiv3/response.js

@@ -0,0 +1,36 @@
+const toArrayIfNot = require('../../../lib/util/toArrayIfNot');
+
+const addCustomFunctionToResponse = (express, crowi) => {
+  const { ErrorV3 } = crowi.models;
+
+  express.response.apiv3 = function(obj) { // not arrow function
+    // obj must be object
+    if (typeof obj !== 'object' || obj instanceof Array) {
+      throw new Error('invalid value supplied to res.apiv3');
+    }
+
+    this.json({ data: obj });
+  };
+
+  express.response.apiv3Err = function(_err, status = 400) { // not arrow function
+    if (!Number.isInteger(status)) {
+      throw new Error('invalid status supplied to res.apiv3Err');
+    }
+
+    let errors = toArrayIfNot(_err);
+    errors = errors.map((e) => {
+      if (e instanceof ErrorV3) {
+        return e;
+      }
+      if (typeof e === 'string') {
+        return { message: e };
+      }
+
+      throw new Error('invalid error supplied to res.apiv3Err');
+    });
+
+    this.status(status).json({ errors });
+  };
+};
+
+module.exports = addCustomFunctionToResponse;

+ 107 - 0
src/server/routes/apiv3/statistics.js

@@ -0,0 +1,107 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:healthcheck'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+const helmet = require('helmet');
+
+const util = require('util');
+
+const USER_STATUS_MASTER = {
+  1: 'registered',
+  2: 'active',
+  3: 'suspended',
+  4: 'deleted',
+  5: 'invited',
+};
+
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Statistics
+ */
+module.exports = (crowi) => {
+
+  const models = crowi.models;
+  const User = models.User;
+
+  const getUserStatistics = async() => {
+    const userCountGroupByStatus = await User.aggregate().group({
+      _id: '$status',
+      totalCount: { $sum: 1 },
+    });
+
+    // Initialize userCountResults with 0
+    const userCountResults = {};
+    Object.values(USER_STATUS_MASTER).forEach((status) => {
+      userCountResults[status] = 0;
+    });
+
+    userCountGroupByStatus.forEach((userCount) => {
+      const key = USER_STATUS_MASTER[userCount._id];
+      userCountResults[key] = userCount.totalCount;
+    });
+    const activeUserCount = userCountResults.active;
+
+    // Use userCountResults for inactive users, so delete unnecessary active
+    delete userCountResults.active;
+
+    // Calculate the total number of inactive users
+    const inactiveUserTotal = userCountResults.invited + userCountResults.deleted + userCountResults.suspended + userCountResults.registered;
+
+    // Get admin users
+    const findAdmins = util.promisify(User.findAdmins).bind(User);
+    const adminUsers = await findAdmins();
+
+    return {
+      total: activeUserCount + inactiveUserTotal,
+      active: {
+        total: activeUserCount,
+        admin: adminUsers.length,
+      },
+      inactive: {
+        total: inactiveUserTotal,
+        ...userCountResults,
+      },
+    };
+  };
+
+  const getUserStatisticsForNotLoggedIn = async() => {
+    const data = await getUserStatistics();
+    delete data.active.admin;
+    delete data.inactive.invited;
+    delete data.inactive.deleted;
+    delete data.inactive.suspended;
+    delete data.inactive.registered;
+    return data;
+  };
+
+  /**
+   * @swagger
+   *
+   *  /statistics/user:
+   *    get:
+   *      tags: [Statistics]
+   *      description: Get statistics for user
+   *      responses:
+   *        200:
+   *          description: Statistics for user
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  data:
+   *                    type: object
+   *                    description: Statistics for all user
+   */
+  router.get('/user', helmet.noCache(), async(req, res) => {
+    const data = req.user == null ? await getUserStatisticsForNotLoggedIn() : await getUserStatistics();
+    res.status(200).send({ data });
+  });
+
+  return router;
+};

+ 127 - 0
src/server/routes/apiv3/user-group-relation.js

@@ -0,0 +1,127 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: UserGroupRelation
+ */
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+
+  const { ErrorV3, UserGroup, UserGroupRelation } = crowi.models;
+
+  /**
+   * @swagger
+   *  paths:
+   *    /_api/v3/user-group-relations:
+   *      get:
+   *        tags: [UserGroupRelation]
+   *        description: Gets the user group relations
+   *        responses:
+   *          200:
+   *            description: user group relations are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroupRelations:
+   *                      type: object
+   *                      description: contains arrays user objects related
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    // TODO: filter with querystring? or body
+    try {
+      const page = parseInt(req.query.page) || 1;
+      const result = await UserGroup.findUserGroupsWithPagination({ page });
+      // const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+      const userGroups = result.docs;
+
+      const userGroupRelationsObj = {};
+      await Promise.all(userGroups.map(async(userGroup) => {
+        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+        userGroupRelationsObj[userGroup._id] = userGroupRelations.map((userGroupRelation) => {
+          return userGroupRelation.relatedUser;
+        });
+      }));
+
+      const data = {
+        userGroupRelations: userGroupRelationsObj,
+      };
+
+      return res.apiv3(data);
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching user group relations';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-relation-list-fetch-failed'));
+    }
+  });
+
+  return router;
+};
+
+// const MAX_PAGE_LIST = 50;
+
+// function createPager(total, limit, page, pagesCount, maxPageList) {
+//   const pager = {
+//     page,
+//     pagesCount,
+//     pages: [],
+//     total,
+//     previous: null,
+//     previousDots: false,
+//     next: null,
+//     nextDots: false,
+//   };
+
+//   if (page > 1) {
+//     pager.previous = page - 1;
+//   }
+
+//   if (page < pagesCount) {
+//     pager.next = page + 1;
+//   }
+
+//   let pagerMin = Math.max(1, Math.ceil(page - maxPageList / 2));
+//   let pagerMax = Math.min(pagesCount, Math.floor(page + maxPageList / 2));
+//   if (pagerMin === 1) {
+//     if (MAX_PAGE_LIST < pagesCount) {
+//       pagerMax = MAX_PAGE_LIST;
+//     }
+//     else {
+//       pagerMax = pagesCount;
+//     }
+//   }
+//   if (pagerMax === pagesCount) {
+//     if ((pagerMax - MAX_PAGE_LIST) < 1) {
+//       pagerMin = 1;
+//     }
+//     else {
+//       pagerMin = pagerMax - MAX_PAGE_LIST;
+//     }
+//   }
+
+//   pager.previousDots = null;
+//   if (pagerMin > 1) {
+//     pager.previousDots = true;
+//   }
+
+//   pager.nextDots = null;
+//   if (pagerMax < pagesCount) {
+//     pager.nextDots = true;
+//   }
+
+//   for (let i = pagerMin; i <= pagerMax; i++) {
+//     pager.pages.push(i);
+//   }
+
+//   return pager;
+// }

+ 567 - 0
src/server/routes/apiv3/user-group.js

@@ -0,0 +1,567 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body, param, query } = require('express-validator/check');
+const { sanitizeQuery } = require('express-validator/filter');
+
+const validator = {};
+
+const { ObjectId } = require('mongoose').Types;
+
+const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
+
+/**
+ * @swagger
+ *  tags:
+ *    name: UserGroup
+ */
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const {
+    ErrorV3,
+    UserGroup,
+    UserGroupRelation,
+    User,
+    Page,
+  } = crowi.models;
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get usergroups
+   *        responses:
+   *          200:
+   *            description: usergroups are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: object
+   *                      description: a result of `UserGroup.find`
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    // TODO: filter with querystring
+    try {
+      const page = parseInt(req.query.page) || 1;
+      const result = await UserGroup.findUserGroupsWithPagination({ page });
+      const { docs: userGroups, total: totalUserGroups, limit: pagingLimit } = result;
+      return res.apiv3({ userGroups, totalUserGroups, pagingLimit });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching user group list';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'));
+    }
+  });
+
+  validator.create = [
+    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups:
+   *      post:
+   *        tags: [UserGroup]
+   *        description: Adds userGroup
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  name:
+   *                    type: string
+   *                    description: name of the userGroup trying to be added
+   *        responses:
+   *          200:
+   *            description: userGroup is added
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroup:
+   *                      type: object
+   *                      description: A result of `UserGroup.createGroupByName`
+   */
+  router.post('/', loginRequiredStrictly, adminRequired, csrf, validator.create, ApiV3FormValidator, async(req, res) => {
+    const { name } = req.body;
+
+    try {
+      const userGroupName = crowi.xss.process(name);
+      const userGroup = await UserGroup.createGroupByName(userGroupName);
+
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in creating a user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-create-failed'));
+    }
+  });
+
+  validator.delete = [
+    param('id').trim().exists({ checkFalsy: true }),
+    query('actionName').trim().exists({ checkFalsy: true }),
+    query('transferToUserGroupId').trim(),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}:
+   *      delete:
+   *        tags: [UserGroup]
+   *        description: Deletes userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *          - name: actionName
+   *            in: query
+   *            description: name of action
+   *            schema:
+   *              type: string
+   *          - name: transferToUserGroupId
+   *            in: query
+   *            description: userGroup id that will be transferred to
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroup is removed
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroups:
+   *                      type: object
+   *                      description: A result of `UserGroup.removeCompletelyById`
+   */
+  router.delete('/:id', loginRequiredStrictly, adminRequired, csrf, validator.delete, ApiV3FormValidator, async(req, res) => {
+    const { id: deleteGroupId } = req.params;
+    const { actionName, transferToUserGroupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.removeCompletelyById(deleteGroupId, actionName, transferToUserGroupId);
+
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in deleting a user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-delete-failed'));
+    }
+  });
+
+  // return one group with the id
+  // router.get('/:id', async(req, res) => {
+  // });
+
+  validator.update = [
+    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}:
+   *      put:
+   *        tags: [UserGroup]
+   *        description: Update userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: userGroup is updated
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroup:
+   *                      type: object
+   *                      description: A result of `UserGroup.updateName`
+   */
+  router.put('/:id', loginRequiredStrictly, adminRequired, csrf, validator.update, ApiV3FormValidator, async(req, res) => {
+    const { id } = req.params;
+    const { name } = req.body;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      if (userGroup == null) {
+        throw new Error('The group does not exist');
+      }
+
+      // check if the new group name is available
+      const isRegisterableName = await UserGroup.isRegisterableName(name);
+      if (!isRegisterableName) {
+        throw new Error('The group name is already taken');
+      }
+
+      await userGroup.updateName(name);
+
+      res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating a user group name';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-update-failed'));
+    }
+  });
+
+  validator.users = {};
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}/users:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get users related to the userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: users are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    users:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: user objects
+   */
+  router.get('/:id/users', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+
+      const users = userGroupRelations.map((userGroupRelation) => {
+        return userGroupRelation.relatedUser;
+      });
+
+      return res.apiv3({ users });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching users for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-user-list-fetch-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}/unrelated-users:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get users unrelated to the userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: users are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    users:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: user objects
+   */
+  router.get('/:id/unrelated-users', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup);
+
+      return res.apiv3({ users });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching unrelated users for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'));
+    }
+  });
+
+  validator.users.post = [
+    param('id').trim().exists({ checkFalsy: true }),
+    param('username').trim().exists({ checkFalsy: true }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}/users:
+   *      post:
+   *        tags: [UserGroup]
+   *        description: Add a user to the userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: a user is added
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    user:
+   *                      type: object
+   *                      description: the user added to the group
+   *                    userGroup:
+   *                      type: object
+   *                      description: the group to which a user was added
+   *                    userGroupRelation:
+   *                      type: object
+   *                      description: the associative entity between user and userGroup
+   */
+  router.post('/:id/users/:username', loginRequiredStrictly, adminRequired, validator.users.post, ApiV3FormValidator, async(req, res) => {
+    const { id, username } = req.params;
+
+    try {
+      const [userGroup, user] = await Promise.all([
+        UserGroup.findById(id),
+        User.findUserByUsername(username),
+      ]);
+
+      const userGroupRelation = await UserGroupRelation.createRelation(userGroup, user);
+      await userGroupRelation.populate('relatedUser', User.USER_PUBLIC_FIELDS).execPopulate();
+
+      return res.apiv3({ user, userGroup, userGroupRelation });
+    }
+    catch (err) {
+      const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-add-user-failed'));
+    }
+  });
+
+  validator.users.delete = [
+    param('id').trim().exists({ checkFalsy: true }),
+    param('username').trim().exists({ checkFalsy: true }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}/users:
+   *      delete:
+   *        tags: [UserGroup]
+   *        description: remove a user from the userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: a user was removed
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    user:
+   *                      type: object
+   *                      description: the user removed from the group
+   *                    userGroup:
+   *                      type: object
+   *                      description: the group from which a user was removed
+   *                    userGroupRelation:
+   *                      type: object
+   *                      description: the associative entity between user and userGroup
+   */
+  router.delete('/:id/users/:username', loginRequiredStrictly, adminRequired, validator.users.delete, ApiV3FormValidator, async(req, res) => {
+    const { id, username } = req.params;
+
+    try {
+      const [userGroup, user] = await Promise.all([
+        UserGroup.findById(id),
+        User.findUserByUsername(username),
+      ]);
+
+      const userGroupRelation = await UserGroupRelation.findOne({ relatedUser: new ObjectId(user._id), relatedGroup: new ObjectId(userGroup._id) });
+      if (userGroupRelation == null) {
+        throw new Error(`Group "${id}" does not exist or user "${username}" does not belong to group "${id}"`);
+      }
+
+      await userGroupRelation.remove();
+
+      return res.apiv3({ user, userGroup, userGroupRelation });
+    }
+    catch (err) {
+      const msg = `Error occurred in removing the user "${username}" from group "${id}"`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
+    }
+  });
+
+  validator.userGroupRelations = {};
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}/user-group-relations:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get the user group relations for the userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: user group relations are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroupRelations:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroupRelation objects
+   */
+  router.get('/:id/user-group-relations', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+
+      return res.apiv3({ userGroupRelations });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching user group relations for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'));
+    }
+  });
+
+  validator.pages = {};
+
+  validator.pages.get = [
+    param('id').trim().exists({ checkFalsy: true }),
+    sanitizeQuery('limit').customSanitizer(toPagingLimit),
+    sanitizeQuery('offset').customSanitizer(toPagingOffset),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{id}/pages:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get closed pages for the userGroup
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: pages are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    pages:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: page objects
+   */
+  router.get('/:id/pages', loginRequiredStrictly, adminRequired, validator.pages.get, ApiV3FormValidator, async(req, res) => {
+    const { id } = req.params;
+    const { limit, offset } = req.query;
+
+    try {
+      const { docs, total } = await Page.paginate({
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: { $in: [id] },
+      }, {
+        offset,
+        limit,
+        populate: {
+          path: 'lastUpdateUser',
+          select: User.USER_PUBLIC_FIELDS,
+        },
+      });
+
+      const current = offset / limit + 1;
+
+      // TODO: create a common moudule for paginated response
+      return res.apiv3({ total, current, pages: docs });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching pages for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-page-list-fetch-failed'));
+    }
+  });
+
+  return router;
+};

+ 330 - 0
src/server/routes/apiv3/users.js

@@ -0,0 +1,330 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:user-group');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator/check');
+const { isEmail } = require('validator');
+
+const validator = {};
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Users
+ */
+
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const {
+    ErrorV3,
+    User,
+    Page,
+    ExternalAccount,
+  } = crowi.models;
+
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users:
+   *      get:
+   *        tags: [Users]
+   *        description: Get users
+   *        responses:
+   *          200:
+   *            description: users are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    users:
+   *                      type: object
+   *                      description: a result of `Users.find`
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    try {
+      const page = parseInt(req.query.page) || 1;
+      const result = await User.findUsersWithPagination({ page });
+      const { docs: users, total: totalUsers, limit: pagingLimit } = result;
+      return res.apiv3({ users, totalUsers, pagingLimit });
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching user group list';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'));
+    }
+  });
+
+  validator.inviteEmail = [
+    // isEmail prevents line breaks, so use isString
+    body('shapedEmailList').custom((value) => {
+      const array = value.filter((value) => { return isEmail(value) });
+      if (array.length === 0) {
+        throw new Error('At least one valid email address is required');
+      }
+      return array;
+    }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/invite:
+   *      post:
+   *        tags: [Users]
+   *        description: Create new users and send Emails
+   *        parameters:
+   *          - name: shapedEmailList
+   *            in: query
+   *            description: Invitation emailList
+   *            schema:
+   *              type: object
+   *          - name: sendEmail
+   *            in: query
+   *            description: Whether to send mail
+   *            schema:
+   *              type: boolean
+   *        responses:
+   *          200:
+   *            description: Inviting user success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    createdUserList:
+   *                      type: object
+   *                      description: Users successfully created
+   *                    existingEmailList:
+   *                      type: object
+   *                      description: Users email that already exists
+   */
+  router.post('/invite', loginRequiredStrictly, adminRequired, csrf, validator.inviteEmail, ApiV3FormValidator, async(req, res) => {
+    try {
+      const emailList = await User.createUsersByInvitation(req.body.shapedEmailList, req.body.sendEmail);
+      return res.apiv3({ emailList });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/{id}/giveAdmin:
+   *      put:
+   *        tags: [Users]
+   *        description: Give user admin
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of user for admin
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Give user admin success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: data of admin user
+   */
+  router.put('/:id/giveAdmin', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userData = await User.findById(id);
+      await userData.makeAdmin();
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/{id}/removeAdmin:
+   *      put:
+   *        tags: [Users]
+   *        description: Remove user admin
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of user for removing admin
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Remove user admin success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: data of removed admin user
+   */
+  router.put('/:id/removeAdmin', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userData = await User.findById(id);
+      await userData.removeFromAdmin();
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/{id}/activate:
+   *      put:
+   *        tags: [Users]
+   *        description: Activate user
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of activate user
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Activationg user success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: data of activate user
+   */
+  router.put('/:id/activate', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    // check user upper limit
+    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
+    if (isUserCountExceedsUpperLimit) {
+      const msg = 'Unable to activate because user has reached limit';
+      logger.error('Error', msg);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+
+    const { id } = req.params;
+
+    try {
+      const userData = await User.findById(id);
+      await userData.statusActivate();
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/{id}/deactivate:
+   *      put:
+   *        tags: [Users]
+   *        description: Deactivate user
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of deactivate user
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Deactivationg user success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: data of deactivate user
+   */
+  router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userData = await User.findById(id);
+      await userData.statusSuspend();
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/users/{id}/remove:
+   *      delete:
+   *        tags: [Users]
+   *        description: Delete user
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of delete user
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Deleting user success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: data of delete user
+   */
+  router.delete('/:id/remove', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userData = await User.findById(id);
+      await userData.statusDelete();
+      await ExternalAccount.remove({ user: userData });
+      await Page.removeByPath(`/user/${userData.username}`);
+
+      return res.apiv3({ userData });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
+  return router;
+};

+ 2 - 1
src/server/routes/attachment.js

@@ -203,7 +203,8 @@ module.exports = function(crowi, app) {
    * @apiGroup Attachment
    */
   api.limit = async function(req, res) {
-    return res.json(ApiResponse.success(await fileUploader.checkLimit(req.query.fileSize)));
+    const fileSize = Number(req.query.fileSize);
+    return res.json(ApiResponse.success(await fileUploader.checkLimit(fileSize)));
   };
 
   /**

+ 0 - 2
src/server/routes/comment.js

@@ -90,8 +90,6 @@ module.exports = function(crowi, app) {
 
     const errors = validationResult(req.body);
     if (!errors.isEmpty()) {
-      // return res.json(ApiResponse.error('Invalid comment.'));
-      // return res.status(422).json({ errors: errors.array() });
       return res.json(ApiResponse.error('コメントを入力してください。'));
     }
 

+ 11 - 25
src/server/routes/index.js

@@ -91,7 +91,7 @@ module.exports = function(crowi, app) {
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback);
 
   // markdown admin
-  app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index);
+  app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index); // TODO delete
   app.post('/admin/markdown/lineBreaksSetting', loginRequiredStrictly , adminRequired , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting); // change form name
   app.post('/admin/markdown/xss-setting'      , loginRequiredStrictly , adminRequired , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
   app.post('/admin/markdown/presentationSetting', loginRequiredStrictly , adminRequired , csrf, form.admin.markdownPresentation, admin.markdown.presentationSetting);
@@ -129,12 +129,6 @@ module.exports = function(crowi, app) {
   app.post('/admin/global-notification/:id/remove', loginRequiredStrictly , adminRequired , admin.globalNotification.remove);
 
   app.get('/admin/users'                , loginRequiredStrictly , adminRequired , admin.user.index);
-  app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequiredStrictly , adminRequired , csrf, admin.user.invite);
-  app.post('/admin/user/:id/makeAdmin'  , loginRequiredStrictly , adminRequired , csrf, admin.user.makeAdmin);
-  app.post('/admin/user/:id/removeFromAdmin', loginRequiredStrictly , adminRequired , admin.user.removeFromAdmin);
-  app.post('/admin/user/:id/activate'   , loginRequiredStrictly , adminRequired , csrf, admin.user.activate);
-  app.post('/admin/user/:id/suspend'    , loginRequiredStrictly , adminRequired , csrf, admin.user.suspend);
-  app.post('/admin/user/:id/remove'     , loginRequiredStrictly , adminRequired , csrf, admin.user.remove);
   app.post('/admin/user/:id/removeCompletely' , loginRequiredStrictly , adminRequired , csrf, admin.user.removeCompletely);
   // new route patterns from here:
   app.post('/_api/admin/users.resetPassword'  , loginRequiredStrictly , adminRequired , csrf, admin.user.resetPassword);
@@ -144,28 +138,20 @@ module.exports = function(crowi, app) {
 
   // user-groups admin
   app.get('/admin/user-groups'             , loginRequiredStrictly, adminRequired, admin.userGroup.index);
-  app.get('/admin/user-group-detail/:id'          , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
-  app.post('/admin/user-group/create'      , form.admin.userGroupCreate, loginRequiredStrictly, adminRequired, csrf, admin.userGroup.create);
-  app.post('/admin/user-group/:userGroupId/update', loginRequiredStrictly, adminRequired, csrf, admin.userGroup.update);
-  app.post('/admin/user-group.remove' , loginRequiredStrictly, adminRequired, csrf, admin.userGroup.removeCompletely);
-  app.get('/_api/admin/user-groups', loginRequiredStrictly, adminRequired, admin.api.userGroups);
-
-  // user-group-relations admin
-  app.post('/admin/user-group-relation/create', loginRequiredStrictly, adminRequired, csrf, admin.userGroupRelation.create);
-  app.post('/admin/user-group-relation/:id/remove-relation/:relationId', loginRequiredStrictly, adminRequired, csrf, admin.userGroupRelation.remove);
+  app.get('/admin/user-group-detail/:id'   , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
 
   // importer management for admin
-  app.get('/admin/importer'                , loginRequiredStrictly , adminRequired , admin.importer.index);
-  app.post('/_api/admin/settings/importerEsa' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerEsa , admin.api.importerSettingEsa);
-  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerQiita , admin.api.importerSettingQiita);
-  app.post('/_api/admin/import/esa'        , loginRequiredStrictly , adminRequired , admin.api.importDataFromEsa);
-  app.post('/_api/admin/import/testEsaAPI' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerEsa , admin.api.testEsaAPI);
-  app.post('/_api/admin/import/qiita'        , loginRequiredStrictly , adminRequired , admin.api.importDataFromQiita);
-  app.post('/_api/admin/import/testQiitaAPI' , loginRequiredStrictly , adminRequired , csrf , form.admin.importerQiita , admin.api.testQiitaAPI);
+  app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
+  app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrf, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
+  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrf , admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
+  app.post('/_api/admin/import/esa'             , loginRequiredStrictly , adminRequired , admin.api.importDataFromEsa);
+  app.post('/_api/admin/import/testEsaAPI'      , loginRequiredStrictly , adminRequired , csrf, admin.api.testEsaAPI);
+  app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , admin.api.importDataFromQiita);
+  app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrf, admin.api.testQiitaAPI);
 
   // export management for admin
-  app.get('/admin/export' , loginRequiredStrictly , adminRequired ,admin.export.index);
-  app.get('/admin/export/:fileName' , loginRequiredStrictly , adminRequired ,admin.export.download);
+  app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
+  app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.download);
 
   app.get('/me'                       , loginRequiredStrictly , me.index);
   app.get('/me/password'              , loginRequiredStrictly , me.password);

+ 4 - 0
src/server/service/app.js

@@ -35,6 +35,10 @@ class AppService {
   }
   /* eslint-enable no-else-return */
 
+  getTzoffset() {
+    return -(this.configManager.getConfig('crowi', 'app:timezone') || 9) * 60;
+  }
+
   /**
    * Execute only once for installing application
    */

+ 25 - 1
src/server/service/config-loader.js

@@ -130,11 +130,17 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: Infinity,
   },
+  FILE_UPLOAD_TOTAL_LIMIT: {
+    ns:      'crowi',
+    key:     'app:fileUploadTotalLimit',
+    type:    TYPES.NUMBER,
+    default: Infinity,
+  },
   MONGO_GRIDFS_TOTAL_LIMIT: {
     ns:      'crowi',
     key:     'gridfs:totalLimit',
     type:    TYPES.NUMBER,
-    default: Infinity,
+    default: null,
   },
   FORCE_WIKI_MODE: {
     ns:      'crowi',
@@ -220,6 +226,24 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  GCS_API_KEY_JSON_PATH: {
+    ns:      'crowi',
+    key:     'gcs:apiKeyJsonPath',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  GCS_BUCKET: {
+    ns:      'crowi',
+    key:     'gcs:bucket',
+    type:    TYPES.STRING,
+    default: null,
+  },
+  GCS_UPLOAD_NAMESPACE: {
+    ns:      'crowi',
+    key:     'gcs:uploadNamespace',
+    type:    TYPES.STRING,
+    default: null,
+  },
 };
 
 class ConfigLoader {

+ 14 - 2
src/server/service/file-uploader/aws.js

@@ -6,7 +6,7 @@ const aws = require('aws-sdk');
 module.exports = function(crowi) {
   const Uploader = require('./uploader');
   const { configManager } = crowi;
-  const lib = new Uploader(configManager);
+  const lib = new Uploader(crowi);
 
   function getAwsConfig() {
     return {
@@ -49,6 +49,17 @@ module.exports = function(crowi) {
     return filePath;
   }
 
+  lib.getIsUploadable = function() {
+    return this.configManager.getConfig('crowi', 'aws:accessKeyId') != null
+      && this.configManager.getConfig('crowi', 'aws:secretAccessKey') != null
+      && (
+        this.configManager.getConfig('crowi', 'aws:region') != null
+          || this.configManager.getConfig('crowi', 'aws:customEndpoint') != null
+      )
+      && this.configManager.getConfig('crowi', 'aws:bucket') != null;
+  };
+
+
   lib.deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     return lib.deleteFileByFilePath(filePath);
@@ -122,7 +133,8 @@ module.exports = function(crowi) {
    */
   lib.checkLimit = async(uploadFileSize) => {
     const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
-    return { isUploadable: uploadFileSize <= maxFileSize, errorMessage: 'File size exceeds the size limit per file' };
+    const totalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
   return lib;

+ 109 - 0
src/server/service/file-uploader/gcs.js

@@ -0,0 +1,109 @@
+const logger = require('@alias/logger')('growi:service:fileUploaderAws');
+
+const urljoin = require('url-join');
+const { Storage } = require('@google-cloud/storage');
+
+let _instance;
+
+
+module.exports = function(crowi) {
+  const Uploader = require('./uploader');
+  const { configManager } = crowi;
+  const lib = new Uploader(crowi);
+
+  function getGcsBucket() {
+    return configManager.getConfig('crowi', 'gcs:bucket');
+  }
+
+  function getGcsInstance(isUploadable) {
+    if (!isUploadable) {
+      throw new Error('GCS is not configured.');
+    }
+    if (_instance == null) {
+      const keyFilename = configManager.getConfig('crowi', 'gcs:apiKeyJsonPath');
+      // see https://googleapis.dev/nodejs/storage/latest/Storage.html
+      _instance = new Storage({ keyFilename });
+    }
+    return _instance;
+  }
+
+  function getFilePathOnStorage(attachment) {
+    const namespace = configManager.getConfig('crowi', 'gcs:uploadNamespace');
+    // const namespace = null;
+    const dirName = (attachment.page != null)
+      ? 'attachment'
+      : 'user';
+    const filePath = urljoin(namespace || '', dirName, attachment.fileName);
+
+    return filePath;
+  }
+
+  lib.getIsUploadable = function() {
+    return this.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
+  };
+
+  lib.deleteFile = async function(attachment) {
+    const filePath = getFilePathOnStorage(attachment);
+    return lib.deleteFileByFilePath(filePath);
+  };
+
+  lib.deleteFileByFilePath = async function(filePath) {
+    const gcs = getGcsInstance(this.getIsUploadable());
+    const myBucket = gcs.bucket(getGcsBucket());
+
+    // TODO: ensure not to throw error even when the file does not exist
+
+    return myBucket.file(filePath).delete();
+  };
+
+  lib.uploadFile = function(fileStream, attachment) {
+    logger.debug(`File uploading: fileName=${attachment.fileName}`);
+
+    const gcs = getGcsInstance(this.getIsUploadable());
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+    const options = {
+      destination: filePath,
+    };
+
+    return myBucket.upload(fileStream.path, options);
+  };
+
+  /**
+   * Find data substance
+   *
+   * @param {Attachment} attachment
+   * @return {stream.Readable} readable stream
+   */
+  lib.findDeliveryFile = async function(attachment) {
+    const gcs = getGcsInstance(this.getIsUploadable());
+    const myBucket = gcs.bucket(getGcsBucket());
+    const filePath = getFilePathOnStorage(attachment);
+
+    let stream;
+    try {
+      stream = myBucket.file(filePath).createReadStream();
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
+    }
+
+    // return stream.Readable
+    return stream;
+  };
+
+  /**
+   * check the file size limit
+   *
+   * In detail, the followings are checked.
+   * - per-file size limit (specified by MAX_FILE_SIZE)
+   */
+  lib.checkLimit = async(uploadFileSize) => {
+    const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
+    const gcsTotalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return lib.doCheckLimit(uploadFileSize, maxFileSize, gcsTotalLimit);
+  };
+
+  return lib;
+};

+ 25 - 35
src/server/service/file-uploader/gridfs.js

@@ -4,9 +4,9 @@ const util = require('util');
 
 module.exports = function(crowi) {
   const Uploader = require('./uploader');
-  const lib = new Uploader(crowi.configManager);
+  const lib = new Uploader(crowi);
   const COLLECTION_NAME = 'attachmentFiles';
-  const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
+  // const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
   // instantiate mongoose-gridfs
   const { createModel } = require('mongoose-gridfs');
@@ -16,12 +16,16 @@ module.exports = function(crowi) {
     connection: mongoose.connection,
   });
   // get Collection instance of chunk
-  const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
+  // const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
 
   // create promisified method
   AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
   AttachmentFile.promisifiedUnlink = util.promisify(AttachmentFile.unlink).bind(AttachmentFile);
 
+  lib.getIsUploadable = function() {
+    return true;
+  };
+
   lib.deleteFile = async function(attachment) {
     let filenameValue = attachment.fileName;
 
@@ -42,20 +46,20 @@ module.exports = function(crowi) {
   /**
    * get size of data uploaded files using (Promise wrapper)
    */
-  const getCollectionSize = () => {
-    return new Promise((resolve, reject) => {
-      chunkCollection.stats((err, data) => {
-        if (err) {
-          // return 0 if not exist
-          if (err.errmsg.includes('not found')) {
-            return resolve(0);
-          }
-          return reject(err);
-        }
-        return resolve(data.size);
-      });
-    });
-  };
+  // const getCollectionSize = () => {
+  //   return new Promise((resolve, reject) => {
+  //     chunkCollection.stats((err, data) => {
+  //       if (err) {
+  //         // return 0 if not exist
+  //         if (err.errmsg.includes('not found')) {
+  //           return resolve(0);
+  //         }
+  //         return reject(err);
+  //       }
+  //       return resolve(data.size);
+  //     });
+  //   });
+  // };
 
   /**
    * check the file size limit
@@ -66,25 +70,11 @@ module.exports = function(crowi) {
    */
   lib.checkLimit = async(uploadFileSize) => {
     const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
-    if (uploadFileSize > maxFileSize) {
-      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
-    }
-
-    let usingFilesSize;
-    try {
-      usingFilesSize = await getCollectionSize();
-    }
-    catch (err) {
-      logger.error(err);
-      return { isUploadable: false, errorMessage: err.errmsg };
-    }
-
-    const gridfsTotalLimit = crowi.configManager.getConfig('crowi', 'gridfs:totalLimit');
-    if (usingFilesSize + uploadFileSize > gridfsTotalLimit) {
-      return { isUploadable: false, errorMessage: 'MongoDB for uploading files reaches limit' };
-    }
 
-    return { isUploadable: true };
+    // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimitd is null)
+    const gridfsTotalLimit = crowi.configManager.getConfig('crowi', 'gridfs:totalLimit')
+      || crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return lib.doCheckLimit(uploadFileSize, maxFileSize, gridfsTotalLimit);
   };
 
   lib.uploadFile = async function(fileStream, attachment) {

+ 2 - 0
src/server/service/file-uploader/index.js

@@ -5,6 +5,8 @@ const envToModuleMappings = {
   mongo:   'gridfs',
   mongodb: 'gridfs',
   gridfs:  'gridfs',
+  gcp:     'gcs',
+  gcs:     'gcs',
 };
 
 class FileUploaderFactory {

+ 7 - 2
src/server/service/file-uploader/local.js

@@ -7,7 +7,7 @@ const streamToPromise = require('stream-to-promise');
 
 module.exports = function(crowi) {
   const Uploader = require('./uploader');
-  const lib = new Uploader(crowi.configManager);
+  const lib = new Uploader(crowi);
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
   function getFilePathOnStorage(attachment) {
@@ -25,6 +25,10 @@ module.exports = function(crowi) {
     return filePath;
   }
 
+  lib.getIsUploadable = function() {
+    return true;
+  };
+
   lib.deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
     return lib.deleteFileByFilePath(filePath);
@@ -84,7 +88,8 @@ module.exports = function(crowi) {
    */
   lib.checkLimit = async(uploadFileSize) => {
     const maxFileSize = crowi.configManager.getConfig('crowi', 'app:maxFileSize');
-    return { isUploadable: uploadFileSize <= maxFileSize, errorMessage: 'File size exceeds the size limit per file' };
+    const totalLimit = crowi.configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
   return lib;

+ 5 - 1
src/server/service/file-uploader/none.js

@@ -3,7 +3,11 @@
 module.exports = function(crowi) {
   const debug = require('debug')('growi:service:fileUploaderNone');
   const Uploader = require('./uploader');
-  const lib = new Uploader(crowi.configManager);
+  const lib = new Uploader(crowi);
+
+  lib.getIsUploadable = function() {
+    return false;
+  };
 
   lib.deleteFile = function(filePath) {
     debug(`File deletion: ${filePath}`);

+ 34 - 15
src/server/service/file-uploader/uploader.js

@@ -3,24 +3,13 @@
 
 class Uploader {
 
-  constructor(configManager) {
-    this.configManager = configManager;
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.configManager = crowi.configManager;
   }
 
   getIsUploadable() {
-    const method = process.env.FILE_UPLOAD || 'aws';
-
-    if (method === 'aws' && (
-      !this.configManager.getConfig('crowi', 'aws:accessKeyId')
-        || !this.configManager.getConfig('crowi', 'aws:secretAccessKey')
-        || (
-          !this.configManager.getConfig('crowi', 'aws:region')
-            && !this.configManager.getConfig('crowi', 'aws:customEndpoint'))
-        || !this.configManager.getConfig('crowi', 'aws:bucket'))) {
-      return false;
-    }
-
-    return method !== 'none';
+    throw new Error('Implement this');
   }
 
   getFileUploadEnabled() {
@@ -31,6 +20,36 @@ class Uploader {
     return !!this.configManager.getConfig('crowi', 'app:fileUpload');
   }
 
+  /**
+   * Check files size limits for all uploaders
+   *
+   * @param {*} uploadFileSize
+   * @param {*} maxFileSize
+   * @param {*} totalLimit
+   * @returns
+   * @memberof Uploader
+   */
+  async doCheckLimit(uploadFileSize, maxFileSize, totalLimit) {
+    if (uploadFileSize > maxFileSize) {
+      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
+    }
+    const Attachment = this.crowi.model('Attachment');
+    // Get attachment total file size
+    const res = await Attachment.aggregate().group({
+      _id: null,
+      total: { $sum: '$fileSize' },
+    });
+    // Return res is [] if not using
+    const usingFilesSize = res.length === 0 ? 0 : res[0].total;
+
+    if (usingFilesSize + uploadFileSize > totalLimit) {
+      return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
+    }
+
+    return { isUploadable: true };
+
+  }
+
 }
 
 module.exports = Uploader;

+ 3 - 1
src/server/service/growi-bridge.js

@@ -27,7 +27,9 @@ class GrowiBridgeService {
    */
   initCollectionMap(models) {
     for (const model of Object.values(models)) {
-      this.collectionMap[model.collection.collectionName] = model;
+      if (model.collection != null) {
+        this.collectionMap[model.collection.name] = model;
+      }
     }
   }
 

+ 7 - 3
src/server/service/import.js

@@ -30,7 +30,11 @@ class ImportService {
   initConvertMap(models) {
     // by default, original value is used for imported documents
     for (const model of Object.values(models)) {
-      const { collectionName } = model.collection;
+      if (model.collection == null) {
+        continue;
+      }
+
+      const collectionName = model.collection.name;
       this.convertMap[collectionName] = {};
 
       for (const key of Object.keys(model.schema.paths)) {
@@ -72,7 +76,7 @@ class ImportService {
   async import(Model, jsonFile, overwriteParams = {}) {
     // streamToPromise(jsonStream) throws an error, use new Promise instead
     return new Promise((resolve, reject) => {
-      const { collectionName } = Model.collection;
+      const collectionName = Model.collection.name;
 
       let counter = 0;
       let insertedIds = [];
@@ -211,7 +215,7 @@ class ImportService {
    * @return {object} document to be persisted
    */
   convertDocuments(Model, _document, overwriteParams) {
-    const collectionName = Model.collection.collectionName;
+    const collectionName = Model.collection.name;
     const schema = Model.schema.paths;
     const convertMap = this.convertMap[collectionName];
 

+ 18 - 0
src/server/util/express-validator/sanitizer.js

@@ -0,0 +1,18 @@
+// custom sanitizers not covered by express-validator
+// https://github.com/validatorjs/validator.js#sanitizers
+
+const sanitizers = {};
+
+sanitizers.toPagingLimit = (_value) => {
+  const value = parseInt(_value);
+  // eslint-disable-next-line no-restricted-globals
+  return !isNaN(value) && isFinite(value) ? value : 20;
+};
+
+sanitizers.toPagingOffset = (_value) => {
+  const value = parseInt(_value);
+  // eslint-disable-next-line no-restricted-globals
+  return !isNaN(value) && isFinite(value) ? value : 0;
+};
+
+module.exports = sanitizers;

+ 2 - 0
src/server/util/express-validator/validator.js

@@ -0,0 +1,2 @@
+// custom validators not covered by express-validator
+// https://github.com/validatorjs/validator.js#validators

+ 5 - 3
src/server/util/middlewares.js

@@ -1,3 +1,5 @@
+// don't add any more middlewares to this file.
+// all new middlewares should be an independent file under /server/routes/middlewares
 // eslint-disable-next-line no-unused-vars
 const logger = require('@alias/logger')('growi:lib:middlewares');
 
@@ -6,7 +8,7 @@ const pathUtils = require('growi-commons').pathUtils;
 const md5 = require('md5');
 const entities = require('entities');
 
-module.exports = (crowi, app) => {
+module.exports = (crowi) => {
   const { configManager, appService } = crowi;
 
   const middlewares = {};
@@ -30,7 +32,7 @@ module.exports = (crowi, app) => {
 
   middlewares.swigFunctions = function() {
     return function(req, res, next) {
-      require('../util/swigFunctions')(crowi, app, req, res.locals);
+      require('../util/swigFunctions')(crowi, req, res.locals);
       next();
     };
   };
@@ -99,7 +101,7 @@ module.exports = (crowi, app) => {
       swig.setFilter('datetz', (input, format) => {
         // timezone
         const swigFilters = require('swig-templates/lib/filters');
-        return swigFilters.date(input, format, app.get('tzoffset'));
+        return swigFilters.date(input, format, crowi.appService.getTzoffset());
       });
 
       swig.setFilter('dateDistance', (input) => {

+ 1 - 1
src/server/util/swigFunctions.js

@@ -1,4 +1,4 @@
-module.exports = function(crowi, app, req, locals) {
+module.exports = function(crowi, req, locals) {
   const debug = require('debug')('growi:lib:swigFunctions');
   const stringWidth = require('string-width');
   const entities = require('entities');

+ 321 - 0
src/server/views/admin/Users_reserve.html

@@ -0,0 +1,321 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitle(t('User_Management')) }}{% endblock %}
+
+{% block content_header %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 id="admin-title" class="title">{{ t('User_Management') }}</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set emessage = req.flash('errorMessage') %}
+  {% if emessage.length %}
+  <div class="alert alert-danger">
+    {{ emessage }}
+  </div>
+  {% endif %}
+
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'user'} %}
+    </div>
+
+    <div class="col-md-9">
+      <p>
+        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm" {% if isUserCountExceedsUpperLimit %}disabled{% endif %}>
+          {{ t("user_management.invite_users") }}
+        </button>
+        <a class="btn btn-default btn-outline" href="/admin/users/external-accounts">
+          <i class="icon-user-follow" aria-hidden="true"></i>
+          {{ t("user_management.external_account") }}
+        </a>
+      </p>
+      <form role="form" action="/admin/user/invite" method="post">
+        <div id="inviteUserForm" class="collapse">
+          <div class="form-group">
+            <label for="inviteForm[emailList]">{{ t('user_management.emails') }}</label>
+            <textarea class="form-control" name="inviteForm[emailList]" placeholder="{{ t('eg') }} user@growi.org"></textarea>
+          </div>
+          <div class="checkbox checkbox-info">
+            <input type="checkbox" id="inviteWithEmail" name="inviteForm[sendEmail]" checked>
+            <label for="inviteWithEmail">{{ t('user_management.invite_thru_email') }}</label>
+          </div>
+          <button type="submit" class="btn btn-primary">{{ t('user_management.invite') }}</button>
+        </div>
+        <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      </form>
+
+      {% if isUserCountExceedsUpperLimit === true %}
+      <label>{{ t('user_management.cannot_invite_maximum_users') }}</label>
+      {% endif %}
+      {% if userUpperLimit !== 0 %}
+      <label>{{ t('user_management.current_users') }}{{ activeUsers }}</label>
+      {% endif %}
+
+      {% set createdUser = req.flash('createdUser') %}
+      {% if createdUser.length %}
+      <div class="modal fade in" id="createdUserModal">
+        <div class="modal-dialog">
+          <div class="modal-content">
+
+            <div class="modal-header">
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+              <div class="modal-title">{{ t('user_management.invited') }}</div>
+            </div>
+
+            <div class="modal-body">
+              <p>
+                {{ t('user_management.temporary_password') }}<br>
+                {{ t('user_management.password_never_seen') }}<span class="text-danger">{{ t('user_management.send_temporary_password') }}</span>
+              </p>
+
+              <pre>{% for cUser in createdUser %}{% if cUser.user %}{{ cUser.email }} {{ cUser.password }}<br>{% else %}{{ cUser.email }} 作成失敗<br>{% endif %}{% endfor %}</pre>
+            </div>
+
+          </div><!-- /.modal-content -->
+        </div><!-- /.modal-dialog -->
+      </div><!-- /.modal -->
+      {% endif %}
+
+      {# FIXME とりあえずクソ実装。React化はやくしたいなー(チラッチラッ #}
+      <div class="modal fade" id="admin-password-reset-modal">
+        <div class="modal-dialog">
+          <div class="modal-content">
+            <div class="modal-header">
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+              <div class="modal-title">{{ t('user_management.reset_password')}}</div>
+            </div>
+
+            <div class="modal-body">
+              <p>
+                {{ t('user_management.password_never_seen') }}<br>
+              <span class="text-danger">{{ t('user_management.send_new_password') }}</span>
+              </p>
+              <p>
+              {{ t('user_management.target_user') }}: <code id="admin-password-reset-user"></code>
+              </p>
+
+              <form method="post" id="admin-users-reset-password">
+                <input type="hidden" name="user_id" value="">
+                <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                <button type="submit" value="" class="btn btn-primary">
+                  {{ t('user_management.reset_password')}}
+                </button>
+              </form>
+
+            </div>
+
+          </div><!-- /.modal-content -->
+        </div>/.modal-dialog
+      </div>
+      <div class="modal fade" id="admin-password-reset-modal-done">
+        <div class="modal-dialog">
+          <div class="modal-content">
+
+            <div class="modal-header">
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+              <div class="modal-title">{{ t('user_management.reset_password') }}</div>
+            </div>
+
+            <div class="modal-body">
+              <p class="alert alert-danger">Let the user know the new password below and strongly recommend to change another one immediately. </p>
+              <p>
+              Reset user: <code id="admin-password-reset-done-user"></code>
+              </p>
+              <p>
+              New password: <code id="admin-password-reset-done-password"></code>
+              </p>
+            </div>
+            <div class="modal-footer">
+              <button class="btn btn-primary" data-dismiss="modal">OK</button>
+            </div>
+          </div><!-- /.modal-content -->
+        </div><!-- /.modal-dialog -->
+      </div>
+
+      <h2>{{ t("User_Management") }}</h2>
+
+      <table class="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="100px">#</th>
+            <th>{{ t('status') }}</th>
+            <th><code>{{ t('User') }}</code></th>
+            <th>{{ t('Name') }}</th>
+            <th>{{ t('Email') }}</th>
+            <th width="100px">{{ t('Created') }}</th>
+            <th width="150px">{{ t('Last_Login') }}</th>
+            <th width="70px"></th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for sUser in users %}
+          {% set sUserId = sUser._id.toString() %}
+          <tr>
+            <td>
+              <img src="{{ sUser|picture }}" class="picture img-circle" />
+              {% if sUser.admin %}
+              <span class="label label-inverse label-admin">
+              {{ t('administrator') }}
+              </span>
+              {% endif %}
+            </td>
+            <td>
+              <span class="label {{ css.userStatus(sUser) }}">
+                {{ consts.userStatus[sUser.status] }}
+              </span>
+            </td>
+            <td>
+              <strong>{{ sUser.username }}</strong>
+            </td>
+            <td>{{ sUser.name }}</td>
+            <td>{{ sUser.email }}</td>
+            <td>{{ sUser.createdAt|date('Y-m-d', sUser.createdAt.getTimezoneOffset()) }}</td>
+            <td>
+              {% if sUser.lastLoginAt %}
+                {{ sUser.lastLoginAt|date('Y-m-d H:i', sUser.createdAt.getTimezoneOffset()) }}
+              {% endif %}
+            </td>
+            <td>
+              <div class="btn-group admin-user-menu">
+                <button type="button" class="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown">
+                  <i class="icon-settings"></i> <span class="caret"></span>
+                </button>
+                <ul class="dropdown-menu" role="menu">
+                  <li class="dropdown-header">{{ t('user_management.edit_menu') }}</li>
+                  <li>
+                    <a href="#"
+                        data-user-id="{{ sUserId }}"
+                        data-user-email="{{ sUser.email }}"
+                        data-target="#admin-password-reset-modal"
+                        data-toggle="modal">
+                      <i class="icon-fw icon-key"></i>
+                      {{ t('user_management.reset_password') }}
+                    </a>
+                  </li>
+                  <li class="divider"></li>
+                  <li class="dropdown-header">{{ t('status') }}</li>
+
+                  {% if sUser.status == 1 %}
+                  <form id="form_activate_{{ sUserId }}" action="/admin/user/{{ sUserId }}/activate" method="post">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  </form>
+                  <li>
+                    <a href="javascript:form_activate_{{ sUserId }}.submit()">
+                      <i class="icon-fw icon-user-following"></i> {{ t('user_management.accept') }}
+                    </a>
+                  </li>
+                  {% endif  %}
+
+                  {% if sUser.status == 2 %}
+                  <form id="form_suspend_{{ sUserId }}" action="/admin/user/{{ sUserId }}/suspend" method="post">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  </form>
+                  <li>
+                    {% if sUser.username != user.username %}
+                    <a href="javascript:form_suspend_{{ sUserId }}.submit()">
+                      <i class="icon-fw icon-ban"></i>
+                      {{ t('user_management.deactivate_account') }}
+                    </a>
+                    {% else %}
+                    <a disabled>
+                      <i class="icon-fw icon-ban"></i>
+                      {{ t('user_management.deactivate_account') }}
+                    </a>
+                    <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.your_own") }}</p>
+                    {% endif %}
+                  </li>
+                  {% endif %}
+
+                  {% if sUser.status == 3 %}
+                  <form id="form_activate_{{ sUserId }}" action="/admin/user/{{ sUserId }}/activate" method="post">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  </form>
+                  <form id="form_remove_{{ sUserId }}" action="/admin/user/{{ sUserId }}/remove" method="post">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  </form>
+                  <li>
+                    <a href="javascript:form_activate_{{ sUserId }}.submit()">
+                      <i class="icon-fw icon-action-redo"></i> {{ t('Undo') }}
+                    </a>
+                  </li>
+                  <li>
+                    {# label は同じだけど、こっちは論理削除 #}
+                    <a href="javascript:form_remove_{{ sUserId }}.submit()">
+                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
+                    </a>
+                  </li>
+                  {% endif %}
+
+                  {% if sUser.status == 1 || sUser.status == 5 %}
+                  <form id="form_removeCompletely_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeCompletely" method="post">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  </form>
+                  <li class="dropdown-button">
+                    {# label は同じだけど、こっちは物理削除 #}
+                    <a href="javascript:form_removeCompletely_{{ sUserId }}.submit()">
+                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
+                    </a>
+                  </li>
+                  {% endif %}
+
+                  {% if sUser.status == 2 %} {# activated な人だけこのメニューを表示 #}
+                  <li class="divider"></li>
+                  <li class="dropdown-header">{{ t('user_management.administrator_menu') }}</li>
+
+                  {% if sUser.admin %}
+                  <form id="form_removeFromAdmin_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeFromAdmin" method="post">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  </form>
+                  <li>
+                    {% if sUser.username != user.username %}
+                      <a href="javascript:form_removeFromAdmin_{{ sUserId }}.submit()">
+                        <i class="icon-fw icon-user-unfollow"></i> {{ t("user_management.remove_admin_access") }}
+                      </a>
+                    {% else %}
+                      <a disabled>
+                        <i class="icon-fw icon-user-unfollow"></i> {{ t("user_management.remove_admin_access") }}
+                      </a>
+                      <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.cannot_remove") }}</p>
+                    {% endif %}
+                  </li>
+                  {% else %}
+                  <form id="form_makeAdmin_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/makeAdmin" method="post">
+                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  </form>
+                  <li>
+                    <a href="javascript:form_makeAdmin_{{ sUserId }}.submit()">
+                      <i class="icon-fw icon-magic-wand"></i> {{ t("user_management.give_admin_access") }}
+                    </a>
+                  </li>
+                  {% endif %}
+
+                  {% endif %}
+                </ul>
+              </div>
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+      {% include '../widget/pager.html' with {path: "/admin/users", pager: pager} %}
+
+    </div>
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %} -->

+ 5 - 2
src/server/views/admin/customize.html

@@ -51,6 +51,9 @@
       {% include './widget/menu.html' with {current: 'customize'} %}
     </div>
     <div class="col-md-9">
+      <!-- TODO reactify admin -->
+      <!-- <div id="admin-customize"></div> -->
+
       <form action="/_api/admin/customize/layout" method="post" class="form-horizontal" id="customlayoutSettingForm" role="form">
       <fieldset>
         <legend>{{ t('customize_page.Layout') }}</legend>
@@ -525,7 +528,7 @@ window.addEventListener('load', (event) => {
   </div>
 {% endblock content_main %}
 
-{% block body_end %}
+<!-- {% block body_end %}
   {% parent %}
   <script>
     $(`#customlayoutSettingForm, #custombehaviorSettingForm, #customhighlightJsStyleSettingForm,
@@ -646,7 +649,7 @@ window.addEventListener('load', (event) => {
   </script>
 
 </div>
-{% endblock %}
+{% endblock %} -->
 
 {% block content_footer %}
 {% endblock content_footer %}

+ 1 - 0
src/server/views/admin/external-accounts.html

@@ -38,6 +38,7 @@
       {% include './widget/menu.html' with {current: 'external-account'} %}
     </div>
 
+    <!-- TODO reactify admin -->
     <div class="col-md-9">
       <p>
         <a class="btn btn-default" href="/admin/users">

+ 2 - 230
src/server/views/admin/importer.html

@@ -24,7 +24,7 @@
       {% if smessage.length %}
       <div class="alert alert-success">
         {% for e in smessage %}
-          {{ e }}<br>
+        {{ e }}<br>
         {% endfor %}
       </div>
       {% endif %}
@@ -39,240 +39,12 @@
       </div>
       {% endif %}
 
-      <!-- TODO: move to /imponents/Admin/Importer.jsx -->
-      <div id="growi-import"></div>
-
-      <!-- esa Importer management forms -->
-      <form action="/_api/admin/settings/importerEsa" method="post" class="form-horizontal" id="importerSettingFormEsa" role="form"
-          data-success-messaage="{{ ('Updated') }}">
-        <fieldset>
-          <legend>{{ t('importer_management.import_from', 'esa.io') }}</legend>
-          <table class="table table-bordered table-mapping">
-            <thead>
-              <tr>
-                <th width="45%">esa.io</th>
-                <th width="10%"></th>
-                <th>GROWI</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <th>{{ t('Article') }}</th>
-                <th><i class="icon-arrow-right-circle text-success"></i></th>
-                <th>{{ t('Page') }}</th>
-              </tr>
-              <tr>
-                <th>{{ t('Category') }}</th>
-                <th><i class="icon-arrow-right-circle text-success"></i></th>
-                <th>{{ t('Page Path') }}</th>
-              </tr>
-              <tr>
-                <th>{{ t('User') }}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-            </tbody>
-          </table>
-          <div class="well well-sm mb-0 small">
-            <ul>
-              <li>{{ t("importer_management.page_skip") }}</li>
-            </ul>
-          </div>
-          <div class="form-group">
-            <input type="password" name="dummypass" style="display:none; top: -100px; left: -100px;" />
-          </div>
-          <div class="form-group">
-            <label for="settingForm[importer:esa:team_name]" class="col-xs-3 control-label">{{ t('importer_management.esa_settings.team_name') }}</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="text" name="settingForm[importer:esa:team_name]" value="{{ settingForm['importer:esa:team_name'] | default('') }}">
-            </div>
-          </div>
-          <div class="form-group">
-            <label for="settingForm[importer:esa:access_token]" class="col-xs-3 control-label">{{ t('importer_management.esa_settings.access_token') }}</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="password" name="settingForm[importer:esa:access_token]" value="{{ settingForm['importer:esa:access_token'] | default('') }}">
-            </div>
-          </div>
-          <div class="form-group">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}" />
-            <div class="col-xs-offset-3 col-xs-6">
-              <button id="testConnectionToEsa" type="button" class="btn btn-primary btn-esa" data-action="/_api/admin/import/esa" name="Esa"
-                  data-success-message="Import posts from esa success." data-error-message="Error occurred in importing pages from esa.io">
-                {{ t("importer_management.import") }}
-              </button>
-              <button type="submit" class="btn btn-secondary">{# the first element is the default button to submit #}
-                {{ t('Update') }}
-              </button>
-              <span class="col-xs-offset-1">
-                <button id="importFromEsa" type="button" class="btn btn-default btn-esa" data-action="/_api/admin/import/testEsaAPI" name="Esa"
-                    data-success-message="Test connection to esa success." data-error-message="Test connection to esa failed.">
-                  {{ t("importer_management.esa_settings.test_connection") }}
-                </button>
-              </span>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-
+      <div id="admin-importer"></div>
 
-      <!-- qiita:team Importer management forms -->
-      <form action="/_api/admin/settings/importerQiita" method="post" class="form-horizontal mt-5" id="importerSettingFormQiita" role="form"
-          data-success-messaage="Updated">
-        <fieldset>
-          <legend>{{ t('importer_management.import_from', 'Qiita:Team') }}</legend>
-          <table class="table table-bordered table-mapping">
-            <thead>
-              <tr>
-                <th width="45%">Qiita:Team</th>
-                <th width="10%"></th>
-                <th>GROWI</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <th>{{ t('Article') }}</th>
-                <th><i class="icon-arrow-right-circle text-success"></i></th>
-                <th>{{ t('Page') }}</th>
-              </tr>
-              <tr>
-                <th>{{ t('Tag')}}</th>
-                <th></th>
-                <th>-</th>
-              </tr>
-              <tr>
-                <th>{{ t("importer_management.Directory_hierarchy_tag") }}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-              <tr>
-                <th>{{ t('User') }}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-            </tbody>
-          </table>
-          <div class="well well-sm mb-0 small">
-            <ul>
-              <li>{{ t("importer_management.page_skip") }}</li>
-            </ul>
-          </div>
-          <div class="form-group">
-            <input type="password" name="dummypass" style="display: none; top: -100px; left: -100px;" />
-          </div>
-          <div class="form-group">
-            <label for="settingForm[importer:qiita:team_name]" class="col-xs-3 control-label">{{ t('importer_management.qiita_settings.team_name') }}</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="text" name="settingForm[importer:qiita:team_name]" value="{{ settingForm['importer:qiita:team_name'] | default('') }}">
-            </div>
-          </div>
-          <div class="form-group">
-            <label for="settingForm[importer:qiita:access_token]" class="col-xs-3 control-label">{{ t('importer_management.qiita_settings.access_token') }}</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="password" name="settingForm[importer:qiita:access_token]" value="{{ settingForm['importer:qiita:access_token'] | default('') }}">
-            </div>
-          </div>
-          <div class="form-group">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}" />
-            <div class="col-xs-offset-3 col-xs-6">
-              <button id="testConnectionToQiita" type="button" class="btn btn-primary btn-qiita" data-action="/_api/admin/import/qiita" name="Qiita"
-                  data-success-message="Import posts from qiita:team success." data-error-message="Error occurred in importing pages from qiita:team">
-                {{ t("importer_management.import") }}
-              </button>
-              <button type="submit" class="btn btn-secondary">{# the first element is the default button to submit #}
-                {{ t('Update') }}
-              </button>
-              <span class="col-xs-offset-1">
-                <button id="importFromQiita" type="button" class="btn btn-default btn-qiita" data-action="/_api/admin/import/testQiitaAPI" name="Qiita"
-                    data-success-message="Test connection to qiita:team success." data-error-message="Test connection to qiita:team failed.">
-                  {{ t("importer_management.qiita_settings.test_connection") }}
-                </button>
-              </span>
-            </div>
-          </div>
-        </fieldset>
-      </form>
     </div>
   </div>
-
 </div>
 
-<script>
-  /**
-   * show flash message
-   */
-  function showMessage(formId, msg, status) {
-    $('#' + formId + ' .alert').remove();
-
-    if (!status) {
-      status = 'success';
-    }
-    var $message = $('<p class="alert"></p>');
-    $message.addClass('alert-' + status);
-    $message.html(msg.replace(/\n/g, '<br>'));
-    $message.insertAfter('#' + formId + ' legend');
-
-    if (status == 'success') {
-      setTimeout(function()
-      {
-        $message.fadeOut({
-          complete: function() {
-            $message.remove();
-          }
-        });
-      }, 5000);
-    }
-  }
-
-  /**
-   * Post form data and process UI
-   */
-  function postData(form, button, action, success_msg = "Success", error_msg = " {{ t('Error occurred') }} " ) {
-    var id = form.attr('id');
-    button.attr('disabled', 'disabled');
-    var jqxhr = $.post(action, form.serialize(), function(data)
-      {
-        if (!data.status) {
-          showMessage(id, `${error_msg} ${data.message}`, 'danger');
-        }
-        else {
-          showMessage(id, success_msg);
-        }
-      })
-      .fail(function() {
-        showMessage(id, "{{ t('Error occurred') }}", 'danger');
-      })
-      .always(function() {
-        button.prop('disabled', false);
-      });
-    return false;
-  }
-
-  /**
-   * Handle button esa
-   */
-  $('.btn-esa, .btn-qiita').each(function() {
-    var $form = $('#importerSettingForm' + $(this).attr('name'));
-    var $button = $(this);
-    var $action = $button.attr('data-action');
-    var $success_msg = $button.attr('data-success-message');
-    var $error_msg = $button.attr('data-error-message');
-    $button.click(function() { return postData($form, $button, $action, $success_msg, $error_msg) });
-  });
-
-  /**
-   * Handle submit button esa
-   */
-  $('#importerSettingFormEsa, #importerSettingFormQiita').each(function() {
-    var $form = $(this);
-    var $button = $("#importerSettingForm" + $(this).attr('name') + " button[type='submit']");
-    var $action = $form.attr('action');
-    var $success_msg = $button.attr('data-success-message');
-    var $error_msg = $button.attr('data-error-message');
-    $form.submit(function() { return postData($form, $button, $action, $success_msg, $error_msg) });
-  });
-
-</script>
-
 {% endblock content_main %}
 
 {% block content_footer %}

+ 1 - 1
src/server/views/admin/markdown.html

@@ -17,8 +17,8 @@
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'markdown'} %}
     </div>
+    <!-- TODO reactify admin -->
     <div class="col-md-9">
-
       {% set smessage = req.flash('successMessage') %}
       {% if smessage.length %}
       <div class="alert alert-success">

+ 7 - 114
src/server/views/admin/search.html

@@ -17,125 +17,18 @@
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'search'} %}
     </div>
-    <div class="col-md-9">
-
-      {% if !searchConfigured() %}
-        <div class="col-md-12">
-          <div class="alert alert-warning">
-            <strong><i class="icon-fw icon-exclamation"></i> Full Text Search is not configured</strong>
-          </div>
-          <p>Check whether the env var <code>ELASTICSEARCH_URI</code> is set.</p>
-        </div>
-      {% else %}
-        {% set smessage = req.flash('successMessage') %}
-        {% if smessage.length %}
-        <div class="alert alert-success">
-          {% for e in smessage %}
-            {{ e }}<br>
-          {% endfor %}
-        </div>
-        {% endif %}
-
-        {% set emessage = req.flash('errorMessage') %}
-        {% if emessage.length %}
-        <div class="alert alert-danger">
-          {% for e in emessage %}
-          {{ e }}<br>
-          {% endfor %}
-        </div>
-        {% endif %}
-
-        <form action="/_api/admin/search/build" method="post" class="form-horizontal" id="buildIndexForm" role="form">
-          <fieldset>
-            <legend>Index Build</legend>
-            <div class="form-group">
-              <label for="" class="col-xs-3 control-label">Index Build</label>
-              <div class="col-xs-6">
-
-                <div id="admin-rebuild-search">
-                </div>
-
-                <button type="submit" class="btn btn-inverse">Build Now</button>
-                <p class="help-block">
-                  Force rebuild index.<br>
-                  Click "Build Now" to delete and create mapping file and add all pages.<br>
-                  This may take a while.
-                </p>
-              </div>
-            </div>
-          </fieldset>
-          <input type="hidden" name="_csrf" value="{{ csrf() }}">
-        </form>
-      {% endif %}
+    <div
+      class="col-md-9"
+      id ="admin-full-text-search-management"
+    >
+      <!-- Reactify Paginator start -->
+      <!-- {% include '../widget/pager.html' with {path: "/admin/search", pager: pager} %} -->
+      <!-- Reactify Paginator end -->
     </div>
   </div>
 
 </div>
 
-<script>
-  /**
-   * show flash message
-   */
-  function showMessage(formId, msg, status) {
-    $('#' + formId + ' .alert').remove();
-
-    if (!status) {
-      status = 'success';
-    }
-    var $message = $('<p class="alert"></p>');
-    $message.addClass('alert-' + status);
-    $message.html(msg.replace(/\n/g, '<br>'));
-    $message.insertAfter('#' + formId + ' legend');
-
-    if (status == 'success') {
-      setTimeout(function()
-      {
-        $message.fadeOut({
-          complete: function() {
-            $message.remove();
-          }
-        });
-      }, 5000);
-    }
-  }
-
-  /**
-   * Post form data and process UI
-   */
-  function postData(form, button, action) {
-    var id = form.attr('id');
-    button.attr('disabled', 'disabled');
-    var jqxhr = $.post(action, form.serialize(), function(res)
-      {
-        if (!res.ok) {
-          showMessage(id, `Error: ${res.message}`, 'danger');
-        }
-        else {
-          showMessage(id, 'Building request is successfully posted.');
-        }
-      })
-      .fail(function() {
-        showMessage(id, "エラーが発生しました", 'danger');
-      })
-      .always(function() {
-        button.prop('disabled', false);
-      });
-    return false;
-  }
-
-  /**
-   * Handle submit button esa
-   */
-  $('#buildIndexForm').each(function() {
-    var $form = $(this);
-    var $button = $("#buildIndexForm" + $(this).attr('name') + " button[type='submit']");
-    var $action = $form.attr('action');
-    var $success_msg = $button.attr('data-success-message');
-    var $error_msg = $button.attr('data-error-message');
-    $form.submit(function() { return postData($form, $button, $action, $success_msg, $error_msg) });
-  });
-
-</script>
 
 {% endblock content_main %}
 

+ 5 - 176
src/server/views/admin/user-group-detail.html

@@ -12,184 +12,15 @@
 
 {% block content_main %}
 <div class="content-main">
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
   <div class="row">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'user-group'} %}
     </div>
-
-    <div class="col-md-9">
-      <a href="/admin/user-groups" class="btn btn-default">
-        <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
-        {{ t('user_group_management.back_to_list') }}
-      </a>
-
-      <div class="modal fade" id="admin-add-user-group-relation-modal">
-        <div class="modal-dialog">
-          <div class="modal-content">
-            <div class="modal-header">
-              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <h4 class="modal-title">
-                {{ t('user_group_management.add_user') }}
-              </h4>
-            </div>
-
-            <div class="modal-body">
-              <p>
-                <strong>{{ t('Method') }}1.</strong> {{ t('user_group_management.how_to_add1') }}
-              </p>
-              <form class="form-inline" role="form" action="/admin/user-group-relation/create" method="post">
-                <div class="form-group">
-                  <input type="text" name="user_name" class="form-control input-sm" id="inputRelatedUserName" placeholder="{{ t('User Name')}}">
-                </div>
-                <input type="hidden" name="user_group_id" value="{{userGroup.id}}">
-                <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" class="btn btn-sm btn-success">{{ t('Add') }}</button>
-              </form>
-
-              {% if 0 < notRelatedusers.length %}
-              <hr>
-              <p>
-                <strong>{{ t('Method') }}2.</strong> {{ t('user_group_management.how_to_add2') }}
-              </p>
-
-              <ul class="list-inline">
-                {% for sUser in notRelatedusers %}
-                <li>
-                  <form role="form" action="/admin/user-group-relation/create" method="post">
-                    <!-- <input type="hidden" name="user_name" value="{{sUser.username}}"> -->
-                    <input type="hidden" name="user_group_id" value="{{userGroup.id}}">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" name="user_name" value="{{sUser.username}}" class="btn btn-xs btn-primary">{{sUser.username}}</button>
-                  </form>
-                </li>
-                {% endfor %}
-              </ul>
-              {% endif %}
-
-            </div>
-
-          </div>
-          <!-- /.modal-content -->
-        </div>
-        <!-- /.modal-dialog -->
-      </div>
-
-      <div class="m-t-20 form-box">
-        <form action="/admin/user-group/{{userGroup.id}}/update" method="post" class="form-horizontal" role="form">
-          <fieldset>
-            <legend>{{ t('Basic Settings') }}</legend>
-            <div class="form-group">
-              <label for="name" class="col-sm-2 control-label">{{ t('Name') }}</label>
-              <div class="col-sm-4">
-                <input class="form-control" type="text" name="name" value="{{ userGroup.name }}" required>
-              </div>
-            </div>
-            <div class="form-group">
-              <label class="col-sm-2 control-label">{{ t('Created') }}</label>
-              <div class="col-sm-4">
-                <input class="form-control" type="text" disabled value="{{userGroup.createdAt|datetz('Y-m-d') }}">
-              </div>
-            </div>
-            <div class="form-group">
-              <div class="col-sm-offset-2 col-sm-10">
-                <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-              </div>
-            </div>
-          </fieldset>
-        </form>
-      </div>
-
-      <legend class="m-t-20">{{ t('User List') }}</legend>
-
-      <table class="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th width="100px">#</th>
-            <th>
-              {{ t('User') }}
-            </th>
-            <th>{{ t('Name') }}</th>
-            <th width="100px">{{ t('Created') }}</th>
-            <th width="160px">{{ t('Last Login')}}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for sRelation in userGroupRelations %}
-          {% set sUser = sRelation.relatedUser%}
-          <tr>
-            <td>
-              <img src="{{ sRelation.relatedUser|picture }}" class="picture img-circle" />
-            </td>
-            <td>
-              <strong>{{ sRelation.relatedUser.username }}</strong>
-            </td>
-            <td>{{ sRelation.relatedUser.name }}</td>
-            <td>{{ sRelation.relatedUser.createdAt|datetz('Y-m-d') }}</td>
-            <td>
-              {% if sRelation.relatedUser.lastLoginAt %} {{ sRelation.relatedUser.lastLoginAt|datetz('Y-m-d H:i:s') }} {% endif %}
-            </td>
-            <td>
-              <div class="btn-group admin-user-menu">
-                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
-                  <i class="icon-settings"></i> <span class="caret"></span>
-                </button>
-                <ul class="dropdown-menu" role="menu">
-                  <form id="form_removeFromGroup_{{ loop.index }}" action="/admin/user-group-relation/{{userGroup._id.toString()}}/remove-relation/{{ sRelation._id.toString() }}" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    <a href="javascript:form_removeFromGroup_{{ loop.index }}.submit()">
-                      <i class="icon-fw icon-user-unfollow"></i> {{ t('user_group_management.remove_from_group')}}
-                    </a>
-                  </li>
-                </ul>
-              </div>
-            </td>
-          </tr>
-          {% endfor %}
-
-          {% if 0 < notRelatedusers.length %}
-          <tr>
-            <td></td>
-            <td class="text-center">
-              <button class="btn btn-default" data-target="#admin-add-user-group-relation-modal" data-toggle="modal">
-                <i class="ti-plus"></i>
-              </button>
-            </td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-          </tr>
-          {% endif %}
-        </tbody>
-      </table>
-
-      <!-- {% include '../widget/pager.html' with {path: "/admin/user-group-detail", pager: pager} %} -->
-
-      <legend class="m-t-20">{{ t('Page') }}</legend>
-
-      <div class="page-list">
-        {% if relatedPages.length == 0 %}<p>{{ t('user_group_management.no_pages') }}</p>{% endif %}
-        {% include '../widget/page_list.html' with { pages: relatedPages } %}
-      </div>
-
+    <div
+      id="admin-user-group-detail"
+      class="col-md-9"
+      data-user-group="{{ userGroup|json }}"
+    >
     </div>
   </div>
 </div>
@@ -197,5 +28,3 @@
 
 {% block content_footer %}
 {% endblock content_footer %}
-
-

+ 8 - 125
src/server/views/admin/user-groups.html

@@ -12,135 +12,18 @@
 
 {% block content_main %}
 <div class="content-main">
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
   <div class="row">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'user-group'} %}
     </div>
-
-    <div class="col-md-9">
-      <p>
-        {% if isAclEnabled %}
-          <button  data-toggle="collapse" class="btn btn-default" href="#createGroupForm">{{ t('user_group_management.create_group') }}</button>
-        {% else %}
-          <p class="alert alert-warning">
-            <i class="icon-exclamation icon-fw"></i><b>{{ t('user_group_management.deny_create_group')}}</b><br>
-            {{ t('security_setting.Fixed by env var', 'FORCE_WIKI_MODE', getConfig('crowi', 'security:wikiMode')) }}
-          </p>
-        {% endif %}
-      </p>
-      <form role="form" action="/admin/user-group/create" method="post">
-        <div id="createGroupForm" class="collapse">
-          <div class="form-group">
-            <label for="createGroupForm[userGroupName]">{{ t('user_group_management.group_name') }}</label>
-            <textarea class="form-control" name="createGroupForm[userGroupName]" placeholder="{{t('user_group_management.group_example')}}"></textarea>
-          </div>
-          <button type="submit" class="btn btn-primary">{{ t('Create') }}</button>
-        </div>
-        <input type="hidden" name="_csrf" value="{{ csrf() }}">
-      </form>
-
-      {% set createdUserGroup = req.flash('createdUserGroup') %}
-      {% if createdUserGroup.length %}
-      <div class="modal fade in" id="createdGroupModal">
-        <div class="modal-dialog">
-          <div class="modal-content">
-
-            <div class="modal-header">
-              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <h4 class="modal-title">{{ t('user_group_management.created_group') }}</h4>
-            </div>
-
-            <div class="modal-body">
-              <p>
-                {{ t('user_group_management.add_user') }}
-              </p>
-
-              <pre>{{ createdUserGroup.name }}</pre>
-            </div>
-
-          </div><!-- /.modal-content -->
-        </div><!-- /.modal-dialog -->
-      </div><!-- /.modal -->
-      {% endif %}
-
-      <div class="modal fade" id="admin-delete-user-group-modal"></div>
-
-      <h2>{{ t('user_group_management.group_list') }}</h2>
-
-      <table class="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th>{{ t('Name') }}</th>
-            <th>{{ t('User') }}</th>
-            <th width="160px">{{ t('Created') }}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for sGroup in userGroups %}
-          {% set sGroupDetailPageUrl = '/admin/user-group-detail/' + sGroup._id.toString() %}
-          <tr>
-            {% if isAclEnabled %}
-              <td><a href="{{ sGroupDetailPageUrl }}">{{ sGroup.name | preventXss }}</a></td>
-            {% else %}
-              <td>{{ sGroup.name | preventXss }}</td>
-            {% endif %}
-            <td><ul class="list-inline">
-              {% for relation in userGroupRelations.get(sGroup) %}
-              <li class="list-inline-item badge badge-primary">{{relation.relatedUser.username}}</li>
-              {% endfor %}
-            </ul></td>
-            <td>{{ sGroup.createdAt|datetz('Y-m-d H:i:s') }}</td>
-            {% if isAclEnabled %}
-            <td>
-              <div class="btn-group admin-group-menu">
-                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
-                  <i class="icon-settings"></i> <span class="caret"></span>
-                </button>
-                <ul class="dropdown-menu" role="menu">
-                  <li>
-                    <a href="{{ sGroupDetailPageUrl }}">
-                      <i class="icon-fw icon-note"></i> {{ t('Edit') }}
-                    </a>
-                  </li>
-
-                  <li>
-                    <a href="#"
-                        data-user-group-id="{{ sGroup._id.toString() }}"
-                        data-user-group-name="{{ sGroup.name.toString() | encodeHTML }}"
-                        data-target="#admin-delete-user-group-modal"
-                        data-toggle="modal">
-                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
-                    </a>
-                  </li>
-
-                </ul>
-              </div>
-            </td>
-            {% else %}
-              <td></td>
-            {% endif %}
-          </tr>
-          {% endfor %}
-        </tbody>
-      </table>
-
-      {% include '../widget/pager.html' with {path: "/admin/user-groups", pager: pager} %}
-
+    <div
+      id ="admin-user-group-page"
+      class="col-md-9"
+      data-isAclEnabled="{{ isAclEnabled }}"
+    >
+      <!-- Reactify Paginator start -->
+      <!-- {% include '../widget/pager.html' with {path: "/admin/user-groups", pager: pager} %} -->
+      <!-- Reactify Paginator end -->
     </div>
   </div>
 </div>

+ 9 - 287
src/server/views/admin/users.html

@@ -12,7 +12,7 @@
 
 {% block content_main %}
 <div class="content-main">
-  {% set smessage = req.flash('successMessage') %}
+    {% set smessage = req.flash('successMessage') %}
   {% if smessage.length %}
   <div class="alert alert-success">
     {{ smessage }}
@@ -26,292 +26,14 @@
   </div>
   {% endif %}
 
-  <div class="row">
-    <div class="col-md-3">
-      {% include './widget/menu.html' with {current: 'user'} %}
-    </div>
-
-    <div class="col-md-9">
-      <p>
-        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm" {% if isUserCountExceedsUpperLimit %}disabled{% endif %}>
-          {{ t("user_management.invite_users") }}
-        </button>
-        <a class="btn btn-default btn-outline" href="/admin/users/external-accounts">
-          <i class="icon-user-follow" aria-hidden="true"></i>
-          {{ t("user_management.external_account") }}
-        </a>
-      </p>
-      <form role="form" action="/admin/user/invite" method="post">
-        <div id="inviteUserForm" class="collapse">
-          <div class="form-group">
-            <label for="inviteForm[emailList]">{{ t('user_management.emails') }}</label>
-            <textarea class="form-control" name="inviteForm[emailList]" placeholder="{{ t('eg') }} user@growi.org"></textarea>
-          </div>
-          <div class="checkbox checkbox-info">
-            <input type="checkbox" id="inviteWithEmail" name="inviteForm[sendEmail]" checked>
-            <label for="inviteWithEmail">{{ t('user_management.invite_thru_email') }}</label>
-          </div>
-          <button type="submit" class="btn btn-primary">{{ t('user_management.invite') }}</button>
-        </div>
-        <input type="hidden" name="_csrf" value="{{ csrf() }}">
-      </form>
-
-      {% if isUserCountExceedsUpperLimit === true %}
-      <label>{{ t('user_management.cannot_invite_maximum_users') }}</label>
-      {% endif %}
-      {% if userUpperLimit !== 0 %}
-      <label>{{ t('user_management.current_users') }}{{ activeUsers }}</label>
-      {% endif %}
-
-      {% set createdUser = req.flash('createdUser') %}
-      {% if createdUser.length %}
-      <div class="modal fade in" id="createdUserModal">
-        <div class="modal-dialog">
-          <div class="modal-content">
-
-            <div class="modal-header">
-              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <div class="modal-title">{{ t('user_management.invited') }}</div>
-            </div>
-
-            <div class="modal-body">
-              <p>
-                {{ t('user_management.temporary_password') }}<br>
-                {{ t('user_management.password_never_seen') }}<span class="text-danger">{{ t('user_management.send_temporary_password') }}</span>
-              </p>
-
-              <pre>{% for cUser in createdUser %}{% if cUser.user %}{{ cUser.email }} {{ cUser.password }}<br>{% else %}{{ cUser.email }} 作成失敗<br>{% endif %}{% endfor %}</pre>
-            </div>
-
-          </div><!-- /.modal-content -->
-        </div><!-- /.modal-dialog -->
-      </div><!-- /.modal -->
-      {% endif %}
-
-      <div class="modal fade" id="admin-password-reset-modal">
-        <div class="modal-dialog">
-          <div class="modal-content">
-            <div class="modal-header">
-              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <div class="modal-title">{{ t('user_management.reset_password')}}</div>
-            </div>
-
-            <div class="modal-body">
-              <p>
-                {{ t('user_management.password_never_seen') }}<br>
-              <span class="text-danger">{{ t('user_management.send_new_password') }}</span>
-              </p>
-              <p>
-              {{ t('user_management.target_user') }}: <code id="admin-password-reset-user"></code>
-              </p>
-
-              <form method="post" id="admin-users-reset-password">
-                <input type="hidden" name="user_id" value="">
-                <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" value="" class="btn btn-primary">
-                  {{ t('user_management.reset_password')}}
-                </button>
-              </form>
-
-            </div>
-
-          </div><!-- /.modal-content -->
-        </div><!-- /.modal-dialog -->
-      </div>
-      <div class="modal fade" id="admin-password-reset-modal-done">
-        <div class="modal-dialog">
-          <div class="modal-content">
-
-            <div class="modal-header">
-              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <div class="modal-title">{{ t('user_management.reset_password') }}</div>
-            </div>
-
-            <div class="modal-body">
-              <p class="alert alert-danger">Let the user know the new password below and strongly recommend to change another one immediately. </p>
-              <p>
-              Reset user: <code id="admin-password-reset-done-user"></code>
-              </p>
-              <p>
-              New password: <code id="admin-password-reset-done-password"></code>
-              </p>
-            </div>
-            <div class="modal-footer">
-              <button class="btn btn-primary" data-dismiss="modal">OK</button>
-            </div>
-          </div><!-- /.modal-content -->
-        </div><!-- /.modal-dialog -->
-      </div>
-
-      <h2>{{ t("User_Management") }}</h2>
-
-      <table class="table table-default table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th width="100px">#</th>
-            <th>{{ t('status') }}</th>
-            <th><code>{{ t('User') }}</code></th>
-            <th>{{ t('Name') }}</th>
-            <th>{{ t('Email') }}</th>
-            <th width="100px">{{ t('Created') }}</th>
-            <th width="160px">{{ t('Last_Login') }}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for sUser in users %}
-          {% set sUserId = sUser._id.toString() %}
-          <tr>
-            <td>
-              <img src="{{ sUser|picture }}" class="picture img-circle" />
-              {% if sUser.admin %}
-              <span class="label label-inverse label-admin">
-              {{ t('administrator') }}
-              </span>
-              {% endif %}
-            </td>
-            <td>
-              <span class="label {{ css.userStatus(sUser) }}">
-                {{ consts.userStatus[sUser.status] }}
-              </span>
-            </td>
-            <td>
-              <strong>{{ sUser.username }}</strong>
-            </td>
-            <td>{{ sUser.name }}</td>
-            <td>{{ sUser.email }}</td>
-            <td>{{ sUser.createdAt|datetz('Y-m-d') }}</td>
-            <td>
-              {% if sUser.lastLoginAt %}
-                {{ sUser.lastLoginAt|datetz('Y-m-d H:i:s') }}
-              {% endif %}
-            </td>
-            <td>
-              <div class="btn-group admin-user-menu">
-                <button type="button" class="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown">
-                  <i class="icon-settings"></i> <span class="caret"></span>
-                </button>
-                <ul class="dropdown-menu" role="menu">
-                  <li class="dropdown-header">{{ t('user_management.edit_menu') }}</li>
-                  <li>
-                    <a href="#"
-                        data-user-id="{{ sUserId }}"
-                        data-user-email="{{ sUser.email }}"
-                        data-target="#admin-password-reset-modal"
-                        data-toggle="modal">
-                      <i class="icon-fw icon-key"></i>
-                      {{ t('user_management.reset_password') }}
-                    </a>
-                  </li>
-                  <li class="divider"></li>
-                  <li class="dropdown-header">{{ t('status') }}</li>
-
-                  {% if sUser.status == 1 %}
-                  <form id="form_activate_{{ sUserId }}" action="/admin/user/{{ sUserId }}/activate" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    <a href="javascript:form_activate_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-user-following"></i> {{ t('user_management.accept') }}
-                    </a>
-                  </li>
-                  {% endif  %}
-
-                  {% if sUser.status == 2 %}
-                  <form id="form_suspend_{{ sUserId }}" action="/admin/user/{{ sUserId }}/suspend" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    {% if sUser.username != user.username %}
-                    <a href="javascript:form_suspend_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-ban"></i>
-                      {{ t('user_management.deactivate_account') }}
-                    </a>
-                    {% else %}
-                    <a disabled>
-                      <i class="icon-fw icon-ban"></i>
-                      {{ t('user_management.deactivate_account') }}
-                    </a>
-                    <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.your_own") }}</p>
-                    {% endif %}
-                  </li>
-                  {% endif %}
-
-                  {% if sUser.status == 3 %}
-                  <form id="form_activate_{{ sUserId }}" action="/admin/user/{{ sUserId }}/activate" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <form id="form_remove_{{ sUserId }}" action="/admin/user/{{ sUserId }}/remove" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    <a href="javascript:form_activate_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-action-redo"></i> {{ t('Undo') }}
-                    </a>
-                  </li>
-                  <li>
-                    {# label は同じだけど、こっちは論理削除 #}
-                    <a href="javascript:form_remove_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
-                    </a>
-                  </li>
-                  {% endif %}
-
-                  {% if sUser.status == 1 || sUser.status == 5 %}
-                  <form id="form_removeCompletely_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeCompletely" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li class="dropdown-button">
-                    {# label は同じだけど、こっちは物理削除 #}
-                    <a href="javascript:form_removeCompletely_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
-                    </a>
-                  </li>
-                  {% endif %}
-
-                  {% if sUser.status == 2 %} {# activated な人だけこのメニューを表示 #}
-                  <li class="divider"></li>
-                  <li class="dropdown-header">{{ t('user_management.administrator_menu') }}</li>
-
-                  {% if sUser.admin %}
-                  <form id="form_removeFromAdmin_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/removeFromAdmin" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    {% if sUser.username != user.username %}
-                      <a href="javascript:form_removeFromAdmin_{{ sUserId }}.submit()">
-                        <i class="icon-fw icon-user-unfollow"></i> {{ t("user_management.remove_admin_access") }}
-                      </a>
-                    {% else %}
-                      <a disabled>
-                        <i class="icon-fw icon-user-unfollow"></i> {{ t("user_management.remove_admin_access") }}
-                      </a>
-                      <p class="alert alert-danger m-l-10 m-r-10 p-10">{{ t("user_management.cannot_remove") }}</p>
-                    {% endif %}
-                  </li>
-                  {% else %}
-                  <form id="form_makeAdmin_{{ sUserId }}" action="/admin/user/{{ sUser._id.toString() }}/makeAdmin" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    <a href="javascript:form_makeAdmin_{{ sUserId }}.submit()">
-                      <i class="icon-fw icon-magic-wand"></i> {{ t("user_management.give_admin_access") }}
-                    </a>
-                  </li>
-                  {% endif %}
-
-                  {% endif %}
-                </ul>
-              </div>
-            </td>
-          </tr>
-          {% endfor %}
-        </tbody>
-      </table>
-
-      {% include '../widget/pager.html' with {path: "/admin/users", pager: pager} %}
-
-    </div>
+  <div class="col-md-3">
+    {% include './widget/menu.html' with {current: 'user'} %}
+  </div>
+  <div
+  class="col-md-9"
+  id ="admin-user-page"
+  users= "{{ users | json }}"
+  >
   </div>
 </div>
 {% endblock content_main %}

+ 1 - 1
src/server/views/me/external-accounts.html

@@ -143,7 +143,7 @@
                 <div class="clearfix">
                   <button type="button" class="btn btn-info pull-right" onclick="associateLdap()">
                     <i class="fa fa-plus-circle" aria-hidden="true"></i>
-                    {{ t('Add') }}
+                    {{ t('add') }}
                   </button>
                 </div>
               </div>

+ 1 - 1
src/server/views/widget/passport/ldap-association-tester.html

@@ -2,7 +2,7 @@
   <div class="alert-container"></div>
   <fieldset>
     <div class="form-group">
-      <label for="username" class="col-xs-3 control-label">{{ t('Username') }}</label>
+      <label for="username" class="col-xs-3 control-label">{{ t('username') }}</label>
       <div class="col-xs-6">
         <input class="form-control" name="loginForm[username]">
       </div>

+ 432 - 14
yarn.lock

@@ -782,6 +782,67 @@
     exec-sh "^0.3.2"
     minimist "^1.2.0"
 
+"@google-cloud/common@^2.1.1":
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/@google-cloud/common/-/common-2.2.2.tgz#bac80e32f860cee64f02b6ab218264990e64c715"
+  integrity sha512-AgMdDgLeYlEG17tXtMCowE7mplm907pcugtfJYYAp06HNe9RDnunUIY5KMnn9yikYl7NXNofARC+hwG77Zsa4g==
+  dependencies:
+    "@google-cloud/projectify" "^1.0.0"
+    "@google-cloud/promisify" "^1.0.0"
+    arrify "^2.0.0"
+    duplexify "^3.6.0"
+    ent "^2.2.0"
+    extend "^3.0.2"
+    google-auth-library "^5.0.0"
+    retry-request "^4.0.0"
+    teeny-request "^5.2.1"
+
+"@google-cloud/paginator@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-2.0.1.tgz#89ca97933eecfdd7eaa07bd79ed01c9869c9531b"
+  integrity sha512-HZ6UTGY/gHGNriD7OCikYWL/Eu0sTEur2qqse2w6OVsz+57se3nTkqH14JIPxtf0vlEJ8IJN5w3BdZ22pjCB8g==
+  dependencies:
+    arrify "^2.0.0"
+    extend "^3.0.2"
+
+"@google-cloud/projectify@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@google-cloud/projectify/-/projectify-1.0.1.tgz#f654c2ea9de923294ec814ff07c42891abf2d143"
+  integrity sha512-xknDOmsMgOYHksKc1GPbwDLsdej8aRNIA17SlSZgQdyrcC0lx0OGo4VZgYfwoEU1YS8oUxF9Y+6EzDOb0eB7Xg==
+
+"@google-cloud/promisify@^1.0.0":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@google-cloud/promisify/-/promisify-1.0.2.tgz#e581aa79ff71fb6074acc1cc59e3d81bf84ce07b"
+  integrity sha512-7WfV4R/3YV5T30WRZW0lqmvZy9hE2/p9MvpI34WuKa2Wz62mLu5XplGTFEMK6uTbJCLWUxTcZ4J4IyClKucE5g==
+
+"@google-cloud/storage@^3.3.0":
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/@google-cloud/storage/-/storage-3.3.0.tgz#fa6e6034383ffe65cd6ee8e18fddf77be89f38f6"
+  integrity sha512-9jmHJ0ncQTcrZRwq5MRjXEwuCFkIjHenYwVbycV6bbZ4O84Hcgg4Yp33sKcJug5rvZeVgrpCzPbYXqO3B0LzJw==
+  dependencies:
+    "@google-cloud/common" "^2.1.1"
+    "@google-cloud/paginator" "^2.0.0"
+    "@google-cloud/promisify" "^1.0.0"
+    arrify "^2.0.0"
+    compressible "^2.0.12"
+    concat-stream "^2.0.0"
+    date-and-time "^0.10.0"
+    duplexify "^3.5.0"
+    extend "^3.0.2"
+    gaxios "^2.0.1"
+    gcs-resumable-upload "^2.2.4"
+    hash-stream-validation "^0.2.1"
+    mime "^2.2.0"
+    mime-types "^2.0.8"
+    onetime "^5.1.0"
+    p-limit "^2.2.0"
+    pumpify "^2.0.0"
+    readable-stream "^3.4.0"
+    snakeize "^0.1.0"
+    stream-events "^1.0.1"
+    through2 "^3.0.0"
+    xdg-basedir "^4.0.0"
+
 "@handsontable/react@=2.1.0":
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/@handsontable/react/-/react-2.1.0.tgz#3b87ebfc0d5d47e1b0d07856bd473017a0a7179f"
@@ -1280,6 +1341,13 @@ abbrev@1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
 
+abort-controller@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
+  integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
+  dependencies:
+    event-target-shim "^5.0.0"
+
 accepts@1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca"
@@ -1336,6 +1404,13 @@ after@0.8.2:
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
 
+agent-base@4, agent-base@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
 agentkeepalive@^3.4.1:
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.4.1.tgz#aa95aebc3a749bca5ed53e3880a09f5235b48f0c"
@@ -1615,6 +1690,11 @@ arrify@^1.0.0, arrify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
 
+arrify@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
+  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
+
 asap@^2.0.0, asap@~2.0.3:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
@@ -1887,6 +1967,11 @@ base64-js@^1.0.2:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886"
 
+base64-js@^1.3.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
+  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
+
 base64id@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
@@ -1987,6 +2072,11 @@ big.js@^5.2.2:
   resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
   integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
 
+bignumber.js@^7.0.0:
+  version "7.2.1"
+  resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-7.2.1.tgz#80c048759d826800807c4bfd521e50edbba57a5f"
+  integrity sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==
+
 bignumber.js@^8.0.1:
   version "8.1.1"
   resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-8.1.1.tgz#4b072ae5aea9c20f6730e4e5d529df1271c4d885"
@@ -2956,6 +3046,13 @@ compress-commons@^2.1.1:
     normalize-path "^3.0.0"
     readable-stream "^2.3.6"
 
+compressible@^2.0.12:
+  version "2.0.17"
+  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.17.tgz#6e8c108a16ad58384a977f3a482ca20bff2f38c1"
+  integrity sha512-BGHeLCK1GV7j1bSmQQAi26X+GgWcTjLr/0tzSvMCl3LH1w1IJ4PFSPoV5316b30cneTziC+B1a+3OjoSUcQYmw==
+  dependencies:
+    mime-db ">= 1.40.0 < 2"
+
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -2977,6 +3074,16 @@ concat-stream@^1.5.2:
     readable-stream "^2.2.2"
     typedarray "^0.0.6"
 
+concat-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1"
+  integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==
+  dependencies:
+    buffer-from "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.0.2"
+    typedarray "^0.0.6"
+
 configstore@^3.0.0:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
@@ -2988,6 +3095,18 @@ configstore@^3.0.0:
     write-file-atomic "^2.0.0"
     xdg-basedir "^3.0.0"
 
+configstore@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.0.tgz#37de662c7a49b5fe8dbcf8f6f5818d2d81ed852b"
+  integrity sha512-eE/hvMs7qw7DlcB5JPRnthmrITuHMmACUJAp89v6PT6iOqzoLS7HRWhBtuHMlhNHo2AhUSA/3Dh1bKNJHcublQ==
+  dependencies:
+    dot-prop "^5.1.0"
+    graceful-fs "^4.1.2"
+    make-dir "^3.0.0"
+    unique-string "^2.0.0"
+    write-file-atomic "^3.0.0"
+    xdg-basedir "^4.0.0"
+
 connect-browser-sync@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/connect-browser-sync/-/connect-browser-sync-2.1.0.tgz#1248da281a439fe99b023270d18555c1f046c229"
@@ -3315,6 +3434,11 @@ crypto-random-string@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
 
+crypto-random-string@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
+  integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
+
 csrf@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/csrf/-/csrf-3.1.0.tgz#ec75e9656d004d674b8ef5ba47b41fbfd6cb9c30"
@@ -3514,6 +3638,11 @@ data-urls@^1.0.0:
     whatwg-mimetype "^2.2.0"
     whatwg-url "^7.0.0"
 
+date-and-time@^0.10.0:
+  version "0.10.0"
+  resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-0.10.0.tgz#53825b774167b55fbdf0bbd0f17f19357df7bc70"
+  integrity sha512-IbIzxtvK80JZOVsWF6+NOjunTaoFVYxkAQoyzmflJyuRCJAJebehy48mPiCAedcGp4P7/UO3QYRWa0fe6INftg==
+
 date-fns@1.30.1:
   version "1.30.1"
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
@@ -3827,6 +3956,13 @@ dot-prop@^4.1.0, dot-prop@^4.1.1:
   dependencies:
     is-obj "^1.0.0"
 
+dot-prop@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.1.0.tgz#bdd8c986a77b83e3fca524e53786df916cabbd8a"
+  integrity sha512-n1oC6NBF+KM9oVXtjmen4Yo7HyAVWV2UUl50dCYJdw2924K6dX9bf9TTTWaKtYlRn0FEtxG27KS80ayVLixxJA==
+  dependencies:
+    is-obj "^2.0.0"
+
 dotenv@>=8.0.0:
   version "8.0.0"
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.0.0.tgz#ed310c165b4e8a97bb745b0a9d99c31bda566440"
@@ -3872,6 +4008,26 @@ duplexify@^3.4.2, duplexify@^3.6.0:
     readable-stream "^2.0.0"
     stream-shift "^1.0.0"
 
+duplexify@^3.5.0:
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
+  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
+  dependencies:
+    end-of-stream "^1.0.0"
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
+    stream-shift "^1.0.0"
+
+duplexify@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-4.1.1.tgz#7027dc374f157b122a8ae08c2d3ea4d2d953aa61"
+  integrity sha512-DY3xVEmVHTv1wSzKNbwoU6nVjzI369Y6sPoqfYr0/xlx3IdX2n94xIszTcjPO8W8ZIv0Wb0PXNcjuZyT4wiICA==
+  dependencies:
+    end-of-stream "^1.4.1"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+    stream-shift "^1.0.0"
+
 dynamic-dedupe@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz#06e44c223f5e4e94d78ef9db23a6515ce2f962a1"
@@ -3903,6 +4059,13 @@ ecdsa-sig-formatter@1.0.10:
   dependencies:
     safe-buffer "^5.0.1"
 
+ecdsa-sig-formatter@1.0.11:
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
+  integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
+  dependencies:
+    safe-buffer "^5.0.1"
+
 ecdsa-sig-formatter@1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1"
@@ -4090,6 +4253,11 @@ enhanced-resolve@4.1.0, enhanced-resolve@^4.1.0:
     memory-fs "^0.4.0"
     tapable "^1.0.0"
 
+ent@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
+  integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0=
+
 entities@^1.1.1, entities@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
@@ -4191,11 +4359,23 @@ es6-promise@^3.2.1:
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
   integrity sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=
 
+es6-promise@^4.0.3:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
 es6-promise@^4.2.6:
   version "4.2.6"
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.6.tgz#b685edd8258886365ea62b57d30de28fadcd974f"
   integrity sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==
 
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  dependencies:
+    es6-promise "^4.0.3"
+
 esa-nodejs@^0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/esa-nodejs/-/esa-nodejs-0.0.7.tgz#c4749412605ad430d5da17aa4928291927561b42"
@@ -4426,6 +4606,11 @@ event-stream@^3.3.2, event-stream@~3.3.0:
     stream-combiner "~0.0.4"
     through "~2.3.1"
 
+event-target-shim@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
+  integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
+
 eventemitter3@1.x.x:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.2.0.tgz#1c86991d816ad1e504750e73874224ecf3bec508"
@@ -4672,7 +4857,7 @@ extend@^3.0.0, extend@~3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
 
-extend@~3.0.2:
+extend@^3.0.2, extend@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
 
@@ -4731,6 +4916,11 @@ fast-levenshtein@~2.0.4:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
 
+fast-text-encoding@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz#3e5ce8293409cfaa7177a71b9ca84e1b1e6f25ef"
+  integrity sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==
+
 fb-watchman@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58"
@@ -5140,12 +5330,42 @@ gauge@~2.7.3:
     strip-ansi "^3.0.1"
     wide-align "^1.1.0"
 
+gaxios@^2.0.0, gaxios@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-2.0.1.tgz#2ca1c9eb64c525d852048721316c138dddf40708"
+  integrity sha512-c1NXovTxkgRJTIgB2FrFmOFg4YIV6N/bAa4f/FZ4jIw13Ql9ya/82x69CswvotJhbV3DiGnlTZwoq2NVXk2Irg==
+  dependencies:
+    abort-controller "^3.0.0"
+    extend "^3.0.2"
+    https-proxy-agent "^2.2.1"
+    node-fetch "^2.3.0"
+
 gaze@^1.0.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a"
   dependencies:
     globule "^1.0.0"
 
+gcp-metadata@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-3.0.0.tgz#6e43f899728d0e1bb7831631cf3f86cc9e4de3aa"
+  integrity sha512-WP5/TZWri9TrD41jNr8ukY9dKYLL+8jwQVwbtUbmprjWuyybdnJNkbXbwqD2sdbXIVXD1WCqzfj7QftSLB6K8Q==
+  dependencies:
+    gaxios "^2.0.1"
+    json-bigint "^0.3.0"
+
+gcs-resumable-upload@^2.2.4:
+  version "2.2.5"
+  resolved "https://registry.yarnpkg.com/gcs-resumable-upload/-/gcs-resumable-upload-2.2.5.tgz#13e832130eb8c12be6d80f4bdc6fc0c410f73ad1"
+  integrity sha512-r98Hnxza8oYT21MzpziAB2thz3AURGz54+osWtczxGNxH7Fodb0HVUEtfqTwBS5vcf9RnKwR7c0EMaI8R39feg==
+  dependencies:
+    abort-controller "^3.0.0"
+    configstore "^5.0.0"
+    gaxios "^2.0.0"
+    google-auth-library "^5.0.0"
+    pumpify "^2.0.0"
+    stream-events "^1.0.4"
+
 get-caller-file@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
@@ -5348,6 +5568,20 @@ gonzales-pe@^4.0.3:
   dependencies:
     minimist "1.1.x"
 
+google-auth-library@^5.0.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-5.3.0.tgz#565a5e9fe6ed703203c1f978da20c88ae5f7baa2"
+  integrity sha512-DnwwP18H6RVtvtmDjmPLipKyx8NsXTQNZcNYud+ueOYaWSVxXeaSFBiBxABb93S3Ae41R60SnaSc19rljeoAxQ==
+  dependencies:
+    arrify "^2.0.0"
+    base64-js "^1.3.0"
+    fast-text-encoding "^1.0.0"
+    gaxios "^2.0.0"
+    gcp-metadata "^3.0.0"
+    gtoken "^4.1.0"
+    jws "^3.1.5"
+    lru-cache "^5.0.0"
+
 google-auth-library@~0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-0.10.0.tgz#6e15babee85fd1dd14d8d128a295b6838d52136e"
@@ -5368,6 +5602,13 @@ google-p12-pem@^0.1.0:
   dependencies:
     node-forge "^0.7.1"
 
+google-p12-pem@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-2.0.2.tgz#39cae8f6fcbe66a01f00be4ddf2d56b95926fa7b"
+  integrity sha512-UfnEARfJKI6pbmC1hfFFm+UAcZxeIwTiEcHfqKe/drMsXD/ilnVjF7zgOGpHXyhuvX6jNJK3S8A0hOQjwtFxEw==
+  dependencies:
+    node-forge "^0.9.0"
+
 googleapis@^16.0.0:
   version "16.1.0"
   resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-16.1.0.tgz#0f19f2d70572d918881a0f626e3b1a2fa8629576"
@@ -5452,6 +5693,16 @@ gtoken@^1.2.1:
     mime "^1.4.1"
     request "^2.72.0"
 
+gtoken@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-4.1.0.tgz#0b315dd1a925e3ad3c82db1eb5b9e89bae875ba8"
+  integrity sha512-wqyn2gf5buzEZN4QNmmiiW2i2JkEdZnL7Z/9p44RtZqgt4077m4khRgAYNuu8cBwHWCc6MsP6eDUn/KkF6jFIw==
+  dependencies:
+    gaxios "^2.0.0"
+    google-p12-pem "^2.0.0"
+    jws "^3.1.5"
+    mime "^2.2.0"
+
 gud@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0"
@@ -5601,6 +5852,13 @@ hash-base@^3.0.0:
     inherits "^2.0.1"
     safe-buffer "^5.0.1"
 
+hash-stream-validation@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/hash-stream-validation/-/hash-stream-validation-0.2.1.tgz#ecc9b997b218be5bb31298628bb807869b73dcd1"
+  integrity sha1-7Mm5l7IYvluzEphii7gHhptz3NE=
+  dependencies:
+    through2 "^2.0.0"
+
 hash.js@^1.0.0, hash.js@^1.0.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.3.tgz#340dedbe6290187151c1ea1d777a3448935df846"
@@ -5760,6 +6018,14 @@ http-errors@1.6.3:
     setprototypeof "1.1.0"
     statuses ">= 1.4.0 < 2"
 
+http-proxy-agent@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
+  integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==
+  dependencies:
+    agent-base "4"
+    debug "3.1.0"
+
 http-proxy@1.15.2:
   version "1.15.2"
   resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.15.2.tgz#642fdcaffe52d3448d2bda3b0079e9409064da31"
@@ -5784,6 +6050,14 @@ https-browserify@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
 
+https-proxy-agent@^2.2.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz#271ea8e90f836ac9f119daccd39c19ff7dfb0793"
+  integrity sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
 humanize-ms@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
@@ -6278,6 +6552,11 @@ is-obj@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
 
+is-obj@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
+  integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
+
 is-object@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
@@ -6374,7 +6653,7 @@ is-symbol@^1.0.2:
   dependencies:
     has-symbols "^1.0.0"
 
-is-typedarray@~1.0.0:
+is-typedarray@^1.0.0, is-typedarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
 
@@ -6968,6 +7247,13 @@ jsesc@~0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
 
+json-bigint@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-0.3.0.tgz#0ccd912c4b8270d05f056fbd13814b53d3825b1e"
+  integrity sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4=
+  dependencies:
+    bignumber.js "^7.0.0"
+
 json-buffer@3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
@@ -7102,6 +7388,15 @@ jwa@^1.1.5:
     ecdsa-sig-formatter "1.0.10"
     safe-buffer "^5.0.1"
 
+jwa@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
+  integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
+  dependencies:
+    buffer-equal-constant-time "1.0.1"
+    ecdsa-sig-formatter "1.0.11"
+    safe-buffer "^5.0.1"
+
 jws@^3.0.0:
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.5.tgz#80d12d05b293d1e841e7cb8b4e69e561adcf834f"
@@ -7117,6 +7412,14 @@ jws@^3.1.4:
     jwa "^1.1.4"
     safe-buffer "^5.0.1"
 
+jws@^3.1.5:
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
+  integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
+  dependencies:
+    jwa "^1.4.1"
+    safe-buffer "^5.0.1"
+
 kareem@2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.0.tgz#ef33c42e9024dce511eeaf440cd684f3af1fc769"
@@ -7520,7 +7823,7 @@ lru-cache@^4.0.1, lru-cache@^4.0.2:
     pseudomap "^1.0.2"
     yallist "^2.1.2"
 
-lru-cache@^5.1.1:
+lru-cache@^5.0.0, lru-cache@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
   dependencies:
@@ -7850,6 +8153,16 @@ miller-rabin@^4.0.0:
     bn.js "^4.0.0"
     brorand "^1.0.1"
 
+mime-db@1.40.0:
+  version "1.40.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
+  integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
+
+"mime-db@>= 1.40.0 < 2":
+  version "1.42.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.42.0.tgz#3e252907b4c7adb906597b4b65636272cf9e7bac"
+  integrity sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==
+
 mime-db@~1.30.0:
   version "1.30.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
@@ -7862,6 +8175,13 @@ mime-db@~1.38.0:
   version "1.38.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.38.0.tgz#1a2aab16da9eb167b49c6e4df2d9c68d63d8e2ad"
 
+mime-types@^2.0.8:
+  version "2.1.24"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
+  integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
+  dependencies:
+    mime-db "1.40.0"
+
 mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.16, mime-types@~2.1.17:
   version "2.1.17"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
@@ -7888,7 +8208,7 @@ mime@1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
 
-mime@>=2.4.4:
+mime@>=2.4.4, mime@^2.2.0:
   version "2.4.4"
   resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
   integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
@@ -8299,6 +8619,11 @@ node-fetch@^1.0.1:
     encoding "^0.1.11"
     is-stream "^1.0.1"
 
+node-fetch@^2.2.0, node-fetch@^2.3.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
+  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
+
 node-forge@^0.7.0:
   version "0.7.6"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
@@ -8312,6 +8637,11 @@ node-forge@^0.8.1:
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.8.4.tgz#d6738662b661be19e2711ef01aa3b18212f13030"
   integrity sha512-UOfdpxivIYY4g5tqp5FNRNgROVNxRACUxxJREntJLFaJr1E0UEqFtUIk0F/jYx/E+Y6sVXd0KDi/m5My0yGCVw==
 
+node-forge@^0.9.0:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5"
+  integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ==
+
 node-gyp@^3.8.0:
   version "3.8.0"
   resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c"
@@ -8816,6 +9146,13 @@ onetime@^2.0.0:
   dependencies:
     mimic-fn "^1.0.0"
 
+onetime@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5"
+  integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==
+  dependencies:
+    mimic-fn "^2.1.0"
+
 ono@^5.0.1:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/ono/-/ono-5.1.0.tgz#8cafa7e56afa2211ad63dd2eb798427e64f1a070"
@@ -10034,6 +10371,15 @@ pumpify@^1.3.3:
     inherits "^2.0.3"
     pump "^2.0.0"
 
+pumpify@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-2.0.0.tgz#975519e5a9890ae0fb4724274e3fec97e43a30b6"
+  integrity sha512-ieN9HmpFPt4J4U4qnjN4BxrnqpPPXJyp3qFErxfwBtFOec6ewpIHdS2eu3TkmGW6S+RzFGEOGpm5ih/X/onRPQ==
+  dependencies:
+    duplexify "^4.1.1"
+    inherits "^2.0.3"
+    pump "^3.0.0"
+
 punycode@1.3.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
@@ -10391,6 +10737,15 @@ readable-stream@1.1.x:
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
+"readable-stream@2 || 3", readable-stream@^3.0.1, readable-stream@^3.0.2, readable-stream@^3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc"
+  integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
 readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.3.3:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
@@ -10403,15 +10758,6 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable
     string_decoder "~1.0.3"
     util-deprecate "~1.0.1"
 
-readable-stream@^3.0.1, readable-stream@^3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc"
-  integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==
-  dependencies:
-    inherits "^2.0.3"
-    string_decoder "^1.1.1"
-    util-deprecate "^1.0.1"
-
 readable-stream@^3.1.1:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.2.0.tgz#de17f229864c120a9f56945756e4f32c4045245d"
@@ -10885,6 +11231,14 @@ ret@~0.1.10:
   version "0.1.15"
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
 
+retry-request@^4.0.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-4.1.1.tgz#f676d0db0de7a6f122c048626ce7ce12101d2bd8"
+  integrity sha512-BINDzVtLI2BDukjWmjAIRZ0oglnCAkpP2vQjM3jdLhmT62h0xnQgciPwBRDAvHqpkPT2Wo1XuUyLyn6nbGrZQQ==
+  dependencies:
+    debug "^4.1.1"
+    through2 "^3.0.1"
+
 reveal.js@^3.5.0:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/reveal.js/-/reveal.js-3.6.0.tgz#ce0e64f30cbebd6e5ce885c2f384085c5e5821e8"
@@ -11388,6 +11742,11 @@ sliced@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41"
 
+snakeize@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/snakeize/-/snakeize-0.1.0.tgz#10c088d8b58eb076b3229bb5a04e232ce126422d"
+  integrity sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0=
+
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -11717,6 +12076,13 @@ stream-each@^1.1.0:
     end-of-stream "^1.1.0"
     stream-shift "^1.0.0"
 
+stream-events@^1.0.1, stream-events@^1.0.4, stream-events@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5"
+  integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==
+  dependencies:
+    stubs "^3.0.0"
+
 stream-http@^2.7.2:
   version "2.7.2"
   resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.7.2.tgz#40a050ec8dc3b53b33d9909415c02c0bf1abfbad"
@@ -11923,6 +12289,11 @@ striptags@>=3.1.1:
   resolved "https://registry.yarnpkg.com/striptags/-/striptags-3.1.1.tgz#c8c3e7fdd6fb4bb3a32a3b752e5b5e3e38093ebd"
   integrity sha1-yMPn/db7S7OjKjt1LltePjgJPr0=
 
+stubs@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b"
+  integrity sha1-6NK6H6nJBXAwPAMLaQD31fiavls=
+
 style-loader@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.0.0.tgz#1d5296f9165e8e2c85d24eee0b7caf9ec8ca1f82"
@@ -12208,6 +12579,17 @@ tar@^4:
     safe-buffer "^5.1.2"
     yallist "^3.0.2"
 
+teeny-request@^5.2.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-5.2.1.tgz#a6394db8359b87e64e47eeb2fbf34a65c9a751ff"
+  integrity sha512-gCVm5EV3z0p/yZOKyeBOFOpSXuxdIs3foeWDWb/foKMBejK18w40L0k0UMd/ZrGkOH+gxodjqpL8KK6x3haYCQ==
+  dependencies:
+    http-proxy-agent "^2.1.0"
+    https-proxy-agent "^2.2.1"
+    node-fetch "^2.2.0"
+    stream-events "^1.0.5"
+    uuid "^3.3.2"
+
 temp-dir@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d"
@@ -12320,6 +12702,13 @@ through2@^2.0.0:
     readable-stream "^2.1.5"
     xtend "~4.0.1"
 
+through2@^3.0.0, through2@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a"
+  integrity sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==
+  dependencies:
+    readable-stream "2 || 3"
+
 through@2, "through@>=2.2.7 <3", through@^2.3.6, through@~2.3, through@~2.3.1:
   version "2.3.8"
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
@@ -12508,6 +12897,13 @@ typed-styles@^0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9"
 
+typedarray-to-buffer@^3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
+  integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
+  dependencies:
+    is-typedarray "^1.0.0"
+
 typedarray@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
@@ -12658,6 +13054,13 @@ unique-string@^1.0.0:
   dependencies:
     crypto-random-string "^1.0.0"
 
+unique-string@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"
+  integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==
+  dependencies:
+    crypto-random-string "^2.0.0"
+
 unist-util-find-all-after@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.2.tgz#9be49cfbae5ca1566b27536670a92836bf2f8d6d"
@@ -12886,7 +13289,7 @@ validator@>=11.0.0:
   resolved "https://registry.yarnpkg.com/validator/-/validator-11.0.0.tgz#fb10128bfb1fd14ce4ed36b79fc94289eae70667"
   integrity sha512-+wnGLYqaKV2++nUv60uGzUJyJQwYVOin6pn1tgEiFCeCQO60yeu3Og9/yPccbBX574kxIcEJicogkzx6s6eyag==
 
-validator@^11.0.0:
+validator@^11.0.0, validator@^11.1.0:
   version "11.1.0"
   resolved "https://registry.yarnpkg.com/validator/-/validator-11.1.0.tgz#ac18cac42e0aa5902b603d7a5d9b7827e2346ac4"
   integrity sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==
@@ -13229,6 +13632,16 @@ write-file-atomic@^2.0.0:
     imurmurhash "^0.1.4"
     signal-exit "^3.0.2"
 
+write-file-atomic@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.0.tgz#1b64dbbf77cb58fd09056963d63e62667ab4fb21"
+  integrity sha512-EIgkf60l2oWsffja2Sf2AL384dx328c0B+cIYPTQq5q2rOYuDV00/iPFBOUiDKKwKMOhkymH8AidPaRvzfxY+Q==
+  dependencies:
+    imurmurhash "^0.1.4"
+    is-typedarray "^1.0.0"
+    signal-exit "^3.0.2"
+    typedarray-to-buffer "^3.1.5"
+
 write@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
@@ -13280,6 +13693,11 @@ xdg-basedir@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
 
+xdg-basedir@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
+  integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
+
 xml-crypto@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-1.0.2.tgz#248df860b1e3f7326e61bcbd00c234886b0d6e3b"