فهرست منبع

Merge pull request #2755 from weseek/master

release v4.1.4
Yuki Takei 5 سال پیش
والد
کامیت
f058673761
47فایلهای تغییر یافته به همراه1249 افزوده شده و 477 حذف شده
  1. 7 1
      CHANGES.md
  2. 1 1
      package.json
  3. 8 7
      resource/locales/en_US/admin/admin.json
  4. 7 1
      resource/locales/en_US/translation.json
  5. 8 7
      resource/locales/ja_JP/admin/admin.json
  6. 7 1
      resource/locales/ja_JP/translation.json
  7. 11 9
      resource/locales/zh_CN/admin/admin.json
  8. 18 2
      resource/locales/zh_CN/translation.json
  9. 1 1
      src/client/js/app.jsx
  10. 0 3
      src/client/js/components/Admin/App/AwsSetting.jsx
  11. 70 169
      src/client/js/components/Admin/App/MailSetting.jsx
  12. 68 0
      src/client/js/components/Admin/App/SesSetting.jsx
  13. 89 0
      src/client/js/components/Admin/App/SmtpSetting.jsx
  14. 244 0
      src/client/js/components/ArchiveCreateModal.jsx
  15. 1 1
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  16. 46 0
      src/client/js/components/Page/PageShareManagement.jsx
  17. 4 0
      src/client/js/components/PageDuplicateModal.jsx
  18. 3 3
      src/client/js/components/PageEditor/OptionsSelector.jsx
  19. 2 0
      src/client/js/components/PageRenameModal.jsx
  20. 15 21
      src/client/js/components/RecentCreated/RecentCreated.jsx
  21. 58 38
      src/client/js/services/AdminAppContainer.js
  22. 15 5
      src/client/styles/scss/theme/_apply-colors-dark.scss
  23. 10 0
      src/client/styles/scss/theme/_apply-colors-light.scss
  24. 6 3
      src/client/styles/scss/theme/_apply-colors.scss
  25. 6 2
      src/client/styles/scss/theme/_reboot-bootstrap-theme-colors.scss
  26. 68 0
      src/migrations/20200828024025-copy-aws-setting.js
  27. 50 0
      src/migrations/20200901034313-update-mail-transmission.js
  28. 1 1
      src/server/middlewares/apiv3-form-validator.js
  29. 2 0
      src/server/models/index.js
  30. 22 0
      src/server/models/page-archive.js
  31. 1 1
      src/server/routes/admin.js
  32. 109 88
      src/server/routes/apiv3/app-settings.js
  33. 1 1
      src/server/routes/apiv3/markdown-setting.js
  34. 1 1
      src/server/routes/apiv3/notification-setting.js
  35. 90 0
      src/server/routes/apiv3/page.js
  36. 6 2
      src/server/routes/apiv3/personal-setting.js
  37. 41 1
      src/server/routes/apiv3/revisions.js
  38. 1 1
      src/server/routes/apiv3/security-setting.js
  39. 1 1
      src/server/routes/apiv3/share-links.js
  40. 2 2
      src/server/routes/apiv3/user-group.js
  41. 72 1
      src/server/routes/apiv3/users.js
  42. 2 2
      src/server/routes/comment.js
  43. 0 1
      src/server/routes/index.js
  44. 13 89
      src/server/routes/page.js
  45. 32 8
      src/server/service/mail.js
  46. 8 2
      src/test/models/shareLink.test.js
  47. 21 0
      src/test/util/path-utils.test.js

+ 7 - 1
CHANGES.md

@@ -1,6 +1,12 @@
 # CHANGES
 
-## v4.1.3-RC
+## v4.1.4-RC
+
+* Feature: Independent S3 configuration and SES configuration for AWS
+* Fix: Author name does not displayed in page history
+* Fix: Hide unnecessary component when pringing
+
+## v4.1.3
 
 * Feature: Create/edit linker with GUI
 * Improvement: Paging page histories

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.3-RC",
+  "version": "4.1.4-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 8 - 7
resource/locales/en_US/admin/admin.json

@@ -27,20 +27,21 @@
     "attach_enable": "You can attach files other than image files if you enable this option.",
     "update": "Update",
     "mail_settings": "E-mail Settings",
-    "smtp_used": "If you have SMTP settings, it will be used.",
-    "smtp_but_aws": "If you do not have SMTP settings but AWS settings,  e-mails will be sent by SES.",
-    "neihter_of": "If neither is selected, then no email will be sent.",
+    "mailer_is_not_set_up": "E-mail setting is not set up.",
     "from_e-mail_address": "From e-mail address",
+    "transmission_method":"Transmission Method",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+    "send_test_email": "Send a test-email",
+    "success_to_send_test_email": "Success to send a test-email",
     "smtp_settings": "SMTP settings",
     "host": "Host",
     "port": "Port",
     "user": "User",
-    "initialize_mail_settings": "initialize e-mail settings",
-    "initialize_mail_modal_header": "Initialize e-mail settings",
-    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
+    "ses_settings":"SES settings",
+    "test_connection": "Test connection to mail",
     "aws_settings": "AWS settings",
     "aws_access": "This is for AWS settings. If you complete AWS settings, file upload function, profile picture function etc will be enabled.",
-    "no_smtp_setting": "If you do not have SMTP settings, e-mails will be sent via SES. You need to verify from e-mail address and production settings.",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
     "region": "Region",
     "bucket_name": "Bucket name",

+ 7 - 1
resource/locales/en_US/translation.json

@@ -50,9 +50,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",
@@ -718,6 +722,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"
   },
@@ -747,6 +752,7 @@
   },
   "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."
+    "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",
+    "failed_to_send_a_test_email":"Failed to send a test email using SMTP. Please check your settings."
   }
 }

+ 8 - 7
resource/locales/ja_JP/admin/admin.json

@@ -27,20 +27,21 @@
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "update": "更新",
     "mail_settings": "メールの設定",
-    "smtp_used": "SMTPの設定がされている場合、それが利用されます。",
-    "smtp_but_aws": "SMTP設定がなく、AWSの設定がある場合、SESでの送信を試みます。",
-    "neihter_of": "どちらの設定もない場合、メールは送信されません。",
+    "mailer_is_not_set_up": "メール設定がセットアップされていません。",
     "from_e-mail_address": "Fromアドレス",
+    "transmission_method":"送信方法",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+    "send_test_email": "テストメールを送信",
+    "success_to_send_test_email": "テストメールを送信しました。",
     "smtp_settings": "SMTP設定",
     "host": "ホスト",
     "port": "ポート",
     "user": "ユーザー",
-    "initialize_mail_settings": "設定を初期化",
-    "initialize_mail_modal_header": "メール設定の初期化",
-    "confirm_to_initialize_mail_settings": "一度初期化した設定は戻せません。本当に初期化しますか?",
+    "ses_settings":"SES設定",
+    "test_connection": "接続テスト",
     "aws_settings": "AWS設定",
     "aws_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
-    "no_smtp_setting": "また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
     "bucket_name": "バケット名",

+ 7 - 1
resource/locales/ja_JP/translation.json

@@ -50,10 +50,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": "最終更新",
@@ -711,6 +715,7 @@
   },
   "export_bulk": {
     "failed_to_export": "ページのエクスポートに失敗しました",
+    "failed_to_count_pages": "ページ数の取得に失敗しました",
     "export_page_markdown": "マークダウン形式でページをエクスポート",
     "export_page_pdf": "PDF形式でページをエクスポート"
   },
@@ -740,6 +745,7 @@
   },
   "validation":{
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
-    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。"
+    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",
+    "failed_to_send_a_test_email":"SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。"
   }
 }

+ 11 - 9
resource/locales/zh_CN/admin/admin.json

@@ -27,20 +27,22 @@
 		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
 		"update": "更新",
 		"mail_settings": "邮件设置",
-		"smtp_used": "如果您有SMTP设置,将使用它。",
-		"smtp_but_aws": "如果您没有SMTP设置,但有AWS设置,则电子邮件将由SES发送。",
-		"neihter_of": "如果两者都未选中,则不会发送电子邮件。",
-		"from_e-mail_address": "From e-mail address",
-		"smtp_settings": "SMTP 设置",
+    "mailer_is_not_set_up": "邮件设置尚未完成。",
+    "transmission_method":"传送方法",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+		"from_e-mail_address": "邮件发出地址",
+    "send_test_email": "发送测试邮件",
+    "success_to_send_test_email": "成功发送了一封测试邮件",
+    "smtp_settings": "SMTP 设置",
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
-    "initialize_mail_settings": "重置邮件设置",
-    "initialize_mail_modal_header": "重置邮件设置",
-    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
+    "ses_settings":"SES设置",
+    "test_connection": "测试邮件服务器连接",
 		"aws_settings": "AWS设置",
 		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
-		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
+		"": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
 		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
 		"region": "Region",
 		"bucket_name": "Bucket name",

+ 18 - 2
resource/locales/zh_CN/translation.json

@@ -50,7 +50,16 @@
 	"History": "历史",
 	"Presentation Mode": "演示文稿",
   "Not available for guest": "Not available for guest",
-	"username": "用户名",
+  "Create Archive Page": "创建归档页",
+  "File type": "文件类型",
+  "Target page": "目标页面",
+  "Include Attachment File": "包含附件",
+  "Include Comment": "包含评论",
+  "Include Subordinated Page": "包括子页面",
+  "All Subordinated Page": "所有子页面",
+  "Specify Hierarchy": "指定层级",
+  "Submitted the request to create the archive": "提交创建归档请求",
+  "username": "用户名",
 	"Created": "创建",
 	"Last updated": "上次更新",
   "Last_Login": "上次登录",
@@ -715,6 +724,12 @@
 		"Registration successful": "注册成功",
 		"Setup": "安装程序"
 	},
+  "export_bulk": {
+    "failed_to_export": "导出失败",
+    "failed_to_count_pages": "页面计数失败",
+    "export_page_markdown": "以Markdown格式导出页面",
+    "export_page_pdf": "以PDF格式导出页面"
+  },
 	"message": {
 		"successfully_connected": "连接成功!",
 		"fail_to_save_access_token": "无法保存访问令牌。请再试一次。",
@@ -741,6 +756,7 @@
   },
   "validation":{
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
-    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”"
+    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”",
+    "failed_to_send_a_test_email":"SMTP方式测试邮件发送失败,请检查相关设定。"
   }
 }

+ 1 - 1
src/client/js/app.jsx

@@ -96,7 +96,7 @@ if (pageContainer.state.pageId != null) {
     'seen-user-list': <SeenUserList />,
     'liker-list': <LikerList />,
 
-    'user-created-list': <RecentCreated />,
+    'user-created-list': <RecentCreated userId={pageContainer.state.creator._id} />,
     'user-draft-list': <MyDraftList />,
   });
 }

+ 0 - 3
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -41,9 +41,6 @@ class AwsSetting extends React.Component {
         <p className="card well">
           {t('admin:app_setting.aws_access')}
           <br />
-          {t('admin:app_setting.no_smtp_setting')}
-          <br />
-          <br />
           <span className="text-danger">
             <i className="ti-unlink"></i>
             {t('admin:app_setting.change_setting')}

+ 70 - 169
src/client/js/components/Admin/App/MailSetting.jsx

@@ -1,204 +1,105 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import loggerFactory from '@alias/logger';
 
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
+import SmtpSetting from './SmtpSetting';
+import SesSetting from './SesSetting';
 
-const logger = loggerFactory('growi:appSettings');
-
-class MailSetting extends React.Component {
 
-  constructor(props) {
-    super(props);
+function MailSetting(props) {
+  const { t, adminAppContainer } = props;
 
-    this.state = {
-      isInitializeValueModalOpen: false,
-    };
+  const transmissionMethods = ['smtp', 'ses'];
 
-    this.emailInput = React.createRef();
-    this.hostInput = React.createRef();
-    this.portInput = React.createRef();
-    this.userInput = React.createRef();
-    this.passwordInput = React.createRef();
-
-    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);
-  }
-
-  openInitializeValueModal() {
-    this.setState({ isInitializeValueModalOpen: true });
-  }
-
-  closeInitializeValueModal() {
-    this.setState({ isInitializeValueModalOpen: false });
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
+  async function submitHandler() {
+    const { t } = props;
 
     try {
       await adminAppContainer.updateMailSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.ses_settings') }));
     }
     catch (err) {
       toastError(err);
-      logger.error(err);
     }
   }
 
-  async submitFromAdressHandler() {
-    const { t, adminAppContainer } = this.props;
-
+  async function sendTestEmailHandler() {
+    const { adminAppContainer } = props;
     try {
-      await adminAppContainer.updateFromAdressHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+      await adminAppContainer.sendTestEmail();
+      toastSuccess(t('admin:app_setting.success_to_send_test_email'));
     }
     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.smtp_settings') }));
-      // convert values to '' if value is null for overwriting values of inputs with refs
-      this.hostInput.current.value = mailSettingParams.smtpHost || '';
-      this.portInput.current.value = mailSettingParams.smtpPort || '';
-      this.userInput.current.value = mailSettingParams.smtpUser || '';
-      this.passwordInput.current.value = mailSettingParams.smtpPassword || '';
-      this.closeInitializeValueModal();
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <p className="card well">{t('admin:app_setting.smtp_used')} {t('admin:app_setting.smtp_but_aws')}<br />{t('admin:app_setting.neihter_of')}</p>
-        <div className="row form-group mb-5">
-          <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.from_e-mail_address')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              ref={this.emailInput}
-              placeholder={`${t('eg')} mail@growi.org`}
-              defaultValue={adminAppContainer.state.fromAddress || ''}
-              onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
-            />
-          </div>
+  return (
+    <React.Fragment>
+      {!adminAppContainer.state.isMailerSetup && (
+        <div className="alert alert-danger"><i className="icon-exclamation"></i> {t('admin:app_setting.mailer_is_not_set_up')}</div>
+      )}
+      <div className="row form-group mb-5">
+        <label className="col-md-3 col-form-label text-right">{t('admin:app_setting.from_e-mail_address')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            placeholder={`${t('eg')} mail@growi.org`}
+            defaultValue={adminAppContainer.state.fromAddress || ''}
+            onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
+          />
         </div>
-        <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">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.transmission_method')}
+        </label>
+        <div className="col-md-6">
+          {transmissionMethods.map((method) => {
+              return (
+                <div key={method} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    className="custom-control-input"
+                    name="transmission-method"
+                    id={`transmission-nethod-radio-${method}`}
+                    checked={adminAppContainer.state.transmissionMethod === method}
+                    onChange={(e) => {
+                    adminAppContainer.changeTransmissionMethod(method);
+                  }}
+                  />
+                  <label className="custom-control-label" htmlFor={`transmission-nethod-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
+                </div>
+              );
+            })}
         </div>
-        <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 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="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>
+
+      {adminAppContainer.state.transmissionMethod === 'smtp' && <SmtpSetting />}
+      {adminAppContainer.state.transmissionMethod === 'ses' && <SesSetting />}
+
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-primary" onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+            { t('Update') }
+          </button>
+          {adminAppContainer.state.transmissionMethod === 'smtp' && (
+          <button type="button" className="btn btn-secondary ml-4" onClick={sendTestEmailHandler}>
+            {t('admin:app_setting.send_test_email')}
+          </button>
+          )}
         </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')}
-          </ModalHeader>
-          <ModalBody>
-            <div className="text-center mb-4">
-              {t('admin:app_setting.confirm_to_initialize_mail_settings')}
-            </div>
-            <div className="text-center my-2">
-              <button type="button" className="btn btn-outline-secondary mr-4" onClick={this.closeInitializeValueModal}>
-                {t('Cancel')}
-              </button>
-              <button type="button" className="btn btn-danger" onClick={this.initialize}>
-                {t('Reset')}
-              </button>
-            </div>
-          </ModalBody>
-        </Modal>
-      </React.Fragment>
-    );
-  }
+      </div>
+    </React.Fragment>
+  );
 
 }
 

+ 68 - 0
src/client/js/components/Admin/App/SesSetting.jsx

@@ -0,0 +1,68 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+function SmtpSetting(props) {
+  const { adminAppContainer } = props;
+
+  return (
+    <React.Fragment>
+      <div id="mail-smtp" className="tab-pane active mt-5">
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            Access key ID
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.sesAccessKeyId || ''}
+              onChange={(e) => {
+                adminAppContainer.changeSesAccessKeyId(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            Secret access key
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.sesSecretAccessKey || ''}
+              onChange={(e) => {
+                adminAppContainer.changeSesSecretAccessKey(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+      </div>
+
+    </React.Fragment>
+  );
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
+
+SmtpSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SmtpSettingWrapper);

+ 89 - 0
src/client/js/components/Admin/App/SmtpSetting.jsx

@@ -0,0 +1,89 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+function SmtpSetting(props) {
+  const { adminAppContainer, t } = props;
+
+  return (
+    <React.Fragment>
+      <div id="mail-smtp" className="tab-pane active mt-5">
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.host')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpHost || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.port')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              defaultValue={adminAppContainer.state.smtpPort || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('admin:app_setting.user')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpUser || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row form-group">
+          <label className="text-left text-md-right col-md-3 col-form-label">
+            {t('Password')}
+          </label>
+          <div className="col-md-6">
+            <input
+              className="form-control"
+              type="password"
+              defaultValue={adminAppContainer.state.smtpPassword || ''}
+              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+            />
+          </div>
+        </div>
+      </div>
+    </React.Fragment>
+  );
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
+
+SmtpSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SmtpSettingWrapper);

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

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

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

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

+ 15 - 21
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
 
 import PaginationWrapper from '../PaginationWrapper';
 
@@ -33,29 +32,23 @@ class RecentCreated extends React.Component {
     await this.getRecentCreatedList(selectedPage);
   }
 
-  getRecentCreatedList(selectPageNumber) {
-    const { appContainer, pageContainer } = this.props;
-    const { pageId } = pageContainer.state;
+  async getRecentCreatedList(selectPageNumber) {
+    const { appContainer, userId } = this.props;
 
-    const userId = appContainer.currentUserId;
     const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
 
     // pagesList get and pagination calculate
-    this.props.appContainer.apiGet('/pages.recentCreated', {
-      page_id: pageId, user: userId, limit, offset,
-    })
-      .then((res) => {
-        const totalPages = res.totalCount;
-        const pages = res.pages;
-        const activePage = selectPageNumber;
-        this.setState({
-          pages,
-          activePage,
-          totalPages,
-          pagingLimit: limit,
-        });
-      });
+    const res = await appContainer.apiv3Get(`/users/${userId}/recent`, { offset, limit });
+    const { totalCount, pages } = res.data;
+
+    this.setState({
+      pages,
+      activePage: selectPageNumber,
+      totalPages: totalCount,
+      pagingLimit: limit,
+    });
+
   }
 
   /**
@@ -95,11 +88,12 @@ class RecentCreated extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const RecentCreatedWrapper = withUnstatedContainers(RecentCreated, [AppContainer, PageContainer]);
+const RecentCreatedWrapper = withUnstatedContainers(RecentCreated, [AppContainer]);
 
 RecentCreated.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  userId: PropTypes.string.isRequired,
 };
 
 export default RecentCreatedWrapper;

+ 58 - 38
src/client/js/services/AdminAppContainer.js

@@ -23,11 +23,15 @@ export default class AdminAppContainer extends Container {
       siteUrl: '',
       envSiteUrl: '',
       isSetSiteUrl: true,
+      isMailerSetup: false,
       fromAddress: '',
+      transmissionMethod: '',
       smtpHost: '',
       smtpPort: '',
       smtpUser: '',
       smtpPassword: '',
+      sesAccessKeyId: '',
+      sesSecretAccessKey: '',
       region: '',
       customEndpoint: '',
       bucket: '',
@@ -36,27 +40,6 @@ export default class AdminAppContainer extends Container {
       isEnabledPlugins: true,
     };
 
-    this.changeTitle = this.changeTitle.bind(this);
-    this.changeConfidential = this.changeConfidential.bind(this);
-    this.changeGlobalLang = this.changeGlobalLang.bind(this);
-    this.changeFileUpload = this.changeFileUpload.bind(this);
-    this.changeSiteUrl = this.changeSiteUrl.bind(this);
-    this.changeFromAddress = this.changeFromAddress.bind(this);
-    this.changeSmtpHost = this.changeSmtpHost.bind(this);
-    this.changeSmtpPort = this.changeSmtpPort.bind(this);
-    this.changeSmtpUser = this.changeSmtpUser.bind(this);
-    this.changeSmtpPassword = this.changeSmtpPassword.bind(this);
-    this.changeRegion = this.changeRegion.bind(this);
-    this.changeCustomEndpoint = this.changeCustomEndpoint.bind(this);
-    this.changeBucket = this.changeBucket.bind(this);
-    this.changeAccessKeyId = this.changeAccessKeyId.bind(this);
-    this.changeSecretAccessKey = this.changeSecretAccessKey.bind(this);
-    this.changeIsEnabledPlugins = this.changeIsEnabledPlugins.bind(this);
-    this.updateAppSettingHandler = this.updateAppSettingHandler.bind(this);
-    this.updateSiteUrlSettingHandler = this.updateSiteUrlSettingHandler.bind(this);
-    this.updateMailSettingHandler = this.updateMailSettingHandler.bind(this);
-    this.updateAwsSettingHandler = this.updateAwsSettingHandler.bind(this);
-    this.updatePluginSettingHandler = this.updatePluginSettingHandler.bind(this);
   }
 
   /**
@@ -81,11 +64,15 @@ export default class AdminAppContainer extends Container {
       siteUrl: appSettingsParams.siteUrl,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
+      isMailerSetup: appSettingsParams.isMailerSetup,
       fromAddress: appSettingsParams.fromAddress,
+      transmissionMethod: appSettingsParams.transmissionMethod,
       smtpHost: appSettingsParams.smtpHost,
       smtpPort: appSettingsParams.smtpPort,
       smtpUser: appSettingsParams.smtpUser,
       smtpPassword: appSettingsParams.smtpPassword,
+      sesAccessKeyId: appSettingsParams.sesAccessKeyId,
+      sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
       region: appSettingsParams.region,
       customEndpoint: appSettingsParams.customEndpoint,
       bucket: appSettingsParams.bucket,
@@ -138,6 +125,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ fromAddress });
   }
 
+  /**
+   * Change from transmission method
+   */
+  changeTransmissionMethod(transmissionMethod) {
+    this.setState({ transmissionMethod });
+  }
+
   /**
    * Change smtp host
    */
@@ -166,6 +160,20 @@ export default class AdminAppContainer extends Container {
     this.setState({ smtpPassword });
   }
 
+  /**
+   * Change sesAccessKeyId
+   */
+  changeSesAccessKeyId(sesAccessKeyId) {
+    this.setState({ sesAccessKeyId });
+  }
+
+  /**
+   * Change sesSecretAccessKey
+   */
+  changeSesSecretAccessKey(sesSecretAccessKey) {
+    this.setState({ sesSecretAccessKey });
+  }
+
   /**
    * Change region
    */
@@ -239,49 +247,61 @@ export default class AdminAppContainer extends Container {
   }
 
   /**
-   * Update from adress
+   * Update mail setting
    * @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;
+  updateMailSettingHandler() {
+    if (this.state.transmissionMethod === 'smtp') {
+      return this.updateSmtpSetting();
+    }
+    return this.updateSesSetting();
   }
 
   /**
-   * Update mail setting
+   * Update smtp setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async updateMailSettingHandler() {
-    const response = await this.appContainer.apiv3.put('/app-settings/mail-setting', {
+  async updateSmtpSetting() {
+    const response = await this.appContainer.apiv3.put('/app-settings/smtp-setting', {
       fromAddress: this.state.fromAddress,
+      transmissionMethod: this.state.transmissionMethod,
       smtpHost: this.state.smtpHost,
       smtpPort: this.state.smtpPort,
       smtpUser: this.state.smtpUser,
       smtpPassword: this.state.smtpPassword,
     });
     const { mailSettingParams } = response.data;
+    this.setState({ isMailerSetup: mailSettingParams.isMailerSetup });
     return mailSettingParams;
   }
 
   /**
-   * Initialize mail setting
+   * Update ses setting
    * @memberOf AdminAppContainer
    * @return {Array} Appearance
    */
-  async initializeMailSettingHandler() {
-    const response = await this.appContainer.apiv3.delete('/app-settings/mail-setting', {});
-    const {
-      mailSettingParams,
-    } = response.data;
-    this.setState(mailSettingParams);
+  async updateSesSetting() {
+    const response = await this.appContainer.apiv3.put('/app-settings/ses-setting', {
+      fromAddress: this.state.fromAddress,
+      transmissionMethod: this.state.transmissionMethod,
+      sesAccessKeyId: this.state.sesAccessKeyId,
+      sesSecretAccessKey: this.state.sesSecretAccessKey,
+    });
+    const { mailSettingParams } = response.data;
+    this.setState({ isMailerSetup: mailSettingParams.isMailerSetup });
     return mailSettingParams;
   }
 
+  /**
+   * send test e-mail
+   * @memberOf AdminAppContainer
+   */
+  async sendTestEmail() {
+    return this.appContainer.apiv3.post('/app-settings/smtp-test');
+  }
+
   /**
    * Update AWS setting
    * @memberOf AdminAppContainer

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

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

@@ -90,11 +90,14 @@ pre:not(.hljs):not(.CodeMirror-line) {
 
 .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
@@ -339,7 +342,7 @@ body.on-edit {
   }
 
   .grw-editor-navbar-bottom {
-    background-color: darken($bgcolor-global, 2%);
+    background-color: $gray-50;
   }
 }
 

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

+ 68 - 0
src/migrations/20200828024025-copy-aws-setting.js

@@ -0,0 +1,68 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:remove-layout-setting');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const [accessKeyId, secretAccessKey] = await Promise.all([
+      Config.findOne({ key: 'aws:accessKeyId' }),
+      Config.findOne({ key: 'aws:secretAccessKey' }),
+    ]);
+
+    const request = [];
+
+    if (accessKeyId != null) {
+      if (accessKeyId.value != null) {
+        request.push({
+          insertOne: {
+            document: {
+              key: 'mail:sesAccessKeyId',
+              ns: 'crowi',
+              value: accessKeyId.value,
+            },
+          },
+        });
+      }
+    }
+
+    if (secretAccessKey != null) {
+      if (secretAccessKey.value != null) {
+        request.push({
+          insertOne: {
+            document: {
+              key: 'mail:sesSecretAccessKey',
+              ns: 'crowi',
+              value: secretAccessKey.value,
+            },
+          },
+        });
+      }
+    }
+
+    if (request.length > 0) {
+      await Config.bulkWrite(request);
+    }
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    await Config.deleteMany({ key: { $in: ['mail:sesAccessKeyId', 'mail:sesSecretAccessKey'] } });
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 50 - 0
src/migrations/20200901034313-update-mail-transmission.js

@@ -0,0 +1,50 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:update-mail-transmission');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const sesExist = await Config.findOne({
+      ns: 'crowi',
+      key: 'mail:sesAccessKeyId',
+    });
+
+    if (sesExist == null) {
+      return logger.info('Document does not exist, value of transmission method will be set smtp automatically.');
+    }
+    const value = (
+      sesExist.value != null ? 'ses' : 'smtp'
+    );
+    await Config.create({
+      ns: 'crowi',
+      key: 'mail:transmissionMethod',
+      value,
+    });
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    // remote 'mail:transmissionMethod'
+    await Config.findOneAndDelete({
+      ns: 'crowi',
+      key: 'mail:transmissionMethod',
+    });
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

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

+ 1 - 1
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 = {};
 

+ 109 - 88
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,15 +50,18 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          envSiteUrl:
  *            type: string
  *            description: environment variable 'APP_SITE_URL'
- *      FromAddress:
+ *      MailSetting:
  *        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
+ *          transmissionMethod:
+ *            type: string
+ *            description: transmission method
+ *      SmtpSettingParams:
+ *        description: SmtpSettingParams
  *        type: object
  *        properties:
  *          smtpHost:
@@ -73,6 +76,16 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          smtpPassword:
  *            type: string
  *            description: password of client's smtp server
+ *      SesSettingParams:
+ *        description: SesSettingParams
+ *        type: object
+ *        properties:
+ *          accessKeyId:
+ *            type: string
+ *            description: accesskey id for authentification of AWS
+ *          secretAccessKey:
+ *            type: string
+ *            description: secret key for authentification of AWS
  *      AwsSettingParams:
  *        description: AwsSettingParams
  *        type: object
@@ -119,20 +132,25 @@ module.exports = (crowi) => {
     siteUrlSetting: [
       body('siteUrl').trim().matches(/^(https?:\/\/[^/]+|)$/).isURL({ require_tld: false }),
     ],
-    fromAddress: [
+    mailSetting: [
       body('fromAddress').trim().if(value => value !== '').isEmail(),
+      body('transmissionMethod').isIn(['smtp', 'ses']),
     ],
-    mailSetting: [
+    smtpSetting: [
       body('smtpHost').trim(),
-      body('smtpPort').trim().isPort(),
+      body('smtpPort').trim().if(value => value !== '').isPort(),
       body('smtpUser').trim(),
       body('smtpPassword').trim(),
     ],
+    sesSetting: [
+      body('sesAccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
+      body('sesSecretAccessKey').trim(),
+    ],
     awsSetting: [
       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('accessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('secretAccessKey').trim(),
     ],
     pluginSetting: [
@@ -168,11 +186,15 @@ module.exports = (crowi) => {
       fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
+      isMailerSetup: crowi.mailService.isMailerSetup,
       fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
+      transmissionMethod: crowi.configManager.getConfig('crowi', 'mail:transmissionMethod'),
       smtpHost: crowi.configManager.getConfig('crowi', 'mail:smtpHost'),
       smtpPort: crowi.configManager.getConfig('crowi', 'mail:smtpPort'),
       smtpUser: crowi.configManager.getConfig('crowi', 'mail:smtpUser'),
       smtpPassword: crowi.configManager.getConfig('crowi', 'mail:smtpPassword'),
+      sesAccessKeyId: crowi.configManager.getConfig('crowi', 'mail:sesAccessKeyId'),
+      sesSecretAccessKey: crowi.configManager.getConfig('crowi', 'mail:sesSecretAccessKey'),
       region: crowi.configManager.getConfig('crowi', 'aws:region'),
       customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),
       bucket: crowi.configManager.getConfig('crowi', 'aws:bucket'),
@@ -297,21 +319,32 @@ module.exports = (crowi) => {
   /**
    * validate mail setting send test mail
    */
-  async function validateMailSetting(req) {
+  async function sendTestEmail(destinationAddress) {
+
     const { configManager, mailService } = crowi;
+
+    if (!mailService.isMailerSetup) {
+      throw Error('mailService is not setup');
+    }
+
     const fromAddress = configManager.getConfig('crowi', 'mail:from');
     if (fromAddress == null) {
       throw Error('fromAddress is not setup');
     }
 
+    const smtpHost = configManager.getConfig('crowi', 'mail:smtpHost');
+    const smtpPort = configManager.getConfig('crowi', 'mail:smtpPort');
+    const smtpUser = configManager.getConfig('crowi', 'mail:smtpUser');
+    const smtpPassword = configManager.getConfig('crowi', 'mail:smtpPassword');
+
     const option = {
-      host: req.body.smtpHost,
-      port: req.body.smtpPort,
+      host: smtpHost,
+      port: smtpPort,
     };
-    if (req.body.smtpUser && req.body.smtpPassword) {
+    if (smtpUser && smtpPassword) {
       option.auth = {
-        user: req.body.smtpUser,
-        pass: req.body.smtpPassword,
+        user: smtpUser,
+        pass: smtpPassword,
       };
     }
     if (option.port === 465) {
@@ -323,7 +356,7 @@ module.exports = (crowi) => {
 
     const mailOptions = {
       from: fromAddress,
-      to: req.user.email,
+      to: destinationAddress,
       subject: 'Wiki管理設定のアップデートによるメール通知',
       text: 'このメールは、WikiのSMTP設定のアップデートにより送信されています。',
     };
@@ -344,143 +377,134 @@ module.exports = (crowi) => {
     mailService.publishUpdatedMessage();
 
     return {
+      isMailerSetup: mailService.isMailerSetup,
+      fromAddress: configManager.getConfig('crowi', 'mail:from'),
       smtpHost: configManager.getConfig('crowi', 'mail:smtpHost'),
       smtpPort: configManager.getConfig('crowi', 'mail:smtpPort'),
       smtpUser: configManager.getConfig('crowi', 'mail:smtpUser'),
       smtpPassword: configManager.getConfig('crowi', 'mail:smtpPassword'),
+      sesAccessKeyId: configManager.getConfig('crowi', 'mail:sesAccessKeyId'),
+      sesSecretAccessKey: configManager.getConfig('crowi', 'mail:sesSecretAccessKey'),
     };
   };
 
   /**
    * @swagger
    *
-   *    /app-settings/from-address:
+   *    /app-settings/smtp-setting:
    *      put:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingFromAddress
-   *        summary: /app-settings/from-address
-   *        description: Update from address
+   *        operationId: updateAppSettingSmtpSetting
+   *        summary: /app-settings/smtp-setting
+   *        description: Update smtp setting
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/FromAddress'
+   *                $ref: '#/components/schemas/SmtpSettingParams'
    *        responses:
    *          200:
-   *            description: Succeeded to update from adress
+   *            description: Succeeded to update smtp setting
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/FromAddress'
+   *                  $ref: '#/components/schemas/SmtpSettingParams'
    */
-  router.put('/from-address', loginRequiredStrictly, adminRequired, csrf, validator.fromAddress, apiV3FormValidator, async(req, res) => {
+  router.put('/smtp-setting', loginRequiredStrictly, adminRequired, csrf, validator.smtpSetting, apiV3FormValidator, async(req, res) => {
+    const requestMailSettingParams = {
+      'mail:from': req.body.fromAddress,
+      'mail:transmissionMethod': req.body.transmissionMethod,
+      'mail:smtpHost': req.body.smtpHost,
+      'mail:smtpPort': req.body.smtpPort,
+      'mail:smtpUser': req.body.smtpUser,
+      'mail:smtpPassword': req.body.smtpPassword,
+    };
 
     try {
-      const mailSettingParams = await updateMailSettinConfig({ 'mail:from': req.body.fromAddress });
-
+      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
       return res.apiv3({ mailSettingParams });
     }
     catch (err) {
-      const msg = 'Error occurred in updating from adress';
+      const msg = 'Error occurred in updating smtp setting';
       logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-from-adress-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'update-smtpSetting-failed'));
     }
-
   });
 
   /**
    * @swagger
    *
-   *    /app-settings/mail-setting:
-   *      put:
+   *    /app-settings/smtp-test:
+   *      post:
    *        tags: [AppSettings]
-   *        operationId: updateAppSettingMailSetting
-   *        summary: /app-settings/mail-setting
-   *        description: Update mail setting
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/MailSettingParams'
+   *        operationId: postSmtpTest
+   *        summary: /app-settings/smtp-setting
+   *        description: Send test mail for smtp
    *        responses:
    *          200:
-   *            description: Succeeded to update mail setting
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/MailSettingParams'
+   *            description: Succeeded to send test mail for smtp
    */
-  router.put('/mail-setting', loginRequiredStrictly, adminRequired, csrf, validator.mailSetting, apiV3FormValidator, async(req, res) => {
+  router.post('/smtp-test', loginRequiredStrictly, adminRequired, async(req, res) => {
     try {
-      await validateMailSetting(req);
+      await sendTestEmail(req.user.email);
+      return res.apiv3({});
     }
     catch (err) {
-      const msg = 'SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。';
+      const msg = req.t('validation.failed_to_send_a_test_email');
       logger.error('Error', err);
       debug('Error validate mail setting: ', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-mailSetting-failed'));
-    }
-
-
-    const requestMailSettingParams = {
-      'mail:smtpHost': req.body.smtpHost,
-      'mail:smtpPort': req.body.smtpPort,
-      'mail:smtpUser': req.body.smtpUser,
-      'mail:smtpPassword': req.body.smtpPassword,
-    };
-
-    try {
-      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
-      return res.apiv3({ mailSettingParams });
-    }
-    catch (err) {
-      const msg = 'Error occurred in updating mail setting';
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'update-mailSetting-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'send-email-with-smtp-failed'));
     }
   });
 
   /**
    * @swagger
    *
-   *    /app-settings/mail-setting:
-   *      delete:
+   *    /app-settings/ses-setting:
+   *      put:
    *        tags: [AppSettings]
-   *        operationId: deleteAppSettingMailSetting
-   *        summary: /app-settings/mail-setting
-   *        description: delete mail setting
+   *        operationId: updateAppSettingSesSetting
+   *        summary: /app-settings/ses-setting
+   *        description: Update ses setting
    *        requestBody:
    *          required: true
    *          content:
    *            application/json:
    *              schema:
-   *                $ref: '#/components/schemas/MailSettingParams'
+   *                $ref: '#/components/schemas/SesSettingParams'
    *        responses:
    *          200:
-   *            description: Succeeded to delete mail setting
+   *            description: Succeeded to update ses setting
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/MailSettingParams'
+   *                  $ref: '#/components/schemas/SesSettingParams'
    */
-  router.delete('/mail-setting', loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
-    const requestMailSettingParams = {
-      'mail:smtpHost': null,
-      'mail:smtpPort': null,
-      'mail:smtpUser': null,
-      'mail:smtpPassword': null,
+  router.put('/ses-setting', loginRequiredStrictly, adminRequired, csrf, validator.sesSetting, apiV3FormValidator, async(req, res) => {
+    const { mailService } = crowi;
+
+    const requestSesSettingParams = {
+      'mail:from': req.body.fromAddress,
+      'mail:transmissionMethod': req.body.transmissionMethod,
+      'mail:sesAccessKeyId': req.body.sesAccessKeyId,
+      'mail:sesSecretAccessKey': req.body.sesSecretAccessKey,
     };
+
+    let mailSettingParams;
     try {
-      const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
-      return res.apiv3({ mailSettingParams });
+      mailSettingParams = await updateMailSettinConfig(requestSesSettingParams);
     }
     catch (err) {
-      const msg = 'Error occurred in initializing mail setting';
+      const msg = 'Error occurred in updating ses setting';
       logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'initialize-mailSetting-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'update-ses-setting-failed'));
     }
+
+    await mailService.initialize();
+    mailService.publishUpdatedMessage();
+
+    return res.apiv3({ mailSettingParams });
   });
 
   /**
@@ -516,14 +540,11 @@ module.exports = (crowi) => {
     };
 
     try {
-      const { configManager, mailService } = crowi;
+      const { configManager } = crowi;
 
       // update config without publishing S2sMessage
       await configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams, true);
 
-      await mailService.initialize();
-      mailService.publishUpdatedMessage();
-
       const awsSettingParams = {
         region: crowi.configManager.getConfig('crowi', 'aws:region'),
         customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),

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

+ 6 - 2
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');
 
 /**
@@ -127,9 +127,13 @@ module.exports = (crowi) => {
 
     try {
       const user = await User.findUserByUsername(username);
+
+      // return email whether it's private
+      const { email } = user;
       const currentUser = user.toObject();
-      return res.apiv3({ currentUser });
+      currentUser.email = email;
 
+      return res.apiv3({ currentUser });
     }
     catch (err) {
       logger.error(err);

+ 41 - 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();
@@ -16,6 +16,46 @@ const PAGE_ITEMS = 30;
  *  tags:
  *    name: Revisions
  */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Revision:
+ *        description: Revision
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: revision ID
+ *            example: 5e0734e472560e001761fa68
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          author:
+ *            $ref: '#/components/schemas/User/properties/_id'
+ *          body:
+ *            type: string
+ *            description: content body
+ *            example: |
+ *              # test
+ *
+ *              test
+ *          format:
+ *            type: string
+ *            description: format
+ *            example: markdown
+ *          path:
+ *            type: string
+ *            description: path
+ *            example: /user/alice/test
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
 module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);

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

+ 72 - 1
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');
@@ -65,6 +65,8 @@ const validator = {};
  */
 
 module.exports = (crowi) => {
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const csrf = require('../../middlewares/csrf')(crowi);
@@ -196,6 +198,73 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /{id}/recent:
+   *      get:
+   *        tags: [Users]
+   *        operationId: recent created page of user id
+   *        summary: /usersIdReacent
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of user
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: users recent created pages are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    paginateResult:
+   *                      $ref: '#/components/schemas/PaginateResult'
+   */
+  router.get('/:id/recent', accessTokenParser, loginRequired, async(req, res) => {
+    const { id } = req.params;
+
+    let user;
+
+    try {
+      user = await User.findById(id);
+    }
+    catch (err) {
+      const msg = 'Error occurred in find user';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
+    }
+
+    if (user == null) {
+      return res.apiv3Err(new ErrorV3('find-user-is-not-found'));
+    }
+
+    const limit = parseInt(req.query.limit) || 50;
+    const offset = parseInt(req.query.offset) || 0;
+    const queryOptions = { offset, limit };
+
+    try {
+      const result = await Page.findListByCreator(user, req.user, queryOptions);
+
+      // Delete unnecessary data about users
+      result.pages = result.pages.map((page) => {
+        const user = page.lastUpdateUser.toObject();
+        page.lastUpdateUser = user;
+        return page;
+      });
+
+      return res.apiv3(result);
+    }
+    catch (err) {
+      const msg = 'Error occurred in retrieve recent created pages for user';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'retrieve-recent-created-pages-failed'), 500);
+    }
+  });
+
   validator.inviteEmail = [
     // isEmail prevents line breaks, so use isString
     body('shapedEmailList').custom((value) => {
@@ -252,6 +321,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(err));
     }
   });
+
   /**
    * @swagger
    *
@@ -293,6 +363,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(err));
     }
   });
+
   /**
    * @swagger
    *

+ 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

@@ -134,7 +134,6 @@ module.exports = function(crowi, app) {
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired , user.api.list);
   app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  app.get('/_api/pages.recentCreated' , accessTokenParser , loginRequired , page.api.recentCreated);
   app.post('/_api/pages.create'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.create);
   app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
   app.get('/_api/pages.get'           , accessTokenParser , loginRequired , page.api.get);

+ 13 - 89
src/server/routes/page.js

@@ -219,10 +219,11 @@ module.exports = function(crowi, app) {
     }
   }
 
-  function addRendarVarsForPage(renderVars, page) {
+  function addRenderVarsForPage(renderVars, page) {
     renderVars.page = page;
+    renderVars.page.creator = renderVars.page.creator.toObject();
     renderVars.revision = page.revision;
-    renderVars.author = page.revision.author;
+    renderVars.revision.author = renderVars.revision.author.toObject();
     renderVars.pageIdOnHackmd = page.pageIdOnHackmd;
     renderVars.revisionHackmdSynced = page.revisionHackmdSynced;
     renderVars.hasDraftOnHackmd = page.hasDraftOnHackmd;
@@ -232,12 +233,12 @@ module.exports = function(crowi, app) {
     const userData = await User.findUserByUsername(User.getUsernameByPath(page.path));
 
     if (userData != null) {
-      renderVars.pageUser = userData;
+      renderVars.pageUser = userData.toObject();
       renderVars.bookmarkList = await Bookmark.findByUser(userData, { limit: 10, populatePage: true, requestUser });
     }
   }
 
-  function addRendarVarsForScope(renderVars, page) {
+  function addRenderVarsForScope(renderVars, page) {
     renderVars.grant = page.grant;
     renderVars.grantedGroupId = page.grantedGroup ? page.grantedGroup.id : null;
     renderVars.grantedGroupName = page.grantedGroup ? page.grantedGroup.name : null;
@@ -297,7 +298,7 @@ module.exports = function(crowi, app) {
 
     // populate
     page = await page.populateDataToMakePresentation(revisionId);
-    addRendarVarsForPage(renderVars, page);
+    addRenderVarsForPage(renderVars, page);
     return res.render('page_presentation', renderVars);
   }
 
@@ -315,7 +316,7 @@ module.exports = function(crowi, app) {
     // populate
     portalPage = await portalPage.populateDataToShowRevision();
 
-    addRendarVarsForPage(renderVars, portalPage);
+    addRenderVarsForPage(renderVars, portalPage);
     await addRenderVarsForSlack(renderVars, portalPage);
 
     const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: portalPage._id });
@@ -359,8 +360,8 @@ module.exports = function(crowi, app) {
 
     // populate
     page = await page.populateDataToShowRevision();
-    addRendarVarsForPage(renderVars, page);
-    addRendarVarsForScope(renderVars, page);
+    addRenderVarsForPage(renderVars, page);
+    addRenderVarsForScope(renderVars, page);
 
     await addRenderVarsForSlack(renderVars, page);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
@@ -444,7 +445,7 @@ module.exports = function(crowi, app) {
       page = await page.populateDataToMakePresentation(revisionId);
 
       // populate
-      addRendarVarsForPage(renderVars, page);
+      addRenderVarsForPage(renderVars, page);
       return res.render('page_presentation', renderVars);
     }
 
@@ -452,8 +453,8 @@ module.exports = function(crowi, app) {
 
     // populate
     page = await page.populateDataToShowRevision();
-    addRendarVarsForPage(renderVars, page);
-    addRendarVarsForScope(renderVars, page);
+    addRenderVarsForPage(renderVars, page);
+    addRenderVarsForScope(renderVars, page);
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
@@ -522,7 +523,7 @@ module.exports = function(crowi, app) {
       const ancestor = await Page.findAncestorByPathAndViewer(path, req.user);
       if (ancestor != null) {
         await ancestor.populate('grantedGroup').execPopulate();
-        addRendarVarsForScope(renderVars, ancestor);
+        addRenderVarsForScope(renderVars, ancestor);
       }
     }
 
@@ -1563,82 +1564,5 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
   };
 
-  /**
-   * @swagger
-   *
-   *    /pages.recentCreated:
-   *      get:
-   *        tags: [Pages]
-   *        operationId: getRecentCreatedPages
-   *        summary: /pages.recentCreated
-   *        description: Get recent created page list
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            required: true
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *          - in: query
-   *            name: offset
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *          - in: query
-   *            name: limit
-   *            schema:
-   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *        responses:
-   *          200:
-   *            description: Succeeded to get recent created page list.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    pages:
-   *                      type: array
-   *                      description: recent created page list
-   *                      items:
-   *                        $ref: '#/components/schemas/Page'
-   *                    totalCount:
-   *                      $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/total'
-   *                    offset:
-   *                      $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
-   *                    limit:
-   *                      $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  api.recentCreated = async function(req, res) {
-    const pageId = req.query.page_id;
-
-    if (pageId == null) {
-      return res.json(ApiResponse.error('param \'pageId\' must not be null'));
-    }
-
-    const page = await Page.findById(pageId);
-    if (page == null) {
-      return res.json(ApiResponse.error(`Page (id='${pageId}') does not exist`));
-    }
-    if (!isUserPage(page.path)) {
-      return res.json(ApiResponse.error(`Page (id='${pageId}') is not a user home`));
-    }
-
-    const limit = +req.query.limit || 50;
-    const offset = +req.query.offset || 0;
-    const queryOptions = { offset, limit };
-
-    try {
-      const result = await Page.findListByCreator(page.creator, req.user, queryOptions);
-
-      return res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
   return actions;
 };

+ 32 - 8
src/server/service/mail.js

@@ -20,6 +20,11 @@ class MailService extends S2sMessageHandlable {
     this.mailConfig = {};
     this.mailer = {};
 
+    /**
+     * the flag whether mailer is set up successfully
+     */
+    this.isMailerSetup = false;
+
     this.initialize();
   }
 
@@ -65,23 +70,29 @@ class MailService extends S2sMessageHandlable {
   initialize() {
     const { appService, configManager } = this;
 
+    this.isMailerSetup = false;
+
     if (!configManager.getConfig('crowi', 'mail:from')) {
       this.mailer = null;
       return;
     }
 
-    // Priority 1. SMTP
-    if (configManager.getConfig('crowi', 'mail:smtpHost') && configManager.getConfig('crowi', 'mail:smtpPort')) {
+    const transmissionMethod = configManager.getConfig('crowi', 'mail:transmissionMethod');
+
+    if (transmissionMethod === 'smtp') {
       this.mailer = this.createSMTPClient();
     }
-    // Priority 2. SES
-    else if (configManager.getConfig('crowi', 'aws:accessKeyId') && configManager.getConfig('crowi', 'aws:secretAccessKey')) {
+    else if (transmissionMethod === 'ses') {
       this.mailer = this.createSESClient();
     }
     else {
       this.mailer = null;
     }
 
+    if (this.mailer != null) {
+      this.isMailerSetup = true;
+    }
+
     this.mailConfig.from = configManager.getConfig('crowi', 'mail:from');
     this.mailConfig.subject = `${appService.getAppTitle()}からのメール`;
 
@@ -93,9 +104,15 @@ class MailService extends S2sMessageHandlable {
 
     logger.debug('createSMTPClient option', option);
     if (!option) {
+      const host = configManager.getConfig('crowi', 'mail:smtpHost');
+      const port = configManager.getConfig('crowi', 'mail:smtpPort');
+
+      if (host == null || port == null) {
+        return null;
+      }
       option = { // eslint-disable-line no-param-reassign
-        host: configManager.getConfig('crowi', 'mail:smtpHost'),
-        port: configManager.getConfig('crowi', 'mail:smtpPort'),
+        host,
+        port,
       };
 
       if (configManager.getConfig('crowi', 'mail:smtpUser') && configManager.getConfig('crowi', 'mail:smtpPassword')) {
@@ -113,6 +130,7 @@ class MailService extends S2sMessageHandlable {
     const client = nodemailer.createTransport(option);
 
     logger.debug('mailer set up for SMTP', client);
+
     return client;
   }
 
@@ -120,9 +138,14 @@ class MailService extends S2sMessageHandlable {
     const { configManager } = this;
 
     if (!option) {
+      const accessKeyId = configManager.getConfig('crowi', 'mail:sesAccessKeyId');
+      const secretAccessKey = configManager.getConfig('crowi', 'mail:sesSecretAccessKey');
+      if (accessKeyId == null || secretAccessKey == null) {
+        return null;
+      }
       option = { // eslint-disable-line no-param-reassign
-        accessKeyId: configManager.getConfig('crowi', 'aws:accessKeyId'),
-        secretAccessKey: configManager.getConfig('crowi', 'aws:secretAccessKey'),
+        accessKeyId,
+        secretAccessKey,
       };
     }
 
@@ -130,6 +153,7 @@ class MailService extends S2sMessageHandlable {
     const client = nodemailer.createTransport(ses(option));
 
     logger.debug('mailer set up for SES', client);
+
     return client;
   }
 

+ 8 - 2
src/test/models/shareLink.test.js

@@ -38,8 +38,14 @@ describe('ShareLink', () => {
       path: '/somePath',
       populateDataToShowRevision: () => {
         return {
-          revision: {},
-          creator: {},
+          revision: {
+            author: {
+              toObject: jest.fn(() => { return {} }),
+            },
+          },
+          creator: {
+            toObject: jest.fn(() => { return {} }),
+          },
         };
       },
       initLatestRevisionField: (revisionId) => {

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