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

Merge pull request #5526 from weseek/feat/maintenance-mode-base

feat: Maintenance mode
Yuki Takei 4 лет назад
Родитель
Сommit
a8c513cc4d
24 измененных файлов с 462 добавлено и 79 удалено
  1. 14 0
      packages/app/resource/locales/en_US/admin/admin.json
  2. 7 0
      packages/app/resource/locales/en_US/translation.json
  3. 14 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  4. 7 0
      packages/app/resource/locales/ja_JP/translation.json
  5. 14 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  6. 7 0
      packages/app/resource/locales/zh_CN/translation.json
  7. 12 1
      packages/app/src/client/services/AdminAppContainer.js
  8. 2 0
      packages/app/src/client/services/AdminHomeContainer.js
  9. 18 0
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  10. 29 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  11. 25 14
      packages/app/src/components/Admin/App/ConfirmModal.tsx
  12. 80 0
      packages/app/src/components/Admin/App/MaintenanceMode.tsx
  13. 5 2
      packages/app/src/components/Admin/App/V5PageMigration.tsx
  14. 27 0
      packages/app/src/server/middlewares/unavailable-when-maintenance-mode.ts
  15. 1 0
      packages/app/src/server/routes/apiv3/admin-home.js
  16. 50 0
      packages/app/src/server/routes/apiv3/app-settings.js
  17. 5 0
      packages/app/src/server/routes/apiv3/import.js
  18. 12 11
      packages/app/src/server/routes/apiv3/index.js
  19. 0 16
      packages/app/src/server/routes/apiv3/pages.js
  20. 57 32
      packages/app/src/server/routes/index.js
  21. 12 0
      packages/app/src/server/service/app.ts
  22. 6 0
      packages/app/src/server/service/config-loader.ts
  23. 2 2
      packages/app/src/server/views/forgot-password.html
  24. 56 0
      packages/app/src/server/views/maintenance-mode.html

+ 14 - 0
packages/app/resource/locales/en_US/admin/admin.json

@@ -30,6 +30,20 @@
     "successfully_started": "Succeeded to start the conversion",
     "successfully_started": "Succeeded to start the conversion",
     "already_upgraded": "You have already completed the conversion to v5 compatibility"
     "already_upgraded": "You have already completed the conversion to v5 compatibility"
   },
   },
+  "maintenance_mode": {
+    "maintenance_mode": "Maintenance Mode",
+    "under_maintenance_mode": "Under Maintenance Mode",
+    "failed_to_start_maintenance_mode": "Failed to start maintenance mode",
+    "failed_to_end_maintenance_mode": "Failed to end maintenance mode",
+    "successfully_started_maintenance_mode": "Succussfully started maintenance mode",
+    "successfully_ended_maintenance_mode": "Succussfully ended maintenance mode",
+    "warning_message_to_start": "You will NOT able to access other than admin settings page. General users will NOT able to access to any contents until maintenance mode ends manually.",
+    "warning_message_to_end": "Please make sure that \"data importing\" or \"upgrading to v5\" is already done or not. If not, it is highly recommended to keep maintenance mode on.",
+    "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.",
+    "start_maintenance_mode": "Start maintenance mode",
+    "end_maintenance_mode": "End maintenance mode",
+    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"Security Settings\" > \"Maintenance Mode\"."
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "Site name",
     "site_name": "Site name",
     "sitename_change": "You can change site name which is used for header and HTML title.",
     "sitename_change": "You can change site name which is used for header and HTML title.",

+ 7 - 0
packages/app/resource/locales/en_US/translation.json

@@ -998,6 +998,13 @@
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },
   },
+  "maintenance_mode":{
+    "maintenance_mode": "Maintenance Mode",
+    "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",
+    "admin_page": "Admin Page",
+    "login": "Login",
+    "logout": "Logout"
+  },
   "pagetree": {
   "pagetree": {
     "private_legacy_pages": "Private Legacy Pages",
     "private_legacy_pages": "Private Legacy Pages",
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",
     "cannot_rename_a_title_that_contains_slash": "Cannot rename a title that contains '/'",

+ 14 - 0
packages/app/resource/locales/ja_JP/admin/admin.json

@@ -30,6 +30,20 @@
     "successfully_started": "正常に v5 互換形式への変換が開始されました",
     "successfully_started": "正常に v5 互換形式への変換が開始されました",
     "already_upgraded": "v5 互換形式への変換は既に完了しています"
     "already_upgraded": "v5 互換形式への変換は既に完了しています"
   },
   },
+  "maintenance_mode": {
+    "maintenance_mode": "メンテナンスモード",
+    "under_maintenance_mode": "メンテナンスモード中",
+    "failed_to_start_maintenance_mode": "メンテナンスモードを開始できませんでした",
+    "failed_to_end_maintenance_mode": "メンテナンスモードを終了できませんでした",
+    "successfully_started_maintenance_mode": "メンテナンスモードを開始しました",
+    "successfully_ended_maintenance_mode": "メンテナンスモードを終了しました",
+    "warning_message_to_start": "メンテナンスモード中は管理画面にしかアクセスできなくなり、一般ユーザーは全ての操作が不能になります。",
+    "warning_message_to_end": "「データのインポート」および「V5 へのアップグレード」が進行中の場合は、処理が終了するまでメンテナンスモードを終了しないようにすることを推奨します。",
+    "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。",
+    "start_maintenance_mode": "メンテナンスモードを開始する",
+    "end_maintenance_mode": "メンテナンスモードを終了する",
+    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「セキュリティ設定」>「メンテナンスモード」から操作してください。"
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "サイト名",
     "site_name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",

+ 7 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -990,6 +990,13 @@
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   },
+  "maintenance_mode":{
+    "maintenance_mode": "メンテナンスモード",
+    "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",
+    "admin_page": "管理画面へ",
+    "login": "ログイン",
+    "logout": "ログアウト"
+  },
   "pagetree": {
   "pagetree": {
     "private_legacy_pages": "旧形式のプライベートページ",
     "private_legacy_pages": "旧形式のプライベートページ",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",
     "cannot_rename_a_title_that_contains_slash": "`/` が含まれているタイトルにリネームできません",

+ 14 - 0
packages/app/resource/locales/zh_CN/admin/admin.json

@@ -30,6 +30,20 @@
     "successfully_started": "成功开始转换",
     "successfully_started": "成功开始转换",
     "already_upgraded": "你已经完成了向v5兼容性的转换"
     "already_upgraded": "你已经完成了向v5兼容性的转换"
   },
   },
+  "maintenance_mode": {
+    "maintenance_mode": "维护模式",
+    "under_maintenance_mode": "在维护模式下",
+    "failed_to_start_maintenance_mode": "启动维护模式失败",
+    "failed_to_end_maintenance_mode": "结束维护模式失败",
+    "successfully_started_maintenance_mode": "成功地启动了维护模式",
+    "successfully_ended_maintenance_mode": "成功地结束了维护模式",
+    "warning_message_to_start": "你将无法访问管理员设置以外的页面。普通用户将无法访问任何内容,直到维护模式手动结束。",
+    "warning_message_to_end": "如果 \"数据导入 \"和 \"升级到V5 \"正在进行中,建议在该过程完成之前不要退出维护模式。",
+    "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。",
+    "start_maintenance_mode": "启动维护模式",
+    "end_maintenance_mode": "结束维护模式",
+    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"安全设置\">\"维护模式\"。"
+  },
   "app_setting": {
   "app_setting": {
     "site_name": "网站名称 ",
     "site_name": "网站名称 ",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
     "sitename_change": "您可以更改用于标题和HTML标题的网站名称。",

+ 7 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -1000,6 +1000,13 @@
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   },
   },
+  "maintenance_mode":{
+    "maintenance_mode": "维护模式",
+    "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",
+    "admin_page": "管理员页",
+    "login": "登录",
+    "logout": "登出"
+  },
   "pagetree": {
   "pagetree": {
     "private_legacy_pages": "私人遗留页面",
     "private_legacy_pages": "私人遗留页面",
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",
     "cannot_rename_a_title_that_contains_slash": "不能重命名包含 ’/' 的标题",

+ 12 - 1
packages/app/src/client/services/AdminAppContainer.js

@@ -58,6 +58,8 @@ export default class AdminAppContainer extends Container {
       s3ReferenceFileWithRelayMode: false,
       s3ReferenceFileWithRelayMode: false,
 
 
       isEnabledPlugins: true,
       isEnabledPlugins: true,
+
+      isMaintenanceMode: false,
     };
     };
 
 
   }
   }
@@ -116,6 +118,7 @@ export default class AdminAppContainer extends Container {
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
+      isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
     });
 
 
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
     // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
@@ -454,9 +457,17 @@ export default class AdminAppContainer extends Container {
    * @memberOf AdminAppContainer
    * @memberOf AdminAppContainer
    */
    */
   async v5PageMigrationHandler() {
   async v5PageMigrationHandler() {
-    const response = await this.appContainer.apiv3.post('/pages/v5-schema-migration');
+    const response = await this.appContainer.apiv3.post('/app-settings/v5-schema-migration');
     const { isV5Compatible } = response.data;
     const { isV5Compatible } = response.data;
     return { isV5Compatible };
     return { isV5Compatible };
   }
   }
 
 
+  async startMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: true });
+  }
+
+  async endMaintenanceMode() {
+    await this.appContainer.apiv3.post('/app-settings/maintenance-mode', { flag: false });
+  }
+
 }
 }

+ 2 - 0
packages/app/src/client/services/AdminHomeContainer.js

@@ -32,6 +32,7 @@ export default class AdminHomeContainer extends Container {
       copyState: this.copyStateValues.DEFAULT,
       copyState: this.copyStateValues.DEFAULT,
       installedPlugins: null,
       installedPlugins: null,
       isV5Compatible: null,
       isV5Compatible: null,
+      isMaintenanceMode: null,
     };
     };
 
 
   }
   }
@@ -64,6 +65,7 @@ export default class AdminHomeContainer extends Container {
         installedPlugins: adminHomeParams.installedPlugins,
         installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         envVars: adminHomeParams.envVars,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isV5Compatible: adminHomeParams.isV5Compatible,
+        isMaintenanceMode: adminHomeParams.isMaintenanceMode,
       }));
       }));
     }
     }
     catch (err) {
     catch (err) {

+ 18 - 0
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -38,6 +38,24 @@ const AdminHome = (props) => {
 
 
   return (
   return (
     <div data-testid="admin-home">
     <div data-testid="admin-home">
+      {
+        // Alert message will be displayed in case that the GROWI is under maintenance
+        adminHomeContainer.state.isMaintenanceMode && (
+          <div className="alert alert-danger alert-link" role="alert">
+            <h3 className="alert-heading">
+              {t('maintenance_mode.maintenance_mode')}
+            </h3>
+            <p>
+              {t('maintenance_mode.description')}
+            </p>
+            <hr />
+            <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+              <i className="fa fa-link ml-1" aria-hidden="true"></i>
+              <strong>{t('maintenance_mode.end_maintenance_mode')}</strong>
+            </a>
+          </div>
+        )
+      }
       {
       {
       // Alert message will be displayed in case that V5 migration has not been compleated
       // Alert message will be displayed in case that V5 migration has not been compleated
         (migrationStatus != null && !migrationStatus.isV5Compatible)
         (migrationStatus != null && !migrationStatus.isV5Compatible)

+ 29 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -1,4 +1,4 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
@@ -9,6 +9,7 @@ import MailSetting from './MailSetting';
 import PluginSetting from './PluginSetting';
 import PluginSetting from './PluginSetting';
 import FileUploadSetting from './FileUploadSetting';
 import FileUploadSetting from './FileUploadSetting';
 import V5PageMigration from './V5PageMigration';
 import V5PageMigration from './V5PageMigration';
+import MaintenanceMode from './MaintenanceMode';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 
@@ -20,6 +21,24 @@ class AppSettingsPageContents extends React.Component {
 
 
     return (
     return (
       <div data-testid="admin-app-settings">
       <div data-testid="admin-app-settings">
+        {
+          // Alert message will be displayed in case that the GROWI is under maintenance
+          adminAppContainer.state.isMaintenanceMode && (
+            <div className="alert alert-danger alert-link" role="alert">
+              <h3 className="alert-heading">
+                {t('admin:maintenance_mode.maintenance_mode')}
+              </h3>
+              <p>
+                {t('admin:maintenance_mode.description')}
+              </p>
+              <hr />
+              <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+                <i className="fa fa-fw fa-arrow-down ml-1" aria-hidden="true"></i>
+                <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+              </a>
+            </div>
+          )
+        }
         {
         {
           !isV5Compatible
           !isV5Compatible
           && (
           && (
@@ -66,7 +85,16 @@ class AppSettingsPageContents extends React.Component {
             <PluginSetting />
             <PluginSetting />
           </div>
           </div>
         </div>
         </div>
+
+        <div className="row">
+          <div className="col-lg-12">
+            <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>
+            <MaintenanceMode />
+          </div>
+        </div>
+
       </div>
       </div>
+
     );
     );
   }
   }
 
 

+ 25 - 14
packages/app/src/components/Admin/App/V5PageMigrationModal.tsx → packages/app/src/components/Admin/App/ConfirmModal.tsx

@@ -3,14 +3,18 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { TFunctionResult } from 'i18next';
 
 
-type V5PageMigrationModalProps = {
+type ConfirmModalProps = {
   isModalOpen: boolean
   isModalOpen: boolean
-  onConfirm?: () => Promise<void>;
-  onCancel?: () => void;
+  warningMessage: TFunctionResult
+  supplymentaryMessage: TFunctionResult | null
+  confirmButtonTitle: TFunctionResult
+  onConfirm?: () => Promise<void>
+  onCancel?: () => void
 };
 };
 
 
-export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5PageMigrationModalProps) => {
+export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const onCancel = () => {
   const onCancel = () => {
@@ -27,18 +31,25 @@ export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5Pag
 
 
   return (
   return (
     <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
     <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
-      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
+      <ModalHeader tag="h4" toggle={onCancel} className="bg-danger">
         <i className="icon-fw icon-question" />
         <i className="icon-fw icon-question" />
-        Warning
+        {t('Warning')}
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        {t('admin:v5_page_migration.modal_migration_warning')}
-        <br />
-        <br />
-        <span className="text-danger">
-          <i className="icon-exclamation icon-fw"></i>
-          {t('admin:v5_page_migration.migration_note')}
-        </span>
+        {props.warningMessage}
+        {
+          props.supplymentaryMessage != null && (
+            <>
+              <br />
+              <br />
+              <span className="text-warning">
+                <i className="icon-exclamation icon-fw"></i>
+                {props.supplymentaryMessage}
+              </span>
+            </>
+          )
+        }
+
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <button
         <button
@@ -53,7 +64,7 @@ export const V5PageMigrationModal: FC<V5PageMigrationModalProps> = (props: V5Pag
           className="btn btn-outline-primary ml-3"
           className="btn btn-outline-primary ml-3"
           onClick={onConfirm}
           onClick={onConfirm}
         >
         >
-          {t('admin:v5_page_migration.start_upgrading')}
+          {props.confirmButtonTitle ?? t('Confirm')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>

+ 80 - 0
packages/app/src/components/Admin/App/MaintenanceMode.tsx

@@ -0,0 +1,80 @@
+import React, { FC, useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { ConfirmModal } from './ConfirmModal';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+
+const logger = loggerFactory('growi:maintenanceMode');
+
+type Props = {
+  adminAppContainer: AdminAppContainer,
+};
+
+const MaintenanceMode: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
+
+  const [isModalOpen, setModalOpen] = useState<boolean>(false);
+  const [isMaintenanceMode, setMaintenanceMode] = useState<boolean | undefined>(adminAppContainer.state.isMaintenanceMode);
+
+  const openModal = () => { setModalOpen(true) };
+  const closeModal = () => { setModalOpen(false) };
+
+  const onConfirmHandler = useCallback(async() => {
+    closeModal();
+
+    try {
+      if (isMaintenanceMode) {
+        await adminAppContainer.endMaintenanceMode();
+        setMaintenanceMode(false);
+      }
+      else {
+        await adminAppContainer.startMaintenanceMode();
+        setMaintenanceMode(true);
+      }
+    }
+    catch (err) {
+      toastError(isMaintenanceMode ? t('admin:maintenance_mode.failed_to_end_maintenance_mode') : t('admin:maintenance_mode.failed_to_start_maintenance_mode'));
+    }
+
+    // eslint-disable-next-line max-len
+    toastSuccess(isMaintenanceMode ? t('admin:maintenance_mode.successfully_ended_maintenance_mode') : t('admin:maintenance_mode.successfully_started_maintenance_mode'));
+  }, [isMaintenanceMode, adminAppContainer, closeModal]);
+
+  return (
+    <div className="mb-5">
+      <ConfirmModal
+        isModalOpen={isModalOpen}
+        warningMessage={isMaintenanceMode ? t('admin:maintenance_mode.warning_message_to_end') : t('admin:maintenance_mode.warning_message_to_start')}
+        // eslint-disable-next-line max-len
+        supplymentaryMessage={isMaintenanceMode ? null : t('admin:maintenance_mode.supplymentary_message_to_start')}
+        confirmButtonTitle={isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+        onConfirm={onConfirmHandler}
+        onCancel={() => closeModal()}
+      />
+      <p className="card well">
+        {t('admin:maintenance_mode.description')}
+        <br />
+        <br />
+        <span className="text-warning">
+          <i className="icon-exclamation icon-fw"></i>
+          {t('admin:maintenance_mode.supplymentary_message_to_start')}
+        </span>
+      </p>
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="button" className="btn btn-success" onClick={() => openModal()}>
+            {isMaintenanceMode ? t('admin:maintenance_mode.end_maintenance_mode') : t('admin:maintenance_mode.start_maintenance_mode')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default withUnstatedContainers(MaintenanceMode, [AdminAppContainer]);

+ 5 - 2
packages/app/src/components/Admin/App/V5PageMigration.tsx

@@ -1,6 +1,6 @@
 import React, { FC, useState } from 'react';
 import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { V5PageMigrationModal } from './V5PageMigrationModal';
+import { ConfirmModal } from './ConfirmModal';
 import AdminAppContainer from '../../../client/services/AdminAppContainer';
 import AdminAppContainer from '../../../client/services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
 import { toastSuccess, toastError } from '../../../client/util/apiNotification';
@@ -31,8 +31,11 @@ const V5PageMigration: FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <>
     <>
-      <V5PageMigrationModal
+      <ConfirmModal
         isModalOpen={isV5PageMigrationModalShown}
         isModalOpen={isV5PageMigrationModalShown}
+        warningMessage={t('admin:v5_page_migration.modal_migration_warning')}
+        supplymentaryMessage={t('admin:v5_page_migration.migration_note')}
+        confirmButtonTitle={t('admin:v5_page_migration.start_upgrading')}
         onConfirm={onConfirm}
         onConfirm={onConfirm}
         onCancel={() => setIsV5PageMigrationModalShown(false)}
         onCancel={() => setIsV5PageMigrationModalShown(false)}
       />
       />

+ 27 - 0
packages/app/src/server/middlewares/unavailable-when-maintenance-mode.ts

@@ -0,0 +1,27 @@
+import { NextFunction, Request, Response } from 'express';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middlewares:unavailable-when-maintenance-mode');
+
+export const generateUnavailableWhenMaintenanceModeMiddleware = crowi => async(req: Request, res: Response, next: NextFunction): Promise<void> => {
+  const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+
+  if (!isMaintenanceMode) {
+    next();
+    return;
+  }
+
+  res.render('maintenance-mode');
+};
+
+export const generateUnavailableWhenMaintenanceModeMiddlewareForApi = crowi => async(req: Request, res: Response, next: NextFunction): Promise<void> => {
+  const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+
+  if (!isMaintenanceMode) {
+    next();
+    return;
+  }
+
+  res.status(503).json({ error: 'GROWI is under maintenance.' });
+};

+ 1 - 0
packages/app/src/server/routes/apiv3/admin-home.js

@@ -74,6 +74,7 @@ module.exports = (crowi) => {
       installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
+      isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),
     };
     };
 
 
     return res.apiv3({ adminHomeParams });
     return res.apiv3({ adminHomeParams });

+ 50 - 0
packages/app/src/server/routes/apiv3/app-settings.js

@@ -197,6 +197,9 @@ module.exports = (crowi) => {
     pluginSetting: [
     pluginSetting: [
       body('isEnabledPlugins').isBoolean(),
       body('isEnabledPlugins').isBoolean(),
     ],
     ],
+    maintenanceMode: [
+      body('flag').isBoolean(),
+    ],
   };
   };
 
 
   /**
   /**
@@ -262,6 +265,7 @@ module.exports = (crowi) => {
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
       envGcsUploadNamespace: crowi.configManager.getConfigFromEnvVars('crowi', 'gcs:uploadNamespace'),
 
 
       isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
       isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
+      isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),
     };
     };
     return res.apiv3({ appSettingsParams });
     return res.apiv3({ appSettingsParams });
 
 
@@ -694,5 +698,51 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  router.post('/v5-schema-migration', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, async(req, res) => {
+    const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+    if (!isMaintenanceMode) {
+      return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
+    }
+
+    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+
+    try {
+      if (!isV5Compatible) {
+        // This method throws and emit socketIo event when error occurs
+        crowi.pageService.normalizeAllPublicPages();
+      }
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
+    }
+
+    return res.apiv3({ isV5Compatible });
+  });
+
+  // eslint-disable-next-line max-len
+  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+    const { flag } = req.body;
+
+    try {
+      if (flag) {
+        await crowi.appService.startMaintenanceMode();
+      }
+      else {
+        await crowi.appService.endMaintenanceMode();
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      if (flag) {
+        res.apiv3Err(new ErrorV3('Failed to start maintenance mode', 'failed_to_start_maintenance_mode'), 500);
+      }
+      else {
+        res.apiv3Err(new ErrorV3('Failed to end maintenance mode', 'failed_to_end_maintenance_mode'), 500);
+      }
+    }
+
+    res.apiv3({ flag });
+  });
+
   return router;
   return router;
 };
 };

+ 5 - 0
packages/app/src/server/routes/apiv3/import.js

@@ -218,6 +218,11 @@ module.exports = (crowi) => {
       }
       }
     }
     }
 
 
+    const isMaintenanceMode = crowi.appService.isMaintenanceMode();
+    if (!isMaintenanceMode) {
+      return res.apiv3Err(new ErrorV3('GROWI is not maintenance mode. To import data, please activate the maintenance mode first.', 'not_maintenance_mode'));
+    }
+
 
 
     const zipFile = importService.getFile(fileName);
     const zipFile = importService.getFile(fileName);
 
 

+ 12 - 11
packages/app/src/server/routes/apiv3/index.js

@@ -9,6 +9,7 @@ const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-un
 const express = require('express');
 const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
+const routerForAdmin = express.Router();
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
 
 
@@ -18,16 +19,16 @@ module.exports = (crowi) => {
   router.use('/healthcheck', require('./healthcheck')(crowi));
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
 
   // admin
   // admin
-  router.use('/admin-home', require('./admin-home')(crowi));
-  router.use('/markdown-setting', require('./markdown-setting')(crowi));
-  router.use('/app-settings', require('./app-settings')(crowi));
-  router.use('/customize-setting', require('./customize-setting')(crowi));
-  router.use('/notification-setting', require('./notification-setting')(crowi));
-  router.use('/users', require('./users')(crowi));
-  router.use('/user-groups', require('./user-group')(crowi));
-  router.use('/export', require('./export')(crowi));
-  router.use('/import', require('./import')(crowi));
-  router.use('/search', require('./search')(crowi));
+  routerForAdmin.use('/admin-home', require('./admin-home')(crowi));
+  routerForAdmin.use('/markdown-setting', require('./markdown-setting')(crowi));
+  routerForAdmin.use('/app-settings', require('./app-settings')(crowi));
+  routerForAdmin.use('/customize-setting', require('./customize-setting')(crowi));
+  routerForAdmin.use('/notification-setting', require('./notification-setting')(crowi));
+  routerForAdmin.use('/users', require('./users')(crowi));
+  routerForAdmin.use('/user-groups', require('./user-group')(crowi));
+  routerForAdmin.use('/export', require('./export')(crowi));
+  routerForAdmin.use('/import', require('./import')(crowi));
+  routerForAdmin.use('/search', require('./search')(crowi));
 
 
 
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
@@ -74,5 +75,5 @@ module.exports = (crowi) => {
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
 
 
-  return router;
+  return [router, routerForAdmin];
 };
 };

+ 0 - 16
packages/app/src/server/routes/apiv3/pages.js

@@ -782,22 +782,6 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
   });
 
 
-  router.post('/v5-schema-migration', accessTokenParser, loginRequired, adminRequired, csrf, async(req, res) => {
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-
-    try {
-      if (!isV5Compatible) {
-        // this method throws and emit socketIo event when error occurs
-        crowi.pageService.normalizeAllPublicPages(); // not await
-      }
-    }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
-    }
-
-    return res.apiv3({ isV5Compatible });
-  });
-
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
     const { pageIds: _pageIds, isRecursively } = req.body;

+ 57 - 32
packages/app/src/server/routes/index.js

@@ -3,6 +3,9 @@ import express from 'express';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
+import {
+  generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
+} from '../middlewares/unavailable-when-maintenance-mode';
 
 
 import * as loginFormValidator from '../middlewares/login-form-validator';
 import * as loginFormValidator from '../middlewares/login-form-validator';
 import * as registerFormValidator from '../middlewares/register-form-validator';
 import * as registerFormValidator from '../middlewares/register-form-validator';
@@ -52,15 +55,21 @@ module.exports = function(crowi, app) {
   const hackmd = require('./hackmd')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
   const ogp = require('./ogp')(crowi);
   const ogp = require('./ogp')(crowi);
 
 
+  const unavailableWhenMaintenanceMode = generateUnavailableWhenMaintenanceModeMiddleware(crowi);
+  const unavailableWhenMaintenanceModeForApi = generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
+
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
   const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
 
-  // API v3
+  const [apiV3Router, apiV3AdminRouter] = require('./apiv3')(crowi);
+
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/api-docs', require('./apiv3/docs')(crowi));
-  app.use('/_api/v3', require('./apiv3')(crowi));
 
 
-  app.get('/'                         , applicationInstalled, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
+  // API v3 for admin
+  app.use('/_api/v3', apiV3AdminRouter);
+
+  app.get('/'                         , applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
@@ -142,6 +151,51 @@ module.exports = function(crowi, app) {
 
 
   app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
   app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
 
 
+  /*
+   * Routes below are unavailable when maintenance mode
+   */
+
+  // API v3
+  app.use('/_api/v3', unavailableWhenMaintenanceModeForApi, apiV3Router);
+
+  const apiV1Router = express.Router();
+
+  apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
+
+  apiV1Router.get('/check_username'           , user.api.checkUsername);
+  apiV1Router.get('/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
+
+  // HTTP RPC Styled API (に徐々に移行していいこうと思う)
+  apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
+  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
+  apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
+  apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
+  apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
+  // allow posting to guests because the client doesn't know whether the user logged in
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , csrf, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , csrf, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
+  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
+  apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
+  apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
+  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, tag.api.update);
+  apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
+  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
+  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, comment.api.remove);
+  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
+  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);
+  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
+  apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
+
+  // API v1
+  app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);
+
+  app.use(unavailableWhenMaintenanceMode);
+
+  app.get('/tags'                     , loginRequired, tag.showPage);
+
   app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
   // external-accounts
   // my in-app-notifications
   // my in-app-notifications
@@ -156,35 +210,6 @@ module.exports = function(crowi, app) {
   app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);
   app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);
 
 
   app.get('/_search'                            , loginRequired, injectUserUISettings, search.searchPage);
   app.get('/_search'                            , loginRequired, injectUserUISettings, search.searchPage);
-  app.get('/_api/search'                        , accessTokenParser , loginRequired , search.api.search);
-
-  app.get('/_api/check_username'           , user.api.checkUsername);
-  app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
-
-  // HTTP RPC Styled API (に徐々に移行していいこうと思う)
-  app.get('/_api/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  app.post('/_api/pages.update'       , accessTokenParser , loginRequiredStrictly , csrf, page.api.update);
-  app.get('/_api/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
-  app.get('/_api/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
-  app.get('/_api/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
-  // allow posting to guests because the client doesn't know whether the user logged in
-  app.post('/_api/pages.remove'       , loginRequiredStrictly , csrf, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  app.post('/_api/pages.revertRemove' , loginRequiredStrictly , csrf, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
-  app.post('/_api/pages.unlink'       , loginRequiredStrictly , csrf, page.api.unlink); // (Avoid from API Token)
-  app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, csrf, page.api.duplicate);
-  app.get('/tags'                     , loginRequired, tag.showPage);
-  app.get('/_api/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
-  app.get('/_api/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  app.post('/_api/tags.update'        , accessTokenParser, loginRequiredStrictly, tag.api.update);
-  app.get('/_api/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
-  app.post('/_api/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.add);
-  app.post('/_api/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , csrf, comment.api.update);
-  app.post('/_api/comments.remove'    , accessTokenParser , loginRequiredStrictly , csrf, comment.api.remove);
-  app.post('/_api/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.add);
-  app.post('/_api/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,csrf, attachment.api.uploadProfileImage);
-  app.post('/_api/attachments.remove'               , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.remove);
-  app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
-  app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
 
   app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
   app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
   app.get('/trash/$'                  , loginRequired, (req, res) => res.redirect('/trash'));
   app.get('/trash/$'                  , loginRequired, (req, res) => res.redirect('/trash'));

+ 12 - 0
packages/app/src/server/service/app.ts

@@ -119,4 +119,16 @@ export default class AppService implements S2sMessageHandlable {
     this.crowi.setupGlobalErrorHandlers();
     this.crowi.setupGlobalErrorHandlers();
   }
   }
 
 
+  isMaintenanceMode(): boolean {
+    return this.configManager.getConfig('crowi', 'app:isMaintenanceMode');
+  }
+
+  async startMaintenanceMode(): Promise<void> {
+    await this.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isMaintenanceMode': true });
+  }
+
+  async endMaintenanceMode(): Promise<void> {
+    await this.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isMaintenanceMode': false });
+  }
+
 }
 }

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -181,6 +181,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     type:    ValueType.BOOLEAN,
     default: undefined,
     default: undefined,
   },
   },
+  IS_MAINTENANCE_MODE: {
+    ns:      'crowi',
+    key:     'app:isMaintenanceMode',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   AUTO_INSTALL_ADMIN_USERNAME: {
   AUTO_INSTALL_ADMIN_USERNAME: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'autoInstall:adminUsername',
     key:     'autoInstall:adminUsername',

+ 2 - 2
packages/app/src/server/views/forgot-password.html

@@ -32,8 +32,8 @@
           <div class="col-md-6 mt-5">
           <div class="col-md-6 mt-5">
             <div class="text-center">
             <div class="text-center">
               <h1><i class="icon-lock large"></i></h1>
               <h1><i class="icon-lock large"></i></h1>
-              <h2 class="text-center">{{ t('forgot_password.forgot_password') }}</h2>
-              <p>{{ t('forgot_password.password_reset_request_desc') }}</p>
+              <h1 class="text-center">{{ t('forgot_password.forgot_password') }}</h1>
+              <h3>{{ t('forgot_password.password_reset_request_desc') }}</h3>
               <div id="password-reset-request-form"></div>
               <div id="password-reset-request-form"></div>
             </div>
             </div>
           </div>
           </div>

+ 56 - 0
packages/app/src/server/views/maintenance-mode.html

@@ -0,0 +1,56 @@
+{% extends './layout/layout.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('maintenance_mode.maintenance_mode')) }}{% endblock %}
+
+
+{#
+  # Remove default contents
+  #}
+ {% block html_head_loading_legacy %}
+ {% endblock %}
+ {% block html_head_loading_app %}
+ {% endblock %}
+ {% block layout_head_nav %}
+ {% endblock %}
+ {% block sidebar %}
+ {% endblock %}
+ {% block head_warn_alert_siteurl_undefined %}
+ {% endblock %}
+ {% block fixed-controls %}
+ {% endblock %}
+
+{% block layout_main %}
+<div id="main" class="main">
+  <div id="content-main" class="content-main container-lg">
+    <div class="container">
+      <div class="row justify-content-md-center">
+        <div class="col-md-6 mt-5">
+          <div class="text-center">
+            <h1><i class="icon-exclamation large"></i></h1>
+            <h1 class="text-center">{{ t('maintenance_mode.maintenance_mode') }}</h1>
+            <h3>{{ t('maintenance_mode.growi_is_under_maintenance') }}</h3>
+            <hr />
+            <div class="text-left">
+              <p>
+                <i class="icon-arrow-right"></i>
+                <a href="/admin">{{ t('maintenance_mode.admin_page') }}</a>
+              </p>
+              {% if not user %}
+                <p>
+                  <i class="icon-arrow-right"></i>
+                  <a href="/login">{{ t('maintenance_mode.login') }}</a>
+                </p>
+              {% else %}
+                <p>
+                  <i class="icon-arrow-right"></i>
+                  <a href="/logout">{{ t('maintenance_mode.logout') }}</a>
+                </p>
+              {% endif %}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+{% endblock %}