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

Merge branch 'master' into feat/transplant-tabs-to-modal-for-master-merge

itizawa 5 лет назад
Родитель
Сommit
125ed40355
78 измененных файлов с 1562 добавлено и 762 удалено
  1. 9 0
      CHANGES.md
  2. 1 0
      config/webpack.prod.js
  3. 2 2
      package.json
  4. 1 1
      resource/locales/en_US/admin/admin.json
  5. 10 19
      resource/locales/en_US/translation.json
  6. 1 1
      resource/locales/ja_JP/admin/admin.json
  7. 9 18
      resource/locales/ja_JP/translation.json
  8. 2 2
      resource/locales/zh_CN/admin/admin.json
  9. 21 20
      resource/locales/zh_CN/translation.json
  10. 4 17
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  11. 80 59
      src/client/js/components/Admin/App/MailSetting.jsx
  12. 4 17
      src/client/js/components/Admin/Customize/Customize.jsx
  13. 1 1
      src/client/js/components/Admin/ImportData/GrowiArchive/ErrorViewer.jsx
  14. 1 1
      src/client/js/components/Admin/ImportData/ImportDataPageContents.jsx
  15. 4 17
      src/client/js/components/Admin/ImportDataPage.jsx
  16. 4 17
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  17. 4 17
      src/client/js/components/Admin/Notification/NotificationSetting.jsx
  18. 4 17
      src/client/js/components/Admin/Security/BasicSecuritySetting.jsx
  19. 4 17
      src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx
  20. 4 17
      src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx
  21. 4 18
      src/client/js/components/Admin/Security/LdapSecuritySetting.jsx
  22. 4 17
      src/client/js/components/Admin/Security/LocalSecuritySetting.jsx
  23. 4 17
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  24. 4 17
      src/client/js/components/Admin/Security/SamlSecuritySetting.jsx
  25. 5 17
      src/client/js/components/Admin/Security/SecurityManagement.jsx
  26. 4 17
      src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx
  27. 244 0
      src/client/js/components/ArchiveCreateModal.jsx
  28. 4 3
      src/client/js/components/Fab.jsx
  29. 26 0
      src/client/js/components/Icons/CreatePageIcon.jsx
  30. 20 0
      src/client/js/components/Icons/MoonIcon.jsx
  31. 20 0
      src/client/js/components/Icons/ReturnTopIcon.jsx
  32. 20 0
      src/client/js/components/Icons/SidebarDockIcon.jsx
  33. 25 0
      src/client/js/components/Icons/SidebarDrawerIcon.jsx
  34. 28 0
      src/client/js/components/Icons/SunIcon.jsx
  35. 25 6
      src/client/js/components/Me/PasswordSettings.jsx
  36. 1 1
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  37. 33 15
      src/client/js/components/Navbar/PersonalDropdown.jsx
  38. 46 0
      src/client/js/components/Page/PageShareManagement.jsx
  39. 4 0
      src/client/js/components/PageDuplicateModal.jsx
  40. 38 25
      src/client/js/components/PageEditor/EditorIcon.jsx
  41. 3 3
      src/client/js/components/PageEditor/OptionsSelector.jsx
  42. 2 0
      src/client/js/components/PageRenameModal.jsx
  43. 13 0
      src/client/js/services/AdminAppContainer.js
  44. 2 2
      src/client/js/services/PageHistoryContainer.js
  45. 0 2
      src/client/js/services/PersonalContainer.js
  46. 1 1
      src/client/styles/scss/_editor-navbar.scss
  47. 7 4
      src/client/styles/scss/_layout.scss
  48. 10 2
      src/client/styles/scss/_navbar.scss
  49. 36 1
      src/client/styles/scss/_override-bootstrap-variables.scss
  50. 15 5
      src/client/styles/scss/theme/_apply-colors-dark.scss
  51. 10 0
      src/client/styles/scss/theme/_apply-colors-light.scss
  52. 25 3
      src/client/styles/scss/theme/_apply-colors.scss
  53. 6 2
      src/client/styles/scss/theme/_reboot-bootstrap-theme-colors.scss
  54. 1 1
      src/server/middlewares/apiv3-form-validator.js
  55. 2 2
      src/server/middlewares/certify-shared-page.js
  56. 2 0
      src/server/models/index.js
  57. 22 0
      src/server/models/page-archive.js
  58. 3 28
      src/server/models/user.js
  59. 1 18
      src/server/routes/admin.js
  60. 56 10
      src/server/routes/apiv3/app-settings.js
  61. 1 1
      src/server/routes/apiv3/markdown-setting.js
  62. 1 1
      src/server/routes/apiv3/notification-setting.js
  63. 90 0
      src/server/routes/apiv3/page.js
  64. 51 3
      src/server/routes/apiv3/personal-setting.js
  65. 1 1
      src/server/routes/apiv3/revisions.js
  66. 1 1
      src/server/routes/apiv3/security-setting.js
  67. 1 1
      src/server/routes/apiv3/share-links.js
  68. 2 2
      src/server/routes/apiv3/user-group.js
  69. 3 2
      src/server/routes/apiv3/users.js
  70. 4 1
      src/server/routes/attachment.js
  71. 2 2
      src/server/routes/comment.js
  72. 0 1
      src/server/routes/index.js
  73. 16 8
      src/server/service/export.js
  74. 26 18
      src/server/service/import.js
  75. 0 1
      src/server/service/search-delegator/elasticsearch.js
  76. 0 7
      src/test/models/user.test.js
  77. 21 0
      src/test/util/path-utils.test.js
  78. 396 215
      yarn.lock

+ 9 - 0
CHANGES.md

@@ -4,7 +4,16 @@
 
 * Feature: Create/edit linker with GUI
 * Improvement: Paging page histories
+* Improvement: Avoid using `cursor.snapshot()` in preparation for MongoDB version upgrade
+* Improvement: Allow to save "From e-mail address" only in App Settings
+* Improvement: Allow to empty "From e-mail address" in App Settings
+* Improvement: Export/Import archive data serially so as not to waste memory
 * Fix: To be able to delete attachment metadata even when the actual data does not exist
+* Fix: Limit the attrubutes of user data for `/_api/v3/users`
+* Fix: Prevent XSS with SVG
+* Upgrade libs
+    * optimize-css-assets-webpack-plugin
+    * terser-webpack-plugin
 
 ## v4.1.2
 

+ 1 - 0
config/webpack.prod.js

@@ -67,6 +67,7 @@ module.exports = require('./webpack.common')({
 
   ],
   optimization: {
+    minimize: true,
     minimizer: [
       new TerserPlugin({}),
       new OptimizeCSSAssetsPlugin({}),

+ 2 - 2
package.json

@@ -220,7 +220,7 @@
     "normalize-path": "^3.0.0",
     "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
-    "optimize-css-assets-webpack-plugin": "^5.0.0",
+    "optimize-css-assets-webpack-plugin": "^5.0.3",
     "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^3.0.0",
@@ -249,7 +249,7 @@
     "stylelint-config-recess-order": "^2.0.1",
     "swagger-jsdoc": "^3.4.0",
     "swagger2openapi": "^5.3.1",
-    "terser-webpack-plugin": "^2.0.1",
+    "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "unstated": "^2.1.1",

+ 1 - 1
resource/locales/en_US/admin/admin.json

@@ -210,7 +210,7 @@
     "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.",
+    "desc_password_seed": "<p>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.</p>",
     "create_new_archive_data": "Create New Archive Data",
     "export": "Export",
     "cancel": "Cancel",

+ 10 - 19
resource/locales/en_US/translation.json

@@ -51,9 +51,13 @@
   "Not available for guest": "Not available for guest",
   "Create Archive Page": "Create Archive Page",
   "File type": "File type",
+  "Target page": "Target page",
   "Include Attachment File": "Include Attachment File",
   "Include Comment": "Include Comment",
   "Include Subordinated Page": "Include Subordinated Page",
+  "All Subordinated Page": "All Subordinated Page",
+  "Specify Hierarchy": "Specify Hierarchy",
+  "Submitted the request to create the archive": "Submitted the request to create the archive",
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
@@ -141,7 +145,7 @@
     "settings": "Settings",
     "color_mode": "Color mode",
     "sidebar_mode": "Sidebar mode",
-    "sidebar_mode_editor": "Sidebar mode on Editor",
+    "sidebar_mode_editor": "Sidebar mode on editor",
     "use_os_settings": "Use OS settings"
   },
   "form_validation": {
@@ -713,24 +717,6 @@
     "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
     "rebuild_description_2": "This may take a while."
   },
-  "export_management": {
-    "exporting_collection_list": "Exporting Collection List",
-    "exported_data_list": "Exported Archive Data List",
-    "export_collections": "Export Collections",
-    "check_all": "Check All",
-    "uncheck_all": "Uncheck All",
-    "desc_password_seed": "<p>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.</p><strong>HINT:</strong><p>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.</p>",
-    "create_new_archive_data": "Create New Archive 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"
-  },
   "login": {
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",
@@ -738,6 +724,7 @@
   },
   "export_bulk": {
     "failed_to_export": "Failed to export",
+    "failed_to_count_pages": "Failed to count pages",
     "export_page_markdown": "Export page as Markdown",
     "export_page_pdf": "Export page as PDF"
   },
@@ -764,5 +751,9 @@
     "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
+  },
+  "validation":{
+    "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
+    "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required."
   }
 }

+ 1 - 1
resource/locales/ja_JP/admin/admin.json

@@ -147,7 +147,7 @@
     "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> に保存されます。",
+    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
     "create_new_archive_data": "アーカイブデータの新規作成",
     "export": "エクスポート",
     "cancel": "キャンセル",

+ 9 - 18
resource/locales/ja_JP/translation.json

@@ -51,10 +51,14 @@
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Create Archive Page": "アーカイブページの作成",
+  "Target page": "対象ページ",
   "File type": "ファイル形式",
   "Include Attachment File": "添付ファイルも含める",
   "Include Comment": "コメントも含める",
   "Include Subordinated Page": "配下ページも含める",
+  "All Subordinated Page": "全ての配下ページ",
+  "Specify Hierarchy": "階層の深さを指定",
+  "Submitted the request to create the archive": "アーカイブ作成のリクエストを正常に送信しました",
   "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
@@ -706,24 +710,6 @@
     "rebuild_description_1": "全てのページのインデックスを削除し、作り直します。",
     "rebuild_description_2": "この作業には数秒かかります。"
   },
-  "export_management": {
-    "exporting_collection_list": "エクスポート中のコレクション",
-    "exported_data_list": "エクスポートされたアーカイブリスト",
-    "export_collections": "コレクションのエクスポート",
-    "check_all": "全てにチェックを付ける",
-    "uncheck_all": "全てからチェックを外す",
-    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。</p><strong>ヒント:</strong><p>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
-    "create_new_archive_data": "アーカイブデータの新規作成",
-    "export": "エクスポート",
-    "cancel": "キャンセル",
-    "file": "ファイル名",
-    "growi_version": "Growi バージョン",
-    "collections": "コレクション",
-    "exported_at": "エクスポートされた時間",
-    "export_menu": "エクスポートメニュー",
-    "download": "ダウンロード",
-    "delete": "削除"
-  },
   "login": {
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
@@ -731,6 +717,7 @@
   },
   "export_bulk": {
     "failed_to_export": "ページのエクスポートに失敗しました",
+    "failed_to_count_pages": "ページ数の取得に失敗しました",
     "export_page_markdown": "マークダウン形式でページをエクスポート",
     "export_page_pdf": "PDF形式でページをエクスポート"
   },
@@ -757,5 +744,9 @@
     "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
+  },
+  "validation":{
+    "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
+    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。"
   }
 }

+ 2 - 2
resource/locales/zh_CN/admin/admin.json

@@ -225,7 +225,7 @@
 		"export_collections": "导出集合",
 		"check_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.",
+		"desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
 		"create_new_archive_data": "创建新的存档数据",
 		"export": "导出",
 		"cancel": "取消",
@@ -236,7 +236,7 @@
 		"export_menu": "导出菜单",
 		"download": "下载",
 		"delete": "删除"
-	},
+  },
 	"user_management": {
 		"invite_users": "邀请新用户",
 		"click_twice_same_checkbox": "您应该至少选中一个复选框。",

+ 21 - 20
resource/locales/zh_CN/translation.json

@@ -51,7 +51,16 @@
   "attachment_data": "Attachment Data",
 	"Presentation Mode": "演示文稿",
   "Not available for guest": "Not available for guest",
-	"username": "用户名",
+  "Create Archive Page": "Create Archive Page",
+  "File type": "File type",
+  "Target page": "Target page",
+  "Include Attachment File": "Include Attachment File",
+  "Include Comment": "Include Comment",
+  "Include Subordinated Page": "Include Subordinated Page",
+  "All Subordinated Page": "All Subordinated Page",
+  "Specify Hierarchy": "Specify Hierarchy",
+  "Submitted the request to create the archive": "Submitted the request to create the archive",
+  "username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
   "Last_Login": "上次登录",
@@ -695,24 +704,6 @@
 		"rebuild_description_1": "单击按钮以重新生成索引并添加所有页面数据。",
 		"rebuild_description_2": "这可能需要一段时间。"
 	},
-	"export_management": {
-		"exporting_collection_list": "正在导出集合列表",
-		"exported_data_list": "导出的存档数据列表",
-		"export_collections": "导出集合",
-		"check_all": "全部选中",
-		"uncheck_all": "全部取消选中",
-		"desc_password_seed": "还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。",
-		"create_new_archive_data": "创建新的存档数据",
-		"export": "导出",
-		"cancel": "取消",
-		"file": "文件",
-		"growi_version": "Growi 版本号",
-		"collections": "收藏",
-		"exported_at": "导出在",
-		"export_menu": "导出菜单",
-		"download": "下载",
-		"delete": "删除"
-	},
 	"personal_dropdown": {
 		"home": "家",
 		"settings": "设置",
@@ -735,6 +726,12 @@
 		"Registration successful": "注册成功",
 		"Setup": "安装程序"
 	},
+  "export_bulk": {
+    "failed_to_export": "Failed to export",
+    "failed_to_count_pages": "Failed to count pages",
+    "export_page_markdown": "Export page as Markdown",
+    "export_page_pdf": "Export page as PDF"
+  },
 	"message": {
 		"successfully_connected": "连接成功!",
 		"fail_to_save_access_token": "无法保存访问令牌。请再试一次。",
@@ -758,5 +755,9 @@
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
-	}
+  },
+  "validation":{
+    "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
+    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”"
+  }
 }

+ 4 - 17
src/client/js/components/Admin/App/AppSettingsPage.jsx

@@ -1,10 +1,11 @@
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminAppContainer from '../../../services/AdminAppContainer';
 
@@ -12,20 +13,6 @@ import AppSettingsPageContents from './AppSettingsPageContents';
 
 const logger = loggerFactory('growi:appSettings');
 
-function AppSettingsPageWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <AppSettingsPageWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
 let retrieveErrors = null;
 function AppSettingsPage(props) {
   if (props.adminAppContainer.state.title === props.adminAppContainer.dummyTitle) {
@@ -59,6 +46,6 @@ AppSettingsPage.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const AppSettingsPageWithUnstatedContainer = withUnstatedContainers(AppSettingsPage, [AdminAppContainer]);
+const AppSettingsPageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(AppSettingsPage), [AdminAppContainer]);
 
-export default AppSettingsPageWithContainerWithSuspense;
+export default AppSettingsPageWithUnstatedContainer;

+ 80 - 59
src/client/js/components/Admin/App/MailSetting.jsx

@@ -29,6 +29,7 @@ class MailSetting extends React.Component {
 
     this.openInitializeValueModal = this.openInitializeValueModal.bind(this);
     this.closeInitializeValueModal = this.closeInitializeValueModal.bind(this);
+    this.submitFromAdressHandler = this.submitFromAdressHandler.bind(this);
     this.submitHandler = this.submitHandler.bind(this);
     this.initialize = this.initialize.bind(this);
   }
@@ -54,14 +55,26 @@ class MailSetting extends React.Component {
     }
   }
 
+  async submitFromAdressHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateFromAdressHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
   async initialize() {
     const { t, adminAppContainer } = this.props;
 
     try {
       const mailSettingParams = await adminAppContainer.initializeMailSettingHandler();
-      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.mail_settings') }));
+      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.smtp_settings') }));
       // convert values to '' if value is null for overwriting values of inputs with refs
-      this.emailInput.current.value = mailSettingParams.fromAddress || '';
       this.hostInput.current.value = mailSettingParams.smtpHost || '';
       this.portInput.current.value = mailSettingParams.smtpPort || '';
       this.userInput.current.value = mailSettingParams.smtpUser || '';
@@ -93,70 +106,78 @@ class MailSetting extends React.Component {
             />
           </div>
         </div>
-
-        <div className="row form-group mb-5">
-          <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.smtp_settings')}</label>
-          <div className="col-md-4">
-            <label>{t('admin:app_setting.host')}</label>
-            <input
-              className="form-control"
-              type="text"
-              ref={this.hostInput}
-              defaultValue={adminAppContainer.state.smtpHost || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
-            />
-          </div>
-          <div className="col-md-2">
-            <label>{t('admin:app_setting.port')}</label>
-            <input
-              className="form-control"
-              ref={this.portInput}
-              defaultValue={adminAppContainer.state.smtpPort || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
-            />
+        <div className="row my-3">
+          <div className="mx-auto">
+            <button type="button" className="btn btn-primary" onClick={this.submitFromAdressHandler}>{ t('Update') }</button>
           </div>
         </div>
-
-        <div className="row form-group mb-5">
-          <div className="col-md-3 offset-md-3">
-            <label>{t('admin:app_setting.user')}</label>
-            <input
-              className="form-control"
-              type="text"
-              ref={this.userInput}
-              defaultValue={adminAppContainer.state.smtpUser || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
-            />
-          </div>
-          <div className="col-md-3">
-            <label>{t('Password')}</label>
-            <input
-              className="form-control"
-              type="password"
-              ref={this.passwordInput}
-              defaultValue={adminAppContainer.state.smtpPassword || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
-            />
+        <div id="mail-smtp" className="tab-pane active mt-5">
+          <div className="row form-group mb-5">
+            <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.smtp_settings')}</label>
+            <div className="col-md-4">
+              <label>{t('admin:app_setting.host')}</label>
+              <input
+                className="form-control"
+                type="text"
+                ref={this.hostInput}
+                defaultValue={adminAppContainer.state.smtpHost || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+              />
+            </div>
+            <div className="col-md-2">
+              <label>{t('admin:app_setting.port')}</label>
+              <input
+                className="form-control"
+                ref={this.portInput}
+                defaultValue={adminAppContainer.state.smtpPort || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+              />
+            </div>
           </div>
-        </div>
 
-        <div className="row my-3">
-          <div className="offset-5">
-            <button type="button" className="btn btn-primary" onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
-              { t('Update') }
-            </button>
+          <div className="row form-group mb-5">
+            <div className="col-md-3 offset-md-3">
+              <label>{t('admin:app_setting.user')}</label>
+              <input
+                className="form-control"
+                type="text"
+                ref={this.userInput}
+                defaultValue={adminAppContainer.state.smtpUser || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+              />
+            </div>
+            <div className="col-md-3">
+              <label>{t('Password')}</label>
+              <input
+                className="form-control"
+                type="password"
+                ref={this.passwordInput}
+                defaultValue={adminAppContainer.state.smtpPassword || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+              />
+            </div>
           </div>
-          <div className="offset-1">
-            <button
-              type="button"
-              className="btn btn-secondary"
-              onClick={this.openInitializeValueModal}
-              disabled={adminAppContainer.state.retrieveError != null}
-            >
-              {t('admin:app_setting.initialize_mail_settings')}
-            </button>
+
+          <div className="row my-3">
+            <div className="offset-5">
+              <button type="button" className="btn btn-primary" onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+                { t('Update') }
+              </button>
+            </div>
+            <div className="offset-1">
+              <button
+                type="button"
+                className="btn btn-secondary"
+                onClick={this.openInitializeValueModal}
+                disabled={adminAppContainer.state.retrieveError != null}
+              >
+                {t('admin:app_setting.initialize_mail_settings')}
+              </button>
+            </div>
           </div>
         </div>
+
+
         <Modal isOpen={this.state.isInitializeValueModalOpen} toggle={this.closeInitializeValueModal} className="initialize-mail-settings">
           <ModalHeader tag="h4" toggle={this.closeInitializeValueModal} className="bg-danger text-light">
             {t('admin:app_setting.initialize_mail_modal_header')}

+ 4 - 17
src/client/js/components/Admin/Customize/Customize.jsx

@@ -1,5 +1,5 @@
 
-import React, { Fragment, Suspense } from 'react';
+import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 
 import loggerFactory from '@alias/logger';
@@ -8,6 +8,7 @@ import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import CustomizeLayoutSetting from './CustomizeLayoutSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
@@ -19,20 +20,6 @@ import CustomizeTitle from './CustomizeTitle';
 
 const logger = loggerFactory('growi:services:AdminCustomizePage');
 
-function CustomizePageWithContainerWithSusupense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <CustomizePageWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
 let retrieveErrors = null;
 function Customize(props) {
   const { adminCustomizeContainer } = props;
@@ -83,10 +70,10 @@ function Customize(props) {
   );
 }
 
-const CustomizePageWithUnstatedContainer = withUnstatedContainers(Customize, [AdminCustomizeContainer]);
+const CustomizePageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(Customize), [AdminCustomizeContainer]);
 
 Customize.propTypes = {
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 
-export default CustomizePageWithContainerWithSusupense;
+export default CustomizePageWithUnstatedContainer;

+ 1 - 1
src/client/js/components/Admin/ImportData/GrowiArchive/ErrorViewer.jsx

@@ -20,7 +20,7 @@ class ErrorViewer extends React.Component {
     }
 
     return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
+      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} size="lg">
         <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-danger text-light">
           Errors
         </ModalHeader>

+ 1 - 1
src/client/js/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -189,7 +189,7 @@ class ImportDataPageContents extends React.Component {
                   className="form-control"
                   type="password"
                   name="qiitaAccessToken"
-                  value={adminImportContainer.stateqiitaAccessToken}
+                  value={adminImportContainer.state.qiitaAccessToken}
                   onChange={adminImportContainer.handleInputValue}
                 />
               </div>

+ 4 - 17
src/client/js/components/Admin/ImportDataPage.jsx

@@ -1,9 +1,10 @@
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import toArrayIfNot from '../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../SuspenseUtils';
 
 import AdminImportContainer from '../../services/AdminImportContainer';
 import { toastError } from '../../util/apiNotification';
@@ -12,20 +13,6 @@ import ImportDataPageContents from './ImportData/ImportDataPageContents';
 
 const logger = loggerFactory('growi:importer');
 
-function ImportDataPageWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <ImportDataPageWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
 let retrieveErrors = null;
 function ImportDataPage(props) {
   const { adminImportContainer } = props;
@@ -60,6 +47,6 @@ ImportDataPage.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ImportDataPageWithUnstatedContainer = withUnstatedContainers(ImportDataPage, [AdminImportContainer]);
+const ImportDataPageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(ImportDataPage), [AdminImportContainer]);
 
-export default ImportDataPageWithContainerWithSuspense;
+export default ImportDataPageWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx

@@ -1,4 +1,4 @@
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import loggerFactory from '@alias/logger';
@@ -6,26 +6,13 @@ import loggerFactory from '@alias/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import MarkDownSettingContents from './MarkDownSettingContents';
 import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
 
 const logger = loggerFactory('growi:MarkDown');
 
-function MarkdownSettingWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <MarkdownSettingWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
 let retrieveErrors = null;
 function MarkdownSetting(props) {
   const { adminMarkDownContainer } = props;
@@ -52,10 +39,10 @@ function MarkdownSetting(props) {
   return <MarkDownSettingContents />;
 }
 
-const MarkdownSettingWithUnstatedContainer = withUnstatedContainers(MarkdownSetting, [AdminMarkDownContainer]);
+const MarkdownSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(MarkdownSetting), [AdminMarkDownContainer]);
 
 MarkdownSetting.propTypes = {
   adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 };
 
-export default MarkdownSettingWithContainerWithSuspense;
+export default MarkdownSettingWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Notification/NotificationSetting.jsx

@@ -1,4 +1,4 @@
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import loggerFactory from '@alias/logger';
@@ -6,6 +6,7 @@ import loggerFactory from '@alias/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
 
@@ -13,20 +14,6 @@ import NotificationSettingContents from './NotificationSettingContents';
 
 const logger = loggerFactory('growi:NotificationSetting');
 
-function NotificationSettingWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <NotificationSettingWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
 let retrieveErrors = null;
 function NotificationSetting(props) {
   const { adminNotificationContainer } = props;
@@ -52,10 +39,10 @@ function NotificationSetting(props) {
   return <NotificationSettingContents />;
 }
 
-const NotificationSettingWithUnstatedContainer = withUnstatedContainers(NotificationSetting, [AdminNotificationContainer]);
+const NotificationSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(NotificationSetting), [AdminNotificationContainer]);
 
 NotificationSetting.propTypes = {
   adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
 };
 
-export default NotificationSettingWithContainerWithSuspense;
+export default NotificationSettingWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Security/BasicSecuritySetting.jsx

@@ -1,10 +1,11 @@
 /* eslint-disable react/no-danger */
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminBasicSecurityContainer from '../../../services/AdminBasicSecurityContainer';
 
@@ -43,22 +44,8 @@ BasicSecurityManagement.propTypes = {
   adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,
 };
 
-const BasicSecurityManagementWithUnstatedContainer = withUnstatedContainers(BasicSecurityManagement, [
+const BasicSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(BasicSecurityManagement), [
   AdminBasicSecurityContainer,
 ]);
 
-function BasicSecurityManagementWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <BasicSecurityManagementWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-export default BasicSecurityManagementWithContainerWithSuspense;
+export default BasicSecurityManagementWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx

@@ -1,10 +1,11 @@
 /* eslint-disable react/no-danger */
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminGitHubSecurityContainer from '../../../services/AdminGitHubSecurityContainer';
 
@@ -39,22 +40,8 @@ GitHubSecurityManagement.propTypes = {
   adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
 };
 
-const GitHubSecurityManagementWithUnstatedContainer = withUnstatedContainers(GitHubSecurityManagement, [
+const GitHubSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(GitHubSecurityManagement), [
   AdminGitHubSecurityContainer,
 ]);
 
-function GitHubSecurityManagementWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <GitHubSecurityManagementWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-export default GitHubSecurityManagementWithContainerWithSuspense;
+export default GitHubSecurityManagementWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -1,10 +1,11 @@
 /* eslint-disable react/no-danger */
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminGoogleSecurityContainer from '../../../services/AdminGoogleSecurityContainer';
 import GoogleSecurityManagementContents from './GoogleSecuritySettingContents';
@@ -38,22 +39,8 @@ GoogleSecurityManagement.propTypes = {
   adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
 };
 
-const GoogleSecurityManagementWithUnstatedContainer = withUnstatedContainers(GoogleSecurityManagement, [
+const GoogleSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(GoogleSecurityManagement), [
   AdminGoogleSecurityContainer,
 ]);
 
-function GoogleSecurityManagementWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <GoogleSecurityManagementWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-export default GoogleSecurityManagementWithContainerWithSuspense;
+export default GoogleSecurityManagementWithUnstatedContainer;

+ 4 - 18
src/client/js/components/Admin/Security/LdapSecuritySetting.jsx

@@ -1,9 +1,10 @@
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
 
@@ -37,23 +38,8 @@ LdapSecuritySetting.propTypes = {
   adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
 };
 
-const LdapSecuritySettingWithUnstatedContainer = withUnstatedContainers(LdapSecuritySetting, [
+const LdapSecuritySettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(LdapSecuritySetting), [
   AdminLdapSecurityContainer,
 ]);
 
-function LdapSecuritySettingWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <LdapSecuritySettingWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-
-export default LdapSecuritySettingWithContainerWithSuspense;
+export default LdapSecuritySettingWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Security/LocalSecuritySetting.jsx

@@ -1,10 +1,11 @@
 /* eslint-disable react/no-danger */
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminLocalSecurityContainer from '../../../services/AdminLocalSecurityContainer';
 
@@ -38,22 +39,8 @@ LocalSecuritySetting.propTypes = {
   adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
 };
 
-const LocalSecuritySettingWithUnstatedContainer = withUnstatedContainers(LocalSecuritySetting, [
+const LocalSecuritySettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(LocalSecuritySetting), [
   AdminLocalSecurityContainer,
 ]);
 
-function LocalSecuritySettingWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-)}
-    >
-      <LocalSecuritySettingWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-export default LocalSecuritySettingWithContainerWithSuspense;
+export default LocalSecuritySettingWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -1,10 +1,11 @@
 /* eslint-disable react/no-danger */
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminOidcSecurityContainer from '../../../services/AdminOidcSecurityContainer';
 
@@ -38,22 +39,8 @@ OidcSecurityManagement.propTypes = {
   adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
 };
 
-const OidcSecurityManagementWithUnstatedContainer = withUnstatedContainers(OidcSecurityManagement, [
+const OidcSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(OidcSecurityManagement), [
   AdminOidcSecurityContainer,
 ]);
 
-function OidcSecurityManagementWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <OidcSecurityManagementWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-export default OidcSecurityManagementWithContainerWithSuspense;
+export default OidcSecurityManagementWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Security/SamlSecuritySetting.jsx

@@ -1,5 +1,5 @@
 /* eslint-disable react/no-danger */
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -7,6 +7,7 @@ import { toastError } from '../../../util/apiNotification';
 
 import AdminSamlSecurityContainer from '../../../services/AdminSamlSecurityContainer';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import SamlSecuritySettingContents from './SamlSecuritySettingContents';
 
@@ -38,22 +39,8 @@ SamlSecurityManagement.propTypes = {
   adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,
 };
 
-const SamlSecurityManagementWithUnstatedContainer = withUnstatedContainers(SamlSecurityManagement, [
+const SamlSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(SamlSecurityManagement), [
   AdminSamlSecurityContainer,
 ]);
 
-function SamlSecurityManagementWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <SamlSecurityManagementWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-export default SamlSecurityManagementWithContainerWithSuspense;
+export default SamlSecurityManagementWithUnstatedContainer;

+ 5 - 17
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -1,9 +1,11 @@
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import { toastError } from '../../../util/apiNotification';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
 import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 import SecurityManagementContents from './SecurityManagementContents';
 
@@ -38,20 +40,6 @@ SecurityManagement.propTypes = {
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
 };
 
-const SecurityManagementWithUnstatedContainer = withUnstatedContainers(SecurityManagement, [AdminGeneralSecurityContainer]);
-
-function SecurityManagementWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <SecurityManagementWithUnstatedContainer {...props} />
-    </Suspense>
-  );
-}
+const SecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(SecurityManagement), [AdminGeneralSecurityContainer]);
 
-export default SecurityManagementWithContainerWithSuspense;
+export default SecurityManagementWithUnstatedContainer;

+ 4 - 17
src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx

@@ -1,10 +1,11 @@
 /* eslint-disable react/no-danger */
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
 import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminTwitterSecurityContainer from '../../../services/AdminTwitterSecurityContainer';
 
@@ -40,22 +41,8 @@ TwitterSecurityManagement.propTypes = {
   adminTwitterSecurityContainer: PropTypes.instanceOf(AdminTwitterSecurityContainer).isRequired,
 };
 
-const TwitterSecurityManagementWithUnstatedContainer = withUnstatedContainers(TwitterSecurityManagement, [
+const TwitterSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(TwitterSecurityManagement), [
   AdminTwitterSecurityContainer,
 ]);
 
-function TwitterSecurityManagementWithContainerWithSuspense(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-      )}
-    >
-      <TwitterSecurityManagementWithUnstatedContainer />
-    </Suspense>
-  );
-}
-
-export default TwitterSecurityManagementWithContainerWithSuspense;
+export default TwitterSecurityManagementWithUnstatedContainer;

+ 244 - 0
src/client/js/components/ArchiveCreateModal.jsx

@@ -0,0 +1,244 @@
+import React, { useState, useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import AppContainer from '../services/AppContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+import { toastSuccess, toastError } from '../util/apiNotification';
+
+
+const ArchiveCreateModal = (props) => {
+  const { t, appContainer } = props;
+  const [isCommentDownload, setIsCommentDownload] = useState(false);
+  const [isAttachmentFileDownload, setIsAttachmentFileDownload] = useState(false);
+  const [isSubordinatedPageDownload, setIsSubordinatedPageDownload] = useState(false);
+  const [fileType, setFileType] = useState('markdown');
+  const [hierarchyType, setHierarchyType] = useState('allSubordinatedPage');
+  const [hierarchyValue, setHierarchyValue] = useState(1);
+
+  function changeIsCommentDownloadHandler() {
+    setIsCommentDownload(!isCommentDownload);
+  }
+
+  function changeIsAttachmentFileDownloadHandler() {
+    setIsAttachmentFileDownload(!isAttachmentFileDownload);
+  }
+
+  function changeIsSubordinatedPageDownloadHandler() {
+    setIsSubordinatedPageDownload(!isSubordinatedPageDownload);
+  }
+
+  function closeModalHandler() {
+    if (props.onClose == null) {
+      return;
+    }
+
+    props.onClose();
+  }
+
+  const handleChangeFileType = useCallback(
+    (filetype) => {
+      setFileType(filetype);
+    },
+    [],
+  );
+
+  function handleChangeSubordinatedType(hierarchyType) {
+    setHierarchyType(hierarchyType);
+  }
+
+  function handleHierarchyDepth(hierarchyValue) {
+    setHierarchyValue(hierarchyValue);
+  }
+
+
+  async function done() {
+    try {
+      await appContainer.apiv3Post('/page/archive', {
+        rootPagePath: props.path,
+        isCommentDownload,
+        isAttachmentFileDownload,
+        isSubordinatedPageDownload,
+        fileType,
+        hierarchyType,
+        hierarchyValue,
+      });
+      toastSuccess(t('Submitted the request to create the archive'));
+      closeModalHandler();
+    }
+    catch (e) {
+      toastError(e);
+    }
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeModalHandler}>
+      <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-white">
+        {t('Create Archive Page')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group">
+          <div className="form-group">
+            <label>{t('Target page')}</label>
+            <br />
+            <code>{props.path}</code>
+          </div>
+
+          <div className="custom-control-inline">
+            <label>{t('File type')}: </label>
+          </div>
+          <div className="custom-control custom-radio custom-control-inline ">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio1"
+              name="isFileType"
+              value="customRadio1"
+              checked={fileType === 'markdown'}
+              onChange={() => {
+                handleChangeFileType('markdown');
+              }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio1">
+              MarkDown(.md)
+            </label>
+          </div>
+
+          <div className="custom-control custom-radio custom-control-inline ">
+            <input
+              type="radio"
+              className="custom-control-input"
+              id="customRadio2"
+              name="isFileType"
+              value="customRadio2"
+              checked={fileType === 'pdf'}
+              onChange={() => {
+                handleChangeFileType('pdf');
+              }}
+            />
+            <label className="custom-control-label" htmlFor="customRadio2">
+              PDF(.pdf)
+            </label>
+          </div>
+        </div>
+
+        <div className="my-1 custom-control custom-checkbox custom-checkbox-info">
+          <input
+            className="custom-control-input"
+            name="comment"
+            id="commentFile"
+            type="checkbox"
+            checked={isCommentDownload}
+            onChange={changeIsCommentDownloadHandler}
+          />
+          <label className="custom-control-label" htmlFor="commentFile">
+            {t('Include Comment')}
+          </label>
+        </div>
+        <div className="my-1 custom-control custom-checkbox custom-checkbox-info">
+          <input
+            className="custom-control-input"
+            id="downloadFile"
+            type="checkbox"
+            checked={isAttachmentFileDownload}
+            onChange={changeIsAttachmentFileDownloadHandler}
+          />
+          <label className="custom-control-label" htmlFor="downloadFile">
+            {t('Include Attachment File')}
+          </label>
+        </div>
+        <div className="my-1 custom-control custom-checkbox custom-checkbox-info">
+          <input
+            className="custom-control-input"
+            id="subordinatedFile"
+            type="checkbox"
+            checked={isSubordinatedPageDownload}
+            onChange={changeIsSubordinatedPageDownloadHandler}
+          />
+          <label className="custom-control-label" htmlFor="subordinatedFile">
+            {t('Include Subordinated Page')}
+          </label>
+          {isSubordinatedPageDownload && (
+            <>
+              <div className="FormGroup">
+                <div className="my-1 custom-control custom-radio custom-control-inline ">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    id="customRadio3"
+                    name="isSubordinatedType"
+                    value="customRadio3"
+                    disabled={!isSubordinatedPageDownload}
+                    checked={hierarchyType === 'allSubordinatedPage'}
+                    onChange={() => {
+                      handleChangeSubordinatedType('allSubordinatedPage');
+                    }}
+                  />
+                  <label className="custom-control-label" htmlFor="customRadio3">
+                    {t('All Subordinated Page')}
+                  </label>
+                </div>
+              </div>
+              <div className="FormGroup">
+                <div className="my-1 custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    id="customRadio4"
+                    name="isSubordinatedType"
+                    value="customRadio4"
+                    disabled={!isSubordinatedPageDownload}
+                    checked={hierarchyType === 'decideHierarchy'}
+                    onChange={() => {
+                      handleChangeSubordinatedType('decideHierarchy');
+                    }}
+                  />
+                  <label className="my-1 custom-control-label" htmlFor="customRadio4">
+                    {t('Specify Hierarchy')}
+                  </label>
+                </div>
+              </div>
+              <div className="my-1 custom-control costom-control-inline">
+                <input
+                  type="number"
+                  min="1"
+                  max="10"
+                  disabled={hierarchyType === 'allSubordinatedPage'}
+                  value={hierarchyValue}
+                  placeholder="1"
+                  onChange={(e) => {
+                    handleHierarchyDepth(e.target.value);
+                  }}
+                />
+              </div>
+            </>
+          )}
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        {/* TO DO implement correct number at GW-3053 */}
+        合計{props.totalPages}ページ取得
+        {props.errorMessage}
+        <button type="button" className="btn btn-primary" onClick={done}>
+          Done
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+const ArchiveCreateModalWrapper = withUnstatedContainers(ArchiveCreateModal, [AppContainer]);
+
+ArchiveCreateModal.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  path: PropTypes.string.isRequired,
+  totalPages: PropTypes.number,
+  errorMessage: PropTypes.string,
+};
+
+export default withTranslation()(ArchiveCreateModalWrapper);

+ 4 - 3
src/client/js/components/Fab.jsx

@@ -6,7 +6,8 @@ import StickyEvents from 'sticky-events';
 
 import NavigationContainer from '../services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
-
+import CreatePageIcon from './Icons/CreatePageIcon';
+import ReturnTopIcon from './Icons/ReturnTopIcon';
 
 const logger = loggerFactory('growi:cli:Fab');
 
@@ -47,12 +48,12 @@ const Fab = (props) => {
           className="btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light"
           onClick={navigationContainer.openPageCreateModal}
         >
-          <i className="icon-pencil"></i>
+          <CreatePageIcon />
         </button>
       </div>
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button type="button" className="btn btn-light btn-scroll-to-top rounded-circle p-0" onClick={() => navigationContainer.smoothScrollIntoView()}>
-          <i className="icon-control-start"></i>
+          <ReturnTopIcon />
         </button>
       </div>
     </div>

+ 26 - 0
src/client/js/components/Icons/CreatePageIcon.jsx

@@ -0,0 +1,26 @@
+import React from 'react';
+
+const CreatePageIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 27 30"
+  >
+    <path
+      d="M22.81,8.2a4.2,4.2,0,0,0,1.36-2.95,4,4,0,0,0-1.43-2.81,4.53,4.53,0,0,0-1.28-.89,3.26,3.26,0,
+      0,0-1.37-.31,4,4,0,0,0-2.91,1.29q-.42.4-14.83,14.84a.7.7,0,0,0-.26.33c-.07.26-.72,2.46-2,6.58a.73.73,0,
+      0,0,.3,1,.78.78,0,0,0,.7,0c3.3-1.08,5.45-1.76,6.47-2.06A.57.57,0,0,0,7.91,23l8.5-8.42Q22.25,8.81,22.81,8.2ZM1.93,
+      23.44c.16-.44,1.39-4.39,1.5-4.78A4.93,4.93,0,0,1,5.59,20a4.53,4.53,0,0,1,1.12,1.87Zm15-18.52a4.7,4.7,0,0,1,2.16,1.31,5.08,5.08,
+      0,0,1,.72,1,5.3,5.3,0,0,1,.37.8c.05.17.09.34.13.51Q17.19,11.65,8,20.79a6.42,6.42,0,0,0-1.29-1.92,6.67,6.67,0,0,0-2.2-1.48Zm4.64,
+      2.37a6.36,6.36,0,0,0-1.36-2.13,6.61,6.61,0,0,0-2.12-1.43s.29-.28.41-.38A3,3,0,0,1,19.17,3a2,2,0,0,1,.9-.21A1.87,1.87,0,0,1,20.9,3a2.53,2.53,0,0,
+      1,.79.56,3.81,3.81,0,0,1,.71.89,1.87,1.87,0,0,1,.25.87,2.75,2.75,0,0,1-.94,1.83Z"
+    />
+    <path d="M26.41,20.05H22.84V16.48a.72.72,0,0,0-1.43,0v3.57H17.84a.72.72,0,0,0,0,1.43h3.57v3.57a.72.72,0,0,0,
+    1.43.17V21.48h3.57a.72.72,0,1,0,.17-1.43A.48.48,0,0,0,26.41,20.05Z"
+    />
+    <rect fillOpacity="0" width="27" height="27" />
+  </svg>
+
+);
+
+
+export default CreatePageIcon;

+ 20 - 0
src/client/js/components/Icons/MoonIcon.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+
+const MoonIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <g transform="translate(-923.5 -688.5)">
+      <rect width="23" height="23" fill="none" transform="translate(923.5 688.5)" />
+      <path d="M934.893,710.532a10.646,10.646,0,0,1-10.378-8.416.7.7,0,0,1,1.138-.686,
+       7.621,7.621,0,0,0,10.721-10.744.7.7,0,0,1,.683-1.14,10.6,10.6,0,0,1-2.164,
+        20.986Zm-8.417-6.9A9.2,9.2,0,1,0,938.583,691.5a9.028,9.028,0,0,1-12.107,12.133Z"
+      />
+    </g>
+  </svg>
+
+);
+
+
+export default MoonIcon;

+ 20 - 0
src/client/js/components/Icons/ReturnTopIcon.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+
+const ReturnTopIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <path d="M.41,18.71a.82.82,0,0,0,0,.26.71.71,0,0,0,0,.29.5.5,0,0,0,.16.22.66.66,0,0,0,.51.21.67.67,0,0,0,
+    .51-.21l9.57-9.56,9.43,9.43a.71.71,0,0,0,.51.21.68.68,0,0,0,.51-.21.72.72,
+    0,0,0,0-1l-9.94-10a.78.78,0,0,0-.51-.19.76.76,0,0,0-.5.19L.58,18.46A.85.85,0,0,0,.41,18.71Z"
+    />
+    <path d="M22.35,4.61H.65a.65.65,0,0,1,0-1.3h21.7a.65.65,0,1,1,0,1.3Z" />
+    <rect fillOpacity="0" width="23" height="23" />
+
+  </svg>
+
+);
+
+
+export default ReturnTopIcon;

+ 20 - 0
src/client/js/components/Icons/SidebarDockIcon.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+
+const SidebarDockIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <rect width="23" height="23" fillOpacity="0" />
+    <path
+      d="M20.86,3.92a.64.64,0,0,1,.64.63v13.9a.64.64,0,0,1-.64.63H2.14a.64.64,0,0,
+      1-.64-.63V4.55a.64.64,0,0,1,.64-.63H20.86m0-1.5H2.14A2.13,2.13,0,0,0,0,4.55v13.9a2.13,
+      2.13,0,0,0,2.14,2.13H20.86A2.13,2.13,0,0,0,23,18.45V4.55a2.13,2.13,0,0,0-2.14-2.13Z"
+    />
+    <rect x="7.49" y="3.05" width="1.2" height="16.91" />
+  </svg>
+
+);
+
+
+export default SidebarDockIcon;

+ 25 - 0
src/client/js/components/Icons/SidebarDrawerIcon.jsx

@@ -0,0 +1,25 @@
+import React from 'react';
+
+const SidebarDrawerIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <rect width="23" height="23" fillOpacity="0" />
+    <path d="M20.9,3.9c0.3,0,0.6,0.3,0.6,0.6v13.9c0,0.3-0.3,0.6-0.6,0.6H2.1c-0.3,0-0.6-0.3-0.6-0.6V4.5c0-0.3,0.3-0.6,0.6-0.6H20.9
+      M20.9,2.4H2.1C1,2.4,0,3.4,0,4.5c0,0,0,0,0,0v13.9c0,1.2,1,2.1,2.1,2.1c0,0,0,0,0,0h18.7c1.2,0,2.1-0.9,2.1-2.1c0,0,0,0,0,0V4.5
+      C23,3.4,22,2.4,20.9,2.4C20.9,2.4,20.9,2.4,20.9,2.4z"
+    />
+    <rect x="7.5" y="3.9" width="1.2" height="0.8" />
+    <rect x="7.5" y="15.3" width="1.2" height="1.5" />
+    <rect x="7.5" y="12.3" width="1.2" height="1.5" />
+    <rect x="7.5" y="9.2" width="1.2" height="1.6" />
+    <rect x="7.5" y="6.1" width="1.2" height="1.6" />
+    <rect x="7.5" y="18.4" width="1.2" height="0.8" />
+    <path d="M15.1,14.9c-0.2,0-0.3-0.1-0.4-0.2l-2.8-2.8c-0.2-0.2-0.2-0.6,0-0.8l2.8-2.8c0.2-0.2,0.6-0.2,0.9,0s0.2,0.6,0,0.9l-2.4,2.4
+      l2.4,2.4c0.2,0.2,0.2,0.6,0,0.9C15.4,14.8,15.3,14.9,15.1,14.9z"
+    />
+  </svg>
+);
+
+export default SidebarDrawerIcon;

+ 28 - 0
src/client/js/components/Icons/SunIcon.jsx

@@ -0,0 +1,28 @@
+import React from 'react';
+
+const SunIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <g transform="translate(-888.497 -688.492)">
+      <rect width="23" height="23" transform="translate(888.503 688.509)" fillOpacity="0" />
+      <path d="M900,695.489a4.5,4.5,0,1,1-4.5,4.5,4.5,4.5,0,0,1,4.5-4.5m0-1.408a5.9,5.9,0,1,0,5.9,5.9,5.91,5.91,0,0,0-5.9-5.9Z" />
+      <path d="M893.968,694.573a.6.6,0,0,1-.426-.176l-1.681-1.681a.6.6,0,0,1,.853-.852l1.681,1.68a.6.6,0,0,1-.427,1.029Z" />
+      <path d="M907.707,708.295a.6.6,0,0,1-.427-.177l-1.681-1.68a.6.6,0,0,1,.854-.853l1.68,1.681a.6.6,0,0,1-.426,1.029Z" />
+
+      <path d="M899.991,692.074a.6.6,0,0,1-.6-.6v-2.377a.6.6,0,0,1,1.206,0v2.377A.6.6,0,0,1,899.991,692.074Z" />
+      <path d="M900,711.491a.6.6,0,0,1-.6-.6v-2.377a.6.6,0,1,1,1.206,0v2.377A.6.6,0,0,1,900,711.491Z" />
+
+      <path d="M906.017,694.564a.6.6,0,0,1-.426-1.029l1.68-1.68a.6.6,0,0,1,.853.854l-1.68,1.68A.6.6,0,0,1,906.017,694.564Z" />
+      <path d="M892.3,708.3a.6.6,0,0,1-.426-1.029l1.68-1.681a.6.6,0,1,1,.853.852l-1.68,1.681A.6.6,0,0,1,892.3,708.3Z" />
+
+      <path d="M910.894,700.587h-2.377a.6.6,0,1,1,0-1.2h2.377a.6.6,0,1,1,0,1.2Z" />
+      <path d="M891.477,700.6H889.1a.6.6,0,1,1,0-1.2h2.377a.6.6,0,1,1,0,1.2Z" />
+    </g>
+  </svg>
+
+);
+
+
+export default SunIcon;

+ 25 - 6
src/client/js/components/Me/PasswordSettings.jsx

@@ -3,7 +3,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-
 import { toastSuccess, toastError } from '../../util/apiNotification';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -23,6 +22,7 @@ class PasswordSettings extends React.Component {
       oldPassword: '',
       newPassword: '',
       newPasswordConfirm: '',
+      isPasswordSet: false,
     };
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
@@ -30,6 +30,21 @@ class PasswordSettings extends React.Component {
 
   }
 
+  async componentDidMount() {
+    const { appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Get('/personal-setting/is-password-set');
+      const { isPasswordSet } = res.data;
+      this.setState({ isPasswordSet });
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ retrieveError: err });
+    }
+
+  }
+
   async onClickSubmit() {
     const { t, appContainer, personalContainer } = this.props;
     const { oldPassword, newPassword, newPasswordConfirm } = this.state;
@@ -61,22 +76,26 @@ class PasswordSettings extends React.Component {
   }
 
   render() {
-    const { t, personalContainer } = this.props;
+    const { t } = this.props;
     const { newPassword, newPasswordConfirm } = this.state;
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
 
+    if (this.state.retrieveError != null) {
+      throw new Error(this.state.retrieveError.message);
+    }
+
     return (
       <React.Fragment>
-        { (!personalContainer.state.isPasswordSet) && (
+        { (!this.state.isPasswordSet) && (
           <div className="alert alert-warning">{ t('personal_settings.password_is_not_set') }</div>
         ) }
 
         <div className="container-fluid my-4">
-          {(personalContainer.state.isPasswordSet)
+          {(this.state.isPasswordSet)
             ? <h2 className="border-bottom">{t('personal_settings.update_password')}</h2>
           : <h2 className="border-bottom">{t('personal_settings.set_new_password')}</h2>}
         </div>
-        {(personalContainer.state.isPasswordSet)
+        {(this.state.isPasswordSet)
         && (
           <div className="row mb-3">
             <label htmlFor="oldPassword" className="col-md-3 text-md-right">{ t('personal_settings.current_password') }</label>
@@ -124,7 +143,7 @@ class PasswordSettings extends React.Component {
               type="button"
               className="btn btn-primary"
               onClick={this.onClickSubmit}
-              disabled={this.state.retrieveError != null || isIncorrectConfirmPassword}
+              disabled={isIncorrectConfirmPassword}
             >
               {t('Update')}
             </button>

+ 1 - 1
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -148,7 +148,7 @@ const GrowiSubNavigation = (props) => {
   }
 
   return (
-    <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact' : ''}`}>
+    <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
 
       {/* Left side */}
       <div className="d-flex">

+ 33 - 15
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -20,6 +20,12 @@ import {
 
 import UserPicture from '../User/UserPicture';
 
+import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
+import SidebarDockIcon from '../Icons/SidebarDockIcon';
+import MoonIcon from '../Icons/MoonIcon';
+import SunIcon from '../Icons/SunIcon';
+
+
 const PersonalDropdown = (props) => {
 
   const { t, appContainer, navigationContainer } = props;
@@ -79,16 +85,12 @@ const PersonalDropdown = (props) => {
   } = navigationContainer.state;
 
   /* eslint-disable react/prop-types */
-  const DrawerIcon = props => (
-    <>
-      <i id={props.id} className="icon-drawer px-2"></i>
-      <UncontrolledTooltip placement="bottom" fade={false} target={props.id}>Drawer</UncontrolledTooltip>
-    </>
-  );
-  const DockIcon = props => (
+  const IconWithTooltip = ({
+    id, label, children, additionalClasses,
+  }) => (
     <>
-      <i id={props.id} className="ti-layout-sidebar-left px-2"></i>
-      <UncontrolledTooltip placement="bottom" fade={false} target={props.id}>Dock</UncontrolledTooltip>
+      <div id={id} className={`px-2 grw-icon-container ${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
+      <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
     </>
   );
   /* eslint-enable react/prop-types */
@@ -129,11 +131,14 @@ const PersonalDropdown = (props) => {
 
         <div className="dropdown-divider"></div>
 
+        {/* Sidebar Mode */}
         <h6 className="dropdown-header">{t('personal_dropdown.sidebar_mode')}</h6>
         <form className="px-4">
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <DrawerIcon id="icon-prefer-drawer" />
+              <IconWithTooltip id="iwt-sidebar-drawer" label="Drawer">
+                <SidebarDrawerIcon />
+              </IconWithTooltip>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                   id="swSidebarMode"
@@ -144,15 +149,21 @@ const PersonalDropdown = (props) => {
                 />
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
               </div>
-              <DockIcon id="icon-prefer-dock" />
+              <IconWithTooltip id="iwt-sidebar-dock" label="Dock">
+                <SidebarDockIcon />
+              </IconWithTooltip>
             </div>
           </div>
         </form>
+
+        {/* Sidebar Mode on Editor */}
         <h6 className="dropdown-header">{t('personal_dropdown.sidebar_mode_editor')}</h6>
         <form className="px-4">
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <DrawerIcon id="icon-prefer-drawer-on-edit" />
+              <IconWithTooltip id="iwt-sidebar-editor-drawer" label="Drawer">
+                <SidebarDrawerIcon />
+              </IconWithTooltip>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                   id="swSidebarModeOnEditor"
@@ -163,13 +174,16 @@ const PersonalDropdown = (props) => {
                 />
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
               </div>
-              <DockIcon id="icon-prefer-dock-on-edit" />
+              <IconWithTooltip id="iwt-sidebar-editor-dock" label="Dock">
+                <SidebarDockIcon />
+              </IconWithTooltip>
             </div>
           </div>
         </form>
 
         <div className="dropdown-divider"></div>
 
+        {/* Color Mode */}
         <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>
         <form className="px-4">
           <div className="form-row">
@@ -188,7 +202,9 @@ const PersonalDropdown = (props) => {
           </div>
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <span className={useOsSettings ? '' : 'text-muted'}>Light</span>
+              <IconWithTooltip id="iwt-light" label="Light" additionalClasses={useOsSettings ? 'grw-icon-container-muted' : ''}>
+                <SunIcon />
+              </IconWithTooltip>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                   id="swUserPreference"
@@ -200,7 +216,9 @@ const PersonalDropdown = (props) => {
                 />
                 <label className="custom-control-label" htmlFor="swUserPreference"></label>
               </div>
-              <span className={useOsSettings ? '' : 'text-muted'}>Dark</span>
+              <IconWithTooltip id="iwt-dark" label="Dark" additionalClasses={useOsSettings ? 'grw-icon-container-muted' : ''}>
+                <MoonIcon />
+              </IconWithTooltip>
             </div>
           </div>
         </form>

+ 46 - 0
src/client/js/components/Page/PageShareManagement.jsx

@@ -9,13 +9,23 @@ import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
 import OutsideShareLinkModal from '../OutsideShareLinkModal';
 
+// TODO GW-2746 bulk export pages
+// import ArchiveCreateModal from '../ArchiveCreateModal';
+
 const PageShareManagement = (props) => {
   const { t, appContainer, pageContainer } = props;
 
+  // TODO GW-2746 bulk export pages
+  // eslint-disable-next-line no-unused-vars
+  const { path, pageId } = pageContainer.state;
   const { currentUser } = appContainer;
 
   const [isOutsideShareLinkModalShown, setIsOutsideShareLinkModalShown] = useState(false);
 
+  // TODO GW-2746 bulk export pages
+  // const [isArchiveCreateModalShown, setIsArchiveCreateModalShown] = useState(false);
+  // const [totalPages, setTotalPages] = useState(null);
+  // const [errorMessage, setErrorMessage] = useState(null);
 
   function openOutsideShareLinkModalHandler() {
     setIsOutsideShareLinkModalShown(true);
@@ -25,6 +35,17 @@ const PageShareManagement = (props) => {
     setIsOutsideShareLinkModalShown(false);
   }
 
+  // TODO GW-2746 bulk export pages
+  // async function getArchivePageData() {
+  //   try {
+  //     const res = await appContainer.apiv3Get('page/count-children-pages', { pageId });
+  //     setTotalPages(res.data.dummy);
+  //   }
+  //   catch (err) {
+  //     setErrorMessage(t('export_bulk.failed_to_count_pages'));
+  //   }
+  // }
+
   async function exportPageHandler(format) {
     const { pageId, revisionId } = pageContainer.state;
     const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
@@ -33,6 +54,18 @@ const PageShareManagement = (props) => {
     window.location.href = url.href;
   }
 
+  // TODO GW-2746 create api to bulk export pages
+  // function openArchiveModalHandler() {
+  //   setIsArchiveCreateModalShown(true);
+  //   getArchivePageData();
+  // }
+
+  // TODO GW-2746 create api to bulk export pages
+  // function closeArchiveCreateModalHandler() {
+  //   setIsArchiveCreateModalShown(false);
+  // }
+
+
   function renderModals() {
     if (currentUser == null) {
       return null;
@@ -44,6 +77,15 @@ const PageShareManagement = (props) => {
           isOpen={isOutsideShareLinkModalShown}
           onClose={closeOutsideShareLinkModalHandler}
         />
+
+        {/* TODO GW-2746 bulk export pages */}
+        {/* <ArchiveCreateModal
+          isOpen={isArchiveCreateModalShown}
+          onClose={closeArchiveCreateModalHandler}
+          path={path}
+          errorMessage={errorMessage}
+          totalPages={totalPages}
+        /> */}
       </>
     );
   }
@@ -91,6 +133,10 @@ const PageShareManagement = (props) => {
         <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
           <span>{t('export_bulk.export_page_markdown')}</span>
         </button>
+        {/* TODO GW-2746 create api to bulk export pages */}
+        {/* <button className="dropdown-item" type="button" onClick={openArchiveModalHandler}>
+          <i className="icon-fw"></i>{t('Create Archive Page')}
+        </button> */}
       </div>
       {renderModals()}
     </>

+ 4 - 0
src/client/js/components/PageDuplicateModal.jsx

@@ -31,6 +31,8 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    */
   function ppacInputChangeHandler(value) {
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 
@@ -39,6 +41,8 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    */
   function inputChangeHandler(value) {
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 

Разница между файлами не показана из-за своего большого размера
+ 38 - 25
src/client/js/components/PageEditor/EditorIcon.jsx


+ 3 - 3
src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -247,9 +247,9 @@ class OptionsSelector extends React.Component {
   render() {
     return (
       <div className="d-flex flex-row">
-        <span className="ml-2">{this.renderThemeSelector()}</span>
-        <span className="ml-2">{this.renderKeymapModeSelector()}</span>
-        <span className="ml-2">{this.renderConfigurationDropdown()}</span>
+        <span className="ml-3">{this.renderThemeSelector()}</span>
+        <span className="ml-4">{this.renderKeymapModeSelector()}</span>
+        <span className="ml-4">{this.renderConfigurationDropdown()}</span>
       </div>
     );
   }

+ 2 - 0
src/client/js/components/PageRenameModal.jsx

@@ -47,6 +47,8 @@ const PageRenameModal = (props) => {
    * @param {string} value
    */
   function inputChangeHandler(value) {
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 

+ 13 - 0
src/client/js/services/AdminAppContainer.js

@@ -238,6 +238,19 @@ export default class AdminAppContainer extends Container {
     return siteUrlSettingParams;
   }
 
+  /**
+   * Update from adress
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateFromAdressHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/from-address', {
+      fromAddress: this.state.fromAddress,
+    });
+    const { mailSettingParams } = response.data;
+    return mailSettingParams;
+  }
+
   /**
    * Update mail setting
    * @memberOf AdminAppContainer

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

@@ -55,7 +55,7 @@ export default class PageHistoryContainer extends Container {
       return;
     }
 
-    const res = await this.appContainer.apiv3Get('/revisions/list', { pageId, share_link_id: shareLinkId, selectedPage });
+    const res = await this.appContainer.apiv3Get('/revisions/list', { pageId, shareLinkId, selectedPage });
     const rev = res.data.docs;
 
     // set Pagination state
@@ -138,7 +138,7 @@ export default class PageHistoryContainer extends Container {
     }
 
     try {
-      const res = await this.appContainer.apiv3Get(`/revisions/${revision._id}`, { pageId, share_link_id: shareLinkId });
+      const res = await this.appContainer.apiv3Get(`/revisions/${revision._id}`, { pageId, shareLinkId });
       this.setState({
         revisions: this.state.revisions.map((rev) => {
           // comparing ObjectId

+ 0 - 2
src/client/js/services/PersonalContainer.js

@@ -29,7 +29,6 @@ export default class PersonalContainer extends Container {
       isUploadedPicture: false,
       uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
-      isPasswordSet: false,
       apiToken: '',
     };
 
@@ -55,7 +54,6 @@ export default class PersonalContainer extends Container {
         isEmailPublished: currentUser.isEmailPublished,
         lang: currentUser.lang,
         isGravatarEnabled: currentUser.isGravatarEnabled,
-        isPasswordSet: (currentUser.password != null),
         apiToken: currentUser.apiToken,
       });
     }

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

@@ -10,7 +10,7 @@
     }
 
     button {
-      padding: 8px;
+      padding: 0px;
       margin: 0 2px;
       font-size: 1rem;
       line-height: 1;

+ 7 - 4
src/client/styles/scss/_layout.scss

@@ -46,6 +46,10 @@ body {
     font-size: 24px;
 
     box-shadow: 2px 3px 6px #0000005d;
+    svg {
+      width: 28px;
+      height: 28px;
+    }
   }
 
   .btn-scroll-to-top {
@@ -53,10 +57,9 @@ body {
     height: 40px;
 
     opacity: 0.4;
-
-    i {
-      display: inline-block;
-      transform: rotate(90deg);
+    svg {
+      width: 18px;
+      height: 18px;
     }
   }
 }

+ 10 - 2
src/client/styles/scss/_navbar.scss

@@ -50,8 +50,16 @@
   }
 
   .grw-personal-dropdown {
-    .grw-email-sm {
-      font-size: 0.75em;
+    .dropdown-menu {
+      min-width: 15rem;
+
+      .grw-email-sm {
+        font-size: 0.75em;
+      }
+      .grw-icon-container svg {
+        width: 18px;
+        height: 18px;
+      }
     }
   }
 }

+ 36 - 1
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -36,6 +36,9 @@ $font-family-base:        $font-family-sans-serif;
 $font-size-root: 14px;
 $line-height-base: 1.42857;
 
+$text-muted: $gray-500;
+$blockquote-small-color: $gray-500;
+
 //== Components
 //
 $border-radius:               .15rem;
@@ -43,16 +46,30 @@ $border-radius-sm:            .1rem;
 $border-radius-lg:            .25rem;
 $border-radius-xl:            .35rem;
 
+// Buttons
+//
+// For each of Bootstrap's buttons, define text, background, and border color.
+$btn-link-disabled-color: $gray-500;
 
 //== Forms
 //
+$input-border-color: $gray-300;
+
 $input-border-radius: $border-radius-sm;
 $input-border-radius-sm: $border-radius-sm;
 $input-border-radius-lg: $border-radius;
 
+$input-placeholder-color: $gray-500;
+
+$custom-control-indicator-border-color: $gray-400;
+$custom-control-label-disabled-color: $gray-500;
+$custom-select-disabled-color: $gray-500;
+$custom-range-thumb-disabled-bg: $gray-400;
+
 //== Navs
 $nav-link-padding-y: 0.75rem;
 $nav-link-padding-x: 1rem;
+$nav-link-disabled-color: $gray-500;
 
 //== Navbar
 $navbar-padding-y: 0;
@@ -61,11 +78,19 @@ $navbar-nav-link-padding-x: 1rem;
 
 //== Dropdowns
 $dropdown-border-radius: $border-radius-sm;
+$dropdown-link-disabled-color: $gray-500;
+$dropdown-header-color: $gray-500;
 
-//== card
+//== Pagination
+$pagination-disabled-color: $gray-500;
+
+//== Cards
 $card-spacer-y: 7px;
 $card-spacer-x: 15px;
 
+//== Toasts
+$toast-header-color: $gray-500;
+
 //== Modals
 $modal-content-border-width: 0;
 $modal-header-padding-y: 0.75rem;
@@ -82,6 +107,16 @@ $progress-border-radius: $border-radius-sm;
 $progress-bg: $gray-100;
 $progress-box-shadow: none;
 
+//== List group
+$list-group-disabled-color: $gray-500;
+
+//==  Figures
+$figure-caption-color: $gray-500;
+
+//==  Breadcrumbs
+$breadcrumb-divider-color: $gray-500;
+$breadcrumb-active-color: $gray-500;
+
 //== Code
 $pre-color: dummyinvalildcolor; // disable pre color specification with invalid value
 

+ 15 - 5
src/client/styles/scss/theme/_apply-colors-dark.scss

@@ -51,8 +51,8 @@ textarea.form-control {
 }
 
 .input-group > .input-group-prepend > .input-group-text {
-  color: color-yiq(theme-color('dark'));
-  background-color: theme-color('dark');
+  color: theme-color('light');
+  background-color: theme-color('secondary');
   border: 1px solid theme-color('secondary');
   border-right: none;
 }
@@ -64,6 +64,13 @@ textarea.form-control {
   background-color: $bgcolor-global;
 }
 
+.dropdown-item {
+  &:hover {
+    color: $light;
+    background-color: lighten($bgcolor-global, 15%);
+  }
+}
+
 /*
  * Table
  */
@@ -226,9 +233,12 @@ ul.pagination {
 /*
  * GROWI on-edit
  */
-.grw-editor-navbar-bottom {
-  #slack-mark-black {
-    display: none;
+body.on-edit {
+  .grw-editor-navbar-bottom {
+    background-color: $bgcolor-global;
+    #slack-mark-black {
+      display: none;
+    }
   }
 }
 

+ 10 - 0
src/client/styles/scss/theme/_apply-colors-light.scss

@@ -165,6 +165,16 @@ $table-hover-bg: $bgcolor-table-hover;
   #slack-mark-white {
     display: none;
   }
+
+  .input-group-text {
+    margin-right: 1px;
+    color: $secondary;
+    border-color: $light;
+  }
+
+  .btn.btn-outline-secondary {
+    border-color: $border-color;
+  }
 }
 
 /*

+ 25 - 3
src/client/styles/scss/theme/_apply-colors.scss

@@ -81,13 +81,25 @@ pre:not(.hljs):not(.CodeMirror-line) {
   color: $color-global;
 }
 
+.grw-personal-dropdown {
+  .grw-icon-container svg {
+    fill: $color-global;
+  }
+  .grw-icon-container-muted svg {
+    fill: $secondary;
+  }
+}
+
 .dropdown-item {
   color: $color-global;
-  &.active,
-  &:active {
+  &:active,
+  &.active {
     color: $color-dropdown-link-active;
     background-color: $bgcolor-dropdown-link-active;
   }
+  &:hover {
+    background-color: $light;
+  }
 }
 
 // Form
@@ -361,7 +373,7 @@ body.on-edit {
   }
 
   .grw-editor-navbar-bottom {
-    background-color: darken($bgcolor-global, 2%);
+    background-color: $gray-50;
   }
 }
 
@@ -455,3 +467,13 @@ mark.rbt-highlight-text {
 .bg-box {
   background-color: $bgcolor-global;
 }
+
+.grw-fab {
+  .btn-create-page {
+    fill: color-yiq($primary);
+  }
+
+  .btn-scroll-to-top {
+    fill: $gray-900;
+  }
+}

+ 6 - 2
src/client/styles/scss/theme/_reboot-bootstrap-theme-colors.scss

@@ -19,10 +19,14 @@ $theme-colors: map-merge($theme-colors, $colors);
     @include button-variant($value, $value);
   }
 }
-
 @each $color, $value in $theme-colors {
   .btn-outline-#{$color} {
-    @include button-outline-variant($value);
+    @include button-outline-variant($value, $color-hover: $value, $active-background: rgba($value, 0.1), $active-border: $value);
+    &:not(:disabled):not(.disabled):active,
+    &:not(:disabled):not(.disabled).active,
+    .show > &.dropdown-toggle {
+      color: $value;
+    }
   }
 }
 

+ 1 - 1
src/server/middlewares/apiv3-form-validator.js

@@ -1,5 +1,5 @@
 const logger = require('@alias/logger')('growi:middlewares:ApiV3FormValidator');
-const { validationResult } = require('express-validator/check');
+const { validationResult } = require('express-validator');
 
 const ErrorV3 = require('../models/vo/error-apiv3');
 

+ 2 - 2
src/server/middlewares/certify-shared-page.js

@@ -5,8 +5,8 @@ const logger = loggerFactory('growi:middleware:certify-shared-page');
 module.exports = (crowi) => {
 
   return async(req, res, next) => {
-    const pageId = req.query.page_id || req.body.page_id || null;
-    const shareLinkId = req.query.share_link_id || req.body.share_link_id || null;
+    const pageId = req.query.pageId || req.body.pageId || null;
+    const shareLinkId = req.query.shareLinkId || req.body.shareLinkId || null;
     if (pageId == null || shareLinkId == null) {
       return next();
     }

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

@@ -1,6 +1,8 @@
 module.exports = {
   Config: require('./config'),
   Page: require('./page'),
+  // TODO GW-2746 bulk export pages
+  // PageArchive: require('./page-archive'),
   PageTagRelation: require('./page-tag-relation'),
   User: require('./user'),
   ExternalAccount: require('./external-account'),

+ 22 - 0
src/server/models/page-archive.js

@@ -0,0 +1,22 @@
+module.exports = function(crowi) {
+  const mongoose = require('mongoose');
+  const ObjectId = mongoose.Schema.Types.ObjectId;
+
+  const pageArchiveSchema = new mongoose.Schema({
+    owner: {
+      type: ObjectId,
+      ref: 'User',
+      index: true,
+      required: true,
+    },
+    rootPagePath: { type: String, required: true },
+    fileType: { type: String, enum: ['pdf', 'markdown'], required: true },
+    numOfPages: { type: Number, required: true },
+    hasComment: { type: Boolean, required: true },
+    hasAttachment: { type: Boolean, required: true },
+  }, {
+    timestamps: true,
+  });
+
+  return mongoose.model('PageArchive', pageArchiveSchema);
+};

+ 3 - 28
src/server/models/user.js

@@ -20,7 +20,7 @@ module.exports = function(crowi) {
   const STATUS_DELETED = 4;
   const STATUS_INVITED = 5;
   const USER_PUBLIC_FIELDS = '_id image isEmailPublished isGravatarEnabled googleId name username email introduction'
-  + 'status lang createdAt lastLoginAt admin imageUrlCached';
+  + ' status lang createdAt lastLoginAt admin imageUrlCached';
 
   const PAGE_ITEMS = 50;
 
@@ -384,7 +384,7 @@ module.exports = function(crowi) {
     option = option || {};
 
     const sort = option.sort || { createdAt: -1 };
-    const fields = option.fields || USER_PUBLIC_FIELDS;
+    const fields = option.fields || {};
 
     let status = option.status || [STATUS_ACTIVE, STATUS_SUSPENDED];
     if (!Array.isArray(status)) {
@@ -403,7 +403,7 @@ module.exports = function(crowi) {
 
     const sort = option.sort || { createdAt: -1 };
     const status = option.status || STATUS_ACTIVE;
-    const fields = option.fields || USER_PUBLIC_FIELDS;
+    const fields = option.fields || {};
 
     return this.find({ _id: { $in: ids }, status })
       .select(fields)
@@ -414,31 +414,6 @@ module.exports = function(crowi) {
     return this.find({ admin: true });
   };
 
-  userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {
-    const status = options.status || null;
-    const emailPartRegExp = new RegExp(emailPart.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'));
-    const User = this;
-
-    return new Promise((resolve, reject) => {
-      const query = User.find({ email: emailPartRegExp }, USER_PUBLIC_FIELDS);
-
-      if (status) {
-        query.and({ status });
-      }
-
-      query
-        .limit(PAGE_ITEMS + 1)
-        .exec((err, userData) => {
-          if (err) {
-            return reject(err);
-          }
-
-          return resolve(userData);
-        });
-    });
-  };
-
-
   userSchema.statics.findUserByUsername = function(username) {
     if (username == null) {
       return Promise.resolve(null);

+ 1 - 18
src/server/routes/admin.js

@@ -22,7 +22,7 @@ module.exports = function(crowi, app) {
   const MAX_PAGE_LIST = 50;
   const actions = {};
 
-  const { check } = require('express-validator/check');
+  const { check } = require('express-validator');
 
   const api = {};
 
@@ -337,23 +337,6 @@ module.exports = function(crowi, app) {
 
   actions.api = {};
 
-  // app.get('/_api/admin/users.search' , admin.api.userSearch);
-  actions.api.usersSearch = function(req, res) {
-    const User = crowi.model('User');
-    const email = req.query.email;
-
-    User.findUsersByPartOfEmail(email, {})
-      .then((users) => {
-        const result = {
-          data: users,
-        };
-        return res.json(ApiResponse.success(result));
-      })
-      .catch((err) => {
-        return res.json(ApiResponse.error());
-      });
-  };
-
   /**
    * save esa settings, update config cache, and response json
    *

+ 56 - 10
src/server/routes/apiv3/app-settings.js

@@ -10,7 +10,7 @@ const { listLocaleIds } = require('@commons/util/locale-utils');
 
 const router = express.Router();
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
@@ -50,13 +50,17 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          envSiteUrl:
  *            type: string
  *            description: environment variable 'APP_SITE_URL'
- *      MailSettingParams:
+ *      FromAddress:
  *        description: MailSettingParams
  *        type: object
  *        properties:
  *          fromAddress:
  *            type: string
  *            description: e-mail address used as from address of mail which sent from GROWI app
+ *      MailSettingParams:
+ *        description: MailSettingParams
+ *        type: object
+ *        properties:
  *          smtpHost:
  *            type: string
  *            description: host name of client's smtp server
@@ -115,16 +119,18 @@ module.exports = (crowi) => {
     siteUrlSetting: [
       body('siteUrl').trim().matches(/^(https?:\/\/[^/]+|)$/).isURL({ require_tld: false }),
     ],
+    fromAddress: [
+      body('fromAddress').trim().if(value => value !== '').isEmail(),
+    ],
     mailSetting: [
-      body('fromAddress').trim().isEmail(),
       body('smtpHost').trim(),
       body('smtpPort').trim().isPort(),
       body('smtpUser').trim(),
       body('smtpPassword').trim(),
     ],
     awsSetting: [
-      body('region').trim().matches(/^[a-z]+-[a-z]+-\d+$/).withMessage('リージョンには、AWSリージョン名を入力してください。 例: ap-northeast-1'),
-      body('customEndpoint').trim().matches(/^(https?:\/\/[^/]+|)$/).withMessage('カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。'),
+      body('region').trim().matches(/^[a-z]+-[a-z]+-\d+$/).withMessage((value, { req }) => req.t('validation.aws_region')),
+      body('customEndpoint').trim().matches(/^(https?:\/\/[^/]+|)$/).withMessage((value, { req }) => req.t('validation.aws_custom_endpoint')),
       body('bucket').trim(),
       body('accessKeyId').trim().matches(/^[\da-zA-Z]+$/),
       body('secretAccessKey').trim(),
@@ -292,7 +298,12 @@ module.exports = (crowi) => {
    * validate mail setting send test mail
    */
   async function validateMailSetting(req) {
-    const { mailService } = crowi;
+    const { configManager, mailService } = crowi;
+    const fromAddress = configManager.getConfig('crowi', 'mail:from');
+    if (fromAddress == null) {
+      throw Error('fromAddress is not setup');
+    }
+
     const option = {
       host: req.body.smtpHost,
       port: req.body.smtpPort,
@@ -311,7 +322,7 @@ module.exports = (crowi) => {
     debug('mailer setup for validate SMTP setting', smtpClient);
 
     const mailOptions = {
-      from: req.body.fromAddress,
+      from: fromAddress,
       to: req.user.email,
       subject: 'Wiki管理設定のアップデートによるメール通知',
       text: 'このメールは、WikiのSMTP設定のアップデートにより送信されています。',
@@ -333,7 +344,6 @@ module.exports = (crowi) => {
     mailService.publishUpdatedMessage();
 
     return {
-      fromAddress: configManager.getConfig('crowi', 'mail:from'),
       smtpHost: configManager.getConfig('crowi', 'mail:smtpHost'),
       smtpPort: configManager.getConfig('crowi', 'mail:smtpPort'),
       smtpUser: configManager.getConfig('crowi', 'mail:smtpUser'),
@@ -341,6 +351,44 @@ module.exports = (crowi) => {
     };
   };
 
+  /**
+   * @swagger
+   *
+   *    /app-settings/from-address:
+   *      put:
+   *        tags: [AppSettings]
+   *        operationId: updateAppSettingFromAddress
+   *        summary: /app-settings/from-address
+   *        description: Update from address
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/FromAddress'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update from adress
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/FromAddress'
+   */
+  router.put('/from-address', loginRequiredStrictly, adminRequired, csrf, validator.fromAddress, apiV3FormValidator, async(req, res) => {
+
+    try {
+      const mailSettingParams = await updateMailSettinConfig({ 'mail:from': req.body.fromAddress });
+
+      return res.apiv3({ mailSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating from adress';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-from-adress-failed'));
+    }
+
+  });
+
   /**
    * @swagger
    *
@@ -377,7 +425,6 @@ module.exports = (crowi) => {
 
 
     const requestMailSettingParams = {
-      'mail:from': req.body.fromAddress,
       'mail:smtpHost': req.body.smtpHost,
       'mail:smtpPort': req.body.smtpPort,
       'mail:smtpUser': req.body.smtpUser,
@@ -420,7 +467,6 @@ module.exports = (crowi) => {
    */
   router.delete('/mail-setting', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
     const requestMailSettingParams = {
-      'mail:from': null,
       'mail:smtpHost': null,
       'mail:smtpPort': null,
       'mail:smtpUser': null,

+ 1 - 1
src/server/routes/apiv3/markdown-setting.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 

+ 1 - 1
src/server/routes/apiv3/notification-setting.js

@@ -7,7 +7,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const removeNullPropertyFromObject = require('../../../lib/util/removeNullPropertyFromObject');

+ 90 - 0
src/server/routes/apiv3/page.js

@@ -129,6 +129,15 @@ module.exports = (crowi) => {
       query('format').isString().isIn(['md', 'pdf']),
       query('revisionId').isString(),
     ],
+    archive: [
+      body('rootPagePath').isString(),
+      body('isCommentDownload').isBoolean(),
+      body('isAttachmentFileDownload').isBoolean(),
+      body('isSubordinatedPageDownload').isBoolean(),
+      body('fileType').isString().isIn(['pdf', 'markdown']),
+      body('hierarchyType').isString().isIn(['allSubordinatedPage', 'decideHierarchy']),
+      body('hierarchyValue').isNumeric(),
+    ],
   };
 
   /**
@@ -244,5 +253,86 @@ module.exports = (crowi) => {
     return stream.pipe(res);
   });
 
+  // TODO GW-2746 bulk export pages
+  // /**
+  //  * @swagger
+  //  *
+  //  *    /page/archive:
+  //  *      post:
+  //  *        tags: [Page]
+  //  *        summary: /page/archive
+  //  *        description: create page archive
+  //  *        requestBody:
+  //  *          content:
+  //  *            application/json:
+  //  *              schema:
+  //  *                properties:
+  //  *                  rootPagePath:
+  //  *                    type: string
+  //  *                    description: path of the root page
+  //  *                  isCommentDownload:
+  //  *                    type: boolean
+  //  *                    description: whether archive data contains comments
+  //  *                  isAttachmentFileDownload:
+  //  *                    type: boolean
+  //  *                    description: whether archive data contains attachments
+  //  *                  isSubordinatedPageDownload:
+  //  *                    type: boolean
+  //  *                    description: whether archive data children pages
+  //  *                  fileType:
+  //  *                    type: string
+  //  *                    description: file type of archive data(.md, .pdf)
+  //  *                  hierarchyType:
+  //  *                    type: string
+  //  *                    description: method of select children pages archive data contains('allSubordinatedPage', 'decideHierarchy')
+  //  *                  hierarchyValue:
+  //  *                    type: number
+  //  *                    description: depth of hierarchy(use when hierarchyType is 'decideHierarchy')
+  //  *        responses:
+  //  *          200:
+  //  *            description: create page archive
+  //  *            content:
+  //  *              application/json:
+  //  *                schema:
+  //  *                  $ref: '#/components/schemas/Page'
+  //  */
+  // router.post('/archive', accessTokenParser, loginRequired, csrf, validator.archive, apiV3FormValidator, async(req, res) => {
+  //   const PageArchive = crowi.model('PageArchive');
+
+  //   const {
+  //     rootPagePath,
+  //     isCommentDownload,
+  //     isAttachmentFileDownload,
+  //     fileType,
+  //   } = req.body;
+  //   const owner = req.user._id;
+
+  //   const numOfPages = 1; // TODO 最終的にzipファイルに取り込むページ数を入れる
+
+  //   const createdPageArchive = PageArchive.create({
+  //     owner,
+  //     fileType,
+  //     rootPagePath,
+  //     numOfPages,
+  //     hasComment: isCommentDownload,
+  //     hasAttachment: isAttachmentFileDownload,
+  //   });
+
+  //   console.log(createdPageArchive);
+  //   return res.apiv3({ });
+
+  // });
+
+  // router.get('/count-children-pages', accessTokenParser, loginRequired, async(req, res) => {
+
+  //   // TO DO implement correct number at another task
+
+  //   const { pageId } = req.query;
+  //   console.log(pageId);
+
+  //   const dummy = 6;
+  //   return res.apiv3({ dummy });
+  // });
+
   return router;
 };

+ 51 - 3
src/server/routes/apiv3/personal-setting.js

@@ -11,7 +11,7 @@ const { listLocaleIds } = require('@commons/util/locale-utils');
 
 const router = express.Router();
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 /**
@@ -123,8 +123,56 @@ module.exports = (crowi) => {
    *                      description: personal params
    */
   router.get('/', accessTokenParser, loginRequiredStrictly, async(req, res) => {
-    const currentUser = await User.findUserByUsername(req.user.username);
-    return res.apiv3({ currentUser });
+    const { username } = req.user;
+
+    try {
+      const user = await User.findUserByUsername(username);
+
+      // return email whether it's private
+      const { email } = user;
+      const currentUser = user.toObject();
+      currentUser.email = email;
+
+      return res.apiv3({ currentUser });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('update-personal-settings-failed');
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /personal-setting/is-password-set:
+   *      get:
+   *        tags: [PersonalSetting]
+   *        operationId: getIsPasswordSet
+   *        summary: /personal-setting
+   *        description: Get whether a password has been set
+   *        responses:
+   *          200:
+   *            description: Whether a password has been set
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    isPasswordSet:
+   *                      type: boolean
+   */
+  router.get('/is-password-set', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+    const { username } = req.user;
+
+    try {
+      const user = await User.findUserByUsername(username);
+      const isPasswordSet = user.isPasswordSet();
+      return res.apiv3({ isPasswordSet });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err('fail-to-get-whether-password-is-set');
+    }
+
   });
 
   /**

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

@@ -4,7 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:pages');
 
 const express = require('express');
 
-const { query, param } = require('express-validator/check');
+const { query, param } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 const router = express.Router();

+ 1 - 1
src/server/routes/apiv3/security-setting.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const removeNullPropertyFromObject = require('../../../lib/util/removeNullPropertyFromObject');
 

+ 1 - 1
src/server/routes/apiv3/share-links.js

@@ -8,7 +8,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body } = require('express-validator/check');
+const { body } = require('express-validator');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 

+ 2 - 2
src/server/routes/apiv3/user-group.js

@@ -6,8 +6,8 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body, param, query } = require('express-validator/check');
-const { sanitizeQuery } = require('express-validator/filter');
+const { body, param, query } = require('express-validator');
+const { sanitizeQuery } = require('express-validator');
 
 const mongoose = require('mongoose');
 

+ 3 - 2
src/server/routes/apiv3/users.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body, query } = require('express-validator/check');
+const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
@@ -119,7 +119,7 @@ module.exports = (crowi) => {
    *            description: page number
    *            schema:
    *              type: number
-   *          - name:  selectedStatusList
+   *          - name: selectedStatusList
    *            in: query
    *            description: status list
    *            schema:
@@ -184,6 +184,7 @@ module.exports = (crowi) => {
           sort: sortOutput,
           page,
           limit: PAGE_ITEMS,
+          select: User.USER_PUBLIC_FIELDS,
         },
       );
       return res.apiv3({ paginateResult });

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

@@ -228,7 +228,10 @@ module.exports = function(crowi, app) {
     }
     // reference
     else {
-      res.set('Content-Type', attachment.fileFormat);
+      res.set({
+        'Content-Type': attachment.fileFormat,
+        'Content-Security-Policy': "script-src 'unsafe-hashes'",
+      });
     }
   }
 

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

@@ -49,7 +49,7 @@ module.exports = function(crowi, app) {
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ApiResponse = require('../util/apiResponse');
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { body } = require('express-validator/check');
+  const { body } = require('express-validator');
   const mongoose = require('mongoose');
   const ObjectId = mongoose.Types.ObjectId;
 
@@ -208,7 +208,7 @@ module.exports = function(crowi, app) {
    */
   api.add = async function(req, res) {
     const { commentForm, slackNotificationForm } = req.body;
-    const { validationResult } = require('express-validator/check');
+    const { validationResult } = require('express-validator');
 
     const errors = validationResult(req.body);
     if (!errors.isEmpty()) {

+ 0 - 1
src/server/routes/index.js

@@ -89,7 +89,6 @@ module.exports = function(crowi, app) {
   app.get('/admin/notification'              , loginRequiredStrictly , adminRequired , admin.notification.index);
   app.get('/admin/notification/slackAuth'    , loginRequiredStrictly , adminRequired , admin.notification.slackAuth);
   app.get('/admin/notification/slackSetting/disconnect', loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
-  app.get('/_api/admin/users.search'         , loginRequiredStrictly , adminRequired , admin.api.usersSearch);
   app.get('/admin/global-notification/new'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
   app.get('/admin/global-notification/:id'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
 

+ 16 - 8
src/server/service/export.js

@@ -50,11 +50,17 @@ class ExportService {
    * @return {object} info for zip files and whether currentProgressingStatus exists
    */
   async getStatus() {
-    const zipFiles = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' });
-    const zipFileStats = await Promise.all(zipFiles.map((file) => {
+    const zipFiles = fs.readdirSync(this.baseDir).filter(file => path.extname(file) === '.zip');
+
+    // process serially so as not to waste memory
+    const zipFileStats = [];
+    const parseZipFilePromises = zipFiles.map((file) => {
       const zipFile = this.getFile(file);
       return this.growiBridgeService.parseZipFile(zipFile);
-    }));
+    });
+    for await (const stat of parseZipFilePromises) {
+      zipFileStats.push(stat);
+    }
 
     // filter null object (broken zip)
     const filtered = zipFileStats.filter(element => element != null);
@@ -166,9 +172,7 @@ class ExportService {
     const collection = mongoose.connection.collection(collectionName);
 
     const nativeCursor = collection.find();
-    const readStream = nativeCursor
-      .snapshot()
-      .stream({ transform: JSON.stringify });
+    const readStream = nativeCursor.stream({ transform: JSON.stringify });
 
     // get TransformStream
     const transformStream = this.generateTransformStream();
@@ -201,8 +205,12 @@ class ExportService {
   async exportCollectionsToZippedJson(collections) {
     const metaJson = await this.createMetaJson();
 
-    const promises = collections.map(collectionName => this.exportCollectionToJson(collectionName));
-    const jsonFiles = await Promise.all(promises);
+    // process serially so as not to waste memory
+    const jsonFiles = [];
+    const jsonFilesPromises = collections.map(collectionName => this.exportCollectionToJson(collectionName));
+    for await (const jsonFile of jsonFilesPromises) {
+      jsonFiles.push(jsonFile);
+    }
 
     // send terminate event
     this.emitStartZippingEvent();

+ 26 - 18
src/server/service/import.js

@@ -128,10 +128,16 @@ class ImportService {
    */
   async getStatus() {
     const zipFiles = fs.readdirSync(this.baseDir).filter(file => path.extname(file) === '.zip');
-    const zipFileStats = await Promise.all(zipFiles.map((file) => {
+
+    // process serially so as not to waste memory
+    const zipFileStats = [];
+    const parseZipFilePromises = zipFiles.map((file) => {
       const zipFile = this.getFile(file);
       return this.growiBridgeService.parseZipFile(zipFile);
-    }));
+    });
+    for await (const stat of parseZipFilePromises) {
+      zipFileStats.push(stat);
+    }
 
     // filter null object (broken zip)
     const filtered = zipFileStats
@@ -158,23 +164,25 @@ class ImportService {
     // init status object
     this.currentProgressingStatus = new CollectionProgressingStatus(collections);
 
-    try {
-      const promises = collections.map((collectionName) => {
-        const importSettings = importSettingsMap[collectionName];
-        return this.importCollection(collectionName, importSettings);
-      });
-      await Promise.all(promises);
-    }
-    // catch ImportingCollectionError
-    catch (err) {
-      const { collectionProgress } = err;
-      logger.error(`failed to import to ${collectionProgress.collectionName}`, err);
-      this.emitProgressEvent(collectionProgress, { message: err.message });
-    }
-    finally {
-      this.currentProgressingStatus = null;
-      this.emitTerminateEvent();
+    // process serially so as not to waste memory
+    const promises = collections.map((collectionName) => {
+      const importSettings = importSettingsMap[collectionName];
+      return this.importCollection(collectionName, importSettings);
+    });
+    for await (const promise of promises) {
+      try {
+        await promise;
+      }
+      // catch ImportingCollectionError
+      catch (err) {
+        const { collectionProgress } = err;
+        logger.error(`failed to import to ${collectionProgress.collectionName}`, err);
+        this.emitProgressEvent(collectionProgress, { message: err.message });
+      }
     }
+
+    this.currentProgressingStatus = null;
+    this.emitTerminateEvent();
   }
 
   /**

+ 0 - 1
src/server/service/search-delegator/elasticsearch.js

@@ -378,7 +378,6 @@ class ElasticsearchDelegator {
         { path: 'creator', model: 'User', select: 'username' },
         { path: 'revision', model: 'Revision', select: 'body' },
       ])
-      .snapshot()
       .lean()
       .cursor();
 

+ 0 - 7
src/test/models/user.test.js

@@ -42,13 +42,6 @@ describe('User', () => {
         expect(user.name).toBe('Example for User Test');
       });
 
-      test('should be found by findUsersByPartOfEmail', async() => {
-        const users = await User.findUsersByPartOfEmail('usert', {});
-        expect(users).toBeInstanceOf(Array);
-        expect(users.length).toBe(2);
-        expect(users[0]).toBeInstanceOf(User);
-        expect(users[1]).toBeInstanceOf(User);
-      });
     });
   });
 

+ 21 - 0
src/test/util/path-utils.test.js

@@ -0,0 +1,21 @@
+const { isTopPage } = require('../../lib/util/path-utils');
+
+
+describe('TopPage Path test', () => {
+  test('Path is only "/"', () => {
+    const result = isTopPage('/');
+    expect(result).toBe(true);
+  });
+  test('Path is not match string ', () => {
+    const result = isTopPage('/test');
+    expect(result).toBe(false);
+  });
+  test('Path is integer', () => {
+    const result = isTopPage(1);
+    expect(result).toBe(false);
+  });
+  test('Path is null', () => {
+    const result = isTopPage(null);
+    expect(result).toBe(false);
+  });
+});

Разница между файлами не показана из-за своего большого размера
+ 396 - 215
yarn.lock


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