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

Merge commit '6deb71236cb58f4ef9b991b96939282dd7b7abdb' into reactify-admin/app-settings-stocks

# Conflicts:
#	src/client/js/app.jsx
yusuketk 6 лет назад
Родитель
Сommit
545e47a820

+ 3 - 1
resource/locales/en-US/translation.json

@@ -438,7 +438,9 @@
     "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"
   },
 
   "security_setting": {

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

@@ -437,8 +437,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を更新しました"
+  },
 
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",

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

@@ -37,7 +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 AppSettingPage from './components/Admin/AppSettingPage';
+import AppSettingPage from './components/Admin/App/AppSettingPage';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
@@ -53,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';
@@ -108,7 +109,6 @@ let componentMappings = {
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
 
-  'admin-app': <AppSettingPage />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
 
   'staff-credit': <StaffCredit />,
@@ -169,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}>
+        <AppSettingPage />
+      </I18nextProvider>
+    </Provider>,
+    adminAppElem,
+  );
+}
+
 /**
  * define components
  *  key: id of element

+ 22 - 66
src/client/js/components/Admin/App/AppSetting.jsx

@@ -7,6 +7,7 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
 
 const logger = loggerFactory('growi:appSettings');
 
@@ -15,51 +16,15 @@ class AppSetting extends React.Component {
   constructor(props) {
     super(props);
 
-    this.state = {
-      title: '',
-      confidential: '',
-      globalLang: 'en-US',
-      fileUpload: false,
-    };
-
     this.submitHandler = this.submitHandler.bind(this);
-    this.inputTitleChangeHandler = this.inputTitleChangeHandler.bind(this);
-    this.inputConfidentialChangeHandler = this.inputConfidentialChangeHandler.bind(this);
-    this.inputGlobalLangChangeHandler = this.inputGlobalLangChangeHandler.bind(this);
-    this.inputFileUploadChangeHandler = this.inputFileUploadChangeHandler.bind(this);
-  }
-
-  async componentDidMount() {
-    try {
-      const response = await this.props.appContainer.apiv3.get('/app-settings/app-setting');
-      const appSettingParams = response.data.appSettingParams;
-
-      this.setState({
-        title: appSettingParams.title || '',
-        confidential: appSettingParams.confidential || '',
-        globalLang: appSettingParams.globalLang || 'en-US',
-        fileUpload: appSettingParams.fileUpload || false,
-      });
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
   }
 
   async submitHandler() {
-    const { t } = this.props;
-
-    const params = {
-      title: this.state.title,
-      confidential: this.state.confidential,
-      globalLang: this.state.globalLang,
-      fileUpload: this.state.fileUpload,
-    };
+    const { t, adminAppContainer } = this.props;
 
     try {
-      await this.props.appContainer.apiv3.put('/app-settings/app-setting', params);
-      toastSuccess(t('Updated app setting'));
+      await adminAppContainer.updateAppSettingHandler();
+      toastSuccess(t('app_setting.updated_app_setting'));
     }
     catch (err) {
       toastError(err);
@@ -67,24 +32,8 @@ class AppSetting extends React.Component {
     }
   }
 
-  inputTitleChangeHandler(event) {
-    this.setState({ title: event.target.value });
-  }
-
-  inputConfidentialChangeHandler(event) {
-    this.setState({ confidential: event.target.value });
-  }
-
-  inputGlobalLangChangeHandler(event) {
-    this.setState({ globalLang: event.target.value });
-  }
-
-  inputFileUploadChangeHandler(event) {
-    this.setState({ fileUpload: event.target.checked });
-  }
-
   render() {
-    const { t } = this.props;
+    const { t, adminAppContainer } = this.props;
 
     return (
       <React.Fragment>
@@ -98,8 +47,8 @@ class AppSetting extends React.Component {
                   id="settingForm[app:title]"
                   type="text"
                   name="title"
-                  value={this.state.title}
-                  onChange={this.inputTitleChangeHandler}
+                  defaultValue={adminAppContainer.state.title}
+                  onChange={(e) => { adminAppContainer.changeTitle(e.target.value) }}
                   placeholder="GROWI"
                 />
                 <p className="help-block">{t('app_setting.sitename_change')}</p>
@@ -118,8 +67,8 @@ class AppSetting extends React.Component {
                   id="settingForm[app:confidential]"
                   type="text"
                   name="confidential"
-                  value={this.state.confidential}
-                  onChange={this.inputConfidentialChangeHandler}
+                  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>
@@ -137,8 +86,8 @@ class AppSetting extends React.Component {
                 id="radioLangEn"
                 name="globalLang"
                 value="en-US"
-                checked={this.state.globalLang === 'en-US'}
-                onClick={this.inputGlobalLangChangeHandler}
+                checked={adminAppContainer.state.globalLang === 'en-US'}
+                onClick={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
               />
               <label htmlFor="radioLangEn">{t('English')}</label>
             </div>
@@ -148,8 +97,8 @@ class AppSetting extends React.Component {
                 id="radioLangJa"
                 name="globalLang"
                 value="ja"
-                checked={this.state.globalLang === 'ja'}
-                onClick={this.inputGlobalLangChangeHandler}
+                checked={adminAppContainer.state.globalLang === 'ja'}
+                onClick={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
               />
               <label htmlFor="radioLangJa">{t('Japanese')}</label>
             </div>
@@ -162,7 +111,13 @@ class AppSetting extends React.Component {
               <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={this.state.fileUpload} onChange={this.inputFileUploadChangeHandler} />
+                  <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>
 
@@ -197,12 +152,13 @@ class AppSetting extends React.Component {
  * Wrapper component for using unstated
  */
 const AppSettingWrapper = (props) => {
-  return createSubscribedElement(AppSetting, props, [AppContainer]);
+  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);

+ 25 - 12
src/client/js/components/Admin/AppSettingPage.jsx → src/client/js/components/Admin/App/AppSettingPage.jsx

@@ -1,23 +1,35 @@
 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 { createSubscribedElement } from '../../UnstatedUtils';
+import { toastError } from '../../../util/apiNotification';
 
-import AppContainer from '../../services/AppContainer';
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
 
-import AppSetting from './App/AppSetting';
-import SiteUrlSetting from './App/SiteUrlSetting';
-import MailSetting from './App/MailSetting';
-import AwsSetting from './App/AwsSetting';
-import PluginSetting from './App/PluginSetting';
+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 AppSettingPage extends React.Component {
 
-  constructor(props) {
-    super(props);
-    this.state = {
-    };
+  async componentDidMount() {
+    const { adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.retrieveAppSettingsData();
+    }
+    catch (err) {
+      toastError(err);
+      adminAppContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
   }
 
   render() {
@@ -68,13 +80,14 @@ class AppSettingPage extends React.Component {
 AppSettingPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
 const AppSettingPageWrapper = (props) => {
-  return createSubscribedElement(AppSettingPage, props, [AppContainer]);
+  return createSubscribedElement(AppSettingPage, props, [AppContainer, AdminAppContainer]);
 };
 
 

+ 36 - 60
src/client/js/components/Admin/App/SiteUrlSetting.jsx

@@ -1,30 +1,44 @@
 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';
+
+const logger = loggerFactory('growi:appSettings');
 
 class SiteUrlSetting extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-    };
+    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 } = this.props;
+    const { t, adminAppContainer } = this.props;
 
     return (
       <React.Fragment>
         <p className="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 %} */}
+        {!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">
@@ -35,7 +49,10 @@ class SiteUrlSetting extends React.Component {
                   <col className="from-env-vars" />
                 </colgroup>
                 <thead>
-                  <tr><th>Database</th><th>Environment variables</th></tr>
+                  <tr>
+                    <th>Database</th>
+                    <th>Environment variables</th>
+                  </tr>
                 </thead>
                 <tbody>
                   <tr>
@@ -44,20 +61,20 @@ class SiteUrlSetting extends React.Component {
                         className="form-control"
                         type="text"
                         name="settingForm[app:siteUrl]"
-                        value="{{ getConfigFromDB('crowi', 'app:siteUrl') | default('') }}"
+                        defaultValue={adminAppContainer.state.siteUrl}
+                        onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
                         placeholder="e.g. https://my.growi.org"
                       />
-                      <p className="help-block">{t('app_setting.siteurl_help')}</p>
+                      <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="{{ getConfigFromEnvVars('crowi', 'app:siteUrl') | default('') }}"
-                        readOnly
-                      />
+                      <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl} readOnly />
                       <p className="help-block">
-                        {t('app_setting.Use env var if empty', 'APP_SITE_URL')}
+                        {/* 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>
@@ -67,53 +84,11 @@ class SiteUrlSetting extends React.Component {
           </div>
         </div>
 
-        <div className="row">
-          <div className="col-md-12">
-            <div className="form-group">
-              <label htmlFor="settingForm[app:confidential]" className="col-xs-3 control-label">
-                {t('app_setting.Confidential name')}
-              </label>
-              <div className="col-xs-6">
-                <input
-                  className="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 className="help-block">{t('app_setting.header_content')}</p>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <div className="row">
-          <div className="col-md-12">
-            <div className="form-group">
-              <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="settingForm[app:fileUpload]" value="1" />
-                  <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>
-          </div>
-        </div>
-
         <div className="row">
           <div className="col-md-12">
             <div className="form-group">
               <div className="col-xs-offset-3 col-xs-6">
-                <input type="hidden" name="_csrf" value="{{ csrf() }}" />
-                <button type="submit" className="btn btn-primary">
+                <button type="submit" className="btn btn-primary" onClick={this.submitHandler}>
                   {t('app_setting.Update')}
                 </button>
               </div>
@@ -130,12 +105,13 @@ class SiteUrlSetting extends React.Component {
  * Wrapper component for using unstated
  */
 const SiteUrlSettingWrapper = (props) => {
-  return createSubscribedElement(SiteUrlSetting, props, [AppContainer]);
+  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);

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

@@ -0,0 +1,136 @@
+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 = {
+      title: '',
+      confidential: '',
+      globalLang: '',
+      fileUpload: '',
+      siteUrl: '',
+      envSiteUrl: '',
+      isSetSiteUrl: 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.updateAppSettingHandler = this.updateAppSettingHandler.bind(this);
+    this.updateSiteUrlSettingHandler = this.updateSiteUrlSettingHandler.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 { appSettingParams } = response.data;
+
+      this.setState({
+        title: appSettingParams.title,
+        confidential: appSettingParams.confidential,
+        globalLang: appSettingParams.globalLang,
+        fileUpload: appSettingParams.fileUpload,
+        siteUrl: appSettingParams.siteUrl,
+        envSiteUrl: appSettingParams.envSiteUrl,
+        isSetSiteUrl: !!appSettingParams.siteUrl,
+      });
+
+    }
+    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 });
+  }
+
+  /**
+   * 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 { appSettingParams } = response.data;
+    return appSettingParams;
+  }
+
+}

+ 67 - 3
src/server/routes/apiv3/app-settings.js

@@ -17,6 +17,9 @@ const validator = {
     body('globalLang').isIn(['en-US', 'ja']),
     body('fileUpload').isBoolean(),
   ],
+  siteUrlSetting: [
+    body('siteUrl').trim(),
+  ],
 };
 
 
@@ -46,6 +49,12 @@ const validator = {
  *          fileUpload:
  *            type: boolean
  *            description: enable upload file except image file
+ *          siteUrl:
+ *            type: String
+ *            description: Site URL. e.g. https://example.com, https://example.com:8080
+ *          envSiteUrl:
+ *            type: String
+ *            description: environment variable 'APP_SITE_URL'
  */
 
 module.exports = (crowi) => {
@@ -60,7 +69,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /app-settings/app-setting:
+   *    /app-settings/:
    *      get:
    *        tags: [AppSettings]
    *        description: get app setting params
@@ -83,13 +92,21 @@ module.exports = (crowi) => {
    *                    fileUpload:
    *                      type: boolean
    *                      description: enable upload file except image file
+   *                    siteUrl:
+   *                      type: String
+   *                      description: Site URL. e.g. https://example.com, https://example.com:8080
+   *                    envSiteUrl:
+   *                      type: String
+   *                      description: environment variable 'APP_SITE_URL'
    */
-  router.get('/app-setting', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.get('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
     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'),
+      siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
+      envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
     };
     return res.apiv3({ appSettingParams });
 
@@ -133,7 +150,6 @@ module.exports = (crowi) => {
    *                      $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,
@@ -159,6 +175,54 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+ * @swagger
+ *
+ *    /app-settings/site-url-setting:
+ *      put:
+ *        tags: [AppSettings]
+ *        description: Update site url setting
+ *        requestBody:
+ *          required: true
+ *          content:
+ *            application/json:
+ *              schema:
+ *                type: object
+ *                properties:
+ *                  siteUrl:
+ *                    type: String
+ *                    description: Site URL. e.g. https://example.com, https://example.com:8080
+ *        responses:
+ *          200:
+ *            description: Succeeded to update site url setting
+ *            content:
+ *              application/json:
+ *                schema:
+ *                  properties:
+ *                    status:
+ *                      $ref: '#/components/schemas/appSettingParams'
+ */
+  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 appSettingParams = {
+        siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
+      };
+      return res.apiv3({ appSettingParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating site url setting';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-failed'));
+    }
+
+  });
+
 
   return router;
 };