Przeglądaj źródła

Merge pull request #1508 from weseek/reactify-admin/app-settings-stocks

Reactify admin/app settings stocks
Yuki Takei 6 lat temu
rodzic
commit
7eb8d4fa54

+ 5 - 2
resource/locales/en-US/translation.json

@@ -373,7 +373,7 @@
     "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "Confidential name": "Confidential name",
     "Default Language for new users": "Default Language for new users",
-    "ex): internal use only": "ex): internal use only",
+    "ex) internal use only":"ex): internal use only",
     "File Uploading": "File Uploading",
     "enable_files_except_image": "Enable file upload other than image files.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
@@ -400,7 +400,10 @@
     "Load plugins": "Load plugins",
     "Enable": "Enable",
     "Disable": "Disable",
-    "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used."
+    "Use env var if empty": "If the value in the database is empty, the value of the environment variable <cod>{{variable}}</code> is used.",
+    "updated_app_setting": "Succeeded to update app setting",
+    "updated_site_url": "Succeeded to update site URL",
+    "updated_plugin_setting": "Succeeded to update plugin setting"
   },
   "security_setting": {
     "Security settings": "Security settings",

+ 5 - 2
resource/locales/ja/translation.json

@@ -371,7 +371,7 @@
     "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL",
     "Confidential name": "コンフィデンシャル表示",
     "Default Language for new users": "新規ユーザーのデフォルト設定言語",
-    "ex): internal use only": "例: 社外秘",
+    "ex) internal use only": "例: 社外秘",
     "File Uploading": "ファイルアップロード",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
@@ -398,7 +398,10 @@
     "Load plugins": "プラグインを読み込む",
     "Enable": "有効",
     "Disable": "無効",
-    "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します"
+    "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
+    "updated_app_setting": "アプリ設定を更新しました",
+    "updated_site_url": "サイトURLを更新しました",
+    "updated_plugin_setting": "プラグイン設定を更新しました"
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

+ 16 - 0
src/client/js/app.jsx

@@ -37,6 +37,7 @@ import TableOfContents from './components/TableOfContents';
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
+import AppSettingsPage from './components/Admin/App/AppSettingsPage';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
@@ -52,6 +53,7 @@ import TagContainer from './services/TagContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
+import AdminAppContainer from './services/AdminAppContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 import AdminMarkDownContainer from './services/AdminMarkDownContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
@@ -167,6 +169,20 @@ const adminContainers = {
   'admin-export-page': websocketContainer,
 };
 
+// render for admin
+const adminAppElem = document.getElementById('admin-app');
+if (adminAppElem != null) {
+  const adminAppContainer = new AdminAppContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[injectableContainers, adminAppContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <AppSettingsPage />
+      </I18nextProvider>
+    </Provider>,
+    adminAppElem,
+  );
+}
+
 /**
  * define components
  *  key: id of element

+ 139 - 0
src/client/js/components/Admin/App/AppSetting.jsx

@@ -0,0 +1,139 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class AppSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateAppSettingHandler();
+      toastSuccess(t('app_setting.updated_app_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.Site Name')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.title}
+              onChange={(e) => { adminAppContainer.changeTitle(e.target.value) }}
+              placeholder="GROWI"
+            />
+            <p className="help-block">{t('app_setting.sitename_change')}</p>
+          </div>
+        </div>
+
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.Confidential name')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.confidential}
+              onChange={(e) => { adminAppContainer.changeConfidential(e.target.value) }}
+              placeholder={t('app_setting.ex) internal use only')}
+            />
+            <p className="help-block">{t('app_setting.header_content')}</p>
+          </div>
+        </div>
+
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.Default Language for new users')}</label>
+          <div className="col-xs-6">
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioLangEn"
+                name="globalLang"
+                value="en-US"
+                checked={adminAppContainer.state.globalLang === 'en-US'}
+                onClick={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
+              />
+              <label htmlFor="radioLangEn">{t('English')}</label>
+            </div>
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioLangJa"
+                name="globalLang"
+                value="ja"
+                checked={adminAppContainer.state.globalLang === 'ja'}
+                onClick={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
+              />
+              <label htmlFor="radioLangJa">{t('Japanese')}</label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.File Uploading')}</label>
+          <div className="col-xs-6">
+            <div className="checkbox checkbox-info">
+              <input
+                type="checkbox"
+                id="cbFileUpload"
+                name="fileUpload"
+                checked={adminAppContainer.state.fileUpload}
+                onChange={(e) => { adminAppContainer.changeFileUpload(e.target.checked) }}
+              />
+              <label htmlFor="cbFileUpload">{t('app_setting.enable_files_except_image')}</label>
+            </div>
+
+            <p className="help-block">
+              {t('app_setting.enable_files_except_image')}
+              <br />
+              {t('app_setting.attach_enable')}
+            </p>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingWrapper = (props) => {
+  return createSubscribedElement(AppSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+AppSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(AppSettingWrapper);

+ 94 - 0
src/client/js/components/Admin/App/AppSettingsPage.jsx

@@ -0,0 +1,94 @@
+import React, { Fragment } from 'react';
+import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+import AppSetting from './AppSetting';
+import SiteUrlSetting from './SiteUrlSetting';
+import MailSetting from './MailSetting';
+import AwsSetting from './AwsSetting';
+import PluginSetting from './PluginSetting';
+
+const logger = loggerFactory('growi:appSettings');
+
+class AppSettingsPage extends React.Component {
+
+  async componentDidMount() {
+    const { adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.retrieveAppSettingsData();
+    }
+    catch (err) {
+      toastError(err);
+      adminAppContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('App Settings')}</h2>
+            <AppSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('Site URL settings')}</h2>
+            <SiteUrlSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('app_setting.Mail settings')}</h2>
+            <MailSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('app_setting.AWS settings')}</h2>
+            <AwsSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('app_setting.Plugin settings')}</h2>
+            <PluginSetting />
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+AppSettingsPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageWrapper = (props) => {
+  return createSubscribedElement(AppSettingsPage, props, [AppContainer, AdminAppContainer]);
+};
+
+
+export default withTranslation()(AppSettingsPageWrapper);

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

@@ -0,0 +1,156 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class AwsSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateAwsSettingHandler();
+      toastSuccess(t('app_setting.updated_app_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">
+          {t('app_setting.AWS_access')}
+          <br />
+          {t('app_setting.No_SMTP_setting')}
+          <br />
+          <br />
+          <span className="text-danger">
+            <i className="ti-unlink"></i>
+            {t('app_setting.change_setting')}
+          </span>
+        </p>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            {t('app_setting.region')}
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              placeholder={`${t('eg')} ap-northeast-1`}
+              defaultValue={adminAppContainer.state.region}
+              onChange={(e) => {
+                adminAppContainer.changeRegion(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            {t('app_setting.custom endpoint')}
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={`${t('eg')} http://localhost:9000`}
+              defaultValue={adminAppContainer.state.customEndpoint}
+              onChange={(e) => {
+                adminAppContainer.changeCustomEndpoint(e.target.value);
+              }}
+            />
+            <p className="help-block">{t('app_setting.custom_endpoint_change')}</p>
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            {t('app_setting.bucket name')}
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={`${t('eg')} crowi`}
+              defaultValue={adminAppContainer.state.bucket}
+              onChange={(e) => {
+                adminAppContainer.changeBucket(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            Access Key ID
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.accessKeyId}
+              onChange={(e) => {
+                adminAppContainer.changeAccessKeyId(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            Secret Access Key
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.secretKey}
+              onChange={(e) => {
+                adminAppContainer.changeSecretKey(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const AwsSettingWrapper = (props) => {
+  return createSubscribedElement(AwsSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+AwsSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(AwsSettingWrapper);

+ 117 - 0
src/client/js/components/Admin/App/MailSetting.jsx

@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class MailSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateMailSettingHandler();
+      toastSuccess(t('app_setting.updated_app_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">{t('app_setting.SMTP_used')} {t('app_setting.SMTP_but_AWS')}<br />{t('app_setting.neihter_of')}</p>
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">{t('app_setting.From e-mail address')}</label>
+          <div className="col-xs-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>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">{ t('app_setting.SMTP settings') }</label>
+          <div className="col-xs-4">
+            <label>{ t('app_setting.Host') }</label>
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpHost}
+              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+            />
+          </div>
+          <div className="col-xs-2">
+            <label>{ t('app_setting.Port') }</label>
+            <input
+              className="form-control"
+              defaultValue={adminAppContainer.state.smtpPort}
+              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <div className="col-xs-3 col-xs-offset-3">
+            <label>{ t('app_setting.User') }</label>
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.SmtpUser}
+              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+            />
+          </div>
+          <div className="col-xs-3">
+            <label>{ t('Password') }</label>
+            <input
+              className="form-control"
+              type="password"
+              defaultValue={adminAppContainer.state.smtpPassword}
+              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const MailSettingWrapper = (props) => {
+  return createSubscribedElement(MailSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+MailSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(MailSettingWrapper);

+ 80 - 0
src/client/js/components/Admin/App/PluginSetting.jsx

@@ -0,0 +1,80 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:app:pluginSetting');
+
+class PluginSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updatePluginSettingHandler();
+      toastSuccess(t('app_setting.updated_plugin_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">{t('app_setting.Enable plugin loading')}</p>
+
+        <div className="row mb-5">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isEnabledPlugins"
+                type="checkbox"
+                checked={adminAppContainer.state.isEnabledPlugins}
+                onChange={(e) => {
+                  adminAppContainer.changeIsEnabledPlugins(e.target.checked);
+                }}
+              />
+              <label htmlFor="isEnabledPlugins">{t('app_setting.Load plugins')}</label>
+            </div>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const PluginSettingWrapper = (props) => {
+  return createSubscribedElement(PluginSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+PluginSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(PluginSettingWrapper);

+ 108 - 0
src/client/js/components/Admin/App/SiteUrlSetting.jsx

@@ -0,0 +1,108 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class SiteUrlSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateSiteUrlSettingHandler();
+      toastSuccess(t('app_setting.updated_site_url'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">{t('app_setting.Site URL desc')}</p>
+        {!adminAppContainer.state.isSetSiteUrl && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('app_setting.Site URL warn')}</p>)}
+
+        <div className="row">
+          <div className="col-md-12">
+            <div className="col-xs-offset-3">
+              <table className="table settings-table">
+                <colgroup>
+                  <col className="from-db" />
+                  <col className="from-env-vars" />
+                </colgroup>
+                <thead>
+                  <tr>
+                    <th>Database</th>
+                    <th>Environment variables</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td>
+                      <input
+                        className="form-control"
+                        type="text"
+                        name="settingForm[app:siteUrl]"
+                        defaultValue={adminAppContainer.state.siteUrl}
+                        onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
+                        placeholder="e.g. https://my.growi.org"
+                      />
+                      <p className="help-block">
+                        {/* eslint-disable-next-line react/no-danger */}
+                        <div dangerouslySetInnerHTML={{ __html: t('app_setting.siteurl_help') }} />
+                      </p>
+                    </td>
+                    <td>
+                      <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl} readOnly />
+                      <p className="help-block">
+                        {/* eslint-disable-next-line react/no-danger */}
+                        <div dangerouslySetInnerHTML={{ __html: t('app_setting.Use env var if empty', { variable: 'APP_SITE_URL' }) }} />
+                      </p>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SiteUrlSettingWrapper = (props) => {
+  return createSubscribedElement(SiteUrlSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+SiteUrlSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SiteUrlSettingWrapper);

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

@@ -0,0 +1,299 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+const logger = loggerFactory('growi:appSettings');
+
+/**
+ * Service container for admin app setting page (AppSettings.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminAppContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      title: '',
+      confidential: '',
+      globalLang: '',
+      fileUpload: '',
+      siteUrl: '',
+      envSiteUrl: '',
+      isSetSiteUrl: true,
+      fromAddress: '',
+      smtpHost: '',
+      smtpPort: '',
+      smtpUser: '',
+      smtpPassword: '',
+      region: '',
+      customEndpoint: '',
+      bucket: '',
+      accessKeyId: '',
+      secretKey: '',
+      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.changeSecretKey = this.changeSecretKey.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);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminAppContainer';
+  }
+
+  /**
+   * retrieve app sttings data
+   */
+  async retrieveAppSettingsData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/app-settings/');
+      const { appSettingsParams } = response.data;
+
+      this.setState({
+        title: appSettingsParams.title,
+        confidential: appSettingsParams.confidential,
+        globalLang: appSettingsParams.globalLang,
+        fileUpload: appSettingsParams.fileUpload,
+        siteUrl: appSettingsParams.siteUrl,
+        envSiteUrl: appSettingsParams.envSiteUrl,
+        isSetSiteUrl: !!appSettingsParams.siteUrl,
+        fromAddress: appSettingsParams.fromAddress,
+        smtpHost: appSettingsParams.smtpHost,
+        smtpPort: appSettingsParams.smtpPort,
+        smtpUser: appSettingsParams.smtpUser,
+        smtpPassword: appSettingsParams.smtpPassword,
+        region: appSettingsParams.region,
+        customEndpoint: appSettingsParams.customEndpoint,
+        bucket: appSettingsParams.bucket,
+        accessKeyId: appSettingsParams.accessKeyId,
+        secretKey: appSettingsParams.secretKey,
+        isEnabledPlugins: appSettingsParams.isEnabledPlugins,
+      });
+
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+  /**
+   * Change title
+   */
+  changeTitle(title) {
+    this.setState({ title });
+  }
+
+  /**
+   * Change confidential
+   */
+  changeConfidential(confidential) {
+    this.setState({ confidential });
+  }
+
+  /**
+   * Change globalLang
+   */
+  changeGlobalLang(globalLang) {
+    this.setState({ globalLang });
+  }
+
+  /**
+   * Change fileUpload
+   */
+  changeFileUpload(fileUpload) {
+    this.setState({ fileUpload });
+  }
+
+  /**
+   * Change site url
+   */
+  changeSiteUrl(siteUrl) {
+    this.setState({ siteUrl });
+  }
+
+
+  /**
+   * Change from address
+   */
+  changeFromAddress(fromAddress) {
+    this.setState({ fromAddress });
+  }
+
+  /**
+   * Change smtp host
+   */
+  changeSmtpHost(smtpHost) {
+    this.setState({ smtpHost });
+  }
+
+  /**
+   * Change smtp port
+   */
+  changeSmtpPort(smtpPort) {
+    this.setState({ smtpPort });
+  }
+
+  /**
+   * Change smtp user
+   */
+  changeSmtpUser(smtpUser) {
+    this.setState({ smtpUser });
+  }
+
+  /**
+   * Change smtp password
+   */
+  changeSmtpPassword(smtpPassword) {
+    this.setState({ smtpPassword });
+  }
+
+  /**
+   * Change region
+   */
+  changeRegion(region) {
+    this.setState({ region });
+  }
+
+  /**
+   * Change custom endpoint
+   */
+  changeCustomEndpoint(customEndpoint) {
+    this.setState({ customEndpoint });
+  }
+
+  /**
+   * Change bucket name
+   */
+  changeBucket(bucket) {
+    this.setState({ bucket });
+  }
+
+  /**
+   * Change access key id
+   */
+  changeAccessKeyId(accessKeyId) {
+    this.setState({ accessKeyId });
+  }
+
+  /**
+   * Change secret key
+   */
+  changeSecretKey(secretKey) {
+    this.setState({ secretKey });
+  }
+
+  /**
+   * Change secret key
+   */
+  changeIsEnabledPlugins(isEnabledPlugins) {
+    this.setState({ isEnabledPlugins });
+  }
+
+  /**
+   * Update app setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateAppSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/app-setting', {
+      title: this.state.title,
+      confidential: this.state.confidential,
+      globalLang: this.state.globalLang,
+      fileUpload: this.state.fileUpload,
+    });
+    const { appSettingParams } = response.data;
+    return appSettingParams;
+  }
+
+
+  /**
+   * Update site url setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateSiteUrlSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/site-url-setting', {
+      siteUrl: this.state.siteUrl,
+    });
+    const { siteUrlSettingParams } = response.data;
+    return siteUrlSettingParams;
+  }
+
+  /**
+   * Update mail setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateMailSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/mail-setting', {
+      fromAddress: this.state.fromAddress,
+      smtpHost: this.state.smtpHost,
+      smtpPort: this.state.smtpPort,
+      smtpUser: this.state.smtpUser,
+      smtpPassword: this.state.smtpPassword,
+    });
+    const { mailSettingParams } = response.data;
+    return mailSettingParams;
+  }
+
+  /**
+   * Update AWS setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateAwsSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/aws-setting', {
+      region: this.state.region,
+      customEndpoint: this.state.customEndpoint,
+      bucket: this.state.bucket,
+      accessKeyId: this.state.accessKeyId,
+      secretKey: this.state.secretKey,
+    });
+    const { awsSettingParams } = response.data;
+    return awsSettingParams;
+  }
+
+  /**
+   * Update plugin setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updatePluginSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/plugin-setting', {
+      isEnabledPlugins: this.state.isEnabledPlugins,
+    });
+    const { pluginSettingParams } = response.data;
+    return pluginSettingParams;
+  }
+
+
+}

+ 0 - 10
src/server/form/admin/app.js

@@ -1,10 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[app:title]').trim(),
-  field('settingForm[app:confidential]'),
-  field('settingForm[app:globalLang]'),
-  field('settingForm[app:fileUpload]').trim().toBooleanStrict(),
-);

+ 0 - 11
src/server/form/admin/aws.js

@@ -1,11 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[aws:region]', 'リージョン').trim().is(/^[a-z]+-[a-z]+-\d+$/, 'リージョンには、AWSリージョン名を入力してください。 例: ap-northeast-1'),
-  field('settingForm[aws:customEndpoint]', 'カスタムエンドポイント').trim().is(/^(https?:\/\/[^/]+|)$/, 'カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。'),
-  field('settingForm[aws:bucket]', 'バケット名').trim(),
-  field('settingForm[aws:accessKeyId]', 'Access Key Id').trim().is(/^[\da-zA-Z]+$/),
-  field('settingForm[aws:secretAccessKey]', 'Secret Access Key').trim(),
-);

+ 0 - 11
src/server/form/admin/mail.js

@@ -1,11 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[mail:from]', 'メールFrom').trim(),
-  field('settingForm[mail:smtpHost]', 'SMTPホスト').trim(),
-  field('settingForm[mail:smtpPort]', 'SMTPポート').trim().toInt(),
-  field('settingForm[mail:smtpUser]', 'SMTPユーザー').trim(),
-  field('settingForm[mail:smtpPassword]', 'SMTPパスワード').trim(),
-);

+ 0 - 7
src/server/form/admin/plugin.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[plugin:isEnabledPlugins]').trim().toBooleanStrict(),
-);

+ 0 - 7
src/server/form/admin/siteUrl.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[app:siteUrl]').trim().isUrl(),
-);

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

@@ -11,11 +11,6 @@ module.exports = {
     apiToken: require('./me/apiToken'),
   },
   admin: {
-    app: require('./admin/app'),
-    siteUrl: require('./admin/siteUrl'),
-    mail: require('./admin/mail'),
-    aws: require('./admin/aws'),
-    plugin: require('./admin/plugin'),
     securityGeneral: require('./admin/securityGeneral'),
     securityPassportLocal: require('./admin/securityPassportLocal'),
     securityPassportLdap: require('./admin/securityPassportLdap'),

+ 0 - 75
src/server/routes/admin.js

@@ -567,54 +567,6 @@ module.exports = function(crowi, app) {
   };
 
   actions.api = {};
-  actions.api.appSetting = async function(req, res) {
-    const form = req.form.settingForm;
-
-    if (req.form.isValid) {
-      debug('form content', form);
-
-      // mail setting ならここで validation
-      if (form['mail:from']) {
-        validateMailSetting(req, form, async(err, data) => {
-          debug('Error validate mail setting: ', err, data);
-          if (err) {
-            req.form.errors.push('SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。');
-            return res.json({ status: false, message: req.form.errors.join('\n') });
-          }
-
-          await configManager.updateConfigsInTheSameNamespace('crowi', form);
-          return res.json({ status: true });
-        });
-      }
-      else {
-        await configManager.updateConfigsInTheSameNamespace('crowi', form);
-        return res.json({ status: true });
-      }
-    }
-    else {
-      return res.json({ status: false, message: req.form.errors.join('\n') });
-    }
-  };
-
-  actions.api.asyncAppSetting = async(req, res) => {
-    const form = req.form.settingForm;
-
-    if (!req.form.isValid) {
-      return res.json({ status: false, message: req.form.errors.join('\n') });
-    }
-
-    debug('form content', form);
-
-    try {
-      await configManager.updateConfigsInTheSameNamespace('crowi', form);
-      return res.json({ status: true });
-    }
-    catch (err) {
-      logger.error(err);
-      return res.json({ status: false });
-    }
-  };
-
   actions.api.securitySetting = async function(req, res) {
     if (!req.form.isValid) {
       return res.json({ status: false, message: req.form.errors.join('\n') });
@@ -1063,33 +1015,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
   };
 
-  function validateMailSetting(req, form, callback) {
-    const mailer = crowi.mailer;
-    const option = {
-      host: form['mail:smtpHost'],
-      port: form['mail:smtpPort'],
-    };
-    if (form['mail:smtpUser'] && form['mail:smtpPassword']) {
-      option.auth = {
-        user: form['mail:smtpUser'],
-        pass: form['mail:smtpPassword'],
-      };
-    }
-    if (option.port === 465) {
-      option.secure = true;
-    }
-
-    const smtpClient = mailer.createSMTPClient(option);
-    debug('mailer setup for validate SMTP setting', smtpClient);
-
-    smtpClient.sendMail({
-      from: form['mail:from'],
-      to: req.user.email,
-      subject: 'Wiki管理設定のアップデートによるメール通知',
-      text: 'このメールは、WikiのSMTP設定のアップデートにより送信されています。',
-    }, callback);
-  }
-
   /**
    * validate setting form values for SAML
    *

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

@@ -0,0 +1,460 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:app-settings');
+
+const debug = require('debug')('growi:routes:admin');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator/check');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+/**
+ * @swagger
+ *  tags:
+ *    name: AppSettings
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      AppSettingParams:
+ *        type: object
+ *        properties:
+ *          title:
+ *            type: String
+ *            description: site name show on page header and tilte of HTML
+ *          confidential:
+ *            type: String
+ *            description: confidential show on page header
+ *          globalLang:
+ *            type: String
+ *            description: language set when create user
+ *          fileUpload:
+ *            type: boolean
+ *            description: enable upload file except image file
+ *     SiteUrlSettingParams:
+ *        type: object
+ *        properties:
+ *          siteUrl:
+ *            type: String
+ *            description: Site URL. e.g. https://example.com, https://example.com:8080
+ *          envSiteUrl:
+ *            type: String
+ *            description: environment variable 'APP_SITE_URL'
+ *     MailSettingParams:
+ *        type: object
+ *        properties:
+ *          fromAddress:
+ *            type: String
+ *            description: e-mail address used as from address of mail which sent from GROWI app
+ *          smtpHost:
+ *            type: String
+ *            description: host name of client's smtp server
+ *          smtpPort:
+ *            type: String
+ *            description: port of client's smtp server
+ *          smtpUser:
+ *            type: String
+ *            description: user name of client's smtp server
+ *          smtpPassword:
+ *            type: String
+ *            description: password of client's smtp server
+ *      AwsSettingParams:
+ *        type: object
+ *          region:
+ *            type: String
+ *            description: region of AWS S3
+ *          customEndpoint:
+ *            type: String
+ *            description: custom endpoint of AWS S3
+ *          bucket:
+ *            type: String
+ *            description: AWS S3 bucket name
+ *          accessKeyId:
+ *            type: String
+ *            description: accesskey id for authentification of AWS
+ *          secretKey:
+ *            type: String
+ *            description: secret key for authentification of AWS
+ *      PluginSettingParams:
+ *        type: object
+ *          isEnabledPlugins:
+ *            type: String
+ *            description: enable use plugins
+ */
+
+module.exports = (crowi) => {
+  const accessTokenParser = require('../../middleware/access-token-parser')(crowi);
+  const loginRequired = require('../../middleware/login-required')(crowi);
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  const validator = {
+    appSetting: [
+      body('title').trim(),
+      body('confidential'),
+      body('globalLang').isIn(['en-US', 'ja']),
+      body('fileUpload').isBoolean(),
+    ],
+    siteUrlSetting: [
+      body('siteUrl').trim().matches(/^(https?:\/\/[^/]+|)$/).isURL({ require_tld: false }),
+    ],
+    mailSetting: [
+      body('fromAddress').trim().isEmail(),
+      body('smtpHost').trim(),
+      body('smtpPort').trim().isPort(),
+      body('smtpUser').trim(),
+      body('smtpPassword').trim(),
+    ],
+    awsSetting: [
+      body('region').trim().matches(/^[a-z]+-[a-z]+-\d+$/).withMessage('リージョンには、AWSリージョン名を入力してください。 例: ap-northeast-1'),
+      body('customEndpoint').trim().matches(/^(https?:\/\/[^/]+|)$/).withMessage('カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。'),
+      body('bucket').trim(),
+      body('accessKeyId').trim().matches(/^[\da-zA-Z]+$/),
+      body('secretKey').trim(),
+    ],
+    pluginSetting: [
+      body('isEnabledPlugins').isBoolean(),
+    ],
+  };
+
+  /**
+   * @swagger
+   *
+   *    /app-settings/:
+   *      get:
+   *        tags: [AppSettings]
+   *        description: get app setting params
+   *        responses:
+   *          200:
+   *            description: Resources are available
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    appSettingsParams:
+   *                      type: object
+   *                      description: app settings params
+   */
+  router.get('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    const appSettingsParams = {
+      title: crowi.configManager.getConfig('crowi', 'app:title'),
+      confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
+      globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+      fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
+      siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
+      envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
+      fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
+      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'),
+      region: crowi.configManager.getConfig('crowi', 'aws:region'),
+      customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),
+      bucket: crowi.configManager.getConfig('crowi', 'aws:bucket'),
+      accessKeyId: crowi.configManager.getConfig('crowi', 'aws:accessKeyId'),
+      secretKey: crowi.configManager.getConfig('crowi', 'aws:secretKey'),
+      isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
+    };
+    return res.apiv3({ appSettingsParams });
+
+  });
+
+
+  /**
+   * @swagger
+   *
+   *    /app-settings/app-setting:
+   *      put:
+   *        tags: [AppSettings]
+   *        description: Update app setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/AppSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update app setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/AppSettingParams'
+   */
+  router.put('/app-setting', loginRequiredStrictly, adminRequired, csrf, validator.appSetting, ApiV3FormValidator, async(req, res) => {
+    const requestAppSettingParams = {
+      'app:title': req.body.title,
+      'app:confidential': req.body.confidential,
+      'app:globalLang': req.body.globalLang,
+      'app:fileUpload': req.body.fileUpload,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestAppSettingParams);
+      const appSettingParams = {
+        title: crowi.configManager.getConfig('crowi', 'app:title'),
+        confidential: crowi.configManager.getConfig('crowi', 'app:confidential'),
+        globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
+        fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
+      };
+      return res.apiv3({ appSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating app setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-appSetting-failed'));
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /app-settings/site-url-setting:
+   *      put:
+   *        tags: [AppSettings]
+   *        description: Update site url setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/SiteUrlSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update site url setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/SiteUrlSettingParams'
+   */
+  router.put('/site-url-setting', loginRequiredStrictly, adminRequired, csrf, validator.siteUrlSetting, ApiV3FormValidator, async(req, res) => {
+
+    const requestSiteUrlSettingParams = {
+      'app:siteUrl': req.body.siteUrl,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestSiteUrlSettingParams);
+      const siteUrlSettingParams = {
+        siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
+      };
+      return res.apiv3({ siteUrlSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating site url setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
+    }
+
+  });
+
+  /**
+   * send mail (Promise wrapper)
+   */
+  async function sendMailPromiseWrapper(smtpClient, options) {
+    return new Promise((resolve, reject) => {
+      smtpClient.sendMail(options, (err, res) => {
+        if (err) {
+          reject(err);
+        }
+        else {
+          resolve(res);
+        }
+      });
+    });
+  }
+
+  /**
+   * validate mail setting send test mail
+   */
+  async function validateMailSetting(req) {
+    const mailer = crowi.mailer;
+    const option = {
+      host: req.body.smtpHost,
+      port: req.body.smtpPort,
+    };
+    if (req.body.smtpUser && req.body.smtpPassword) {
+      option.auth = {
+        user: req.body.smtpUser,
+        pass: req.body.smtpPassword,
+      };
+    }
+    if (option.port === 465) {
+      option.secure = true;
+    }
+
+    const smtpClient = mailer.createSMTPClient(option);
+    debug('mailer setup for validate SMTP setting', smtpClient);
+
+    const mailOptions = {
+      from: req.body.fromAddress,
+      to: req.user.email,
+      subject: 'Wiki管理設定のアップデートによるメール通知',
+      text: 'このメールは、WikiのSMTP設定のアップデートにより送信されています。',
+    };
+
+    await sendMailPromiseWrapper(smtpClient, mailOptions);
+  }
+
+  /**
+   * @swagger
+   *
+   *    /app-settings/site-url-setting:
+   *      put:
+   *        tags: [AppSettings]
+   *        description: Update site url setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/MailSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update site url setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/MailSettingParams'
+   */
+  router.put('/mail-setting', loginRequiredStrictly, adminRequired, csrf, validator.mailSetting, ApiV3FormValidator, async(req, res) => {
+    // テストメール送信によるバリデート
+    try {
+      await validateMailSetting(req);
+    }
+    catch (err) {
+      const msg = 'SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。';
+      logger.error('Error', err);
+      debug('Error validate mail setting: ', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-mailSetting-failed'));
+    }
+
+
+    const requestMailSettingParams = {
+      'mail:from': req.body.fromAddress,
+      'mail:smtpHost': req.body.smtpHost,
+      'mail:smtpPort': req.body.smtpPort,
+      'mail:smtpUser': req.body.smtpUser,
+      'mail:smtpPassword': req.body.smtpPassword,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestMailSettingParams);
+      const mailSettingParams = {
+        fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
+        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'),
+      };
+      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'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /app-settings/aws-setting:
+   *      put:
+   *        tags: [AppSettings]
+   *        description: Update aws setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/AwsSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update aws setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/AwsSettingParams'
+   */
+  router.put('/aws-setting', loginRequiredStrictly, adminRequired, csrf, validator.awsSetting, ApiV3FormValidator, async(req, res) => {
+    const requestAwsSettingParams = {
+      'aws:region': req.body.region,
+      'aws:customEndpoint': req.body.customEndpoint,
+      'aws:bucket': req.body.bucket,
+      'aws:accessKeyId': req.body.accessKeyId,
+      'aws:secretKey': req.body.secretKey,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestAwsSettingParams);
+      const awsSettingParams = {
+        region: crowi.configManager.getConfig('crowi', 'aws:region'),
+        customEndpoint: crowi.configManager.getConfig('crowi', 'aws:customEndpoint'),
+        bucket: crowi.configManager.getConfig('crowi', 'aws:bucket'),
+        accessKeyId: crowi.configManager.getConfig('crowi', 'aws:accessKeyId'),
+        secretKey: crowi.configManager.getConfig('crowi', 'aws:secretKey'),
+      };
+      return res.apiv3({ awsSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating aws setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-awsSetting-failed'));
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /app-settings/plugin-setting:
+   *      put:
+   *        tags: [AppSettings]
+   *        description: Update plugin setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/PluginSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update plugin setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/PluginSettingParams'
+   */
+  router.put('/plugin-setting', loginRequiredStrictly, adminRequired, csrf, validator.pluginSetting, ApiV3FormValidator, async(req, res) => {
+    const requestPluginSettingParams = {
+      'plugin:isEnabledPlugins': req.body.isEnabledPlugins,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestPluginSettingParams);
+      const pluginSettingParams = {
+        isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
+      };
+      return res.apiv3({ pluginSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating plugin setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-pluginSetting-failed'));
+    }
+
+  });
+
+  return router;
+};

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

@@ -15,6 +15,8 @@ module.exports = (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('/users', require('./users')(crowi));

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

@@ -59,11 +59,6 @@ module.exports = function(crowi, app) {
 
   app.get('/admin'                          , loginRequiredStrictly , adminRequired , admin.index);
   app.get('/admin/app'                      , loginRequiredStrictly , adminRequired , admin.app.index);
-  app.post('/_api/admin/settings/app'       , loginRequiredStrictly , adminRequired , csrf, form.admin.app, admin.api.appSetting);
-  app.post('/_api/admin/settings/siteUrl'   , loginRequiredStrictly , adminRequired , csrf, form.admin.siteUrl, admin.api.asyncAppSetting);
-  app.post('/_api/admin/settings/mail'      , loginRequiredStrictly , adminRequired , csrf, form.admin.mail, admin.api.appSetting);
-  app.post('/_api/admin/settings/aws'       , loginRequiredStrictly , adminRequired , csrf, form.admin.aws, admin.api.appSetting);
-  app.post('/_api/admin/settings/plugin'    , loginRequiredStrictly , adminRequired , csrf, form.admin.plugin, admin.api.appSetting);
 
   // security admin
   app.get('/admin/security'                     , loginRequiredStrictly , adminRequired , admin.security.index);

+ 1 - 393
src/server/views/admin/app.html

@@ -28,406 +28,14 @@
     {{ emessage }}
   </div>
   {% endif %}
-
   <div class="row">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'app'} %}
     </div>
-    <div class="col-md-9">
-
-      <form action="/_api/admin/settings/app" method="post" class="form-horizontal" id="appSettingForm" role="form">
-      <fieldset>
-        <legend>{{ t('App settings') }}</legend>
-        <div class="form-group">
-          <label for="settingForm[app:title]" class="col-xs-3 control-label">{{ t('app_setting.Site Name') }}</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[app:title]"
-                   type="text"
-                   name="settingForm[app:title]"
-                   value="{{ getConfig('crowi', 'app:title') | default('') }}"
-                   placeholder="GROWI">
-            <p class="help-block">{{ t("app_setting.sitename_change") }}</p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[app:confidential]" class="col-xs-3 control-label">{{ t('app_setting.Confidential name') }}</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[app:confidential]"
-                   type="text"
-                   name="settingForm[app:confidential]"
-                   value="{{ getConfig('crowi', 'app:confidential') | default('') }}"
-                   placeholder="{{ t('app_setting. ex&rpar;: internal use only') }}">
-            <p class="help-block">{{ t("app_setting.header_content") }}</p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label class="col-xs-3 control-label">{{ t('app_setting.Default Language for new users') }}</label>
-          <div class="col-xs-6">
-            <div class="radio radio-primary radio-inline">
-                <input type="radio"
-                       id="radioLangEn"
-                       name="settingForm[app:globalLang]"
-                       value="{{ consts.language.LANG_EN_US }}"
-                       {% if getConfig('crowi', 'app:globalLang') == consts.language.LANG_EN_US %}checked="checked"{% endif %}>
-                <label for="radioLangEn">{{ t('English') }}</label>
-            </div>
-            <div class="radio radio-primary radio-inline">
-                <input type="radio"
-                       id="radioLangJa"
-                       name="settingForm[app:globalLang]"
-                       value="{{ consts.language.LANG_JA }}"
-                       {% if getConfig('crowi', 'app:globalLang') == consts.language.LANG_JA %}checked="checked"{% endif %}>
-                <label for="radioLangJa">{{ t('Japanese') }}</label>
-            </div>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label class="col-xs-3 control-label">{{ t('app_setting.File Uploading') }}</label>
-          <div class="col-xs-6">
-            <div class="checkbox checkbox-info">
-              <input type="checkbox"
-                     id="cbFileUpload"
-                     name="settingForm[app:fileUpload]"
-                     value="1"
-                     {% if getConfig('crowi', 'app:fileUpload') %}checked{% endif %}
-                     {% if not fileUploadService.getIsUploadable() %}disabled="disabled"{% endif %}>
-              <label for="cbFileUpload">
-                {{ t("app_setting.enable_files_except_image") }}
-              </label>
-            </div>
-
-              <p class="help-block">
-                {{ t("app_setting.enable_files_except_image") }}<br>
-                {{ t("app_setting.attach_enable") }}
-              </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-3 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('app_setting.Update') }}</button>
-          </div>
-        </div>
-      </fieldset>
-      </form>
-
-      <form action="/_api/admin/settings/siteUrl" method="post" class="form-horizontal" id="siteUrlSettingForm" role="form">
-        <fieldset>
-          <legend>{{ t('Site URL settings') }}</legend>
-          <p class="well">{{ t('app_setting.Site URL desc') }}</p>
-          {% if !getConfig('crowi', 'app:siteUrl') %}
-            <p class="alert alert-danger"><i class="icon-exclamation"></i> {{ t('app_setting.Site URL warn') }}</p>
-          {% endif %}
-
-          <div class="col-xs-offset-3">
-            <table class="table settings-table">
-              <colgroup>
-                <col class="from-db">
-                <col class="from-env-vars">
-              </colgroup>
-              <thead>
-              <tr><th>Database</th><th>Environment variables</th></tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <td>
-                    <input class="form-control"
-                           type="text"
-                           name="settingForm[app:siteUrl]"
-                           value="{{ getConfigFromDB('crowi', 'app:siteUrl') | default('') }}"
-                           placeholder="e.g. https://my.growi.org">
-                    <p class="help-block">{{ t("app_setting.siteurl_help") }}</p>
-                  </td>
-                  <td>
-                    <input class="form-control"
-                           type="text"
-                           value="{{ getConfigFromEnvVars('crowi', 'app:siteUrl') | default('') }}"
-                           readonly>
-                    <p class="help-block">
-                      {{ t("app_setting.Use env var if empty", "APP_SITE_URL") }}
-                    </p>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-
-          <div class="form-group">
-            <div class="col-xs-offset-3 col-xs-6">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">{{ t('app_setting.Update') }}</button>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-
-
-      <form action="/_api/admin/settings/mail" method="post" class="form-horizontal" id="mailSettingForm" role="form">
-      <fieldset>
-      <legend>{{ t('app_setting.Mail settings') }}</legend>
-      <p class="well">{{ t("app_setting.SMTP_used") }} {{ t("app_setting.SMTP_but_AWS") }}<br>{{ t("app_setting.neihter_of") }}</p>
-
-        <div class="form-group">
-          <label for="settingForm[mail.from]" class="col-xs-3 control-label">{{ t('app_setting.From e-mail address') }}</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[mail.from]"
-                   type="text"
-                   name="settingForm[mail:from]"
-                   placeholder="{{ t('eg') }} mail@growi.org"
-                   value="{{ getConfig('crowi', 'mail:from') | default('') }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label class="col-xs-3 control-label">{{ t('app_setting.SMTP settings') }}</label>
-          <div class="col-xs-4">
-            <label>{{ t('app_setting.Host') }}</label>
-            <input class="form-control"
-                   type="text"
-                   name="settingForm[mail:smtpHost]"
-                   value="{{ getConfig('crowi', 'mail:smtpHost') | default('') }}">
-          </div>
-          <div class="col-xs-2">
-            <label>{{ t('app_setting.Port') }}</label>
-            <input class="form-control"
-                   type="text"
-                   name="settingForm[mail:smtpPort]"
-                   value="{{ getConfig('crowi', 'mail:smtpPort') | default('') }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-3 col-xs-offset-3">
-            <label>{{ t('app_setting.User') }}</label>
-            <input class="form-control"
-                   type="text"
-                   name="settingForm[mail:smtpUser]"
-                   value="{{ getConfig('crowi', 'mail:smtpUser') | default('') }}">
-          </div>
-          <div class="col-xs-3">
-            <label>{{ t('Password') }}</label>
-            <input class="form-control"
-                   type="password"
-                   name="settingForm[mail:smtpPassword]"
-                   value="{{ getConfig('crowi', 'mail:smtpPassword') | default('') }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-3 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('app_setting.Update') }}</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
-      <form action="/_api/admin/settings/aws" method="post" class="form-horizontal" id="awsSettingForm" role="form">
-      <fieldset>
-      <legend>{{ t('app_setting.AWS settings') }}</legend>
-        <p class="well">{{ t("app_setting.AWS_access") }}<br>
-        {{ t("app_setting.No_SMTP_setting") }}<br>
-          <br>
-
-          <span class="text-danger"><i class="ti-unlink"></i> {{ t("app_setting.change_setting") }}</span>
-        </p>
-
-        <div class="form-group">
-          <label for="settingForm[app:region]" class="col-xs-3 control-label">{{ t('app_setting.region') }}</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[app:region]"
-                   type="text"
-                   name="settingForm[aws:region]"
-                   placeholder="例: ap-northeast-1"
-                   value="{{ getConfig('crowi', 'aws:region') | default('') }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[aws:customEndpoint]" class="col-xs-3 control-label">{{ t('app_setting.custom endpoint') }}</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[aws:customEndpoint]"
-                   type="text"
-                   name="settingForm[aws:customEndpoint]"
-                   placeholder="例: http://localhost:9000"
-                   value="{{ getConfig('crowi', 'aws:customEndpoint') | default('') }}">
-                   <p class="help-block">{{ t("app_setting.custom_endpoint_change") }}</p>
-          </div>
-        </div>
 
-        <div class="form-group">
-          <label for="settingForm[aws:bucket]" class="col-xs-3 control-label">{{ t('app_setting.bucket name') }}</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[aws:bucket]"
-                   type="text"
-                   name="settingForm[aws:bucket]"
-                   placeholder="例: crowi"
-                   value="{{ getConfig('crowi', 'aws:bucket') | default('') }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[aws:accessKeyId]" class="col-xs-3 control-label">Access Key ID</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[aws:accessKeyId]"
-                   type="text"
-                   name="settingForm[aws:accessKeyId]"
-                   value="{{ getConfig('crowi', 'aws:accessKeyId') | default('') }}">
-          </div>
-
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[aws:secretAccessKey]" class="col-xs-3 control-label">Secret Access Key</label>
-          <div class="col-xs-6">
-            <input class="form-control"
-                   id="settingForm[aws:secretAccessKey]"
-                   type="text"
-                   name="settingForm[aws:secretAccessKey]"
-                   value="{{ getConfig('crowi', 'aws:secretAccessKey') | default('') }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-3 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('app_setting.Update') }}</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
-      <form action="/_api/admin/settings/plugin" method="post" class="form-horizontal" id="pluginSettingForm" role="form">
-      <fieldset>
-      <legend>{{ t('app_setting.Plugin settings') }}</legend>
-        <p class="well">{{ t('app_setting.Enable plugin loading') }}</p>
-
-        <div class="form-group">
-          <label class="col-xs-3 control-label">{{ t('app_setting.Load plugins') }}</label>
-          <div class="col-xs-6">
-
-            <div class="btn-group btn-toggle" data-toggle="buttons">
-              <label class="btn btn-default btn-rounded btn-outline {% if getConfig('crowi', 'plugin:isEnabledPlugins') %}active{% endif %}" data-active-class="primary">
-                <input name="settingForm[plugin:isEnabledPlugins]"
-                       value="true"
-                       type="radio"
-                       {% if true === getConfig('crowi', 'plugin:isEnabledPlugins') %}checked{% endif %}>
-                ON
-              </label>
-              <label class="btn btn-default btn-rounded btn-outline {% if !getConfig('crowi', 'plugin:isEnabledPlugins') %}active{% endif %}" data-active-class="default">
-                <input name="settingForm[plugin:isEnabledPlugins]"
-                       value="false"
-                       type="radio"
-                       {% if !getConfig('crowi', 'plugin:isEnabledPlugins') %}checked{% endif %}>
-                OFF
-              </label>
-            </div>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-3 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('app_setting.Update') }}</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
-    </div>
+    <div class="col-md-9" id="admin-app"></div>
   </div>
 
-  <script>
-    $('#appSettingForm, #siteUrlSettingForm, #mailSettingForm, #awsSettingForm, #pluginSettingForm').each(function() {
-      $(this).submit(function()
-      {
-        function showMessage(formId, msg, status) {
-          $('#' + formId + ' .alert').remove();
-
-          if (!status) {
-            status = 'success';
-          }
-          var $message = $('<p class="alert"></p>');
-          $message.addClass('alert-' + status);
-          $message.html(msg.replace(/\n/g, '<br>'));
-          $message.insertAfter('#' + formId + ' legend');
-
-          if (status == 'success') {
-            setTimeout(function()
-            {
-              $message.fadeOut({
-                complete: function() {
-                  $message.remove();
-                }
-              });
-            }, 5000);
-          }
-        }
-
-        var $form = $(this);
-        var $id = $form.attr('id');
-        var $button = $('button', this);
-        $button.attr('disabled', 'disabled');
-        var jqxhr = $.post($form.attr('action'), $form.serialize(), function(data)
-          {
-            if (data.status) {
-              showMessage($id, '更新しました');
-            } else {
-              showMessage($id, data.message, 'danger');
-            }
-          })
-          .fail(function() {
-            showMessage($id, 'エラーが発生しました', 'danger');
-          })
-          .always(function() {
-            $button.prop('disabled', false);
-        });
-        return false;
-      });
-    });
-
-    /**
-     * The following script sets the class name 'unused' to the cell in from-env-vars column
-     * when the value of the corresponding cell from the database is not empty.
-     * It is used to indicate that the system does not use a value from the environment variables by setting a css style.
-     *
-     * TODO The following script is duplicated from saml.html. It is desirable to integrate those in the future.
-     */
-    $('.settings-table tbody tr').each(function(_, element) {
-      const inputElemFromDB      = $('td:nth-of-type(1) input[type="text"], td:nth-of-type(1) textarea', element);
-      const inputElemFromEnvVars = $('td:nth-of-type(2) input[type="text"], td:nth-of-type(2) textarea', element);
-
-      // initialize
-      addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars);
-
-      // set keyup event handler
-      inputElemFromDB.keyup(function () { addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) });
-    });
-
-    function addClassToUnusedInputElemFromEnvVars(inputElemFromDB, inputElemFromEnvVars) {
-      if (inputElemFromDB.val() === '') {
-        inputElemFromEnvVars.parent().removeClass('unused');
-      }
-      else {
-        inputElemFromEnvVars.parent().addClass('unused');
-      }
-    };
-  </script>
-
 </div>
 {% endblock content_main %}