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

Merge branch 'master' into support/apply-bootstrap4

# Conflicts:
#	src/client/js/components/MyDraftList/MyDraftList.jsx
#	src/client/js/components/PageEditor/CodeMirrorEditor.jsx
#	src/client/js/components/RecentCreated/RecentCreated.jsx
#	src/client/js/components/TagsList.jsx
#	src/client/styles/bootstrap4/_utilities.scss
#	src/client/styles/bootstrap4/_variables.scss
Yuki Takei 6 лет назад
Родитель
Сommit
3fc97da290
100 измененных файлов с 6017 добавлено и 1512 удалено
  1. 21 1
      CHANGES.md
  2. 2 0
      README.md
  3. 1 1
      bin/wercker/trigger-growi-docker.sh
  4. 28 0
      bin/wercker/trigger-growi-docs.sh
  5. 0 0
      checkout
  6. 2 8
      config/migrate.js
  7. 5 1
      config/swagger-definition.js
  8. 0 0
      master
  9. 13 3
      package.json
  10. 55 6
      resource/locales/en-US/translation.json
  11. 54 3
      resource/locales/ja/translation.json
  12. 70 13
      src/client/js/app.jsx
  13. 0 82
      src/client/js/components/Admin/AdminRebuildSearch.jsx
  14. 45 0
      src/client/js/components/Admin/Common/ProgressBar.jsx
  15. 51 0
      src/client/js/components/Admin/Customize/Customize.jsx
  16. 0 100
      src/client/js/components/Admin/Export/ExportAsZip.jsx
  17. 237 3
      src/client/js/components/Admin/Export/ExportPage.jsx
  18. 52 0
      src/client/js/components/Admin/Export/ExportTableMenu.jsx
  19. 245 0
      src/client/js/components/Admin/Export/ExportZipFormModal.jsx
  20. 67 0
      src/client/js/components/Admin/Export/ZipFileTable.jsx
  21. 35 0
      src/client/js/components/Admin/FullTextSearchManagement.jsx
  22. 133 0
      src/client/js/components/Admin/FullTextSearchManagement/RebuildIndex.jsx
  23. 181 0
      src/client/js/components/Admin/Import/GrowiZipImportForm.jsx
  24. 119 0
      src/client/js/components/Admin/Import/GrowiZipImportSection.jsx
  25. 93 0
      src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx
  26. 344 0
      src/client/js/components/Admin/Importer.jsx
  27. 115 0
      src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx
  28. 123 0
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  29. 96 0
      src/client/js/components/Admin/MarkdownSetting/WhiteListInput.jsx
  30. 117 0
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  31. 120 0
      src/client/js/components/Admin/UserGroup/UserGroupCreateForm.jsx
  32. 206 0
      src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  33. 186 0
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  34. 134 0
      src/client/js/components/Admin/UserGroup/UserGroupTable.jsx
  35. 51 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  36. 106 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  37. 87 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  38. 83 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  39. 43 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  40. 116 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  41. 57 0
      src/client/js/components/Admin/Users/GiveAdminButton.jsx
  42. 37 0
      src/client/js/components/Admin/Users/InviteUserControl.jsx
  43. 78 0
      src/client/js/components/Admin/Users/ManageExternalAccount.jsx
  44. 119 0
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  45. 81 0
      src/client/js/components/Admin/Users/RemoveAdminButton.jsx
  46. 57 0
      src/client/js/components/Admin/Users/StatusActivateButton.jsx
  47. 80 0
      src/client/js/components/Admin/Users/StatusSuspendedButton.jsx
  48. 222 0
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  49. 110 0
      src/client/js/components/Admin/Users/UserMenu.jsx
  50. 57 0
      src/client/js/components/Admin/Users/UserRemoveButton.jsx
  51. 127 0
      src/client/js/components/Admin/Users/UserTable.jsx
  52. 70 0
      src/client/js/components/Admin/Users/Users.jsx
  53. 0 260
      src/client/js/components/GroupDeleteModal/GroupDeleteModal.jsx
  54. 23 122
      src/client/js/components/MyDraftList/MyDraftList.jsx
  55. 1 0
      src/client/js/components/PageComment/Comment.jsx
  56. 2 0
      src/client/js/components/PageComment/CommentEditor.jsx
  57. 7 7
      src/client/js/components/PageEditor/Cheatsheet.jsx
  58. 38 41
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  59. 33 2
      src/client/js/components/PageEditor/Editor.jsx
  60. 174 0
      src/client/js/components/PaginationWrapper.jsx
  61. 19 126
      src/client/js/components/RecentCreated/RecentCreated.jsx
  62. 8 4
      src/client/js/components/SearchPage/SearchResultList.jsx
  63. 18 118
      src/client/js/components/TagsList.jsx
  64. 0 8
      src/client/js/legacy/crowi-admin.js
  65. 167 0
      src/client/js/services/AdminUsersContainer.js
  66. 66 17
      src/client/js/services/AppContainer.js
  67. 2 2
      src/client/js/services/CommentContainer.js
  68. 41 0
      src/client/js/services/MarkDownSettingContainer.js
  69. 135 0
      src/client/js/services/UserGroupDetailContainer.js
  70. 37 0
      src/client/js/util/apiNotification.js
  71. 20 0
      src/client/js/util/apiv3ErrorHandler.js
  72. 2 1
      src/client/js/util/markdown-it/plantuml.js
  73. 2 2
      src/client/styles/hackmd/style.scss
  74. 1 1
      src/client/styles/scss/_editor-overlay.scss
  75. 1 1
      src/client/styles/scss/_on-edit.scss
  76. 11 0
      src/lib/util/mongoose-utils.js
  77. 15 0
      src/lib/util/toArrayIfNot.js
  78. 47 0
      src/server/console.js
  79. 1 7
      src/server/crowi/express-init.js
  80. 36 11
      src/server/crowi/index.js
  81. 11 0
      src/server/events/admin.js
  82. 0 8
      src/server/form/admin/userInvite.js
  83. 0 3
      src/server/form/index.js
  84. 27 0
      src/server/middleware/access-token-parser.js
  85. 24 0
      src/server/middleware/admin-required.js
  86. 27 0
      src/server/middleware/csrf.js
  87. 49 0
      src/server/middleware/login-required.js
  88. 30 0
      src/server/middlewares/ApiV3FormValidator.js
  89. 21 0
      src/server/middlewares/index.js
  90. 13 0
      src/server/models/ErrorV3.js
  91. 20 3
      src/server/models/bookmark.js
  92. 3 0
      src/server/models/index.js
  93. 48 0
      src/server/models/page-tag-relation.js
  94. 8 25
      src/server/models/page.js
  95. 11 0
      src/server/models/tag.js
  96. 8 4
      src/server/models/user-group.js
  97. 97 128
      src/server/models/user.js
  98. 86 319
      src/server/routes/admin.js
  99. 96 69
      src/server/routes/apiv3/export.js
  100. 0 2
      src/server/routes/apiv3/healthcheck.js

+ 21 - 1
CHANGES.md

@@ -1,10 +1,30 @@
 # CHANGES
 
-## 3.5.14-RC
+## 3.5.17-RC
 
+* Feature: Upload to GCS (Google Cloud Storage)
+* Feature: Statistics API
+* Improvement: Export progress bar
+* Improvement: Reactify admin pages
+* Fix: Use HTTP PlantUML URL in default
+    * Introduced by 3.5.12
+* Fix: Config default values
+* Support: REPL with `console` npm scripts
+
+## 3.5.16
+
+* Fix: Full Text Search doesn't work after when building indices
+    * Introduced by 3.5.12
+
+## 3.5.15
+
+* Feature: Import/Export Page data
+* Fix: The link to Sandbox on Markdown Help Modal doesn't work
 * Support: Upgrade libs
     * codemirror
 
+## 3.5.14 (Missing number)
+
 ## 3.5.13
 
 * Feature: Re-edit comments

+ 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

+ 1 - 1
bin/wercker/trigger-growi-docker.sh

@@ -9,7 +9,7 @@
 #   - $WERCKER_TOKEN
 #   - $GROWI_DOCKER_PIPELINE_ID
 #   - $RELEASE_VERSION
-#   - $WERCKER_GIT_COMMIT
+#   - $RELEASE_GIT_COMMIT
 #
 RESPONSE=`curl -X POST \
   -H "Content-Type: application/json" \

+ 28 - 0
bin/wercker/trigger-growi-docs.sh

@@ -0,0 +1,28 @@
+#!/bin/sh
+
+# Trigger a new run
+# see: http://devcenter.wercker.com/docs/api/endpoints/runs#trigger-a-run
+
+# exec curl
+#
+# require
+#   - $WERCKER_TOKEN
+#   - $GROWI_DOCS_PIPELINE_ID
+#
+RESPONSE=`curl -X POST \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer $WERCKER_TOKEN" \
+  https://app.wercker.com/api/v3/runs -d '{ \
+    "pipelineId": "'$GROWI_DOCS_PIPELINE_ID'", \
+    "branch": "master"
+  }' \
+`
+
+echo $RESPONSE | jq .
+
+# get wercker run id
+RUN_ID=`echo $RESPONSE | jq .id`
+# exit with failure status
+if [ "$RUN_ID" = "null" ]; then
+  exit 1
+fi


+ 2 - 8
config/migrate.js

@@ -7,15 +7,9 @@
 
 require('module-alias/register');
 
-function getMongoUri(env) {
-  return env.MONGOLAB_URI // for B.C.
-    || env.MONGODB_URI // MONGOLAB changes their env name
-    || env.MONGOHQ_URL
-    || env.MONGO_URI
-    || ((env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
-}
+const { getMongoUri } = require('@commons/util/mongoose-utils');
 
-const mongoUri = getMongoUri(process.env);
+const mongoUri = getMongoUri();
 const match = mongoUri.match(/^(.+)\/([^/]+)$/);
 
 module.exports = {

+ 5 - 1
config/swagger-definition.js

@@ -6,5 +6,9 @@ module.exports = {
     title: 'GROWI REST API v3',
     version: pkg.version,
   },
-  basePath: '/api/v3/',
+  servers: [
+    {
+      url: 'https://demo.growi.org/_api/v3/',
+    },
+  ],
 };


+ 13 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.14-RC",
+  "version": "3.5.17-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -20,6 +20,7 @@
     "url": "https://github.com/weseek/growi/issues"
   },
   "scripts": {
+    "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/routes/apiv3/**/*.js",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
@@ -31,12 +32,14 @@
     "clean:app": "rimraf -- public/js public/styles",
     "clean:report": "rimraf -- report",
     "clean": "npm-run-all -p clean:*",
+    "console": "env-cmd -f config/env.dev.js node --experimental-repl-await src/server/console.js",
     "heroku-postbuild": "sh bin/heroku/install-plugins.sh && npm run build:prod",
     "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
     "lint:js": "eslint \"**/*.{js,jsx}\"",
     "lint:styles:fix": "prettier-stylelint --quiet --write src/client/styles/scss/**/*.scss",
     "lint:styles": "stylelint src/client/styles/scss/**/*.scss",
-    "lint": "npm-run-all -p lint:js lint:styles",
+    "lint:swagger2openapi": "node node_modules/swagger2openapi/oas-validate tmp/swagger.json",
+    "lint": "npm-run-all -p lint:js lint:styles lint:swagger2openapi",
     "migrate": "npm run migrate:up",
     "migrate:create": "migrate-mongo create -f config/migrate.js -- ",
     "migrate:status": "migrate-mongo status -f config/migrate.js",
@@ -46,6 +49,7 @@
     "prebuild:dev:watch": "npm run prebuild:dev",
     "prebuild:dev": "npm run clean:app && env-cmd -f config/env.dev.js npm run plugin:def && env-cmd -f config/env.dev.js npm run resource",
     "prebuild:prod": "npm run clean && env-cmd -f config/env.prod.js npm run plugin:def && env-cmd -f config/env.prod.js npm run resource",
+    "prelint:swagger2openapi": "npm run build:apiv3:jsdoc",
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "resource": "node bin/download-cdn-resources.js",
@@ -65,7 +69,10 @@
       "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",
+    "array.prototype.flatmap": "^1.2.2",
     "async": "^3.0.1",
     "aws-sdk": "^2.88.0",
     "axios": "^0.19.0",
@@ -130,7 +137,9 @@
     "string-width": "^4.1.0",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
+    "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
+    "validator": "^11.1.0",
     "xss": "^1.0.6"
   },
   "devDependencies": {
@@ -220,7 +229,8 @@
     "socket.io-client": "^2.0.3",
     "style-loader": "^1.0.0",
     "stylelint-config-recess-order": "^2.0.1",
-    "swagger-jsdoc": "^3.2.9",
+    "swagger-jsdoc": "^3.4.0",
+    "swagger2openapi": "^5.3.1",
     "terser-webpack-plugin": "^2.0.1",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",

+ 55 - 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,13 +742,24 @@
     "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",
+    "growi_settings": {
+      "overwrite_documents": "Imported documents will overwrite existing documents",
+      "zip_file": "Zip File",
+      "uploaded_data": "Uploaded Data",
+      "extracted_file": "Extracted File",
+      "collection": "Collection",
+      "upload": "Upload",
+      "discard": "Discard Uploaded Data"
+    },
     "esa_settings": {
       "team_name": "Team name",
       "access_token": "Access token",
@@ -751,5 +773,32 @@
     "import": "Import",
     "page_skip": "Pages with a name that already exists on GROWI are not imported",
     "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",
+    "exported_data_list": "Exported Data List",
+    "export_collections": "Export Collections",
+    "check_all": "Check All",
+    "uncheck_all": "Uncheck All",
+    "desc_password_seed": "DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.<br><br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.",
+    "create_new_exported_data": "Create New Exported Data",
+    "export": "Export",
+    "cancel": "Cancel",
+    "file": "File",
+    "growi_version": "Growi Version",
+    "collections": "Collections",
+    "exported_at": "Exported At",
+    "export_menu": "Export Menu",
+    "download": "Download",
+    "delete": "Delete"
   }
 }

+ 54 - 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,13 +727,24 @@
     "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からインポート",
+    "growi_settings": {
+      "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
+      "zip_file": "Zip ファイル",
+      "uploaded_data": "アップロードされたデータ",
+      "extracted_file": "展開されたファイル",
+      "collection": "コレクション",
+      "upload": "アップロード",
+      "discard": "アップロードしたデータを破棄する"
+    },
     "esa_settings": {
       "team_name": "チーム名",
       "access_token": "アクセストークン",
@@ -734,5 +758,32 @@
     "import": "インポート",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "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": "エクスポート中のデータ",
+    "exported_data_list": "エクスポートデータリスト",
+    "export_collections": "コレクションのエクスポート",
+    "check_all": "全てにチェックを付ける",
+    "uncheck_all": "全てからチェックを外す",
+    "desc_password_seed": "ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。",
+    "create_new_exported_data": "エクスポートデータの新規作成",
+    "export": "エクスポート",
+    "cancel": "キャンセル",
+    "file": "ファイル名",
+    "growi_version": "Growi バージョン",
+    "collections": "コレクション",
+    "exported_at": "エクスポートされた時間",
+    "export_menu": "エクスポートメニュー",
+    "download": "ダウンロード",
+    "delete": "削除"
   }
 }

+ 70 - 13
src/client/js/app.jsx

@@ -34,19 +34,28 @@ 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';
 import ExportPage from './components/Admin/Export/ExportPage';
-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');
 
@@ -99,7 +108,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
@@ -119,8 +133,6 @@ if (pageContainer.state.pageId != null) {
     'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
     'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
     'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
-
-    'admin-rebuild-search': <AdminRebuildSearch crowi={appContainer} />,
   }, componentMappings);
 }
 if (pageContainer.state.path != null) {
@@ -147,6 +159,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,22 +228,28 @@ if (customHeaderEditorElem != null) {
     customHeaderEditorElem,
   );
 }
-const adminGrantSelectorElem = document.getElementById('admin-delete-user-group-modal');
-if (adminGrantSelectorElem != null) {
+
+const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
+if (adminUserGroupPageElem != null) {
+  const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
+
   ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <GroupDeleteModal
-        crowi={appContainer}
-      />
-    </I18nextProvider>,
-    adminGrantSelectorElem,
+    <Provider inject={[websocketContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <UserGroupPage
+          crowi={appContainer}
+          isAclEnabled={isAclEnabled}
+        />
+      </I18nextProvider>
+    </Provider>,
+    adminUserGroupPageElem,
   );
 }
 
 const adminExportPageElem = document.getElementById('admin-export-page');
 if (adminExportPageElem != null) {
   ReactDOM.render(
-    <Provider inject={[]}>
+    <Provider inject={[appContainer, websocketContainer]}>
       <I18nextProvider i18n={i18n}>
         <ExportPage
           crowi={appContainer}

+ 0 - 82
src/client/js/components/Admin/AdminRebuildSearch.jsx

@@ -1,82 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { createSubscribedElement } from '../UnstatedUtils';
-import WebsocketContainer from '../../services/AppContainer';
-
-class AdminRebuildSearch extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isCompleted: false,
-      total: 0,
-      current: 0,
-      skip: 0,
-    };
-  }
-
-  componentDidMount() {
-    const socket = this.props.webspcketContainer.getWebSocket();
-
-    socket.on('admin:addPageProgress', (data) => {
-      const newStates = Object.assign(data, { isCompleted: false });
-      this.setState(newStates);
-    });
-
-    socket.on('admin:finishAddPage', (data) => {
-      const newStates = Object.assign(data, { isCompleted: true });
-      this.setState(newStates);
-    });
-  }
-
-  render() {
-    const {
-      total, current, skip, isCompleted,
-    } = this.state;
-    if (total === 0) {
-      return null;
-    }
-
-    const progressBarLabel = isCompleted ? 'Completed' : `Processing.. ${current}/${total} (${skip} skips)`;
-    const progressBarWidth = isCompleted ? '100%' : `${(current / total) * 100}%`;
-    const progressBarClassNames = isCompleted
-      ? 'progress-bar progress-bar-success'
-      : 'progress-bar progress-bar-striped progress-bar-animated active';
-
-    return (
-      <div>
-        <h5>
-          {progressBarLabel}
-          <span className="pull-right">{progressBarWidth}</span>
-        </h5>
-        <div className="progress progress-sm">
-          <div
-            className={progressBarClassNames}
-            role="progressbar"
-            aria-valuemin="0"
-            aria-valuenow={current}
-            aria-valuemax={total}
-            style={{ width: progressBarWidth }}
-          >
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const AdminRebuildSearchWrapper = (props) => {
-  return createSubscribedElement(AdminRebuildSearch, props, [WebsocketContainer]);
-};
-
-AdminRebuildSearch.propTypes = {
-  webspcketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
-};
-
-export default AdminRebuildSearchWrapper;

+ 45 - 0
src/client/js/components/Admin/Common/ProgressBar.jsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class ProgressBar extends React.Component {
+
+
+  render() {
+    const {
+      header, currentCount, totalCount, isInProgress,
+    } = this.props;
+
+    const percentage = currentCount / totalCount * 100;
+    const isActive = (isInProgress != null)
+      ? isInProgress //                         apply props.isInProgress if set
+      : (currentCount !== totalCount); //       otherwise, set true when currentCount does not equal totalCount
+
+    return (
+      <>
+        <h5 className="my-1">
+          {header}
+          <div className="pull-right">{currentCount} / {totalCount}</div>
+        </h5>
+        <div className="progress progress-sm">
+          <div
+            className={`progress-bar ${isActive ? 'progress-bar-info progress-bar-striped active' : 'progress-bar-success'}`}
+            style={{ width: `${percentage}%` }}
+          >
+            <span className="sr-only">{percentage.toFixed(0)}% Complete</span>
+          </div>
+        </div>
+      </>
+    );
+  }
+
+}
+
+ProgressBar.propTypes = {
+  header: PropTypes.string.isRequired,
+  currentCount: PropTypes.number.isRequired,
+  totalCount: PropTypes.number.isRequired,
+  isInProgress: PropTypes.bool,
+};
+
+export default withTranslation()(ProgressBar);

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

+ 0 - 100
src/client/js/components/Admin/Export/ExportAsZip.jsx

@@ -1,100 +0,0 @@
-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';
-
-class ExportPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      files: {},
-    };
-
-    this.createZipFile = this.createZipFile.bind(this);
-    this.deleteZipFile = this.deleteZipFile.bind(this);
-  }
-
-  async componentDidMount() {
-    const res = await this.props.appContainer.apiGet('/v3/export', {});
-
-    this.setState({ files: res.files });
-  }
-
-  async createZipFile() {
-    // TODO use appContainer.apiv3.post
-    const res = await this.props.appContainer.apiPost('/v3/export/pages', {});
-    // TODO toastSuccess, toastError
-    this.setState((prevState) => {
-      return {
-        files: {
-          ...prevState.files,
-          [res.collection]: res.file,
-        },
-      };
-    });
-  }
-
-  async deleteZipFile() {
-    // TODO use appContainer.apiv3.delete
-    // TODO toastSuccess, toastError
-  }
-
-  render() {
-    // const { t } = this.props;
-
-    return (
-      <Fragment>
-        <h2>Export Data as Zip</h2>
-        <form className="my-5">
-          {Object.keys(this.state.files).map((file) => {
-            const disabled = file !== 'pages';
-            return (
-              <div className="form-check" key={file}>
-                <input
-                  type="radio"
-                  id={file}
-                  name="collection"
-                  className="form-check-input"
-                  value={file}
-                  disabled={disabled}
-                  checked={!disabled}
-                  onChange={() => {}}
-                />
-                <label className={`form-check-label ml-3 ${disabled ? 'text-muted' : ''}`} htmlFor={file}>
-                  {file} ({this.state.files[file] || 'not found'})
-                </label>
-              </div>
-            );
-          })}
-        </form>
-        <button type="button" className="btn btn-sm btn-default" onClick={this.createZipFile}>Generate</button>
-        <a href="/_api/v3/export/pages">
-          <button type="button" className="btn btn-sm btn-primary ml-2">Download</button>
-        </a>
-        {/* <button type="button" className="btn btn-sm btn-danger ml-2" onClick={this.deleteZipFile}>Clear</button> */}
-      </Fragment>
-    );
-  }
-
-}
-
-ExportPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isAclEnabled: PropTypes.bool,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ExportPageWrapper = (props) => {
-  return createSubscribedElement(ExportPage, props, [AppContainer]);
-};
-
-export default withTranslation()(ExportPageWrapper);

+ 237 - 3
src/client/js/components/Admin/Export/ExportPage.jsx

@@ -1,17 +1,251 @@
 import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
 
-import ExportAsZip from './ExportAsZip';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import WebsocketContainer from '../../../services/WebsocketContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import ProgressBar from '../Common/ProgressBar';
+
+import ExportZipFormModal from './ExportZipFormModal';
+import ZipFileTable from './ZipFileTable';
 
 class ExportPage extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      collections: [],
+      zipFileStats: [],
+      progressList: [],
+      isExportModalOpen: false,
+      isExporting: false,
+      isZipping: false,
+      isExported: false,
+    };
+
+    this.onZipFileStatAdd = this.onZipFileStatAdd.bind(this);
+    this.onZipFileStatRemove = this.onZipFileStatRemove.bind(this);
+    this.openExportModal = this.openExportModal.bind(this);
+    this.closeExportModal = this.closeExportModal.bind(this);
+    this.exportingRequestedHandler = this.exportingRequestedHandler.bind(this);
+  }
+
+  async componentWillMount() {
+    // TODO:: use apiv3.get
+    // eslint-disable-next-line no-unused-vars
+    const [{ collections }, { status }] = await Promise.all([
+      this.props.appContainer.apiGet('/v3/mongo/collections', {}),
+      this.props.appContainer.apiGet('/v3/export/status', {}),
+    ]);
+    // TODO: toastSuccess, toastError
+
+    const { zipFileStats, isExporting, progressList } = status;
+    this.setState({
+      collections,
+      zipFileStats,
+      isExporting,
+      progressList,
+    });
+
+    this.setupWebsocketEventHandler();
+  }
+
+  setupWebsocketEventHandler() {
+    const socket = this.props.websocketContainer.getWebSocket();
+
+    // websocket event
+    socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
+      this.setState({
+        isExporting: true,
+        progressList,
+      });
+    });
+
+    // websocket event
+    socket.on('admin:onStartZippingForExport', () => {
+      this.setState({
+        isZipping: true,
+      });
+    });
+
+    // websocket event
+    socket.on('admin:onTerminateForExport', ({ addedZipFileStat }) => {
+      const zipFileStats = this.state.zipFileStats.concat([addedZipFileStat]);
+
+      this.setState({
+        isExporting: false,
+        isZipping: false,
+        isExported: true,
+        zipFileStats,
+      });
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `New Exported Data '${addedZipFileStat.fileName}' is added`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    });
+  }
+
+  onZipFileStatAdd(newStat) {
+    this.setState((prevState) => {
+      return {
+        zipFileStats: [...prevState.zipFileStats, newStat],
+      };
+    });
+  }
+
+  async onZipFileStatRemove(fileName) {
+    try {
+      await this.props.appContainer.apiDelete(`/v3/export/${fileName}`, {});
+
+      this.setState((prevState) => {
+        return {
+          zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== fileName),
+        };
+      });
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  openExportModal() {
+    this.setState({ isExportModalOpen: true });
+  }
+
+  closeExportModal() {
+    this.setState({ isExportModalOpen: false });
+  }
+
+  /**
+   * event handler invoked when export process was requested successfully
+   */
+  exportingRequestedHandler() {
+  }
+
+  renderProgressBarsForCollections() {
+    const cols = this.state.progressList.map((progressData) => {
+      const { collectionName, currentCount, totalCount } = progressData;
+      return (
+        <div className="col-md-6" key={collectionName}>
+          <ProgressBar
+            header={collectionName}
+            currentCount={currentCount}
+            totalCount={totalCount}
+          />
+        </div>
+      );
+    });
+
+    return <div className="row px-3">{cols}</div>;
+  }
+
+  renderProgressBarForZipping() {
+    const { isZipping, isExported } = this.state;
+    const showZippingBar = isZipping || isExported;
+
+    if (!showZippingBar) {
+      return <></>;
+    }
+
+    return (
+      <div className="row px-3">
+        <div className="col-md-12" key="progressBarForZipping">
+          <ProgressBar
+            header="Zip Files"
+            currentCount={1}
+            totalCount={1}
+            isInProgress={isZipping}
+          />
+        </div>
+      </div>
+    );
+  }
+
   render() {
+    const { t } = this.props;
+    const { isExporting, isExported, progressList } = this.state;
+
+    const showExportingData = (isExported || isExporting) && (progressList != null);
+
     return (
       <Fragment>
-        <ExportAsZip />
+        <h2>{t('Export Data')}</h2>
+
+        <button type="button" className="btn btn-default" disabled={isExporting} onClick={this.openExportModal}>
+          {t('export_management.create_new_exported_data')}
+        </button>
+
+        { showExportingData && (
+          <div className="mt-5">
+            <h3>{t('export_management.exporting_data_list')}</h3>
+            { this.renderProgressBarsForCollections() }
+            { this.renderProgressBarForZipping() }
+          </div>
+        ) }
+
+        <div className="mt-5">
+          <h3>{t('export_management.exported_data_list')}</h3>
+          <ZipFileTable
+            zipFileStats={this.state.zipFileStats}
+            onZipFileStatRemove={this.onZipFileStatRemove}
+          />
+        </div>
+
+        <ExportZipFormModal
+          isOpen={this.state.isExportModalOpen}
+          onExportingRequested={this.exportingRequestedHandler}
+          onClose={this.closeExportModal}
+          collections={this.state.collections}
+        />
       </Fragment>
     );
   }
 
 }
 
-export default ExportPage;
+ExportPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportPageFormWrapper = (props) => {
+  return createSubscribedElement(ExportPage, props, [AppContainer, WebsocketContainer]);
+};
+
+export default withTranslation()(ExportPageFormWrapper);

+ 52 - 0
src/client/js/components/Admin/Export/ExportTableMenu.jsx

@@ -0,0 +1,52 @@
+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 ExportTableMenu extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <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">
+          <li className="dropdown-header">{t('export_management.export_menu')}</li>
+          <li>
+            <a type="button" href={`/admin/export/${this.props.fileName}`}>
+              <i className="icon-cloud-download" /> {t('export_management.download')}
+            </a>
+          </li>
+          <li>
+            <a type="button" role="button" onClick={() => this.props.onZipFileStatRemove(this.props.fileName)}>
+              <span className="text-danger"><i className="icon-trash" /> {t('export_management.delete')}</span>
+            </a>
+          </li>
+        </ul>
+      </div>
+    );
+  }
+
+}
+
+ExportTableMenu.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  fileName: PropTypes.string.isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportTableMenuWrapper = (props) => {
+  return createSubscribedElement(ExportTableMenu, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportTableMenuWrapper);

+ 245 - 0
src/client/js/components/Admin/Export/ExportZipFormModal.jsx

@@ -0,0 +1,245 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+class ExportZipFormModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      selectedCollections: new Set(),
+    };
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.checkAll = this.checkAll.bind(this);
+    this.uncheckAll = this.uncheckAll.bind(this);
+    this.export = this.export.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const selectedCollections = new Set(prevState.selectedCollections);
+      if (checked) {
+        selectedCollections.add(name);
+      }
+      else {
+        selectedCollections.delete(name);
+      }
+
+      return { selectedCollections };
+    });
+  }
+
+  checkAll() {
+    this.setState({ selectedCollections: new Set(this.props.collections) });
+  }
+
+  uncheckAll() {
+    this.setState({ selectedCollections: new Set() });
+  }
+
+  async export(e) {
+    e.preventDefault();
+
+    try {
+      // TODO: use appContainer.apiv3.post
+      const result = await this.props.appContainer.apiPost('/v3/export', { collections: Array.from(this.state.selectedCollections) });
+      // TODO: toastSuccess, toastError
+
+      if (!result.ok) {
+        throw new Error('Error occured.');
+      }
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Export process has requested.', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      this.props.onExportingRequested();
+      this.props.onClose();
+
+      this.setState({ selectedCollections: new Set() });
+
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  validateForm() {
+    return this.state.selectedCollections.size > 0;
+  }
+
+  renderWarnForUser() {
+    // whether this.state.selectedCollections includes one of GROUPS_USER
+    const isUserRelatedDataSelected = GROUPS_USER.some((collectionName) => {
+      return this.state.selectedCollections.has(collectionName);
+    });
+
+    if (!isUserRelatedDataSelected) {
+      return <></>;
+    }
+
+    const html = this.props.t('export_management.desc_password_seed');
+
+    // eslint-disable-next-line react/no-danger
+    return <div className="well well-sm" dangerouslySetInnerHTML={{ __html: html }}></div>;
+  }
+
+  renderGroups(groupList, color) {
+    const collectionNames = groupList.filter((collectionName) => {
+      return this.props.collections.includes(collectionName);
+    });
+
+    return this.renderCheckboxes(collectionNames, color);
+  }
+
+  renderOthers() {
+    const collectionNames = this.props.collections.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    return this.renderCheckboxes(collectionNames);
+  }
+
+  renderCheckboxes(collectionNames, color) {
+    const checkboxColor = color ? `checkbox-${color}` : 'checkbox-info';
+
+    return (
+      <div className={`row checkbox ${checkboxColor}`}>
+        {collectionNames.map((collectionName) => {
+          return (
+            <div className="col-xs-6 my-1" key={collectionName}>
+              <input
+                type="checkbox"
+                id={collectionName}
+                name={collectionName}
+                className="form-check-input"
+                value={collectionName}
+                checked={this.state.selectedCollections.has(collectionName)}
+                onChange={this.toggleCheckbox}
+              />
+              <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
+                {collectionName}
+              </label>
+            </div>
+          );
+        })}
+      </div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
+        <Modal.Header closeButton>
+          <Modal.Title>{t('export_management.export_collections')}</Modal.Title>
+        </Modal.Header>
+
+        <form onSubmit={this.export}>
+          <Modal.Body>
+            <div className="row">
+              <div className="col-sm-12">
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.checkAll}>
+                  <i className="fa fa-check-square-o"></i> {t('export_management.check_all')}
+                </button>
+                <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.uncheckAll}>
+                  <i className="fa fa-square-o"></i> {t('export_management.uncheck_all')}
+                </button>
+              </div>
+            </div>
+            <div className="row mt-4">
+              <div className="col-xs-12">
+                <legend>Page Collections</legend>
+                { this.renderGroups(GROUPS_PAGE) }
+              </div>
+            </div>
+            <div className="row mt-4">
+              <div className="col-xs-12">
+                <legend>User Collections</legend>
+                { this.renderGroups(GROUPS_USER, 'danger') }
+                { this.renderWarnForUser() }
+              </div>
+            </div>
+            <div className="row mt-4">
+              <div className="col-xs-12">
+                <legend>Config Collections</legend>
+                { this.renderGroups(GROUPS_CONFIG) }
+              </div>
+            </div>
+            <div className="row mt-4">
+              <div className="col-xs-12">
+                <legend>Other Collections</legend>
+                { this.renderOthers() }
+              </div>
+            </div>
+          </Modal.Body>
+
+          <Modal.Footer>
+            <button type="button" className="btn btn-sm btn-default" onClick={this.props.onClose}>{t('export_management.cancel')}</button>
+            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('export_management.export')}</button>
+          </Modal.Footer>
+        </form>
+      </Modal>
+    );
+  }
+
+}
+
+ExportZipFormModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onExportingRequested: PropTypes.func.isRequired,
+  onClose: PropTypes.func.isRequired,
+  collections: PropTypes.arrayOf(PropTypes.string).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ExportZipFormModalWrapper = (props) => {
+  return createSubscribedElement(ExportZipFormModal, props, [AppContainer]);
+};
+
+export default withTranslation()(ExportZipFormModalWrapper);

+ 67 - 0
src/client/js/components/Admin/Export/ZipFileTable.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import { format } from 'date-fns';
+
+import ExportTableMenu from './ExportTableMenu';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class ZipFileTable extends React.Component {
+
+  render() {
+    // eslint-disable-next-line no-unused-vars
+    const { t } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('export_management.file')}</th>
+            <th>{t('export_management.growi_version')}</th>
+            <th>{t('export_management.collections')}</th>
+            <th>{t('export_management.exported_at')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {this.props.zipFileStats.map(({ meta, fileName, fileStats }) => {
+            return (
+              <tr key={fileName}>
+                <th>{fileName}</th>
+                <td>{meta.version}</td>
+                <td className="text-capitalize">{fileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+                <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+                <td>
+                  <ExportTableMenu
+                    fileName={fileName}
+                    onZipFileStatRemove={this.props.onZipFileStatRemove}
+                  />
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+ZipFileTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onZipFileStatRemove: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ZipFileTableWrapper = (props) => {
+  return createSubscribedElement(ZipFileTable, props, [AppContainer]);
+};
+
+export default withTranslation()(ZipFileTableWrapper);

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

@@ -0,0 +1,35 @@
+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 RebuildIndex from './FullTextSearchManagement/RebuildIndex';
+
+
+class FullTextSearchManagement extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <h2> { t('full_text_search_management.elasticsearch_management') } </h2>
+        <RebuildIndex />
+      </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);

+ 133 - 0
src/client/js/components/Admin/FullTextSearchManagement/RebuildIndex.jsx

@@ -0,0 +1,133 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import WebsocketContainer from '../../../services/WebsocketContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import ProgressBar from '../Common/ProgressBar';
+
+class RebuildIndex extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isProcessing: false,
+      isCompleted: false,
+
+      total: 0,
+      current: 0,
+      skip: 0,
+    };
+
+    this.buildIndex = this.buildIndex.bind(this);
+  }
+
+  componentDidMount() {
+    const socket = this.props.websocketContainer.getWebSocket();
+
+    socket.on('admin:addPageProgress', (data) => {
+      this.setState({
+        isProcessing: true,
+        ...data,
+      });
+    });
+
+    socket.on('admin:finishAddPage', (data) => {
+      this.setState({
+        isProcessing: false,
+        isCompleted: true,
+        ...data,
+      });
+    });
+  }
+
+  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);
+      }
+
+      this.setState({ isProcessing: true });
+      toastSuccess('Rebuilding is requested');
+    }
+    catch (e) {
+      toastError(e);
+    }
+  }
+
+  renderProgressBar() {
+    const {
+      total, current, skip, isProcessing, isCompleted,
+    } = this.state;
+    const showProgressBar = isProcessing || isCompleted;
+
+    if (!showProgressBar) {
+      return null;
+    }
+
+    const header = isCompleted ? 'Completed' : `Processing.. (${skip} skips)`;
+
+    return (
+      <ProgressBar
+        header={header}
+        currentCount={current}
+        totalCount={total}
+      />
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <>
+        <div className="row">
+          <div className="col-xs-3 control-label"></div>
+          <div className="col-xs-9">
+            { this.renderProgressBar() }
+
+            <button
+              type="submit"
+              className="btn btn-inverse"
+              onClick={this.buildIndex}
+              disabled={this.state.isProcessing}
+            >
+              { 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>
+      </>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const RebuildIndexWrapper = (props) => {
+  return createSubscribedElement(RebuildIndex, props, [AppContainer, WebsocketContainer]);
+};
+
+RebuildIndex.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
+};
+
+export default withTranslation()(RebuildIndexWrapper);

+ 181 - 0
src/client/js/components/Admin/Import/GrowiZipImportForm.jsx

@@ -0,0 +1,181 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      collections: new Set(),
+      schema: {
+        pages: {},
+        revisions: {},
+        // ...
+      },
+    };
+
+    this.state = this.initialState;
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.import = this.import.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    this.setState((prevState) => {
+      const collections = new Set(prevState.collections);
+      if (checked) {
+        collections.add(name);
+      }
+      else {
+        collections.delete(name);
+      }
+      return { collections };
+    });
+  }
+
+  async import(e) {
+    e.preventDefault();
+
+    try {
+      // TODO: use appContainer.apiv3.post
+      const { results } = await this.props.appContainer.apiPost('/v3/import', {
+        fileName: this.props.fileName,
+        collections: Array.from(this.state.collections),
+        schema: this.state.schema,
+      });
+
+      this.setState(this.initialState);
+      this.props.onPostImport();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Imported documents', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      for (const { collectionName, failedIds } of results) {
+        if (failedIds.length > 0) {
+          toastr.error(`failed to insert ${failedIds.join(', ')}`, collectionName, {
+            closeButton: true,
+            progressBar: true,
+            newestOnTop: false,
+            timeOut: '30000',
+          });
+        }
+      }
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  validateForm() {
+    return this.state.collections.size > 0;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="row" onSubmit={this.import}>
+        <div className="col-xs-12">
+          <table className="table table-bordered table-mapping">
+            <caption>{t('importer_management.growi_settings.uploaded_data')}</caption>
+            <thead>
+              <tr>
+                <th></th>
+                <th>{t('importer_management.growi_settings.extracted_file')}</th>
+                <th>{t('importer_management.growi_settings.collection')}</th>
+              </tr>
+            </thead>
+            <tbody>
+              {this.props.fileStats.map((fileStat) => {
+                  const { fileName, collectionName } = fileStat;
+                  const checked = this.state.collections.has(collectionName);
+                  return (
+                    <Fragment key={collectionName}>
+                      <tr>
+                        <td>
+                          <input
+                            type="checkbox"
+                            id={collectionName}
+                            name={collectionName}
+                            className="form-check-input"
+                            value={collectionName}
+                            checked={checked}
+                            onChange={this.toggleCheckbox}
+                          />
+                        </td>
+                        <td>{fileName}</td>
+                        <td className="text-capitalize">{collectionName}</td>
+                      </tr>
+                      {checked && (
+                        <tr>
+                          <td className="text-muted" colSpan="3">
+                            TBD: define how {collectionName} are imported
+                            {/* TODO: create a component for each collection to modify this.state.schema */}
+                          </td>
+                        </tr>
+                      )}
+                    </Fragment>
+                  );
+                })}
+            </tbody>
+          </table>
+        </div>
+        <div className="col-xs-12 text-center">
+          <button type="submit" className="btn btn-primary mx-1" disabled={!this.validateForm()}>
+            { t('importer_management.import') }
+          </button>
+          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
+            { t('importer_management.growi_settings.discard') }
+          </button>
+        </div>
+      </form>
+    );
+  }
+
+}
+
+GrowiImportForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  fileName: PropTypes.string,
+  fileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onDiscard: PropTypes.func.isRequired,
+  onPostImport: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiImportFormWrapper = (props) => {
+  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiImportFormWrapper);

+ 119 - 0
src/client/js/components/Admin/Import/GrowiZipImportSection.jsx

@@ -0,0 +1,119 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import GrowiZipUploadForm from './GrowiZipUploadForm';
+import GrowiZipImportForm from './GrowiZipImportForm';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class GrowiZipImportSection extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      fileName: '',
+      fileStats: [],
+    };
+
+    this.state = this.initialState;
+
+    this.handleUpload = this.handleUpload.bind(this);
+    this.discardData = this.discardData.bind(this);
+    this.resetState = this.resetState.bind(this);
+  }
+
+  handleUpload({ meta, fileName, fileStats }) {
+    this.setState({
+      fileName,
+      fileStats,
+    });
+  }
+
+  async discardData() {
+    try {
+      const { fileName } = this.state;
+      await this.props.appContainer.apiDelete(`/v3/import/${this.state.fileName}`, {});
+      this.resetState();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  resetState() {
+    this.setState(this.initialState);
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <legend>{t('importer_management.import_form_growi')}</legend>
+
+        <div className="alert alert-warning">
+          <i className="icon-exclamation"></i> { t('importer_management.beta_warning') }
+        </div>
+
+        <div className="well well-sm small">
+          <ul>
+            <li>{t('importer_management.growi_settings.overwrite_documents')}</li>
+          </ul>
+        </div>
+
+        {this.state.fileName ? (
+          <Fragment>
+            <GrowiZipImportForm
+              fileName={this.state.fileName}
+              fileStats={this.state.fileStats}
+              onDiscard={this.discardData}
+              onPostImport={this.resetState}
+            />
+          </Fragment>
+        ) : (
+          <GrowiZipUploadForm
+            onUpload={this.handleUpload}
+          />
+        )}
+      </Fragment>
+    );
+  }
+
+}
+
+GrowiZipImportSection.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipImportSectionWrapper = (props) => {
+  return createSubscribedElement(GrowiZipImportSection, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipImportSectionWrapper);

+ 93 - 0
src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx

@@ -0,0 +1,93 @@
+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 GrowiZipUploadForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.inputRef = React.createRef();
+
+    this.changeFileName = this.changeFileName.bind(this);
+    this.uploadZipFile = this.uploadZipFile.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeFileName(e) {
+    // to trigger rerender at onChange event
+    // eslint-disable-next-line react/no-unused-state
+    this.setState({ dummy: e.target.files[0].name });
+  }
+
+  async uploadZipFile(e) {
+    e.preventDefault();
+
+    const formData = new FormData();
+    formData.append('_csrf', this.props.appContainer.csrfToken);
+    formData.append('file', this.inputRef.current.files[0]);
+
+    // TODO: use appContainer.apiv3.post
+    const { data } = await this.props.appContainer.apiPost('/v3/import/upload', formData);
+    this.props.onUpload(data);
+    // TODO: toastSuccess, toastError
+  }
+
+  validateForm() {
+    return (
+      this.inputRef.current // null check
+      && this.inputRef.current.files[0] // null check
+      && /\.zip$/.test(this.inputRef.current.files[0].name) // validate extension
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-horizontal" onSubmit={this.uploadZipFile}>
+        <fieldset>
+          <div className="form-group d-flex align-items-center">
+            <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.zip_file')}</label>
+            <div className="col-xs-6">
+              <input
+                type="file"
+                name="file"
+                className="form-control-file"
+                ref={this.inputRef}
+                onChange={this.changeFileName}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-xs-offset-3 col-xs-6">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>
+                {t('importer_management.growi_settings.upload')}
+              </button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    );
+  }
+
+}
+
+GrowiZipUploadForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  onUpload: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiZipUploadFormWrapper = (props) => {
+  return createSubscribedElement(GrowiZipUploadForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiZipUploadFormWrapper);

+ 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 - 122
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -3,14 +3,13 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
-// TODO: GW-333
-// 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 {
@@ -22,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() {
@@ -37,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;
 
@@ -54,7 +59,7 @@ class MyDraftList extends React.Component {
       };
     });
 
-    this.setState({ drafts });
+    this.setState({ drafts, totalDrafts: drafts.length });
   }
 
   getCurrentDrafts(selectPageNumber) {
@@ -62,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,
     });
   }
 
@@ -113,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">
@@ -255,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>
         ) }
 

+ 1 - 0
src/client/js/components/PageComment/Comment.jsx

@@ -295,6 +295,7 @@ class Comment extends React.Component {
             commentBody={comment.comment}
             replyTo={undefined}
             commentButtonClickedHandler={this.commentButtonClickedHandler}
+            commentCreator={creator.username}
           />
         ) : (
           <div className={rootClassName}>

+ 2 - 0
src/client/js/components/PageComment/CommentEditor.jsx

@@ -120,6 +120,7 @@ class CommentEditor extends React.Component {
           this.state.comment,
           this.state.isMarkdown,
           this.props.currentCommentId,
+          this.props.commentCreator,
         );
       }
       else {
@@ -341,6 +342,7 @@ CommentEditor.propTypes = {
   replyTo: PropTypes.string,
   currentCommentId: PropTypes.string,
   commentBody: PropTypes.string,
+  commentCreator: PropTypes.string,
   commentButtonClickedHandler: PropTypes.func.isRequired,
 };
 

+ 7 - 7
src/client/js/components/PageEditor/Cheatsheet.jsx

@@ -28,7 +28,7 @@ class Cheatsheet extends React.Component {
           <h4>{t('sandbox.line_break')}</h4>
           <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
           <ul className="hljs">
-            <li>text</li>
+            <li>text&nbsp;&nbsp;</li>
             <li>text</li>
           </ul>
           <h4>{t('sandbox.typography')}</h4>
@@ -76,12 +76,12 @@ class Cheatsheet extends React.Component {
             <li>&gt;&gt;&gt;&gt; {t('sandbox.quote_nested')}</li>
           </ul>
           <h4>{t('sandbox.table')}</h4>
-          <ul className="hljs text-center">
-            <li>|Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|</li>
-            <li>|:----------|:---------:|----------:|</li>
-            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
-            <li>|col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|</li>
-          </ul>
+          <pre className="border-0">
+            |Left&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;Mid&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Right|<br />
+            |:----------|:---------:|----------:|<br />
+            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
+            |col 1&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;col 2&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;col 3|<br />
+          </pre>
           <h4>{t('sandbox.image')}</h4>
           <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
           <ul className="hljs">

+ 38 - 41
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,20 +1,17 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import Button from 'react-bootstrap/es/Button';
 import urljoin from 'url-join';
 import * as codemirror from 'codemirror';
 
-import {
-  Button, Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 
 import AbstractEditor from './AbstractEditor';
 import SimpleCheatsheet from './SimpleCheatsheet';
-import Cheatsheet from './Cheatsheet';
+
 import pasteHelper from './PasteHelper';
 import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
@@ -64,7 +61,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
-      isCheatsheetModalButtonShown: this.props.isGfmMode && this.props.value.length > 0,
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
     };
@@ -509,10 +505,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const isGfmMode = isGfmModeTmp || this.state.isGfmMode;
     const value = valueTmp || this.getCodeMirror().getDoc().getValue();
 
-    // update isSimpleCheatsheetShown, isCheatsheetModalButtonShown
+    // update isSimpleCheatsheetShown
     const isSimpleCheatsheetShown = isGfmMode && value.length === 0;
-    const isCheatsheetModalButtonShown = isGfmMode && value.length > 0;
-    this.setState({ isSimpleCheatsheetShown, isCheatsheetModalButtonShown });
+    this.setState({ isSimpleCheatsheetShown });
+  }
+
+  markdownHelpButtonClickedHandler() {
+    if (this.props.onMarkdownHelpButtonClicked != null) {
+      this.props.onMarkdownHelpButtonClicked();
+    }
   }
 
   renderLoadingKeymapOverlay() {
@@ -535,38 +536,35 @@ export default class CodeMirrorEditor extends AbstractEditor {
       : '';
   }
 
-  renderSimpleCheatsheet() {
-    return <SimpleCheatsheet />;
-  }
-
-  renderCheatsheetModalBody() {
-    return <Cheatsheet />;
-  }
-
   renderCheatsheetModalButton() {
-    const showCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: true });
-    };
+    return (
+      <button type="button" className="btn-link gfm-cheatsheet-modal-link text-muted small p-0" onClick={() => { this.markdownHelpButtonClickedHandler() }}>
+        <i className="icon-question" /> Markdown
+      </button>
+    );
+  }
 
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
+  renderCheatsheetOverlay() {
+    const cheatsheetModalButton = this.renderCheatsheetModalButton();
 
     return (
-      <>
-        <Modal className="modal-gfm-cheatsheet" isOpen={this.state.isCheatsheetModalShown} toggle={() => { hideCheatsheetModal() }}>
-          <ModalHeader toggle={() => { hideCheatsheetModal() }}>
-            <i className="icon-fw icon-question" />Markdown Help
-          </ModalHeader>
-          <ModalBody className="pt-1">
-            { this.renderCheatsheetModalBody() }
-          </ModalBody>
-        </Modal>
-
-        <button type="button" className="btn-link gfm-cheatsheet-modal-link text-muted small mr-3" onClick={() => { showCheatsheetModal() }}>
-          <i className="icon-question" /> Markdown
-        </button>
-      </>
+      <div className="overlay overlay-gfm-cheatsheet mt-1 p-3">
+        { this.state.isSimpleCheatsheetShown
+          ? (
+            <div className="text-right">
+              {cheatsheetModalButton}
+              <div className="mt-2">
+                <SimpleCheatsheet />
+              </div>
+            </div>
+          )
+          : (
+            <div className="mr-4">
+              {cheatsheetModalButton}
+            </div>
+          )
+        }
+      </div>
     );
   }
 
@@ -810,15 +808,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         { this.renderLoadingKeymapOverlay() }
 
-        <div className="overlay overlay-gfm-cheatsheet d-none d-sm-block mt-1 p-3 pt-3">
-          { this.state.isSimpleCheatsheetShown && this.renderSimpleCheatsheet() }
-          { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
-        </div>
+        { this.renderCheatsheetOverlay() }
 
         <HandsontableModal
           ref={(c) => { this.handsontableModal = c }}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
         />
+
       </React.Fragment>
     );
   }
@@ -829,6 +825,7 @@ CodeMirrorEditor.propTypes = Object.assign({
   editorOptions: PropTypes.object.isRequired,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
+  onMarkdownHelpButtonClicked: PropTypes.func,
 }, AbstractEditor.propTypes);
 CodeMirrorEditor.defaultProps = {
   lineNumbers: true,

+ 33 - 2
src/client/js/components/PageEditor/Editor.jsx

@@ -3,14 +3,17 @@ import PropTypes from 'prop-types';
 
 import { Subscribe } from 'unstated';
 
+import Modal from 'react-bootstrap/es/Modal';
 import Dropzone from 'react-dropzone';
+
+import EditorContainer from '../../services/EditorContainer';
+
+import Cheatsheet from './Cheatsheet';
 import AbstractEditor from './AbstractEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import TextAreaEditor from './TextAreaEditor';
 
-
 import pasteHelper from './PasteHelper';
-import EditorContainer from '../../services/EditorContainer';
 
 export default class Editor extends AbstractEditor {
 
@@ -21,6 +24,7 @@ export default class Editor extends AbstractEditor {
       isComponentDidMount: false,
       dropzoneActive: false,
       isUploading: false,
+      isCheatsheetModalShown: false,
     };
 
     this.getEditorSubstance = this.getEditorSubstance.bind(this);
@@ -31,6 +35,8 @@ export default class Editor extends AbstractEditor {
     this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
     this.dropHandler = this.dropHandler.bind(this);
 
+    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
+
     this.getAcceptableType = this.getAcceptableType.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
@@ -174,6 +180,10 @@ export default class Editor extends AbstractEditor {
     this.setState({ isUploading: true });
   }
 
+  showMarkdownHelp() {
+    this.setState({ isCheatsheetModalShown: true });
+  }
+
   getDropzoneClassName(isDragAccept, isDragReject) {
     let className = 'dropzone';
     if (!this.props.isUploadable) {
@@ -240,6 +250,23 @@ export default class Editor extends AbstractEditor {
     return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
   }
 
+  renderCheatsheetModal() {
+    const hideCheatsheetModal = () => {
+      this.setState({ isCheatsheetModalShown: false });
+    };
+
+    return (
+      <Modal className="modal-gfm-cheatsheet" show={this.state.isCheatsheetModalShown} onHide={() => { hideCheatsheetModal() }}>
+        <Modal.Header closeButton>
+          <Modal.Title><i className="icon-fw icon-question" />Markdown Help</Modal.Title>
+        </Modal.Header>
+        <Modal.Body className="pt-1">
+          <Cheatsheet />
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
   render() {
     const flexContainer = {
       height: '100%',
@@ -282,6 +309,7 @@ export default class Editor extends AbstractEditor {
                         editorOptions={editorContainer.state.editorOptions}
                         onPasteFiles={this.pasteFilesHandler}
                         onDragEnter={this.dragEnterHandler}
+                        onMarkdownHelpButtonClicked={this.showMarkdownHelp}
                         {...this.props}
                       />
                     )}
@@ -322,6 +350,9 @@ export default class Editor extends AbstractEditor {
           </button>
           )
         }
+
+        { this.renderCheatsheetModal() }
+
       </div>
     );
   }

+ 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 - 126
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -1,12 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
-
 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,106 +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(
-        <PaginationItem>
-          <PaginationLink first onClick={() => { return this.getRecentCreatedList(1) }} />
-        </PaginationItem>,
-        <PaginationItem>
-          <PaginationLink previous onClick={() => { return this.getRecentCreatedList(this.state.activePage - 1) }} />
-        </PaginationItem>,
-      );
-    }
-    else {
-      paginationItems.push(
-        <PaginationItem disabled>
-          <PaginationLink first />
-        </PaginationItem>,
-        <PaginationItem disabled>
-          <PaginationLink previous />
-        </PaginationItem>,
-      );
-    }
-    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(
-        <PaginationItem active={number === activePage}>
-          <PaginationLink key={number} onClick={() => { return this.getRecentCreatedList(number) }}>{number}</PaginationLink>
-        </PaginationItem>,
-      );
-    }
-    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(
-        <PaginationItem>
-          <PaginationLink next onClick={() => { return this.getRecentCreatedList(this.state.activePage + 1) }} />
-        </PaginationItem>,
-        <PaginationItem>
-          <PaginationLink last onClick={() => { return this.getRecentCreatedList(totalPage) }} />
-        </PaginationItem>,
-      );
-    }
-    else {
-      paginationItems.push(
-        <PaginationItem disabled>
-          <PaginationLink next />
-        </PaginationItem>,
-        <PaginationItem disabled>
-          <PaginationLink last />
-        </PaginationItem>,
-      );
-    }
-    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 size="sm">{paginationItems}</Pagination>
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={this.state.totalPages}
+          pagingLimit={this.state.pagingLimit}
+        />
       </div>
     );
   }

+ 8 - 4
src/client/js/components/SearchPage/SearchResultList.jsx

@@ -15,12 +15,16 @@ class SearchResultList extends React.Component {
 
   render() {
     const resultList = this.props.pages.map((page) => {
+      const showTags = (page.tags != null) && (page.tags.length > 0);
+
       return (
         <div id={page._id} key={page._id} className="search-result-page mb-5">
-          <h2><a href={page.path}>{page.path}</a></h2>
-          { page.tags.length > 0 && (
-            <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
-          )}
+          <h2>
+            <a href={page.path}>{page.path}</a>
+            { showTags && (
+              <div className="mt-1 small"><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</div>
+            )}
+          </h2>
           <RevisionLoader
             growiRenderer={this.growiRenderer}
             pageId={page._id}

+ 18 - 118
src/client/js/components/TagsList.jsx

@@ -3,8 +3,7 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
-// TODO: GW-333
-// import Pagination from 'react-bootstrap/lib/Pagination';
+import PaginationWrapper from './PaginationWrapper';
 
 class TagsList extends React.Component {
 
@@ -14,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
    *
@@ -84,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">
@@ -180,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;
+  }
+
+}

+ 66 - 17
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
@@ -67,7 +69,16 @@ export default class AppContainer extends Container {
     this.fetchUsers = this.fetchUsers.bind(this);
     this.apiGet = this.apiGet.bind(this);
     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),
+    };
   }
 
   /**
@@ -278,11 +289,11 @@ export default class AppContainer extends Container {
     targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
   }
 
-  apiGet(path, params) {
+  async apiGet(path, params) {
     return this.apiRequest('get', path, { params });
   }
 
-  apiPost(path, params) {
+  async apiPost(path, params) {
     if (!params._csrf) {
       params._csrf = this.csrfToken;
     }
@@ -290,21 +301,59 @@ export default class AppContainer extends Container {
     return this.apiRequest('post', path, params);
   }
 
-  apiRequest(method, path, params) {
-    return new Promise((resolve, reject) => {
-      axios[method](`/_api${path}`, params)
-        .then((res) => {
-          if (res.data.ok) {
-            resolve(res.data);
-          }
-          else {
-            reject(new Error(res.data.error));
-          }
-        })
-        .catch((res) => {
-          reject(res);
-        });
-    });
+  async apiDelete(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiRequest('delete', path, { data: params });
+  }
+
+  async apiRequest(method, path, params) {
+    const res = await axios[method](`/_api${path}`, params);
+    if (res.data.ok) {
+      return res.data;
+    }
+    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 });
   }
 
 }

+ 2 - 2
src/client/js/services/CommentContainer.js

@@ -103,7 +103,7 @@ export default class CommentContainer extends Container {
   /**
    * Load data of comments and rerender <PageComments />
    */
-  putComment(comment, isMarkdown, commentId) {
+  putComment(comment, isMarkdown, commentId, author) {
     const { pageId, revisionId } = this.getPageContainer().state;
 
     return this.appContainer.apiPost('/comments.update', {
@@ -113,7 +113,7 @@ export default class CommentContainer extends Container {
         revision_id: revisionId,
         is_markdown: isMarkdown,
         comment_id: commentId,
-        author: this.appContainer.me,
+        author,
       },
     })
       .then((res) => {

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

+ 2 - 1
src/client/js/util/markdown-it/plantuml.js

@@ -7,7 +7,8 @@ export default class PlantUMLConfigurer {
     this.crowi = crowi;
     const config = crowi.getConfig();
 
-    this.serverUrl = config.env.PLANTUML_URI || 'https://plantuml.com/plantuml';
+    // Do NOT use HTTPS URL because plantuml.com refuse request except from members
+    this.serverUrl = config.env.PLANTUML_URI || 'http://plantuml.com/plantuml';
 
     this.generateSource = this.generateSource.bind(this);
   }

+ 2 - 2
src/client/styles/hackmd/style.scss

@@ -14,8 +14,8 @@
   }
 }
 
-.CodeMirror pre {
-  font-family: Osaka-Mono, "MS Gothic", Monaco, Menlo, Consolas, "Courier New", monospace;
+.CodeMirror pre.CodeMirror-line {
+  font-family: Osaka-Mono, 'MS Gothic', Monaco, Menlo, Consolas, 'Courier New', monospace;
   font-size: 14px;
   line-height: 20px;
 }

+ 1 - 1
src/client/styles/scss/_editor-overlay.scss

@@ -64,6 +64,6 @@
 
 .modal-gfm-cheatsheet .modal-body {
   .hljs {
-    font-family: monospace;
+    font-family: $font-family-monospace;
   }
 }

+ 1 - 1
src/client/styles/scss/_on-edit.scss

@@ -291,7 +291,7 @@ body.on-edit {
 }
 
 // overwrite .CodeMirror pre
-.CodeMirror pre {
+.CodeMirror pre.CodeMirror-line {
   font-family: $font-family-monospace;
 }
 

+ 11 - 0
src/lib/util/mongoose-utils.js

@@ -1,5 +1,15 @@
 const mongoose = require('mongoose');
 
+const getMongoUri = () => {
+  const { env } = process;
+
+  return env.MONGOLAB_URI // for B.C.
+    || env.MONGODB_URI // MONGOLAB changes their env name
+    || env.MONGOHQ_URL
+    || env.MONGO_URI
+    || ((env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
+};
+
 const getModelSafely = (modelName) => {
   if (mongoose.modelNames().includes(modelName)) {
     return mongoose.model(modelName);
@@ -8,5 +18,6 @@ const getModelSafely = (modelName) => {
 };
 
 module.exports = {
+  getMongoUri,
   getModelSafely,
 };

+ 15 - 0
src/lib/util/toArrayIfNot.js

@@ -0,0 +1,15 @@
+// converts non-array item to array
+
+const toArrayIfNot = (item) => {
+  if (item == null) {
+    return [];
+  }
+
+  if (Array.isArray(item)) {
+    return item;
+  }
+
+  return [item];
+};
+
+module.exports = toArrayIfNot;

+ 47 - 0
src/server/console.js

@@ -0,0 +1,47 @@
+require('module-alias/register');
+
+const repl = require('repl');
+const fs = require('fs');
+const path = require('path');
+const mongoose = require('mongoose');
+const { getMongoUri } = require('@commons/util/mongoose-utils');
+
+const models = require('./models');
+
+Object.keys(models).forEach((modelName) => {
+  global[modelName] = models[modelName];
+});
+
+mongoose.Promise = global.Promise;
+
+const replServer = repl.start({
+  prompt: `${process.env.NODE_ENV} > `,
+  ignoreUndefined: true,
+});
+
+// add history function into repl
+// see: https://qiita.com/acro5piano/items/dc62b94d7b04505a4aca
+// see: https://qiita.com/potato4d/items/7131028497de53ceb48e
+const userHome = process.env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME'];
+const replHistoryPath = path.join(userHome, '.node_repl_history');
+fs.readFile(replHistoryPath, 'utf8', (err, data) => {
+  if (err != null) {
+    return;
+  }
+  return data.split('\n').forEach((command) => { return replServer.history.push(command) });
+});
+
+replServer.context.mongoose = mongoose;
+replServer.context.models = models;
+
+mongoose.connect(getMongoUri(), { useNewUrlParser: true })
+  .then(() => {
+    replServer.context.db = mongoose.connection.db;
+  });
+
+replServer.on('exit', () => {
+  fs.writeFile(replHistoryPath, replServer.history.join('\n'), (err) => {
+    console.log(err); // eslint-disable-line no-console
+    process.exit();
+  });
+});

+ 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(),

+ 36 - 11
src/server/crowi/index.js

@@ -6,6 +6,7 @@ const pkg = require('@root/package.json');
 const InterceptorManager = require('@commons/service/interceptor-manager');
 const CdnResourcesService = require('@commons/service/cdn-resources-service');
 const Xss = require('@commons/service/xss');
+const { getMongoUri } = require('@commons/util/mongoose-utils');
 
 const path = require('path');
 
@@ -14,6 +15,7 @@ const sep = path.sep;
 const mongoose = require('mongoose');
 
 const models = require('../models');
+const initMiddlewares = require('../middlewares');
 
 const PluginService = require('../plugins/plugin.service');
 
@@ -46,7 +48,9 @@ function Crowi(rootdir) {
   this.appService = null;
   this.fileUploadService = null;
   this.restQiitaAPIService = null;
+  this.growiBridgeService = null;
   this.exportService = null;
+  this.importService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.interceptorManager = new InterceptorManager();
   this.xss = new Xss();
@@ -54,6 +58,7 @@ function Crowi(rootdir) {
   this.tokens = null;
 
   this.models = {};
+  this.middlewares = {};
 
   this.env = process.env;
   this.node_env = this.env.NODE_ENV || 'development';
@@ -66,30 +71,26 @@ function Crowi(rootdir) {
     search: new (require(`${self.eventsDir}search`))(this),
     bookmark: new (require(`${self.eventsDir}bookmark`))(this),
     tag: new (require(`${self.eventsDir}tag`))(this),
+    admin: new (require(`${self.eventsDir}admin`))(this),
   };
 }
 
-function getMongoUrl(env) {
-  return env.MONGOLAB_URI // for B.C.
-    || env.MONGODB_URI // MONGOLAB changes their env name
-    || env.MONGOHQ_URL
-    || env.MONGO_URI
-    || ((process.env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
-}
-
 Crowi.prototype.init = async function() {
   await this.setupDatabase();
   await this.setupModels();
+  await this.setupMiddlewares();
   await this.setupSessionConfig();
   await this.setupConfigManager();
 
   // customizeService depends on AppService and XssService
   // passportService depends on appService
   // slack depends on setUpSlacklNotification
+  // export and import depends on setUpGrowiBridge
   await Promise.all([
     this.setUpApp(),
     this.setUpXss(),
     this.setUpSlacklNotification(),
+    this.setUpGrowiBridge(),
   ]);
 
   await Promise.all([
@@ -105,6 +106,7 @@ Crowi.prototype.init = async function() {
     this.setUpRestQiitaAPI(),
     this.setupUserGroup(),
     this.setupExport(),
+    this.setupImport(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -124,6 +126,7 @@ Crowi.prototype.initForTest = async function() {
     this.setUpApp(),
     // this.setUpXss(),
     // this.setUpSlacklNotification(),
+    // this.setUpGrowiBridge(),
   ]);
 
   await Promise.all([
@@ -137,6 +140,9 @@ Crowi.prototype.initForTest = async function() {
     this.setUpAcl(),
   //   this.setUpCustomize(),
   //   this.setUpRestQiitaAPI(),
+  //   this.setupUserGroup(),
+  //   this.setupExport(),
+  //   this.setupImport(),
   ]);
 
   // globalNotification depends on slack and mailer
@@ -189,10 +195,10 @@ Crowi.prototype.event = function(name, event) {
 };
 
 Crowi.prototype.setupDatabase = function() {
-  // mongoUri = mongodb://user:password@host/dbname
   mongoose.Promise = global.Promise;
 
-  const mongoUri = getMongoUrl(this.env);
+  // mongoUri = mongodb://user:password@host/dbname
+  const mongoUri = getMongoUri();
 
   return mongoose.connect(mongoUri, { useNewUrlParser: true });
 };
@@ -203,7 +209,7 @@ Crowi.prototype.setupSessionConfig = function() {
   const sessionAge = (1000 * 3600 * 24 * 30);
   const redisUrl = this.env.REDISTOGO_URL || this.env.REDIS_URI || this.env.REDIS_URL || null;
 
-  const mongoUrl = getMongoUrl(this.env);
+  const mongoUrl = getMongoUri();
   let sessionConfig;
 
   return new Promise(((resolve, reject) => {
@@ -249,6 +255,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;
 };
@@ -534,6 +545,13 @@ Crowi.prototype.setupUserGroup = async function() {
   }
 };
 
+Crowi.prototype.setUpGrowiBridge = async function() {
+  const GrowiBridgeService = require('../service/growi-bridge');
+  if (this.growiBridgeService == null) {
+    this.growiBridgeService = new GrowiBridgeService(this);
+  }
+};
+
 Crowi.prototype.setupExport = async function() {
   const ExportService = require('../service/export');
   if (this.exportService == null) {
@@ -541,4 +559,11 @@ Crowi.prototype.setupExport = async function() {
   }
 };
 
+Crowi.prototype.setupImport = async function() {
+  const ImportService = require('../service/import');
+  if (this.importService == null) {
+    this.importService = new ImportService(this);
+  }
+};
+
 module.exports = Crowi;

+ 11 - 0
src/server/events/admin.js

@@ -0,0 +1,11 @@
+const util = require('util');
+const events = require('events');
+
+function AdminEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(AdminEvent, events.EventEmitter);
+
+module.exports = AdminEvent;

+ 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'),

+ 27 - 0
src/server/middleware/access-token-parser.js

@@ -0,0 +1,27 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:access-token-parser');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
+    const accessToken = req.query.access_token || req.body.access_token || null;
+    if (!accessToken) {
+      return next();
+    }
+
+    const User = crowi.model('User');
+
+    logger.debug('accessToken is', accessToken);
+
+    const user = await User.findUserByApiToken(accessToken);
+    req.user = user;
+    req.skipCsrfVerify = true;
+
+    logger.debug('Access token parsed: skipCsrfVerify');
+
+    next();
+  };
+
+};

+ 24 - 0
src/server/middleware/admin-required.js

@@ -0,0 +1,24 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:admin-required');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+      if (req.user.admin) {
+        next();
+        return;
+      }
+
+      logger.warn('This user is not admin.');
+
+      return res.redirect('/');
+    }
+
+    logger.warn('This user has not logged in.');
+
+    return res.redirect('/login');
+  };
+
+};

+ 27 - 0
src/server/middleware/csrf.js

@@ -0,0 +1,27 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:csrf');
+
+module.exports = (crowi) => {
+
+  return async(req, res, next) => {
+    const token = req.body._csrf || req.query._csrf || null;
+    const csrfKey = (req.session && req.session.id) || 'anon';
+
+    logger.debug('req.skipCsrfVerify', req.skipCsrfVerify);
+
+    if (req.skipCsrfVerify) {
+      logger.debug('csrf verify skipped');
+      return next();
+    }
+
+    if (crowi.getTokens().verify(csrfKey, token)) {
+      logger.debug('csrf successfully verified');
+      return next();
+    }
+
+    logger.warn('csrf verification failed. return 403', csrfKey, token);
+    return res.sendStatus(403);
+  };
+
+};

+ 49 - 0
src/server/middleware/login-required.js

@@ -0,0 +1,49 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:login-required');
+
+/**
+ * require login handler
+ *
+ * @param {boolean} isGuestAllowed whethere guest user is allowed (default false)
+ */
+module.exports = (crowi, isGuestAllowed = false) => {
+
+  return function(req, res, next) {
+
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    const User = crowi.model('User');
+
+    // check the user logged in
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
+      if (req.user.status === User.STATUS_ACTIVE) {
+        // Active の人だけ先に進める
+        return next();
+      }
+      if (req.user.status === User.STATUS_REGISTERED) {
+        return res.redirect('/login/error/registered');
+      }
+      if (req.user.status === User.STATUS_SUSPENDED) {
+        return res.redirect('/login/error/suspended');
+      }
+      if (req.user.status === User.STATUS_INVITED) {
+        return res.redirect('/login/invited');
+      }
+    }
+
+    // is api path
+    const path = req.path || '';
+    if (path.match(/^\/_api\/.+$/)) {
+      return res.sendStatus(403);
+    }
+
+    req.session.jumpTo = req.originalUrl;
+    return res.redirect('/login');
+  };
+
+};

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

+ 20 - 3
src/server/models/bookmark.js

@@ -21,6 +21,23 @@ module.exports = function(crowi) {
     return await this.count({ page: pageId });
   };
 
+  /**
+   * @return {object} key: page._id, value: bookmark count
+   */
+  bookmarkSchema.statics.getPageIdToCountMap = async function(pageIds) {
+    const results = await this.aggregate()
+      .match({ page: { $in: pageIds } })
+      .group({ _id: '$page', count: { $sum: 1 } });
+
+    // convert to map
+    const idToCountMap = {};
+    results.forEach((result) => {
+      idToCountMap[result._id] = result.count;
+    });
+
+    return idToCountMap;
+  };
+
   bookmarkSchema.statics.populatePage = async function(bookmarks) {
     const Bookmark = this;
     const User = crowi.model('User');
@@ -122,12 +139,12 @@ module.exports = function(crowi) {
     }
   };
 
-  bookmarkSchema.statics.removeBookmark = async function(page, user) {
+  bookmarkSchema.statics.removeBookmark = async function(pageId, user) {
     const Bookmark = this;
 
     try {
-      const data = await Bookmark.findOneAndRemove({ page, user });
-      bookmarkEvent.emit('delete', page);
+      const data = await Bookmark.findOneAndRemove({ page: pageId, user });
+      bookmarkEvent.emit('delete', pageId);
       return data;
     }
     catch (err) {

+ 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'),
 };

+ 48 - 0
src/server/models/page-tag-relation.js

@@ -1,6 +1,8 @@
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
+const flatMap = require('array.prototype.flatmap');
+
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate');
 
@@ -60,6 +62,52 @@ class PageTagRelation {
     return relations.map((relation) => { return relation.relatedTag.name });
   }
 
+  /**
+   * @return {object} key: Page._id, value: array of tag names
+   */
+  static async getIdToTagNamesMap(pageIds) {
+    /**
+     * @see https://docs.mongodb.com/manual/reference/operator/aggregation/group/#pivot-data
+     *
+     * results will be:
+     * [
+     *   { _id: 58dca7b2c435b3480098dbbc, tagIds: [ 5da630f71a677515601e36d7, 5da77163ec786e4fe43e0e3e ]},
+     *   { _id: 58dca7b2c435b3480098dbbd, tagIds: [ ... ]},
+     *   ...
+     * ]
+     */
+    const results = await this.aggregate()
+      .match({ relatedPage: { $in: pageIds } })
+      .group({ _id: '$relatedPage', tagIds: { $push: '$relatedTag' } });
+
+    if (results.length === 0) {
+      return {};
+    }
+
+    results.flatMap = flatMap.shim(); // TODO: remove after upgrading to node v12
+
+    // extract distinct tag ids
+    const allTagIds = results
+      .flatMap(result => result.tagIds); // map + flatten
+    const distinctTagIds = Array.from(new Set(allTagIds));
+
+    // retrieve tag documents
+    const Tag = mongoose.model('Tag');
+    const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
+
+    // convert to map
+    const idToTagNamesMap = {};
+    results.forEach((result) => {
+      const tagNames = result.tagIds
+        .map(tagId => tagIdToNameMap[tagId])
+        .filter(tagName => tagName != null); // filter null object
+
+      idToTagNamesMap[result._id] = tagNames;
+    });
+
+    return idToTagNamesMap;
+  }
+
   static async updatePageTags(pageId, tags) {
     if (pageId == null || tags == null) {
       throw new Error('args \'pageId\' and \'tags\' are required.');

+ 8 - 25
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);
 
 
@@ -921,21 +923,6 @@ module.exports = function(crowi) {
     return { templateBody, templateTags };
   };
 
-  /**
-   * Bulk get (for internal only)
-   */
-  pageSchema.statics.getStreamOfFindAll = function(options) {
-    const criteria = { redirectTo: null };
-
-    return this.find(criteria)
-      .populate([
-        { path: 'creator', model: 'User' },
-        { path: 'revision', model: 'Revision' },
-      ])
-      .lean()
-      .cursor();
-  };
-
   async function pushRevision(pageData, newRevision, user) {
     await newRevision.save();
     debug('Successfully saved new revision', newRevision);
@@ -1281,7 +1268,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 +1286,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 +1300,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);
     }
   };
 
@@ -1382,10 +1369,6 @@ module.exports = function(crowi) {
     return addSlashOfEnd(path);
   };
 
-  pageSchema.statics.allPageCount = function() {
-    return this.count({ redirectTo: null });
-  };
-
   pageSchema.statics.GRANT_PUBLIC = GRANT_PUBLIC;
   pageSchema.statics.GRANT_RESTRICTED = GRANT_RESTRICTED;
   pageSchema.statics.GRANT_SPECIFIED = GRANT_SPECIFIED;

+ 11 - 0
src/server/models/tag.js

@@ -23,6 +23,17 @@ schema.plugin(mongoosePaginate);
  */
 class Tag {
 
+  static async getIdToNameMap(tagIds) {
+    const tags = await this.find({ _id: { $in: tagIds } });
+
+    const idToNameMap = {};
+    tags.forEach((tag) => {
+      idToNameMap[tag._id.toString()] = tag.name;
+    });
+
+    return idToNameMap;
+  }
+
   static async findOrCreate(tagName) {
     const tag = await this.findOne({ name: tagName });
     if (!tag) {

+ 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) {

+ 86 - 319
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;
@@ -18,6 +17,7 @@ module.exports = function(crowi, app) {
     aclService,
     slackNotificationService,
     customizeService,
+    exportService,
   } = crowi;
 
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
@@ -31,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,
@@ -87,6 +91,14 @@ module.exports = function(crowi, app) {
     return pager;
   }
 
+  // setup websocket event for rebuild index
+  searchEvent.on('addPageProgress', (total, current, skip) => {
+    crowi.getIo().sockets.emit('admin:addPageProgress', { total, current, skip });
+  });
+  searchEvent.on('finishAddPage', (total, current, skip) => {
+    crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
+  });
+
   actions.index = function(req, res) {
     return res.render('admin/index', {
       plugins: pluginUtils.listPlugins(crowi.rootDir),
@@ -127,6 +139,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) {
@@ -136,8 +149,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);
@@ -455,135 +468,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) {
     // ユーザーの物理削除
@@ -676,7 +560,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;
+                  }),
+                });
               });
           });
         });
@@ -685,7 +574,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);
       })
@@ -698,182 +589,67 @@ 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) => {
     return res.render('admin/export');
   };
 
+  actions.export.download = (req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
+    try {
+      const zipFile = exportService.getFile(fileName);
+      return res.download(zipFile);
+    }
+    catch (err) {
+      // TODO: use ApiV3Error
+      logger.error(err);
+      return res.json(ApiResponse.error());
+    }
+  };
+
   actions.api = {};
   actions.api.appSetting = async function(req, res) {
     const form = req.form.settingForm;
@@ -1256,16 +1032,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());
   };
 
   /**
@@ -1275,16 +1052,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());
   };
 
   /**
@@ -1305,9 +1085,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());
   };
 
   /**
@@ -1328,9 +1108,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());
   };
 
   /**
@@ -1342,10 +1122,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));
     }
   };
 
@@ -1358,10 +1138,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));
     }
   };
 
@@ -1372,27 +1152,14 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('ElasticSearch Integration is not set up.'));
     }
 
-    searchEvent.on('addPageProgress', (total, current, skip) => {
-      crowi.getIo().sockets.emit('admin:addPageProgress', { total, current, skip });
-    });
-    searchEvent.on('finishAddPage', (total, current, skip) => {
-      crowi.getIo().sockets.emit('admin:finishAddPage', { total, current, skip });
-    });
-
-    await search.buildIndex();
-
-    return res.json(ApiResponse.success());
-  };
-
-  actions.api.userGroups = async(req, res) => {
     try {
-      const userGroups = await UserGroup.find();
-      return res.json(ApiResponse.success({ userGroups }));
+      search.buildIndex();
     }
     catch (err) {
-      logger.error('Error', err);
-      return res.json(ApiResponse.error('Error'));
+      return res.json(ApiResponse.error(err));
     }
+
+    return res.json(ApiResponse.success());
   };
 
   function validateMailSetting(req, form, callback) {

+ 96 - 69
src/server/routes/apiv3/export.js

@@ -1,7 +1,7 @@
 const loggerFactory = require('@alias/logger');
 
-const logger = loggerFactory('growi:routes:apiv3:export'); // eslint-disable-line no-unused-vars
-const path = require('path');
+const logger = loggerFactory('growi:routes:apiv3:export');
+const fs = require('fs');
 
 const express = require('express');
 
@@ -13,57 +13,106 @@ const router = express.Router();
  *    name: Export
  */
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      ExportStatus:
+ *        type: object
+ *        properties:
+ *          zipFileStats:
+ *            type: array
+ *            items:
+ *              type: object
+ *              description: the property of each file
+ *          progressList:
+ *            type: array
+ *            items:
+ *              type: object
+ *              description: progress data for each exporting collections
+ *          isExporting:
+ *            type: boolean
+ *            description: whether the current exporting job exists or not
+ */
+
 module.exports = (crowi) => {
+  const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
+  const loginRequired = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
   const { exportService } = crowi;
-  const { Page } = crowi.models;
+
+  this.adminEvent = crowi.event('admin');
+
+  // setup event
+  this.adminEvent.on('onProgressForExport', (data) => {
+    crowi.getIo().sockets.emit('admin:onProgressForExport', data);
+  });
+  this.adminEvent.on('onStartZippingForExport', (data) => {
+    crowi.getIo().sockets.emit('admin:onStartZippingForExport', data);
+  });
+  this.adminEvent.on('onTerminateForExport', (data) => {
+    crowi.getIo().sockets.emit('admin:onTerminateForExport', data);
+  });
+
 
   /**
    * @swagger
    *
-   *  /export:
+   *  /export/status:
    *    get:
    *      tags: [Export]
-   *      description: get mongodb collections names and zip files for them
-   *      produces:
-   *        - application/json
+   *      description: get properties of stored zip files for export
    *      responses:
    *        200:
-   *          description: export cache info
+   *          description: the zip file statuses
    *          content:
    *            application/json:
+   *              schema:
+   *                properties:
+   *                  status:
+   *                    $ref: '#/components/schemas/ExportStatus'
    */
-  router.get('/', async(req, res) => {
-    const files = exportService.getStatus();
+  router.get('/status', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    const status = await exportService.getStatus();
 
     // TODO: use res.apiv3
-    return res.json({ ok: true, files });
+    return res.json({
+      ok: true,
+      status,
+    });
   });
 
   /**
    * @swagger
    *
-   *  /export/pages:
-   *    get:
+   *  /export:
+   *    post:
    *      tags: [Export]
-   *      description: download a zipped json for page collection
-   *      produces:
-   *        - application/json
+   *      description: generate zipped jsons for collections
    *      responses:
    *        200:
-   *          description: a zip file
+   *          description: a zip file is generated
    *          content:
-   *            application/zip:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  status:
+   *                    $ref: '#/components/schemas/ExportStatus'
    */
-  router.get('/pages', async(req, res) => {
-    // TODO: rename path to "/:collection" and add express validator
+  router.post('/', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    // TODO: add express validator
     try {
-      const file = exportService.getZipFile(Page);
+      const { collections } = req.body;
 
-      if (file == null) {
-        throw new Error('the target file does not exist');
-      }
+      exportService.export(collections);
 
-      return res.download(file);
+      // TODO: use res.apiv3
+      return res.status(200).json({
+        ok: true,
+      });
     }
     catch (err) {
       // TODO: use ApiV3Error
@@ -75,64 +124,42 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *  /export/pages:
-   *    post:
+   *  /export/{fileName}:
+   *    delete:
    *      tags: [Export]
-   *      description: generate a zipped json for page collection
-   *      produces:
-   *        - application/json
+   *      description: delete the file
+   *      parameters:
+   *        - name: fileName
+   *          in: path
+   *          description: the file name of zip file
+   *          required: true
+   *          schema:
+   *            type: string
    *      responses:
    *        200:
-   *          description: a zip file is generated
+   *          description: the file is deleted
    *          content:
    *            application/json:
+   *              schema:
+   *                type: object
    */
-  router.post('/pages', async(req, res) => {
-    // TODO: rename path to "/:collection" and add express validator
+  router.delete('/:fileName', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
+    // TODO: add express validator
+    const { fileName } = req.params;
+
     try {
-      const file = await exportService.exportCollection(Page);
+      const zipFile = exportService.getFile(fileName);
+      fs.unlinkSync(zipFile);
+
       // TODO: use res.apiv3
-      return res.status(200).json({
-        ok: true,
-        collection: [Page.collection.collectionName],
-        file: path.basename(file),
-      });
+      return res.status(200).send({ ok: true });
     }
     catch (err) {
       // TODO: use ApiV3Error
       logger.error(err);
-      return res.status(500).send({ status: 'ERROR' });
+      return res.status(500).send({ ok: false });
     }
   });
 
-  /**
-   * @swagger
-   *
-   *  /export/pages:
-   *    delete:
-   *      tags: [Export]
-   *      description: unlink a json and zip file for page collection
-   *      produces:
-   *        - application/json
-   *      responses:
-   *        200:
-   *          description: the json and zip file are removed
-   *          content:
-   *            application/json:
-   */
-  // router.delete('/pages', async(req, res) => {
-  //   // TODO: rename path to "/:collection" and add express validator
-  //   try {
-  //     // remove .json and .zip for collection
-  //     // TODO: use res.apiv3
-  //     return res.status(200).send({ status: 'DONE' });
-  //   }
-  //   catch (err) {
-  //     // TODO: use ApiV3Error
-  //     logger.error(err);
-  //     return res.status(500).send({ status: 'ERROR' });
-  //   }
-  // });
-
   return router;
 };

+ 0 - 2
src/server/routes/apiv3/healthcheck.js

@@ -22,8 +22,6 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [Healthcheck]
    *      description: Check whether the server is healthy or not
-   *      produces:
-   *        - application/json
    *      parameters:
    *        - name: connectToMiddlewares
    *          in: query

Некоторые файлы не были показаны из-за большого количества измененных файлов