Browse Source

Merge pull request #1555 from weseek/master

release v3.6.4
Yuki Takei 6 years ago
parent
commit
f5dc3e15df
62 changed files with 3611 additions and 757 deletions
  1. 1 0
      .github/workflows/release.yml
  2. 10 1
      CHANGES.md
  3. 4 2
      config/swagger-definition.js
  4. 5 2
      package.json
  5. 10 3
      resource/locales/en-US/translation.json
  6. 9 3
      resource/locales/ja/translation.json
  7. 21 0
      src/client/js/app.jsx
  8. 71 0
      src/client/js/components/Admin/AdminHome/AdminHome.jsx
  9. 53 0
      src/client/js/components/Admin/AdminHome/InstalledPluginTable.jsx
  10. 53 0
      src/client/js/components/Admin/AdminHome/SystemInfomationTable.jsx
  11. 139 0
      src/client/js/components/Admin/App/AppSetting.jsx
  12. 94 0
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  13. 156 0
      src/client/js/components/Admin/App/AwsSetting.jsx
  14. 117 0
      src/client/js/components/Admin/App/MailSetting.jsx
  15. 80 0
      src/client/js/components/Admin/App/PluginSetting.jsx
  16. 108 0
      src/client/js/components/Admin/App/SiteUrlSetting.jsx
  17. 15 0
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  18. 15 12
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  19. 89 0
      src/client/js/components/PageEditor/EditorIcon.jsx
  20. 299 0
      src/client/js/services/AdminAppContainer.js
  21. 10 0
      src/client/js/services/AdminCustomizeContainer.js
  22. 61 0
      src/client/js/services/AdminHomeContainer.js
  23. 3 0
      src/client/styles/agile-admin/inverse/colors/_apply-colors-dark.scss
  24. 2 1
      src/client/styles/scss/_handsontable.scss
  25. 0 10
      src/server/form/admin/app.js
  26. 0 11
      src/server/form/admin/aws.js
  27. 1 0
      src/server/form/admin/customfeatures.js
  28. 0 11
      src/server/form/admin/mail.js
  29. 0 7
      src/server/form/admin/plugin.js
  30. 0 7
      src/server/form/admin/siteUrl.js
  31. 0 5
      src/server/form/index.js
  32. 10 2
      src/server/models/config.js
  33. 31 0
      src/server/models/openapi/paginate-result.js
  34. 19 0
      src/server/models/openapi/v1-response.js
  35. 5 0
      src/server/models/page.js
  36. 13 9
      src/server/plugins/plugin-utils.js
  37. 1 80
      src/server/routes/admin.js
  38. 77 0
      src/server/routes/apiv3/admin-home.js
  39. 479 0
      src/server/routes/apiv3/app-settings.js
  40. 108 76
      src/server/routes/apiv3/customize-setting.js
  41. 13 6
      src/server/routes/apiv3/export.js
  42. 4 2
      src/server/routes/apiv3/healthcheck.js
  43. 17 8
      src/server/routes/apiv3/import.js
  44. 4 0
      src/server/routes/apiv3/index.js
  45. 20 9
      src/server/routes/apiv3/markdown-setting.js
  46. 4 2
      src/server/routes/apiv3/mongo.js
  47. 4 2
      src/server/routes/apiv3/statistics.js
  48. 4 2
      src/server/routes/apiv3/user-group-relation.js
  49. 40 20
      src/server/routes/apiv3/user-group.js
  50. 71 10
      src/server/routes/apiv3/users.js
  51. 192 0
      src/server/routes/attachment.js
  52. 163 0
      src/server/routes/bookmark.js
  53. 202 0
      src/server/routes/comment.js
  54. 0 5
      src/server/routes/index.js
  55. 10 0
      src/server/routes/login-passport.js
  56. 466 0
      src/server/routes/page.js
  57. 153 0
      src/server/routes/revision.js
  58. 35 0
      src/server/routes/user.js
  59. 1 393
      src/server/views/admin/app.html
  60. 1 47
      src/server/views/admin/index.html
  61. 15 0
      src/server/views/widget/page_alerts.html
  62. 23 9
      yarn.lock

+ 1 - 0
.github/workflows/release.yml

@@ -41,5 +41,6 @@ jobs:
       uses: Roang-zero1/github-create-release-action@master
       uses: Roang-zero1/github-create-release-action@master
       with:
       with:
         created_tag: v${{ env.RELEASE_VERSION }}
         created_tag: v${{ env.RELEASE_VERSION }}
+        changelog_file: CHANGES.md
       env:
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 10 - 1
CHANGES.md

@@ -1,9 +1,18 @@
 # CHANGES
 # CHANGES
 
 
-## v3.6.4-RC
+## v3.6.5-RC
 
 
 *
 *
 
 
+## v3.6.4
+
+* Feature: Alert for stale page
+* Improvement: Reactify admin pages (Home)
+* Improvement: Reactify admin pages (App)
+* Improvement: Accessibility for editor icons of dark themes
+* Improvement: Accessibility for importing table data pane
+* Improvement: Resolve username and email when logging in with Google OAuth
+
 ## v3.6.3
 ## v3.6.3
 
 
 * Improvement: Searching users in UserGroup Management
 * Improvement: Searching users in UserGroup Management

+ 4 - 2
config/swagger-definition.js

@@ -1,14 +1,16 @@
 const pkg = require('../package.json');
 const pkg = require('../package.json');
 
 
+const apiVersion = process.env.API_VERSION || 3;
+
 module.exports = {
 module.exports = {
   openapi: '3.0.1',
   openapi: '3.0.1',
   info: {
   info: {
-    title: 'GROWI REST API v3',
+    title: `GROWI REST API v${apiVersion}`,
     version: pkg.version,
     version: pkg.version,
   },
   },
   servers: [
   servers: [
     {
     {
-      url: 'https://demo.growi.org/_api/v3/',
+      url: 'https://demo.growi.org',
     },
     },
   ],
   ],
 };
 };

+ 5 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.6.3-RC",
+  "version": "3.6.4-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -20,7 +20,9 @@
     "url": "https://github.com/weseek/growi/issues"
     "url": "https://github.com/weseek/growi/issues"
   },
   },
   "scripts": {
   "scripts": {
-    "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/**/*.js",
+    "build:api:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js \"src/server/**/*.js\"",
+    "build:apiv3:jsdoc": "cross-env API_VERSION=3 npm run build:api:jsdoc",
+    "build:apiv1:jsdoc": "cross-env API_VERSION=1 npm run build:api:jsdoc",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:watch": "npm run build:dev:app:watch",
     "build:dev:watch": "npm run build:dev:app:watch",
@@ -122,6 +124,7 @@
     "nodemailer-ses-transport": "~1.5.0",
     "nodemailer-ses-transport": "~1.5.0",
     "npm-run-all": "^4.1.2",
     "npm-run-all": "^4.1.2",
     "openid-client": "=2.5.0",
     "openid-client": "=2.5.0",
+    "package-installed-version-sync": "^2.1.0",
     "passport": "^0.4.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
     "passport-github": "^1.1.0",
     "passport-google-oauth20": "^2.0.0",
     "passport-google-oauth20": "^2.0.0",

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

@@ -223,7 +223,9 @@
       "redirected": "You are redirected from <code>%s</code>",
       "redirected": "You are redirected from <code>%s</code>",
       "duplicated": "This page was duplicated from <code>%s</code>",
       "duplicated": "This page was duplicated from <code>%s</code>",
       "unlinked": "Redirect pages to this page have been deleted.",
       "unlinked": "Redirect pages to this page have been deleted.",
-      "restricted": "Access to this page is restricted"
+      "restricted": "Access to this page is restricted",
+      "stale": "More than {{count}} year has passed since last update.",
+      "stale_plural": "More than {{count}} years has passed since last update."
     }
     }
   },
   },
   "page_edit": {
   "page_edit": {
@@ -371,7 +373,7 @@
     "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "Confidential name": "Confidential name",
     "Confidential name": "Confidential name",
     "Default Language for new users": "Default Language for new users",
     "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",
     "File Uploading": "File Uploading",
     "enable_files_except_image": "Enable file upload other than image files.",
     "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.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
@@ -398,7 +400,10 @@
     "Load plugins": "Load plugins",
     "Load plugins": "Load plugins",
     "Enable": "Enable",
     "Enable": "Enable",
     "Disable": "Disable",
     "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_setting": {
     "Security settings": "Security settings",
     "Security settings": "Security settings",
@@ -636,6 +641,8 @@
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
     "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
     "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
     "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
     "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
+    "stale_notification": "Display Notification on Stale Pages",
+    "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
     "update_layout_success": "Succeeded to update layout",
     "update_layout_success": "Succeeded to update layout",
     "update_behavior_success": "Succeeded to update behavior",
     "update_behavior_success": "Succeeded to update behavior",
     "update_function_success": "Succeeded to update function",
     "update_function_success": "Succeeded to update function",

+ 9 - 3
resource/locales/ja/translation.json

@@ -222,7 +222,8 @@
       "redirected": "リダイレクト元 >> <code>%s</code>",
       "redirected": "リダイレクト元 >> <code>%s</code>",
       "duplicated": "このページは <code>%s</code> から複製されました。",
       "duplicated": "このページは <code>%s</code> から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
-      "restricted": "このページの閲覧は制限されています"
+      "restricted": "このページの閲覧は制限されています",
+      "stale": "このページは最終更新日から{{count}}年以上が経過しています。"
     }
     }
   },
   },
   "page_edit": {
   "page_edit": {
@@ -370,7 +371,7 @@
     "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL",
     "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL",
     "Confidential name": "コンフィデンシャル表示",
     "Confidential name": "コンフィデンシャル表示",
     "Default Language for new users": "新規ユーザーのデフォルト設定言語",
     "Default Language for new users": "新規ユーザーのデフォルト設定言語",
-    "ex): internal use only": "例: 社外秘",
+    "ex) internal use only": "例: 社外秘",
     "File Uploading": "ファイルアップロード",
     "File Uploading": "ファイルアップロード",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
@@ -397,7 +398,10 @@
     "Load plugins": "プラグインを読み込む",
     "Load plugins": "プラグインを読み込む",
     "Enable": "有効",
     "Enable": "有効",
     "Disable": "無効",
     "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": {
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Guest Users Access": "ゲストユーザーのアクセス",
@@ -620,6 +624,8 @@
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
     "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
     "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
     "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
     "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
+    "stale_notification": "更新されていないページに通知を表示",
+    "stale_notification_desc": "最終更新から1年以上が経過しているページに通知を表示します。",
     "update_layout_success": "レイアウトを更新しました",
     "update_layout_success": "レイアウトを更新しました",
     "update_behavior_success": "動作を更新しました",
     "update_behavior_success": "動作を更新しました",
     "update_function_success": "機能を更新しました",
     "update_function_success": "機能を更新しました",

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

@@ -34,9 +34,11 @@ import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 import TableOfContents from './components/TableOfContents';
 
 
+import AdminHome from './components/Admin/AdminHome/AdminHome';
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
 import UserManagement from './components/Admin/UserManagement';
+import AppSettingsPage from './components/Admin/App/AppSettingsPage';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
 import Customize from './components/Admin/Customize/Customize';
@@ -49,9 +51,11 @@ import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import TagContainer from './services/TagContainer';
+import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
+import AdminAppContainer from './services/AdminAppContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 import AdminMarkDownContainer from './services/AdminMarkDownContainer';
 import AdminMarkDownContainer from './services/AdminMarkDownContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
@@ -155,11 +159,13 @@ Object.keys(componentMappings).forEach((key) => {
 });
 });
 
 
 // create unstated container instance for admin
 // create unstated container instance for admin
+const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
 const adminUsersContainer = new AdminUsersContainer(appContainer);
 const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
 const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminContainers = {
 const adminContainers = {
+  'admin-home': adminHomeContainer,
   'admin-customize': adminCustomizeContainer,
   'admin-customize': adminCustomizeContainer,
   'admin-user-page': adminUsersContainer,
   'admin-user-page': adminUsersContainer,
   'admin-external-account-setting': adminExternalAccountsContainer,
   'admin-external-account-setting': adminExternalAccountsContainer,
@@ -167,12 +173,27 @@ const adminContainers = {
   'admin-export-page': websocketContainer,
   '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
  * define components
  *  key: id of element
  *  key: id of element
  *  value: React Element
  *  value: React Element
  */
  */
 const adminComponentMappings = {
 const adminComponentMappings = {
+  'admin-home': <AdminHome />,
   'admin-customize': <Customize />,
   'admin-customize': <Customize />,
   'admin-user-page': <UserManagement />,
   'admin-user-page': <UserManagement />,
   'admin-external-account-setting': <ManageExternalAccount />,
   'admin-external-account-setting': <ManageExternalAccount />,

+ 71 - 0
src/client/js/components/Admin/AdminHome/AdminHome.jsx

@@ -0,0 +1,71 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../../../util/apiNotification';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminHomeContainer from '../../../services/AdminHomeContainer';
+import SystemInfomationTable from './SystemInfomationTable';
+import InstalledPluginTable from './InstalledPluginTable';
+
+const logger = loggerFactory('growi:admin');
+
+class AdminHome extends React.Component {
+
+  async componentDidMount() {
+    const { adminHomeContainer } = this.props;
+
+    try {
+      await adminHomeContainer.retrieveAdminHomeData();
+    }
+    catch (err) {
+      toastError(err);
+      adminHomeContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <p>
+          {t('admin_top.wiki_administrator')}
+          <br></br>
+          {t('admin_top.assign_administrator')}
+        </p>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin_top.System Information')}</h2>
+            <SystemInfomationTable />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin_top.List of installed plugins')}</h2>
+            <InstalledPluginTable />
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+const AdminHomeWrapper = (props) => {
+  return createSubscribedElement(AdminHome, props, [AppContainer, AdminHomeContainer]);
+};
+
+AdminHome.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
+};
+
+export default withTranslation()(AdminHomeWrapper);

+ 53 - 0
src/client/js/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminHomeContainer from '../../../services/AdminHomeContainer';
+
+class InstalledPluginTable extends React.Component {
+
+  render() {
+    const { t, adminHomeContainer } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th className="text-center">{ t('admin_top.Package name') }</th>
+            <th className="text-center">{ t('admin_top.Specified version') }</th>
+            <th className="text-center">{ t('admin_top.Installed version') }</th>
+          </tr>
+        </thead>
+        <tbody>
+          { adminHomeContainer.state.installedPlugins.map((plugin) => {
+            return (
+              <tr key={plugin.name}>
+                <td>{ plugin.name }</td>
+                <td className="text-center">{ plugin.requiredVersion }</td>
+                <td className="text-center">{ plugin.installedVersion }</td>
+              </tr>
+            );
+          }) }
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+InstalledPluginTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const InstalledPluginTableWrapper = (props) => {
+  return createSubscribedElement(InstalledPluginTable, props, [AppContainer, AdminHomeContainer]);
+};
+
+export default withTranslation()(InstalledPluginTableWrapper);

+ 53 - 0
src/client/js/components/Admin/AdminHome/SystemInfomationTable.jsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import AdminHomeContainer from '../../../services/AdminHomeContainer';
+
+class SystemInformationTable extends React.Component {
+
+  render() {
+    const { adminHomeContainer } = this.props;
+
+    return (
+      <table className="table table-bordered">
+        <tbody>
+          <tr>
+            <th className="col-sm-4">GROWI</th>
+            <td>{ adminHomeContainer.state.growiVersion }</td>
+          </tr>
+          <tr>
+            <th>node.js</th>
+            <td>{ adminHomeContainer.state.nodeVersion }</td>
+          </tr>
+          <tr>
+            <th>npm</th>
+            <td>{ adminHomeContainer.state.npmVersion }</td>
+          </tr>
+          <tr>
+            <th>yarn</th>
+            <td>{ adminHomeContainer.state.yarnVersion }</td>
+          </tr>
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+SystemInformationTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SystemInformationTableWrapper = (props) => {
+  return createSubscribedElement(SystemInformationTable, props, [AppContainer, AdminHomeContainer]);
+};
+
+export default withTranslation()(SystemInformationTableWrapper);

+ 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 className="admin-setting-header">{t('App Settings')}</h2>
+            <AppSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
+            <SiteUrlSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('app_setting.Mail settings')}</h2>
+            <MailSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('app_setting.AWS settings')}</h2>
+            <AwsSetting />
+          </div>
+        </div>
+
+        <div className="row mt-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{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);

+ 15 - 0
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -123,6 +123,21 @@ class CustomizeBehaviorSetting extends React.Component {
           </div>
           </div>
         </div>
         </div>
 
 
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isEnabledStaleNotification"
+              label={t('customize_page.stale_notification')}
+              isChecked={adminCustomizeContainer.state.isEnabledStaleNotification}
+              onChecked={() => { adminCustomizeContainer.switchEnableStaleNotification() }}
+            >
+              <p className="help-block">
+                { t('customize_page.stale_notification_desc') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
         <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
         <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
       </React.Fragment>
       </React.Fragment>
     );
     );

+ 15 - 12
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -18,6 +18,7 @@ import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
 import mtu from './MarkdownTableUtil';
 import HandsontableModal from './HandsontableModal';
 import HandsontableModal from './HandsontableModal';
+import EditorIcon from './EditorIcon';
 
 
 const loadScript = require('simple-load-script');
 const loadScript = require('simple-load-script');
 const loadCssSync = require('load-css-file');
 const loadCssSync = require('load-css-file');
@@ -648,13 +649,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
   getNavbarItems() {
   getNavbarItems() {
     return [
     return [
+      /* eslint-disable max-len */
       <Button
       <Button
         key="nav-item-bold"
         key="nav-item-bold"
         bsSize="small"
         bsSize="small"
         title="Bold"
         title="Bold"
         onClick={this.createReplaceSelectionHandler('**', '**')}
         onClick={this.createReplaceSelectionHandler('**', '**')}
       >
       >
-        <img src="/images/icons/editor/bold.svg" alt="icon-bold" height="13" />
+        <EditorIcon icon="Bold" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-italic"
         key="nav-item-italic"
@@ -662,7 +664,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Italic"
         title="Italic"
         onClick={this.createReplaceSelectionHandler('*', '*')}
         onClick={this.createReplaceSelectionHandler('*', '*')}
       >
       >
-        <img src="/images/icons/editor/italic.svg" alt="icon-italic" height="13" />
+        <EditorIcon icon="Italic" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-strikethrough"
         key="nav-item-strikethrough"
@@ -670,7 +672,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Strikethrough"
         title="Strikethrough"
         onClick={this.createReplaceSelectionHandler('~~', '~~')}
         onClick={this.createReplaceSelectionHandler('~~', '~~')}
       >
       >
-        <img src="/images/icons/editor/strikethrough.svg" alt="icon-strikethrough" height="13" />
+        <EditorIcon icon="Strikethrough" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-header"
         key="nav-item-header"
@@ -678,7 +680,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Heading"
         title="Heading"
         onClick={this.makeHeaderHandler}
         onClick={this.makeHeaderHandler}
       >
       >
-        <img src="/images/icons/editor/header.svg" alt="icon-header" height="13" />
+        <EditorIcon icon="Heading" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-code"
         key="nav-item-code"
@@ -686,7 +688,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Inline Code"
         title="Inline Code"
         onClick={this.createReplaceSelectionHandler('`', '`')}
         onClick={this.createReplaceSelectionHandler('`', '`')}
       >
       >
-        <img src="/images/icons/editor/code.svg" alt="icon-code" height="13" />
+        <EditorIcon icon="InlineCode" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-quote"
         key="nav-item-quote"
@@ -694,7 +696,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Quote"
         title="Quote"
         onClick={this.createAddPrefixToEachLinesHandler('> ')}
         onClick={this.createAddPrefixToEachLinesHandler('> ')}
       >
       >
-        <img src="/images/icons/editor/quote.svg" alt="icon-quote" height="13" />
+        <EditorIcon icon="Quote" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-ul"
         key="nav-item-ul"
@@ -702,7 +704,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="List"
         title="List"
         onClick={this.createAddPrefixToEachLinesHandler('- ')}
         onClick={this.createAddPrefixToEachLinesHandler('- ')}
       >
       >
-        <img src="/images/icons/editor/list-ul.svg" alt="icon-list-ul" height="13" />
+        <EditorIcon icon="List" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-ol"
         key="nav-item-ol"
@@ -710,7 +712,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Numbered List"
         title="Numbered List"
         onClick={this.createAddPrefixToEachLinesHandler('1. ')}
         onClick={this.createAddPrefixToEachLinesHandler('1. ')}
       >
       >
-        <img src="/images/icons/editor/list-ol.svg" alt="icon-list-ol" height="13" />
+        <EditorIcon icon="NumberedList" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-checkbox"
         key="nav-item-checkbox"
@@ -718,7 +720,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Check List"
         title="Check List"
         onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
         onClick={this.createAddPrefixToEachLinesHandler('- [ ] ')}
       >
       >
-        <img src="/images/icons/editor/check.svg" alt="icon-check" height="13" />
+        <EditorIcon icon="CheckList" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-link"
         key="nav-item-link"
@@ -726,7 +728,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Link"
         title="Link"
         onClick={this.createReplaceSelectionHandler('[', ']()')}
         onClick={this.createReplaceSelectionHandler('[', ']()')}
       >
       >
-        <img src="/images/icons/editor/link.svg" alt="icon-link" height="13" />
+        <EditorIcon icon="Link" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-image"
         key="nav-item-image"
@@ -734,7 +736,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Image"
         title="Image"
         onClick={this.createReplaceSelectionHandler('![', ']()')}
         onClick={this.createReplaceSelectionHandler('![', ']()')}
       >
       >
-        <img src="/images/icons/editor/picture.svg" alt="icon-picture" height="13" />
+        <EditorIcon icon="Image" />
       </Button>,
       </Button>,
       <Button
       <Button
         key="nav-item-table"
         key="nav-item-table"
@@ -742,8 +744,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
         title="Table"
         title="Table"
         onClick={this.showHandsonTableHandler}
         onClick={this.showHandsonTableHandler}
       >
       >
-        <img src="/images/icons/editor/table.svg" alt="icon-table" height="13" />
+        <EditorIcon icon="Table" />
       </Button>,
       </Button>,
+      /* eslint-able max-len */
     ];
     ];
   }
   }
 
 

+ 89 - 0
src/client/js/components/PageEditor/EditorIcon.jsx

@@ -0,0 +1,89 @@
+/* eslint-disable max-len */
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const EditorIcon = (props) => {
+
+  switch (props.icon) {
+    case 'Bold':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 10.9 14">
+          <path d="M0 0h5.6c3 0 4.7 1.1 4.7 3.4a3.1 3.1 0 0 1-2.5 3.1 3.7 3.7 0 0 1 3.1 3.5c0 2.9-1.4 4-4.2 4H0zm5.2 6.5c2.7 0 2.6-1.4 2.6-3.1S7.9.7 5.6.7H2.3v5.8zm-2.9 6.6h3.4c2.1 0 2.7-1.1 2.7-3.1s0-2.8-3.2-2.8H2.3z" />
+        </svg>
+      );
+    case 'Italic':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 8.6 13.9">
+          <path d="M8.1 0a.6.6 0 0 1 .5.6c0 .3-.2.6-.7.6H6.2L3.8 12.8h1.8c.2 0 .4.3.4.5a.7.7 0 0 1-.7.6H.5c-.3 0-.5-.4-.5-.6s.4-.6.7-.6h1.7L4.9 1.2H3.1a.5.5 0 0 1-.5-.5c0-.3.1-.7.8-.7z" />
+        </svg>
+      );
+    case 'Strikethrough':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19.5 14">
+          <path d="M5.8 6.2H9C7.2 5.7 6.3 5 6.3 3.8a2.2 2.2 0 0 1 .9-1.9 4.3 4.3 0 0 1 2.5-.7 4.3 4.3 0 0 1 2.5.7 3.1 3.1 0 0 1 1.1 1.6.7.7 0 0 0 .6.4h.3a.7.7 0 0 0 .4-.8A3.6 3.6 0 0 0 13.1 1a6.7 6.7 0 0 0-6-.5 3.1 3.1 0 0 0-1.7 1.3 3.6 3.6 0 0 0-.6 2 2.9 2.9 0 0 0 1 2.3zm7 2.5a2 2 0 0 1 .6 1.4 2.4 2.4 0 0 1-1 1.9 3.7 3.7 0 0 1-2.5.7 4.6 4.6 0 0 1-3-.8 3.7 3.7 0 0 1-1.2-2 .6.6 0 0 0-.6-.5h-.2a.7.7 0 0 0-.5.8 4.1 4.1 0 0 0 1.5 2.5A6 6 0 0 0 9.8 14a7.5 7.5 0 0 0 2.6-.5 4.9 4.9 0 0 0 1.8-1.4 4.3 4.3 0 0 0 .6-2.2 5 5 0 0 0-.2-1.2zM.4 7.9a.7.7 0 0 1-.4-.5.4.4 0 0 1 .4-.4h18.8a.4.4 0 0 1 .3.6c0 .1-.1.2-.2.3z" />
+        </svg>
+      );
+    case 'Heading':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 13.7 14">
+          <path d="M10.2 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v11.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-2.9a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.8V7.2H2.7v5.6h.8a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6H.6a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6h.7V1.2H.6A.6.6 0 0 1 0 .6.6.6 0 0 1 .6 0h2.9a.6.6 0 0 1 .6.6.6.6 0 0 1-.6.6h-.8v4.9H11V1.2h-.8a.6.6 0 0 1-.6-.6.6.6 0 0 1 .6-.6z" />
+        </svg>
+      );
+    case 'InlineCode':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 18.1 14">
+          <path d="M17.8 7.9l-4 3.8a.5.5 0 0 1-.8 0 .5.5 0 0 1 0-.8L16.8 7 13 3.2a.6.6 0 0 1 0-.9.5.5 0 0 1 .8 0l4 3.8a1.3 1.3 0 0 1 0 1.8zM5.2 2.3a.7.7 0 0 1 0 .9L1.3 7l3.9 3.9a.6.6 0 0 1 0 .8.6.6 0 0 1-.9 0L.4 7.9a1.3 1.3 0 0 1 0-1.8l3.9-3.8a.6.6 0 0 1 .9 0zM11.5.8L7.8 13.6a.6.6 0 0 1-.7.4.6.6 0 0 1-.5-.8L10.3.4a.7.7 0 0 1 .8-.4.6.6 0 0 1 .4.8z" />
+        </svg>
+      );
+    case 'Quote':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 17 12">
+          <path d="M5 0H2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1H3v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6H2a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1zm10-6h-3a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h3a1.7 1.7 0 0 0 1-.3V10a.9.9 0 0 1-1 1h-2v1h2a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm0 6h-3a.9.9 0 0 1-1-1V2a.9.9 0 0 1 1-1h3a.9.9 0 0 1 1 1v3a.9.9 0 0 1-1 1z" />
+        </svg>
+      );
+    case 'List':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 21.6 13.5">
+          <path d="M6.4 1.5h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zm0 6h14.5a.7.7 0 0 0 .7-.7.7.7 0 0 0-.7-.7H6.4a.8.8 0 0 0-.8.7.8.8 0 0 0 .8.7zM.9 1.5h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.8h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.8zm0 6h1a.8.8 0 0 0 .9-.7.8.8 0 0 0-.9-.7h-1a.8.8 0 0 0-.9.7.8.8 0 0 0 .9.7z" />
+        </svg>
+      );
+    case 'NumberedList':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 23.7 16">
+          <path d="M23.7 2a.8.8 0 0 1-.8.8H6.6a.8.8 0 0 1-.7-.8.7.7 0 0 1 .7-.7h16.3a.7.7 0 0 1 .8.7zM6.6 8.7h16.3a.7.7 0 0 0 .8-.7.8.8 0 0 0-.8-.8H6.6a.8.8 0 0 0-.7.8.7.7 0 0 0 .7.7zm0 5.9h16.3a.7.7 0 0 0 .8-.7.7.7 0 0 0-.8-.7H6.6a.7.7 0 0 0-.7.7.7.7 0 0 0 .7.7zM1.5.5V4h.6V0h-.5L.7.5v.4l.8-.4zM.9 9.6l.3-.3c.9-.9 1.4-1.5 1.4-2.2a1.2 1.2 0 0 0-1.3-1.2h-.1a1.4 1.4 0 0 0-1.2.6l.3.4a1.2 1.2 0 0 1 .9-.5.6.6 0 0 1 .8.6v.2c0 .6-.4 1.1-1.5 2.1l-.4.4v.3h2.6v-.4zm.9 4.1a1 1 0 0 0 .7-.9 1 1 0 0 0-1.1-1 2 2 0 0 0-1.1.3v.4l.8-.2c.5 0 .8.2.8.6s-.5.7-.9.7H.7v.4H1c.6 0 1.1.2 1.1.8a.8.8 0 0 1-.9.8l-.9-.3-.2.4a2 2 0 0 0 1.1.3c1 0 1.5-.6 1.5-1.2a1.2 1.2 0 0 0-.9-1.1z" />
+        </svg>
+      );
+    case 'CheckList':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 14.4 16">
+          <path d="M13.9 5.5a.5.5 0 0 1 .5.5v9a1.1 1.1 0 0 1-1.1 1H1a1.1 1.1 0 0 1-1-1V2.6a1.1 1.1 0 0 1 1-1h7.1a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5H1V15h12.3V6a.6.6 0 0 1 .6-.5zM3.6 8.3a.5.5 0 0 0 0 .7l2.5 2.5a.8.8 0 0 0 1.1 0h.1l7-10.7c.1-.2.1-.6-.2-.7a.5.5 0 0 0-.7.1L6.6 10.6 4.3 8.3a.5.5 0 0 0-.7 0z" />
+        </svg>
+      );
+    case 'Link':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 16 16">
+          <path d="M4.6 11.4l.4.2h.2v-.2l6.1-6.1a.4.4 0 0 0 .1-.3.5.5 0 0 0-.5-.5h-.3l-6 6.2a.6.6 0 0 0-.1.4c0 .1 0 .3.1.3zm2.8-1a2 2 0 0 1 0 1.1 4.1 4.1 0 0 1-.5.9l-2.1 1.9a1.9 1.9 0 0 1-1.5.7 2 2 0 0 1-1.6-.7 1.9 1.9 0 0 1-.7-1.5 2 2 0 0 1 .7-1.6l1.9-2.1a2 2 0 0 1 2.2-.5l.8-.8a3.2 3.2 0 0 0-1.4-.3 3.3 3.3 0 0 0-2.3.9L1 10.5A3.2 3.2 0 0 0 .9 15H1a2.9 2.9 0 0 0 2.3 1 3.2 3.2 0 0 0 2.3-1l2-1.9a4.6 4.6 0 0 0 .9-1.7 2.9 2.9 0 0 0-.3-1.8zM15 1a2.5 2.5 0 0 0-1-.8 3.1 3.1 0 0 0-3.5.8L8.4 2.9a3.1 3.1 0 0 0-.9 1.8 3.2 3.2 0 0 0 .3 1.9l.8-.8a2 2 0 0 1 0-1.1 2.2 2.2 0 0 1 .5-1.1l2.1-1.9.3-.3.4-.2.4-.2h.5a1.9 1.9 0 0 1 1.5.7 2 2 0 0 1 .7 1.6 1.9 1.9 0 0 1-.7 1.5l-2 2.1-.7.4a1.5 1.5 0 0 1-.9.2h-.4l-.8.8H12l1-.7 2-2.1A3 3 0 0 0 15 1z" />
+        </svg>
+      );
+    case 'Image':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 19 16">
+          <path d="M17.8 0H1.2A1.2 1.2 0 0 0 0 1.2v13.6A1.2 1.2 0 0 0 1.2 16h16.6a1.2 1.2 0 0 0 1.2-1.2V1.2a1.4 1.4 0 0 0-.2-.6.8.8 0 0 0-.4-.4zm0 14.8H1.2v-3.5l4.7-4.6 5 4.9.3.2.5-.2 2.1-1.9 3.9 4h.1v1.1zm0-2.8l-3.5-3.5-.4-.2h-.4l-2.2 2-4.9-4.8-.4-.2c-.2 0-.4 0-.5.2L1.2 9.7V1.2h16.6V12zm-4.2-6.1h.6a1.1 1.1 0 0 0 .6-1.1 1.2 1.2 0 0 0-1.2-1.1 1.3 1.3 0 0 0-1.2 1.2 1.2 1.2 0 0 0 .4.8 1.1 1.1 0 0 0 .8.3z" />
+        </svg>
+      );
+    case 'Table':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" height="13" viewBox="0 0 20.3 16">
+          <path d="M19.1 16H1.2A1.2 1.2 0 0 1 0 14.8V1.2A1.2 1.2 0 0 1 1.2 0h17.9a1.2 1.2 0 0 1 1.2 1.2v13.6a1.2 1.2 0 0 1-1.2 1.2zm-5.2-4.3v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm-6.4 0v3.2h5.3v-3.2zm12.8-4.2v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm-6.4 0v3.2h5.3V7.5zm12.8-4.3v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2zm-6.4 0v3.2h5.3V3.2z" />
+        </svg>
+      );
+  }
+
+
+};
+
+EditorIcon.propTypes = {
+  icon: PropTypes.string.isRequired,
+};
+
+export default EditorIcon;

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

+ 10 - 0
src/client/js/services/AdminCustomizeContainer.js

@@ -27,6 +27,7 @@ export default class AdminCustomizeContainer extends Container {
       isSavedStatesOfTabChanges: false,
       isSavedStatesOfTabChanges: false,
       isEnabledAttachTitleHeader: false,
       isEnabledAttachTitleHeader: false,
       currentRecentCreatedLimit: 10,
       currentRecentCreatedLimit: 10,
+      isEnabledStaleNotification: false,
       currentHighlightJsStyleId: '',
       currentHighlightJsStyleId: '',
       isHighlightJsStyleBorderEnabled: false,
       isHighlightJsStyleBorderEnabled: false,
       currentCustomizeTitle: '',
       currentCustomizeTitle: '',
@@ -74,6 +75,7 @@ export default class AdminCustomizeContainer extends Container {
         isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
         isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
         currentRecentCreatedLimit: customizeParams.recentCreatedLimit,
         currentRecentCreatedLimit: customizeParams.recentCreatedLimit,
+        isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
         currentHighlightJsStyleId: customizeParams.styleName,
         currentHighlightJsStyleId: customizeParams.styleName,
         isHighlightJsStyleBorderEnabled: customizeParams.styleBorder,
         isHighlightJsStyleBorderEnabled: customizeParams.styleBorder,
         currentCustomizeTitle: customizeParams.customizeTitle,
         currentCustomizeTitle: customizeParams.customizeTitle,
@@ -144,6 +146,13 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ currentRecentCreatedLimit: value });
     this.setState({ currentRecentCreatedLimit: value });
   }
   }
 
 
+  /**
+   * Switch enabledStaleNotification
+   */
+  switchEnableStaleNotification() {
+    this.setState({ isEnabledStaleNotification:  !this.state.isEnabledStaleNotification });
+  }
+
   /**
   /**
    * Switch highlightJsStyle
    * Switch highlightJsStyle
    */
    */
@@ -228,6 +237,7 @@ export default class AdminCustomizeContainer extends Container {
       isSavedStatesOfTabChanges: this.state.isSavedStatesOfTabChanges,
       isSavedStatesOfTabChanges: this.state.isSavedStatesOfTabChanges,
       isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
       isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
       recentCreatedLimit: this.state.currentRecentCreatedLimit,
       recentCreatedLimit: this.state.currentRecentCreatedLimit,
+      isEnabledStaleNotification: this.state.isEnabledStaleNotification,
     });
     });
     const { customizedParams } = response.data;
     const { customizedParams } = response.data;
     return customizedParams;
     return customizedParams;

+ 61 - 0
src/client/js/services/AdminHomeContainer.js

@@ -0,0 +1,61 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:AdminHomeContainer');
+
+/**
+ * Service container for admin home page (AdminHome.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminHomeContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      growiVersion: '',
+      nodeVersion: '',
+      npmVersion: '',
+      yarnVersion: '',
+      installedPlugins: [],
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminHomeContainer';
+  }
+
+  /**
+   * retrieve admin home data
+   */
+  async retrieveAdminHomeData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/admin-home/');
+      const { adminHomeParams } = response.data;
+
+      this.setState({
+        growiVersion: adminHomeParams.growiVersion,
+        nodeVersion: adminHomeParams.nodeVersion,
+        npmVersion: adminHomeParams.npmVersion,
+        yarnVersion: adminHomeParams.yarnVersion,
+        installedPlugins: adminHomeParams.installedPlugins,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+}

+ 3 - 0
src/client/styles/agile-admin/inverse/colors/_apply-colors-dark.scss

@@ -150,6 +150,9 @@ legend {
     border-color: $border;
     border-color: $border;
   }
   }
 }
 }
+.editor-container .navbar-editor svg {
+  fill: $bodytext;
+}
 
 
 /*
 /*
  * GROWI admin page #themeOptions
  * GROWI admin page #themeOptions

+ 2 - 1
src/client/styles/scss/_handsontable.scss

@@ -42,7 +42,8 @@
   }
   }
 
 
   .data-import-form {
   .data-import-form {
-    background-color: #f8f8f8;
+    color: $headingtext;
+    background-color: $navbar-default-bg;
 
 
     .btn + .btn {
     .btn + .btn {
       margin-left: 5px;
       margin-left: 5px;

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

+ 1 - 0
src/server/form/admin/customfeatures.js

@@ -8,4 +8,5 @@ module.exports = form(
   field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
   field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
   field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict(),
   field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict(),
   field('settingForm[customize:showRecentCreatedNumber]').trim().toInt(),
   field('settingForm[customize:showRecentCreatedNumber]').trim().toInt(),
+  field('settingForm[customize:isEnabledStaleNotification]').trim().toBooleanStrict(),
 );
 );

+ 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'),
     apiToken: require('./me/apiToken'),
   },
   },
   admin: {
   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'),
     securityGeneral: require('./admin/securityGeneral'),
     securityPassportLocal: require('./admin/securityPassportLocal'),
     securityPassportLocal: require('./admin/securityPassportLocal'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
     securityPassportLdap: require('./admin/securityPassportLdap'),

+ 10 - 2
src/server/models/config.js

@@ -84,8 +84,14 @@ module.exports = function(crowi) {
       'mail:smtpUser'     : undefined,
       'mail:smtpUser'     : undefined,
       'mail:smtpPassword' : undefined,
       'mail:smtpPassword' : undefined,
 
 
-      'google:clientId'     : undefined,
-      'google:clientSecret' : undefined,
+      'security:passport-google:clientId'     : undefined,
+      'security:passport-google:clientSecret' : undefined,
+
+      'security:passport-github:clientId': undefined,
+      'security:passport-github:clientSecret': undefined,
+
+      'security:passport-twitter:clientId': undefined,
+      'security:passport-twitter:clientSecret': undefined,
 
 
       'plugin:isEnabledPlugins' : true,
       'plugin:isEnabledPlugins' : true,
 
 
@@ -102,6 +108,7 @@ module.exports = function(crowi) {
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isSavedStatesOfTabChanges' : true,
       'customize:isEnabledAttachTitleHeader' : false,
       'customize:isEnabledAttachTitleHeader' : false,
       'customize:showRecentCreatedNumber' : 10,
       'customize:showRecentCreatedNumber' : 10,
+      'customize:isEnabledStaleNotification': false,
 
 
       'importer:esa:team_name': undefined,
       'importer:esa:team_name': undefined,
       'importer:esa:access_token': undefined,
       'importer:esa:access_token': undefined,
@@ -201,6 +208,7 @@ module.exports = function(crowi) {
         NO_CDN: env.NO_CDN || null,
         NO_CDN: env.NO_CDN || null,
       },
       },
       recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       recentCreatedLimit: crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+      isEnabledStaleNotification: crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAclEnabled: crowi.aclService.isAclEnabled(),
       isAclEnabled: crowi.aclService.isAclEnabled(),
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
       globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     };
     };

+ 31 - 0
src/server/models/openapi/paginate-result.js

@@ -5,6 +5,7 @@
  *  components:
  *  components:
  *    schemas:
  *    schemas:
  *      PaginateResult:
  *      PaginateResult:
+ *        description: PaginateResult
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          docs:
  *          docs:
@@ -45,4 +46,34 @@
  *          meta:
  *          meta:
  *            type: number
  *            type: number
  *            description: Object of pagination meta data (Default false).
  *            description: Object of pagination meta data (Default false).
+ *
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      V1PaginateResult:
+ *        description: Paginate result v1
+ *        type: object
+ *        properties:
+ *          meta:
+ *            type: object
+ *            properties:
+ *              total:
+ *                type: integer
+ *                description: Total number of documents in collection that match a query
+ *                example: 35
+ *              limit:
+ *                type: integer
+ *                description: Limit that was used
+ *                example: 10
+ *              offset:
+ *                type: integer
+ *                description: Only if specified or default page/offset values were used
+ *                example: 20
+ *          data:
+ *            type: object
+ *            description: Object of pagination meta data.
  */
  */

+ 19 - 0
src/server/models/openapi/v1-response.js

@@ -0,0 +1,19 @@
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      V1Response:
+ *        description: Response v1
+ *        type: object
+ *        properties:
+ *          ok:
+ *            type: boolean
+ *            description: API is succeeded
+ *            example: true
+ *    responses:
+ *      403:
+ *        description: 'Forbidden'
+ *      500:
+ *        description: 'Internal Server Error'
+ */

+ 5 - 0
src/server/models/page.js

@@ -9,6 +9,7 @@ const urljoin = require('url-join');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
+const differenceInYears = require('date-fns/differenceInYears');
 
 
 const { pathUtils } = require('growi-commons');
 const { pathUtils } = require('growi-commons');
 const templateChecker = require('@commons/util/template-checker');
 const templateChecker = require('@commons/util/template-checker');
@@ -488,6 +489,10 @@ module.exports = function(crowi) {
     }
     }
   };
   };
 
 
+  pageSchema.methods.getContentAge = function() {
+    return differenceInYears(new Date(), this.updatedAt);
+  };
+
 
 
   pageSchema.statics.updateCommentCount = function(pageId) {
   pageSchema.statics.updateCommentCount = function(pageId) {
     validateCrowi();
     validateCrowi();

+ 13 - 9
src/server/plugins/plugin-utils.js

@@ -1,6 +1,7 @@
 const path = require('path');
 const path = require('path');
 const fs = require('graceful-fs');
 const fs = require('graceful-fs');
 const logger = require('@alias/logger')('growi:plugins:plugin-utils');
 const logger = require('@alias/logger')('growi:plugins:plugin-utils');
+const packageInstalledVersionSync = require('package-installed-version-sync');
 
 
 const PluginUtilsV2 = require('./plugin-utils-v2');
 const PluginUtilsV2 = require('./plugin-utils-v2');
 
 
@@ -48,8 +49,8 @@ class PluginUtils {
    *
    *
    * @returns array of objects
    * @returns array of objects
    *   [
    *   [
-   *     { name: 'growi-plugin-...', version: '1.0.0' },
-   *     { name: 'growi-plugin-...', version: '1.0.0' },
+   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
+   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
    *     ...
    *     ...
    *   ]
    *   ]
    *
    *
@@ -68,14 +69,17 @@ class PluginUtils {
     const json = JSON.parse(content);
     const json = JSON.parse(content);
     const deps = json.dependencies || {};
     const deps = json.dependencies || {};
 
 
-    const objs = {};
-    Object.keys(deps).forEach((name) => {
-      if (/^(crowi|growi)-plugin-/.test(name)) {
-        objs[name] = deps[name];
-      }
+    const pluginNames = Object.keys(deps).filter((name) => {
+      return /^(crowi|growi)-plugin-/.test(name);
     });
     });
 
 
-    return objs;
+    return pluginNames.map((name) => {
+      return {
+        name,
+        requiredVersion: deps[name],
+        installedVersion: packageInstalledVersionSync(name),
+      };
+    });
   }
   }
 
 
   /**
   /**
@@ -87,7 +91,7 @@ class PluginUtils {
    */
    */
   listPluginNames(rootDir) {
   listPluginNames(rootDir) {
     const plugins = this.listPlugins(rootDir);
     const plugins = this.listPlugins(rootDir);
-    return Object.keys(plugins);
+    return plugins.map((plugin) => { return plugin.name });
   }
   }
 
 
 }
 }

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

@@ -20,12 +20,10 @@ module.exports = function(crowi, app) {
   } = crowi;
   } = crowi;
 
 
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
   const recommendedWhitelist = require('@commons/service/xss/recommended-whitelist');
-  const PluginUtils = require('../plugins/plugin-utils');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const importer = require('../util/importer')(crowi);
   const importer = require('../util/importer')(crowi);
 
 
   const searchEvent = crowi.event('search');
   const searchEvent = crowi.event('search');
-  const pluginUtils = new PluginUtils();
 
 
   const MAX_PAGE_LIST = 50;
   const MAX_PAGE_LIST = 50;
   const actions = {};
   const actions = {};
@@ -99,9 +97,7 @@ module.exports = function(crowi, app) {
   });
   });
 
 
   actions.index = function(req, res) {
   actions.index = function(req, res) {
-    return res.render('admin/index', {
-      plugins: pluginUtils.listPlugins(crowi.rootDir),
-    });
+    return res.render('admin/index');
   };
   };
 
 
   // app.get('/admin/app'                  , admin.app.index);
   // app.get('/admin/app'                  , admin.app.index);
@@ -567,54 +563,6 @@ module.exports = function(crowi, app) {
   };
   };
 
 
   actions.api = {};
   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) {
   actions.api.securitySetting = async function(req, res) {
     if (!req.form.isValid) {
     if (!req.form.isValid) {
       return res.json({ status: false, message: req.form.errors.join('\n') });
       return res.json({ status: false, message: req.form.errors.join('\n') });
@@ -1063,33 +1011,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
     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
    * validate setting form values for SAML
    *
    *

+ 77 - 0
src/server/routes/apiv3/admin-home.js

@@ -0,0 +1,77 @@
+const express = require('express');
+const PluginUtils = require('../../plugins/plugin-utils');
+
+const pluginUtils = new PluginUtils();
+
+const router = express.Router();
+
+/**
+ * @swagger
+ *  tags:
+ *    name: adminHome
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      SystemInformationParams:
+ *        type: object
+ *        properties:
+ *          growiVersion:
+ *            type: string
+ *            description: version of growi
+ *          nodeVersion:
+ *            type: string
+ *            description: version of node
+ *          npmVersion:
+ *            type: string
+ *            description: version of npm
+ *          yarnVersion:
+ *            type: string
+ *            description: version of yarn
+ *      InstalledPluginsParams:
+ *        type: object
+ *        properties:
+ *          installedPlugins:
+ *            type: object
+ *            description: installed plugins
+ */
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+
+  /**
+   * @swagger
+   *
+   *    /admin-home/:
+   *      get:
+   *        tags: [adminHome]
+   *        description: Get adminHome parameters
+   *        responses:
+   *          200:
+   *            description: params of adminHome
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    adminHomeParams:
+   *                      type: object
+   *                      description: adminHome params
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const adminHomeParams = {
+      growiVersion: crowi.version,
+      nodeVersion: crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : '-',
+      npmVersion: crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : '-',
+      yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
+      installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
+    };
+
+    return res.apiv3({ adminHomeParams });
+  });
+
+  return router;
+};

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

@@ -0,0 +1,479 @@
+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:
+ *        description: 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:
+ *        description: 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:
+ *        description: 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:
+ *        description: AwsSettingParams
+ *        type: object
+ *        properties:
+ *          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:
+ *        description: PluginSettingParams
+ *        type: object
+ *        properties:
+ *          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
+   *
+   *    /_api/v3/app-settings:
+   *      get:
+   *        tags: [AppSettings, apiv3]
+   *        operationId: getAppSettings
+   *        summary: /_api/v3/app-settings
+   *        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
+   *
+   *    /_api/v3/app-settings/app-setting:
+   *      put:
+   *        tags: [AppSettings, apiv3]
+   *        summary: /_api/v3/app-settings/app-setting
+   *        operationId: updateAppSettings
+   *        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
+   *
+   *    /_api/v3/app-settings/site-url-setting:
+   *      put:
+   *        tags: [AppSettings, apiv3]
+   *        operationId: updateAppSettingSiteUrlSetting
+   *        summary: /_api/v3/app-settings/site-url-setting
+   *        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
+   *
+   *    /_api/v3/app-settings/mail-setting:
+   *      put:
+   *        tags: [AppSettings, apiv3]
+   *        operationId: updateAppSettingMailSetting
+   *        summary: /_api/v3/app-settings/site-url-setting
+   *        description: Update mail setting
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/MailSettingParams'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update mail 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
+   *
+   *    /_api/v3/app-settings/aws-setting:
+   *      put:
+   *        tags: [AppSettings, apiv3]
+   *        operationId: updateAppSettingAwsSetting
+   *        summary: /_api/v3/app-settings/aws-setting
+   *        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
+   *
+   *    /_api/v3/app-settings/plugin-setting:
+   *      put:
+   *        tags: [AppSettings, apiv3]
+   *        operationId: updateAppSettingPluginSetting
+   *        summary: /_api/v3/app-settings/plugin-setting
+   *        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;
+};

+ 108 - 76
src/server/routes/apiv3/customize-setting.js

@@ -22,6 +22,7 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *  components:
  *  components:
  *    schemas:
  *    schemas:
  *      CustomizeLayoutTheme:
  *      CustomizeLayoutTheme:
+ *        description: CustomizeLayoutTheme
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          layoutType:
  *          layoutType:
@@ -29,11 +30,13 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          themeType:
  *          themeType:
  *            type: string
  *            type: string
  *      CustomizeBehavior:
  *      CustomizeBehavior:
+ *        description: CustomizeBehavior
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          behaviorType:
  *          behaviorType:
  *            type: string
  *            type: string
  *      CustomizeFunction:
  *      CustomizeFunction:
+ *        description: CustomizeFunction
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          isEnabledTimeline:
  *          isEnabledTimeline:
@@ -44,7 +47,10 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: boolean
  *            type: boolean
  *          recentCreatedLimit:
  *          recentCreatedLimit:
  *            type: number
  *            type: number
+ *          isEnabledStaleNotification:
+ *            type: boolean
  *      CustomizeHighlight:
  *      CustomizeHighlight:
+ *        description: CustomizeHighlight
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          styleName:
  *          styleName:
@@ -52,21 +58,25 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *          styleBorder:
  *          styleBorder:
  *            type: boolean
  *            type: boolean
  *      CustomizeTitle:
  *      CustomizeTitle:
+ *        description: CustomizeTitle
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          customizeTitle:
  *          customizeTitle:
  *            type: string
  *            type: string
  *      CustomizeHeader:
  *      CustomizeHeader:
+ *        description: CustomizeHeader
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          customizeHeader:
  *          customizeHeader:
  *            type: string
  *            type: string
  *      CustomizeCss:
  *      CustomizeCss:
+ *        description: CustomizeCss
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          customizeCss:
  *          customizeCss:
  *            type: string
  *            type: string
  *      CustomizeScript:
  *      CustomizeScript:
+ *        description: CustomizeScript
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          customizeScript:
  *          customizeScript:
@@ -96,6 +106,7 @@ module.exports = (crowi) => {
       body('isSavedStatesOfTabChanges').isBoolean(),
       body('isSavedStatesOfTabChanges').isBoolean(),
       body('isEnabledAttachTitleHeader').isBoolean(),
       body('isEnabledAttachTitleHeader').isBoolean(),
       body('recentCreatedLimit').isInt().isInt({ min: 1, max: 1000 }),
       body('recentCreatedLimit').isInt().isInt({ min: 1, max: 1000 }),
+      body('isEnabledStaleNotification').isBoolean(),
     ],
     ],
     customizeTitle: [
     customizeTitle: [
       body('customizeTitle').isString(),
       body('customizeTitle').isString(),
@@ -105,7 +116,7 @@ module.exports = (crowi) => {
     ],
     ],
     highlight: [
     highlight: [
       body('highlightJsStyle').isString().isIn([
       body('highlightJsStyle').isString().isIn([
-        'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tororrow-night', 'vs2015',
+        'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tomorrow-night', 'vs2015',
       ]),
       ]),
       body('highlightJsStyleBorder').isBoolean(),
       body('highlightJsStyleBorder').isBoolean(),
     ],
     ],
@@ -120,10 +131,12 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/:
+   *    /_api/v3/customize-setting:
    *      get:
    *      get:
-   *        tags: [CustomizeSetting]
-   *        description: Get customize paramators
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: getCustomizeSetting
+   *        summary: /_api/v3/customize-setting
+   *        description: Get customize parameters
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: params of customize
    *            description: params of customize
@@ -145,6 +158,7 @@ module.exports = (crowi) => {
       isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
       isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
       isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
       isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
       recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+      isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
       customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
@@ -159,23 +173,25 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/layoutTheme:
+   *    /_api/v3/customize-setting/layoutTheme:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateLayoutThemeCustomizeSetting
+   *        summary: /_api/v3/customize-setting/layoutTheme
    *        description: Update layout and theme
    *        description: Update layout and theme
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schama:
-   *                $ref: '#/components/schemas/CustomizeLayoutTheme'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update layout and theme
-   *          content:
-   *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeLayoutTheme'
    *                $ref: '#/components/schemas/CustomizeLayoutTheme'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update layout and theme
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeLayoutTheme'
    */
    */
   router.put('/layoutTheme', loginRequiredStrictly, adminRequired, csrf, validator.layoutTheme, ApiV3FormValidator, async(req, res) => {
   router.put('/layoutTheme', loginRequiredStrictly, adminRequired, csrf, validator.layoutTheme, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
@@ -201,23 +217,25 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/behavior:
+   *    /_api/v3/customize-setting/behavior:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateBehaviorCustomizeSetting
+   *        summary: /_api/v3/customize-setting/behavior
    *        description: Update behavior
    *        description: Update behavior
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schama:
-   *                $ref: '#/components/schemas/CustomizeBehavior'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update behavior
-   *          content:
-   *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeBehavior'
    *                $ref: '#/components/schemas/CustomizeBehavior'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update behavior
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeBehavior'
    */
    */
   router.put('/behavior', loginRequiredStrictly, adminRequired, csrf, validator.behavior, ApiV3FormValidator, async(req, res) => {
   router.put('/behavior', loginRequiredStrictly, adminRequired, csrf, validator.behavior, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
@@ -241,23 +259,25 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/function:
+   *    /_api/v3/customize-setting/function:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateFunctionCustomizeSetting
+   *        summary: /_api/v3/customize-setting/function
    *        description: Update function
    *        description: Update function
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schama:
-   *                $ref: '#/components/schemas/CustomizeFunction'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update function
-   *          content:
-   *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeFunction'
    *                $ref: '#/components/schemas/CustomizeFunction'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update function
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeFunction'
    */
    */
   router.put('/function', loginRequiredStrictly, adminRequired, csrf, validator.function, ApiV3FormValidator, async(req, res) => {
   router.put('/function', loginRequiredStrictly, adminRequired, csrf, validator.function, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
@@ -265,6 +285,7 @@ module.exports = (crowi) => {
       'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
       'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
       'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
       'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
       'customize:showRecentCreatedNumber': req.body.recentCreatedLimit,
       'customize:showRecentCreatedNumber': req.body.recentCreatedLimit,
+      'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
     };
     };
 
 
     try {
     try {
@@ -274,6 +295,7 @@ module.exports = (crowi) => {
         isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
         isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
         isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
         isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
         recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
         recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+        isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       };
       };
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
@@ -287,23 +309,25 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/highlight:
+   *    /_api/v3/customize-setting/highlight:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateHighlightCustomizeSetting
+   *        summary: /_api/v3/customize-setting/highlight
    *        description: Update highlight
    *        description: Update highlight
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schama:
-   *                $ref: '#/components/schemas/CustomizeHighlight'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update highlight
-   *          content:
-   *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeHighlight'
    *                $ref: '#/components/schemas/CustomizeHighlight'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update highlight
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeHighlight'
    */
    */
   router.put('/highlight', loginRequiredStrictly, adminRequired, csrf, validator.highlight, ApiV3FormValidator, async(req, res) => {
   router.put('/highlight', loginRequiredStrictly, adminRequired, csrf, validator.highlight, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
@@ -329,9 +353,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/customizeTitle:
+   *    /_api/v3/customize-setting/customizeTitle:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateCustomizeTitleCustomizeSetting
+   *        summary: /_api/v3/customize-setting/customizeTitle
    *        description: Update customizeTitle
    *        description: Update customizeTitle
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -339,13 +365,13 @@ module.exports = (crowi) => {
    *            application/json:
    *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeTitle'
    *                $ref: '#/components/schemas/CustomizeTitle'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update customizeTitle
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/CustomizeTitle'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update customizeTitle
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeTitle'
    */
    */
   router.put('/customize-title', loginRequiredStrictly, adminRequired, csrf, validator.customizeTitle, ApiV3FormValidator, async(req, res) => {
   router.put('/customize-title', loginRequiredStrictly, adminRequired, csrf, validator.customizeTitle, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
@@ -370,23 +396,25 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/customizeHeader:
+   *    /_api/v3/customize-setting/customizeHeader:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateCustomizeHeaderCustomizeSetting
+   *        summary: /_api/v3/customize-setting/customizeHeader
    *        description: Update customizeHeader
    *        description: Update customizeHeader
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schama:
-   *                $ref: '#/components/schemas/CustomizeHeader'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update customize header
-   *          content:
-   *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeHeader'
    *                $ref: '#/components/schemas/CustomizeHeader'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update customize header
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeHeader'
    */
    */
   router.put('/customize-header', loginRequiredStrictly, adminRequired, csrf, validator.customizeHeader, ApiV3FormValidator, async(req, res) => {
   router.put('/customize-header', loginRequiredStrictly, adminRequired, csrf, validator.customizeHeader, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
@@ -409,23 +437,25 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/customizeCss:
+   *    /_api/v3/customize-setting/customizeCss:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateCustomizeCssCustomizeSetting
+   *        summary: /_api/v3/customize-setting/customizeCss
    *        description: Update customizeCss
    *        description: Update customizeCss
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schama:
-   *                $ref: '#/components/schemas/CustomizeCss'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update customize css
-   *          content:
-   *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeCss'
    *                $ref: '#/components/schemas/CustomizeCss'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update customize css
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeCss'
    */
    */
   router.put('/customize-css', loginRequiredStrictly, adminRequired, csrf, validator.customizeCss, ApiV3FormValidator, async(req, res) => {
   router.put('/customize-css', loginRequiredStrictly, adminRequired, csrf, validator.customizeCss, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
@@ -449,23 +479,25 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /customize-setting/customizeScript:
+   *    /_api/v3/customize-setting/customizeScript:
    *      put:
    *      put:
-   *        tags: [CustomizeSetting]
+   *        tags: [CustomizeSetting, apiv3]
+   *        operationId: updateCustomizeScriptCustomizeSetting
+   *        summary: /_api/v3/customize-setting/customizeScript
    *        description: Update customizeScript
    *        description: Update customizeScript
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
-   *              schama:
-   *                $ref: '#/components/schemas/CustomizeScript'
-   *      responses:
-   *        200:
-   *          description: Succeeded to update customize script
-   *          content:
-   *            application/json:
    *              schema:
    *              schema:
    *                $ref: '#/components/schemas/CustomizeScript'
    *                $ref: '#/components/schemas/CustomizeScript'
+   *        responses:
+   *          200:
+   *            description: Succeeded to update customize script
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeScript'
    */
    */
   router.put('/customize-script', loginRequiredStrictly, adminRequired, csrf, validator.customizeScript, ApiV3FormValidator, async(req, res) => {
   router.put('/customize-script', loginRequiredStrictly, adminRequired, csrf, validator.customizeScript, ApiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {

+ 13 - 6
src/server/routes/apiv3/export.js

@@ -19,6 +19,7 @@ const router = express.Router();
  *  components:
  *  components:
  *    schemas:
  *    schemas:
  *      ExportStatus:
  *      ExportStatus:
+ *        description: ExportStatus
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          zipFileStats:
  *          zipFileStats:
@@ -61,9 +62,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /export/status:
+   *  /_api/v3/export/status:
    *    get:
    *    get:
-   *      tags: [Export]
+   *      tags: [Export, apiv3]
+   *      operationId: getExportStatus
+   *      summary: /_api/v3/export/status
    *      description: get properties of stored zip files for export
    *      description: get properties of stored zip files for export
    *      responses:
    *      responses:
    *        200:
    *        200:
@@ -88,9 +91,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /export:
+   *  /_api/v3/export:
    *    post:
    *    post:
-   *      tags: [Export]
+   *      tags: [Export, apiv3]
+   *      operationId: createExport
+   *      summary: /_api/v3/export
    *      description: generate zipped jsons for collections
    *      description: generate zipped jsons for collections
    *      responses:
    *      responses:
    *        200:
    *        200:
@@ -124,9 +129,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /export/{fileName}:
+   *  /_api/v3/export/{fileName}:
    *    delete:
    *    delete:
-   *      tags: [Export]
+   *      tags: [Export, apiv3]
+   *      operationId: deleteExport
+   *      summary: /_api/v3/export/{fileName}
    *      description: delete the file
    *      description: delete the file
    *      parameters:
    *      parameters:
    *        - name: fileName
    *        - name: fileName

+ 4 - 2
src/server/routes/apiv3/healthcheck.js

@@ -18,9 +18,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /healthcheck:
+   *  /_api/v3/healthcheck:
    *    get:
    *    get:
-   *      tags: [Healthcheck]
+   *      tags: [Healthcheck, apiv3]
+   *      operationId: getHealthcheck
+   *      summary: /_api/v3/healthcheck
    *      description: Check whether the server is healthy or not
    *      description: Check whether the server is healthy or not
    *      parameters:
    *      parameters:
    *        - name: connectToMiddlewares
    *        - name: connectToMiddlewares

+ 17 - 8
src/server/routes/apiv3/import.js

@@ -24,6 +24,7 @@ const router = express.Router();
  *  components:
  *  components:
  *    schemas:
  *    schemas:
  *      ImportStatus:
  *      ImportStatus:
+ *        description: ImportStatus
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          zipFileStat:
  *          zipFileStat:
@@ -100,9 +101,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /import/status:
+   *  /_api/v3/import/status:
    *    get:
    *    get:
-   *      tags: [Import]
+   *      tags: [Import, apiv3]
+   *      operationId: getImportStatus
+   *      summary: /_api/v3/import/status
    *      description: Get properties of stored zip files for import
    *      description: Get properties of stored zip files for import
    *      responses:
    *      responses:
    *        200:
    *        200:
@@ -127,9 +130,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /import:
+   *  /_api/v3/import:
    *    post:
    *    post:
-   *      tags: [Import]
+   *      tags: [Import, apiv3]
+   *      operationId: executeImport
+   *      summary: /_api/v3/import
    *      description: import a collection from a zipped json
    *      description: import a collection from a zipped json
    *      requestBody:
    *      requestBody:
    *        required: true
    *        required: true
@@ -236,9 +241,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /import/upload:
+   *  /_api/v3/import/upload:
    *    post:
    *    post:
-   *      tags: [Import]
+   *      tags: [Import, apiv3]
+   *      operationId: uploadImport
+   *      summary: /_api/v3/import/upload
    *      description: upload a zip file
    *      description: upload a zip file
    *      responses:
    *      responses:
    *        200:
    *        200:
@@ -281,9 +288,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /import/all:
+   *  /_api/v3/import/all:
    *    delete:
    *    delete:
-   *      tags: [Import]
+   *      tags: [Import, apiv3]
+   *      operationId: deleteImportAll
+   *      summary: /_api/v3/import/all
    *      description: Delete all zip files
    *      description: Delete all zip files
    *      responses:
    *      responses:
    *        200:
    *        200:

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

@@ -13,8 +13,12 @@ module.exports = (crowi) => {
 
 
   router.use('/healthcheck', require('./healthcheck')(crowi));
   router.use('/healthcheck', require('./healthcheck')(crowi));
 
 
+  router.use('/admin-home', require('./admin-home')(crowi));
+
   router.use('/markdown-setting', require('./markdown-setting')(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('/customize-setting', require('./customize-setting')(crowi));
 
 
   router.use('/users', require('./users')(crowi));
   router.use('/users', require('./users')(crowi));

+ 20 - 9
src/server/routes/apiv3/markdown-setting.js

@@ -39,6 +39,7 @@ const validator = {
  *  components:
  *  components:
  *    schemas:
  *    schemas:
  *      LineBreakParams:
  *      LineBreakParams:
+ *        description: LineBreakParams
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          isEnabledLinebreaks:
  *          isEnabledLinebreaks:
@@ -48,6 +49,7 @@ const validator = {
  *            type: boolean
  *            type: boolean
  *            description: enable lineBreak in comment
  *            description: enable lineBreak in comment
  *      PresentationParams:
  *      PresentationParams:
+ *        description: PresentationParams
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          pageBreakSeparator:
  *          pageBreakSeparator:
@@ -57,6 +59,7 @@ const validator = {
  *            type: string
  *            type: string
  *            description: string of pageBreakCustomSeparator
  *            description: string of pageBreakCustomSeparator
  *      XssParams:
  *      XssParams:
+ *        description: XssParams
  *        type: object
  *        type: object
  *        properties:
  *        properties:
  *          isEnabledPrevention:
  *          isEnabledPrevention:
@@ -89,10 +92,12 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /markdown-setting/:
+   *    /_api/v3/markdown-setting:
    *      get:
    *      get:
-   *        tags: [MarkDownSettind]
-   *        description: Get markdown paramators
+   *        tags: [MarkDownSetting, apiv3]
+   *        operationId: getMarkdownSetting
+   *        summary: /_api/v3/markdown-setting
+   *        description: Get markdown parameters
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: params of markdown
    *            description: params of markdown
@@ -122,9 +127,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /markdown-setting/lineBreak:
+   *    /_api/v3/markdown-setting/lineBreak:
    *      put:
    *      put:
-   *        tags: [MarkDownSetting]
+   *        tags: [MarkDownSetting, apiv3]
+   *        operationId: updateLineBreakMarkdownSetting
+   *        summary: /_api/v3/markdown-setting/lineBreak
    *        description: Update lineBreak setting
    *        description: Update lineBreak setting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -166,9 +173,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /markdown-setting/presentation:
+   *    /_api/v3/markdown-setting/presentation:
    *      put:
    *      put:
-   *        tags: [MarkDownSetting]
+   *        tags: [MarkDownSetting, apiv3]
+   *        operationId: updatePresentationMarkdownSetting
+   *        summary: /_api/v3/markdown-setting/presentation
    *        description: Update presentation
    *        description: Update presentation
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -213,9 +222,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *    /markdown-setting/xss:
+   *    /_api/v3/markdown-setting/xss:
    *      put:
    *      put:
-   *        tags: [MarkDownSetting]
+   *        tags: [MarkDownSetting, apiv3]
+   *        operationId: updateXssMarkdownSetting
+   *        summary: /_api/v3/markdown-setting/xss
    *        description: Update xss
    *        description: Update xss
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true

+ 4 - 2
src/server/routes/apiv3/mongo.js

@@ -17,9 +17,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /mongo/collections:
+   *  /_api/v3/mongo/collections:
    *    get:
    *    get:
-   *      tags: [Mongo]
+   *      tags: [Mongo, apiv3]
+   *      operationId: getMongoCollections
+   *      summary: /_api/v3/mongo/collections
    *      description: get mongodb collections names
    *      description: get mongodb collections names
    *      responses:
    *      responses:
    *        200:
    *        200:

+ 4 - 2
src/server/routes/apiv3/statistics.js

@@ -83,9 +83,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *
    *
-   *  /statistics/user:
+   *  /_api/v3/statistics/user:
    *    get:
    *    get:
-   *      tags: [Statistics]
+   *      tags: [Statistics, apiv3]
+   *      operationId: getStatisticsUser
+   *      summary: /_api/v3/statistics/user
    *      description: Get statistics for user
    *      description: Get statistics for user
    *      responses:
    *      responses:
    *        200:
    *        200:

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

@@ -23,9 +23,11 @@ module.exports = (crowi) => {
   /**
   /**
    * @swagger
    * @swagger
    *  paths:
    *  paths:
-   *    /user-group-relations:
+   *    /_api/v3/user-group-relations:
    *      get:
    *      get:
-   *        tags: [UserGroupRelation]
+   *        tags: [UserGroupRelation, apiv3]
+   *        operationId: listUserGroupRelations
+   *        summary: /_api/v3/user-group-relations
    *        description: Gets the user group relations
    *        description: Gets the user group relations
    *        responses:
    *        responses:
    *          200:
    *          200:

+ 40 - 20
src/server/routes/apiv3/user-group.js

@@ -43,9 +43,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups:
+   *    /_api/v3/user-groups:
    *      get:
    *      get:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: getUserGroup
+   *        summary: /_api/v3/user-groups
    *        description: Get usergroups
    *        description: Get usergroups
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -81,9 +83,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups:
+   *    /_api/v3/user-groups:
    *      post:
    *      post:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: createUserGroup
+   *        summary: /_api/v3/user-groups
    *        description: Adds userGroup
    *        description: Adds userGroup
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
@@ -131,9 +135,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}:
+   *    /_api/v3/user-groups/{id}:
    *      delete:
    *      delete:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: deleteUserGroup
+   *        summary: /_api/v3/user-groups/{id}
    *        description: Deletes userGroup
    *        description: Deletes userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -191,9 +197,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}:
+   *    /_api/v3/user-groups/{id}:
    *      put:
    *      put:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: updateUserGroups
+   *        summary: /_api/v3/user-groups/{id}
    *        description: Update userGroup
    *        description: Update userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -246,9 +254,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/users:
+   *    /_api/v3/user-groups/{id}/users:
    *      get:
    *      get:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: getUsersUserGroups
+   *        summary: /_api/v3/user-groups/{id}/users
    *        description: Get users related to the userGroup
    *        description: Get users related to the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -294,9 +304,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/unrelated-users:
+   *    /_api/v3/user-groups/{id}/unrelated-users:
    *      get:
    *      get:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: getUnrelatedUsersUserGroups
+   *        summary: /_api/v3/user-groups/{id}/unrelated-users
    *        description: Get users unrelated to the userGroup
    *        description: Get users unrelated to the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -350,9 +362,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/users:
+   *    /_api/v3/user-groups/{id}/users:
    *      post:
    *      post:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: addUserUserGroups
+   *        summary: /_api/v3/user-groups/{id}/users
    *        description: Add a user to the userGroup
    *        description: Add a user to the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -417,9 +431,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/users:
+   *    /_api/v3/user-groups/{id}/users:
    *      delete:
    *      delete:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: deleteUsersUserGroups
+   *        summary: /_api/v3/user-groups/{id}/users
    *        description: remove a user from the userGroup
    *        description: remove a user from the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -477,9 +493,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/user-group-relations:
+   *    /_api/v3/user-groups/{id}/user-group-relations:
    *      get:
    *      get:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: getUserGroupRelationsUserGroups
+   *        summary: /_api/v3/user-groups/{id}/user-group-relations
    *        description: Get the user group relations for the userGroup
    *        description: Get the user group relations for the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -529,9 +547,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /user-groups/{id}/pages:
+   *    /_api/v3/user-groups/{id}/pages:
    *      get:
    *      get:
-   *        tags: [UserGroup]
+   *        tags: [UserGroup, apiv3]
+   *        operationId: getPagesUserGroups
+   *        summary: /_api/v3/user-groups/{id}/pages
    *        description: Get closed pages for the userGroup
    *        description: Get closed pages for the userGroup
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id

+ 71 - 10
src/server/routes/apiv3/users.js

@@ -21,6 +21,49 @@ const validator = {};
  *    name: Users
  *    name: Users
  */
  */
 
 
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      User:
+ *        description: User
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: user ID
+ *            example: 5ae5fccfc5577b0004dbd8ab
+ *          lang:
+ *            type: string
+ *            description: language
+ *            example: 'en-US'
+ *          status:
+ *            type: integer
+ *            description: status
+ *            example: 0
+ *          admin:
+ *            type: boolean
+ *            description: whether the admin
+ *            example: false
+ *          email:
+ *            type: string
+ *            description: E-Mail address
+ *            example: alice@aaa.aaa
+ *          username:
+ *            type: string
+ *            description: username
+ *            example: alice
+ *          name:
+ *            type: string
+ *            description: full name
+ *            example: Alice
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
+
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
   const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
   const adminRequired = require('../../middleware/admin-required')(crowi);
   const adminRequired = require('../../middleware/admin-required')(crowi);
@@ -40,7 +83,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users:
    *    /_api/v3/users:
    *      get:
    *      get:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: listUsers
+   *        summary: /_api/v3/users
    *        description: Get users
    *        description: Get users
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -90,7 +135,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users/invite:
    *    /_api/v3/users/invite:
    *      post:
    *      post:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: inviteUser
+   *        summary: /_api/v3/users/invite
    *        description: Create new users and send Emails
    *        description: Create new users and send Emails
    *        parameters:
    *        parameters:
    *          - name: shapedEmailList
    *          - name: shapedEmailList
@@ -133,7 +180,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users/{id}/giveAdmin:
    *    /_api/v3/users/{id}/giveAdmin:
    *      put:
    *      put:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: giveAdminUser
+   *        summary: /_api/v3/users/{id}/giveAdmin
    *        description: Give user admin
    *        description: Give user admin
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -172,7 +221,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users/{id}/removeAdmin:
    *    /_api/v3/users/{id}/removeAdmin:
    *      put:
    *      put:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: removeAdminUser
+   *        summary: /_api/v3/users/{id}/removeAdmin
    *        description: Remove user admin
    *        description: Remove user admin
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -211,7 +262,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users/{id}/activate:
    *    /_api/v3/users/{id}/activate:
    *      put:
    *      put:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: activateUser
+   *        summary: /_api/v3/users/{id}/activate
    *        description: Activate user
    *        description: Activate user
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -258,7 +311,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users/{id}/deactivate:
    *    /_api/v3/users/{id}/deactivate:
    *      put:
    *      put:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: deactivateUser
+   *        summary: /_api/v3/users/{id}/deactivate
    *        description: Deactivate user
    *        description: Deactivate user
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -297,7 +352,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users/{id}/remove:
    *    /_api/v3/users/{id}/remove:
    *      delete:
    *      delete:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: removeUser
+   *        summary: /_api/v3/users/{id}/remove
    *        description: Delete user
    *        description: Delete user
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
@@ -338,9 +395,11 @@ module.exports = (crowi) => {
    * @swagger
    * @swagger
    *
    *
    *  paths:
    *  paths:
-   *    /_api/v3/users:
+   *    /_api/v3/users/external-accounts:
    *      get:
    *      get:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: listExternalAccountsUsers
+   *        summary: /_api/v3/users/external-accounts
    *        description: Get external-account
    *        description: Get external-account
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -372,7 +431,9 @@ module.exports = (crowi) => {
    *  paths:
    *  paths:
    *    /_api/v3/users/external-accounts/{id}/remove:
    *    /_api/v3/users/external-accounts/{id}/remove:
    *      delete:
    *      delete:
-   *        tags: [Users]
+   *        tags: [Users, apiv3]
+   *        operationId: removeExternalAccountUser
+   *        summary: /_api/v3/users/external-accounts/{id}/remove
    *        description: Delete ExternalAccount
    *        description: Delete ExternalAccount
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id

+ 192 - 0
src/server/routes/attachment.js

@@ -7,6 +7,65 @@ const fs = require('fs');
 
 
 const ApiResponse = require('../util/apiResponse');
 const ApiResponse = require('../util/apiResponse');
 
 
+/**
+ * @swagger
+ *  tags:
+ *    name: Attachments
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Attachment:
+ *        description: Attachment
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: attachment ID
+ *            example: 5e0734e072560e001761fa67
+ *          __v:
+ *            type: number
+ *            description: attachment version
+ *            example: 0
+ *          fileFormat:
+ *            type: string
+ *            description: file format in MIME
+ *            example: text/plain
+ *          fileName:
+ *            type: string
+ *            description: file name
+ *            example: 601b7c59d43a042c0117e08dd37aad0aimage.txt
+ *          originalName:
+ *            type: string
+ *            description: original file name
+ *            example: file.txt
+ *          filePath:
+ *            type: string
+ *            description: file path
+ *            example: attachment/5e07345972560e001761fa63/6b0b3facf3628699263d760e18efd446.txt
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          page:
+ *            type: string
+ *            description: page ID attached at
+ *            example: 5e07345972560e001761fa63
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          fileSize:
+ *            type: number
+ *            description: file size
+ *            example: 3494332
+ *          url:
+ *            type: string
+ *            description: attachment URL
+ *            example: http://localhost/files/5e0734e072560e001761fa67
+ */
+
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const Attachment = crowi.model('Attachment');
   const Attachment = crowi.model('Attachment');
   const User = crowi.model('User');
   const User = crowi.model('User');
@@ -185,6 +244,40 @@ module.exports = function(crowi, app) {
     return responseForAttachment(req, res, attachment);
     return responseForAttachment(req, res, attachment);
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/attachments.list:
+   *      get:
+   *        tags: [Attachments, apiv1]
+   *        operationId: listAttachments
+   *        summary: /_api/attachments.list
+   *        description: Get list of attachments in page
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *            required: true
+   *        responses:
+   *          200:
+   *            description: Succeeded to get list of attachments.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    attachments:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Attachment'
+   *                      description: attachment list
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /attachments.list Get attachments of the page
    * @api {get} /attachments.list Get attachments of the page
    * @apiName ListAttachments
    * @apiName ListAttachments
@@ -219,6 +312,73 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(await fileUploader.checkLimit(fileSize)));
     return res.json(ApiResponse.success(await fileUploader.checkLimit(fileSize)));
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/attachments.add:
+   *      post:
+   *        tags: [Attachments, apiv1]
+   *        operationId: addAttachment
+   *        summary: /_api/attachments.add
+   *        description: Add attachment to the page
+   *        requestBody:
+   *          content:
+   *            "multipart/form-data":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *            "*\/*":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *        responses:
+   *          200:
+   *            description: Succeeded to add attachment.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *                    attachment:
+   *                      $ref: '#/components/schemas/Attachment'
+   *                    url:
+   *                      $ref: '#/components/schemas/Attachment/properties/url'
+   *                    pageCreated:
+   *                      type: boolean
+   *                      description: whether the page was created
+   *                      example: false
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /attachments.add Add attachment to the page
    * @api {post} /attachments.add Add attachment to the page
    * @apiName AddAttachments
    * @apiName AddAttachments
@@ -320,6 +480,38 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/attachments.remove:
+   *      post:
+   *        tags: [Attachments, apiv1]
+   *        operationId: removeAttachment
+   *        summary: /_api/attachments.remove
+   *        description: Remove attachment
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  attachment_id:
+   *                    $ref: '#/components/schemas/Attachment/properties/_id'
+   *                required:
+   *                  - attachment_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove attachment.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /attachments.remove Remove attachments
    * @api {post} /attachments.remove Remove attachments
    * @apiName RemoveAttachments
    * @apiName RemoveAttachments

+ 163 - 0
src/server/routes/bookmark.js

@@ -1,3 +1,36 @@
+/**
+ * @swagger
+ *  tags:
+ *    name: Bookmarks
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Bookmark:
+ *        description: Bookmark
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: page ID
+ *            example: 5e07345972560e001761fa63
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          page:
+ *            $ref: '#/components/schemas/Page/properties/_id'
+ *          user:
+ *            $ref: '#/components/schemas/User/properties/_id'
+ */
+
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:bookmark');
   const debug = require('debug')('growi:routes:bookmark');
   const Bookmark = crowi.model('Bookmark');
   const Bookmark = crowi.model('Bookmark');
@@ -7,6 +40,37 @@ module.exports = function(crowi, app) {
   const actions = {};
   const actions = {};
   actions.api = {};
   actions.api = {};
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/bookmarks.get:
+   *      get:
+   *        tags: [Bookmarks, apiv1]
+   *        operationId: getBookmark
+   *        summary: /_api/bookmarks.get
+   *        description: Get bookmark of the page with the user
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            required: true
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Succeeded to get bookmark of the page with the user.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    bookmark:
+   *                      $ref: '#/components/schemas/Bookmark'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /bookmarks.get Get bookmark of the page with the user
    * @api {get} /bookmarks.get Get bookmark of the page with the user
    * @apiName GetBookmarks
    * @apiName GetBookmarks
@@ -30,8 +94,44 @@ module.exports = function(crowi, app) {
       });
       });
   };
   };
 
 
+
   /**
   /**
+   * @swagger
    *
    *
+   *    /_api/bookmarks.list:
+   *      get:
+   *        tags: [Bookmarks, apiv1]
+   *        operationId: listBookmarks
+   *        summary: /_api/bookmarks.list
+   *        description: Get bookmark list of the page with the user
+   *        parameters:
+   *          - in: query
+   *            name: limit
+   *            schema:
+   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/limit'
+   *          - in: query
+   *            name: offset
+   *            schema:
+   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
+   *        responses:
+   *          200:
+   *            description: Succeeded to get bookmark of the page with the user.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    meta:
+   *                      $ref: '#/components/schemas/V1PaginateResult/properties/meta'
+   *                    data:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/V1PaginateResult/properties/meta'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
    */
    */
   actions.api.list = function(req, res) {
   actions.api.list = function(req, res) {
     const paginateOptions = ApiPaginate.parseOptions(req.query);
     const paginateOptions = ApiPaginate.parseOptions(req.query);
@@ -46,6 +146,37 @@ module.exports = function(crowi, app) {
       });
       });
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/bookmarks.add:
+   *      post:
+   *        tags: [Bookmarks, apiv1]
+   *        operationId: addBookmark
+   *        summary: /_api/bookmarks.add
+   *        description: Add bookmark of the page
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *            required: true
+   *        responses:
+   *          200:
+   *            description: Succeeded to add bookmark of the page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    bookmark:
+   *                      $ref: '#/components/schemas/Bookmark'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /bookmarks.add Add bookmark of the page
    * @api {post} /bookmarks.add Add bookmark of the page
    * @apiName AddBookmark
    * @apiName AddBookmark
@@ -70,6 +201,38 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/bookmarks.remove:
+   *      post:
+   *        tags: [Bookmarks, apiv1]
+   *        operationId: removeBookmark
+   *        summary: /_api/bookmarks.remove
+   *        description: Remove bookmark of the page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                required:
+   *                  - page_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove bookmark of the page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /bookmarks.remove Remove bookmark of the page
    * @api {post} /bookmarks.remove Remove bookmark of the page
    * @apiName RemoveBookmark
    * @apiName RemoveBookmark

+ 202 - 0
src/server/routes/comment.js

@@ -1,3 +1,46 @@
+/**
+ * @swagger
+ *  tags:
+ *    name: Comments
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Comment:
+ *        description: Comment
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: revision ID
+ *            example: 5e079a0a0afa6700170a75fb
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          page:
+ *            $ref: '#/components/schemas/Page/properties/_id'
+ *          creator:
+ *            $ref: '#/components/schemas/User/properties/_id'
+ *          revision:
+ *            $ref: '#/components/schemas/Revision/properties/_id'
+ *          comment:
+ *            type: string
+ *            description: comment
+ *            example: good
+ *          commentPosition:
+ *            type: number
+ *            description: comment position
+ *            example: 0
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
+
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const logger = require('@alias/logger')('growi:routes:comment');
   const logger = require('@alias/logger')('growi:routes:comment');
   const Comment = crowi.model('Comment');
   const Comment = crowi.model('Comment');
@@ -16,6 +59,42 @@ module.exports = function(crowi, app) {
   actions.api = api;
   actions.api = api;
   api.validators = {};
   api.validators = {};
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/comments.get:
+   *      get:
+   *        tags: [Comments, apiv1]
+   *        operationId: getComments
+   *        summary: /_api/comments.get
+   *        description: Get comments of the page of the revision
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *          - in: query
+   *            name: revision_id
+   *            schema:
+   *              $ref: '#/components/schemas/Revision/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Succeeded to get comments of the page of the revision.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    comments:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Comment'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /comments.get Get comments of the page of the revision
    * @api {get} /comments.get Get comments of the page of the revision
    * @apiName GetComments
    * @apiName GetComments
@@ -74,6 +153,49 @@ module.exports = function(crowi, app) {
     return validator;
     return validator;
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/comments.add:
+   *      post:
+   *        tags: [Comments, apiv1]
+   *        operationId: addComment
+   *        summary: /_api/comments.add
+   *        description: Post comment for the page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  commentForm:
+   *                    type: object
+   *                    properties:
+   *                      page_id:
+   *                        $ref: '#/components/schemas/Page/properties/_id'
+   *                      revision_id:
+   *                        $ref: '#/components/schemas/Revision/properties/_id'
+   *                      comment:
+   *                        $ref: '#/components/schemas/Comment/properties/comment'
+   *                      comment_position:
+   *                        $ref: '#/components/schemas/Comment/properties/commentPosition'
+   *                required:
+   *                  - commentForm
+   *        responses:
+   *          200:
+   *            description: Succeeded to post comment for the page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    comment:
+   *                      $ref: '#/components/schemas/Comment'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /comments.add Post comment for the page
    * @api {post} /comments.add Post comment for the page
    * @apiName PostComment
    * @apiName PostComment
@@ -160,6 +282,52 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/comments.update:
+   *      post:
+   *        tags: [Comments, apiv1]
+   *        operationId: updateComment
+   *        summary: /_api/comments.update
+   *        description: Update comment dody
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  form:
+   *                    type: object
+   *                    properties:
+   *                      commentForm:
+   *                        type: object
+   *                        properties:
+   *                          page_id:
+   *                            $ref: '#/components/schemas/Page/properties/_id'
+   *                          revision_id:
+   *                            $ref: '#/components/schemas/Revision/properties/_id'
+   *                          comment:
+   *                            $ref: '#/components/schemas/Comment/properties/comment'
+   *                          comment_position:
+   *                            $ref: '#/components/schemas/Comment/properties/commentPosition'
+   *                required:
+   *                  - form
+   *        responses:
+   *          200:
+   *            description: Succeeded to update comment dody.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    comment:
+   *                      $ref: '#/components/schemas/Comment'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /comments.update Update comment dody
    * @api {post} /comments.update Update comment dody
    * @apiName UpdateComment
    * @apiName UpdateComment
@@ -206,6 +374,40 @@ module.exports = function(crowi, app) {
     // process notification if needed
     // process notification if needed
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/comments.remove:
+   *      post:
+   *        tags: [Comments, apiv1]
+   *        operationId: removeComment
+   *        summary: /_api/comments.remove
+   *        description: Remove specified comment
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  comment_id:
+   *                    $ref: '#/components/schemas/Comment/properties/_id'
+   *                required:
+   *                  - comment_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to remove specified comment.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    comment:
+   *                      $ref: '#/components/schemas/Comment'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /comments.remove Remove specified comment
    * @api {post} /comments.remove Remove specified comment
    * @apiName RemoveComment
    * @apiName RemoveComment

+ 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'                          , loginRequiredStrictly , adminRequired , admin.index);
   app.get('/admin/app'                      , loginRequiredStrictly , adminRequired , admin.app.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
   // security admin
   app.get('/admin/security'                     , loginRequiredStrictly , adminRequired , admin.security.index);
   app.get('/admin/security'                     , loginRequiredStrictly , adminRequired , admin.security.index);

+ 10 - 0
src/server/routes/login-passport.js

@@ -258,6 +258,16 @@ module.exports = function(crowi, app) {
       username: response.displayName,
       username: response.displayName,
       name: `${response.name.givenName} ${response.name.familyName}`,
       name: `${response.name.givenName} ${response.name.familyName}`,
     };
     };
+
+    // Emails are not empty if it exists
+    // See https://github.com/passport/express-4.x-facebook-example/blob/dfce5495d0313174a1b5039bab2c2dcda7e0eb61/views/profile.ejs
+    // Both Facebook and Google use OAuth 2.0, the code is similar
+    // See https://github.com/jaredhanson/passport-google-oauth2/blob/723e8f3e8e711275f89e0163e2c77cfebae33f25/README.md#examples
+    if (response.emails != null) {
+      userInfo.email = response.emails[0].value;
+      userInfo.username = userInfo.email.slice(0, userInfo.email.indexOf('@'));
+    }
+
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
     if (!externalAccount) {
       return loginFailure(req, res, next);
       return loginFailure(req, res, next);

+ 466 - 0
src/server/routes/page.js

@@ -1,3 +1,133 @@
+/**
+ * @swagger
+ *  tags:
+ *    name: Pages
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Page:
+ *        description: Page
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: page ID
+ *            example: 5e07345972560e001761fa63
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          commentCount:
+ *            type: number
+ *            description: count of comments
+ *            example: 3
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          extended:
+ *            type: object
+ *            description: extend data
+ *            example: {}
+ *          grant:
+ *            type: number
+ *            description: grant
+ *            example: 1
+ *          grantedUsers:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          lastUpdateUser:
+ *            $ref: '#/components/schemas/User'
+ *          liker:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: []
+ *          path:
+ *            type: string
+ *            description: page path
+ *            example: /
+ *          redirectTo:
+ *            type: string
+ *            description: redirect path
+ *            example: ""
+ *          revision:
+ *            $ref: '#/components/schemas/Revision'
+ *          seenUsers:
+ *            type: array
+ *            description: granted users
+ *            items:
+ *              type: string
+ *              description: user ID
+ *            example: ["5ae5fccfc5577b0004dbd8ab"]
+ *          status:
+ *            type: string
+ *            description: status
+ *            enum:
+ *              - 'wip'
+ *              - 'published'
+ *              - 'deleted'
+ *              - 'deprecated'
+ *            example: published
+ *          updatedAt:
+ *            type: string
+ *            description: date updated at
+ *            example: 2010-01-01T00:00:00.000Z
+ *
+ *      UpdatePost:
+ *        description: UpdatePost
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: update post ID
+ *            example: 5e0734e472560e001761fa68
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          pathPattern:
+ *            type: string
+ *            description: path pattern
+ *            example: /test
+ *          patternPrefix:
+ *            type: string
+ *            description: patternPrefix prefix
+ *            example: /
+ *          patternPrefix2:
+ *            type: string
+ *            description: path
+ *            example: test
+ *          channel:
+ *            type: string
+ *            description: channel
+ *            example: general
+ *          provider:
+ *            type: string
+ *            description: provider
+ *            enum:
+ *              - slack
+ *            example: slack
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
+
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const debug = require('debug')('growi:routes:page');
@@ -512,6 +642,47 @@ module.exports = function(crowi, app) {
   const api = {};
   const api = {};
   actions.api = api;
   actions.api = api;
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/pages.list:
+   *      get:
+   *        tags: [Pages, apiv1]
+   *        operationId: listPages
+   *        summary: /_api/pages.list
+   *        description: Get list of pages
+   *        parameters:
+   *          - in: query
+   *            name: path
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/path'
+   *          - in: query
+   *            name: user
+   *            schema:
+   *              $ref: '#/components/schemas/User/properties/username'
+   *          - in: query
+   *            name: offset
+   *            schema:
+   *              $ref: '#/components/schemas/V1PaginateResult/properties/meta/properties/offset'
+   *        responses:
+   *          200:
+   *            description: Succeeded to get list of pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    pages:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Page'
+   *                      description: page list
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /pages.list List pages by user
    * @api {get} /pages.list List pages by user
    * @apiName ListPage
    * @apiName ListPage
@@ -561,6 +732,45 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/pages.create:
+   *      post:
+   *        tags: [Pages, apiv1]
+   *        operationId: createPage
+   *        summary: /_api/pages.create
+   *        description: Create page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    $ref: '#/components/schemas/Revision/properties/body'
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                required:
+   *                  - body
+   *                  - path
+   *        responses:
+   *          200:
+   *            description: Succeeded to create page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /pages.create Create new page
    * @api {post} /pages.create Create new page
    * @apiName CreatePage
    * @apiName CreatePage
@@ -631,6 +841,47 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/pages.update:
+   *      post:
+   *        tags: [Pages, apiv1]
+   *        operationId: updatePage
+   *        summary: /_api/pages.update
+   *        description: Update page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  body:
+   *                    $ref: '#/components/schemas/Revision/properties/body'
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  revision_id:
+   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                  grant:
+   *                    $ref: '#/components/schemas/Page/properties/grant'
+   *                required:
+   *                  - body
+   *                  - page_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to update page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /pages.update Update page
    * @api {post} /pages.update Update page
    * @apiName UpdatePage
    * @apiName UpdatePage
@@ -718,6 +969,44 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/pages.get:
+   *      get:
+   *        tags: [Pages, apiv1]
+   *        operationId: getPage
+   *        summary: /_api/pages.get
+   *        description: Get page data
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *          - in: query
+   *            name: path
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/path'
+   *          - in: query
+   *            name: revision_id
+   *            schema:
+   *              $ref: '#/components/schemas/Revision/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Succeeded to get page data.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /pages.get Get page data
    * @api {get} /pages.get Get page data
    * @apiName GetPage
    * @apiName GetPage
@@ -804,6 +1093,40 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/pages.seen:
+   *      post:
+   *        tags: [Pages, apiv1]
+   *        operationId: seenPage
+   *        summary: /_api/pages.seen
+   *        description: Mark as seen user
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                required:
+   *                  - page_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to be page seen.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    seenUser:
+   *                      $ref: '#/components/schemas/Page/properties/seenUsers'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /pages.seen Mark as seen user
    * @api {post} /pages.seen Mark as seen user
    * @apiName SeenPage
    * @apiName SeenPage
@@ -839,6 +1162,40 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/likes.add:
+   *      post:
+   *        tags: [Pages, apiv1]
+   *        operationId: addLike
+   *        summary: /_api/likes.add
+   *        description: Like page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                required:
+   *                  - page_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to be page liked.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /likes.add Like page
    * @api {post} /likes.add Like page
    * @apiName LikePage
    * @apiName LikePage
@@ -881,6 +1238,40 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/likes.remove:
+   *      post:
+   *        tags: [Pages, apiv1]
+   *        operationId: removeLike
+   *        summary: /_api/likes.remove
+   *        description: Unlike page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                required:
+   *                  - page_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to not be page liked.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /likes.remove Unlike page
    * @api {post} /likes.remove Unlike page
    * @apiName UnlikePage
    * @apiName UnlikePage
@@ -915,6 +1306,36 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/pages.updatePost:
+   *      get:
+   *        tags: [Pages, apiv1]
+   *        operationId: getUpdatePostPage
+   *        summary: /_api/pages.updatePost
+   *        description: Get UpdatePost setting list
+   *        parameters:
+   *          - in: query
+   *            name: path
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/path'
+   *        responses:
+   *          200:
+   *            description: Succeeded to get UpdatePost setting list.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    updatePost:
+   *                      $ref: '#/components/schemas/UpdatePost'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /pages.updatePost
    * @api {get} /pages.updatePost
    * @apiName Get UpdatePost setting list
    * @apiName Get UpdatePost setting list
@@ -1053,6 +1474,51 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/pages.rename:
+   *      post:
+   *        tags: [Pages, apiv1]
+   *        operationId: renamePage
+   *        summary: /_api/pages.rename
+   *        description: Rename page
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  path:
+   *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  revision_id:
+   *                    $ref: '#/components/schemas/Revision/properties/_id'
+   *                  new_path:
+   *                    type: string
+   *                    description: new path
+   *                    example: /user/alice/new_test
+   *                  create_redirect:
+   *                    type: boolean
+   *                    description: whether redirect page
+   *                required:
+   *                  - page_id
+   *        responses:
+   *          200:
+   *            description: Succeeded to rename page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {post} /pages.rename Rename page
    * @api {post} /pages.rename Rename page
    * @apiName RenamePage
    * @apiName RenamePage

+ 153 - 0
src/server/routes/revision.js

@@ -1,3 +1,49 @@
+/**
+ * @swagger
+ *  tags:
+ *    name: Revisions
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Revision:
+ *        description: Revision
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: revision ID
+ *            example: 5e0734e472560e001761fa68
+ *          __v:
+ *            type: number
+ *            description: DB record version
+ *            example: 0
+ *          author:
+ *            $ref: '#/components/schemas/User/properties/_id'
+ *          body:
+ *            type: string
+ *            description: content body
+ *            example: |
+ *              # test
+ *
+ *              test
+ *          format:
+ *            type: string
+ *            description: format
+ *            example: markdown
+ *          path:
+ *            type: string
+ *            description: path
+ *            example: /user/alice/test
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ */
+
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:revision');
   const debug = require('debug')('growi:routes:revision');
   const logger = require('@alias/logger')('growi:routes:revision');
   const logger = require('@alias/logger')('growi:routes:revision');
@@ -9,6 +55,42 @@ module.exports = function(crowi, app) {
   const actions = {};
   const actions = {};
   actions.api = {};
   actions.api = {};
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/revisions.get:
+   *      get:
+   *        tags: [Revisions, apiv1]
+   *        operationId: /_api/revisions.get
+   *        summary: /_api/revisions.get
+   *        description: Get revision
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *            required: true
+   *          - in: query
+   *            name: revision_id
+   *            schema:
+   *              $ref: '#/components/schemas/Revision/properties/_id'
+   *            required: true
+   *        responses:
+   *          200:
+   *            description: Succeeded to get revision.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    revision:
+   *                      $ref: '#/components/schemas/Revision'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /revisions.get Get revision
    * @api {get} /revisions.get Get revision
    * @apiName GetRevision
    * @apiName GetRevision
@@ -41,6 +123,39 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/revisions.ids:
+   *      get:
+   *        tags: [Revisions, apiv1]
+   *        operationId: /_api/revisions.ids
+   *        summary: /_api/revisions.ids
+   *        description: Get revision id list of the page
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *            required: true
+   *        responses:
+   *          200:
+   *            description: Succeeded to get revision id list of the page.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    revisions:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Revision'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /revisions.ids Get revision id list of the page
    * @api {get} /revisions.ids Get revision id list of the page
    * @apiName ids
    * @apiName ids
@@ -69,6 +184,44 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/revisions.list:
+   *      get:
+   *        tags: [Revisions, apiv1]
+   *        operationId: /_api/revisions.list
+   *        summary: /_api/revisions.list
+   *        description: Get revisions
+   *        parameters:
+   *          - in: query
+   *            name: page_id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *          - in: query
+   *            name: revision_ids
+   *            schema:
+   *              type: string
+   *              description: revision ids
+   *              example: 5e0734e472560e001761fa68,5e079a0a0afa6700170a75fb
+   *        responses:
+   *          200:
+   *            description: Succeeded to get revisions.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    revisions:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Revision'
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /revisions.list Get revisions
    * @api {get} /revisions.list Get revisions
    * @apiName ListRevision
    * @apiName ListRevision

+ 35 - 0
src/server/routes/user.js

@@ -36,6 +36,41 @@ module.exports = function(crowi, app) {
       });
       });
   };
   };
 
 
+  /**
+   * @swagger
+   *
+   *    /_api/users.list:
+   *      get:
+   *        tags: [Users, apiv1]
+   *        operationId: listUsersV1
+   *        summary: /_api/users.list
+   *        description: Get list of users
+   *        parameters:
+   *          - in: query
+   *            name: user_ids
+   *            schema:
+   *              type: string
+   *              description: user IDs
+   *              example: 5e06fcc7516d64004dbf4da6,5e098d53baa2ac004e7d24ad
+   *        responses:
+   *          200:
+   *            description: Succeeded to get list of users.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    ok:
+   *                      $ref: '#/components/schemas/V1Response/properties/ok'
+   *                    users:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/User'
+   *                      description: user list
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
   /**
   /**
    * @api {get} /users.list Get user list
    * @api {get} /users.list Get user list
    * @apiName GetUserList
    * @apiName GetUserList

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

@@ -28,406 +28,14 @@
     {{ emessage }}
     {{ emessage }}
   </div>
   </div>
   {% endif %}
   {% endif %}
-
   <div class="row">
   <div class="row">
     <div class="col-md-3">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'app'} %}
       {% include './widget/menu.html' with {current: 'app'} %}
     </div>
     </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>
   </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>
 </div>
 {% endblock content_main %}
 {% endblock content_main %}
 
 

+ 1 - 47
src/server/views/admin/index.html

@@ -25,53 +25,7 @@
       {% include './widget/menu.html' %}
       {% include './widget/menu.html' %}
     </div>
     </div>
     <div class="col-md-9">
     <div class="col-md-9">
-      <p> {{ t("admin_top.wiki_administrator") }}<br>
-      {{ t("admin_top.assign_administrator") }}
-      </p>
-
-      <legend>
-        <h2>{{ t('admin_top.System Information') }}</h2>
-      </legend>
-      <table class="table table-bordered">
-        <tr>
-          <th class="col-sm-4">GROWI</th>
-          <td>{{ growiVersion() }}</td>
-        </tr>
-        <tr>
-          <th>node.js</th>
-          <td>{{ nodeVersion() }}</td>
-        </tr>
-        <tr>
-          <th>npm</th>
-          <td>{{ npmVersion() }}</td>
-        </tr>
-        <tr>
-          <th>yarn</th>
-          <td>{{ yarnVersion() }}</td>
-        </tr>
-      </table>
-
-      <legend>
-        <h2>{{ t('admin_top.List of installed plugins') }}</h2>
-      </legend>
-      <table class="table table-bordered">
-        <th class="text-center">
-          {{ t('admin_top.Package name') }}
-        </th>
-        <th class="text-center">
-          {{ t('admin_top.Specified version') }}
-        </th>
-        <th class="text-center">
-          {{ t('admin_top.Installed version') }}
-        </th>
-        {% for pluginName in Object.keys(plugins) %}
-        <tr>
-          <td>{{ pluginName }}</td>
-          <td class="text-center">{{ plugins[pluginName] }}</td>
-          <td class="text-center"><span class="tbd">(TBD)</span></td>
-        </tr>
-        {% endfor %}
-      </table>
+      <div id="admin-home"></div>
     </div>
     </div>
   </div>
   </div>
 
 

+ 15 - 0
src/server/views/widget/page_alerts.html

@@ -13,6 +13,21 @@
       </p>
       </p>
     {% endif %}
     {% endif %}
 
 
+    {% if getConfig('crowi', 'customize:isEnabledStaleNotification') %}
+      {% if page && page.updatedAt && page.getContentAge() > 0 %}
+        {% if page.getContentAge() == 1 %}
+        <div class="alert alert-info">
+        {% elseif page.getContentAge() == 2 %}
+        <div class="alert alert-warning">
+        {% else %}
+        <div class="alert alert-danger">
+        {% endif %}
+          <i class="icon-fw icon-hourglass"></i>
+          <strong>{{ t('page_page.notice.stale', { count: page.getContentAge() }) }}</strong>
+        </div>
+      {% endif %}
+    {% endif %}
+
     {% if redirectFrom or req.query.renamed or req.query.redirectFrom %}
     {% if redirectFrom or req.query.renamed or req.query.redirectFrom %}
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
       <span>
       <span>

+ 23 - 9
yarn.lock

@@ -1534,6 +1534,11 @@
   resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
   resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
 
+"@yarnpkg/lockfile@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
+  integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
+
 JSONStream@^1.3.5:
 JSONStream@^1.3.5:
   version "1.3.5"
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
   resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
@@ -5288,6 +5293,14 @@ find-cache-dir@^3.0.0:
     make-dir "^3.0.0"
     make-dir "^3.0.0"
     pkg-dir "^4.1.0"
     pkg-dir "^4.1.0"
 
 
+find-up@4.1.0, find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
 find-up@^1.0.0:
 find-up@^1.0.0:
   version "1.1.2"
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
@@ -5307,14 +5320,6 @@ find-up@^3.0.0:
   dependencies:
   dependencies:
     locate-path "^3.0.0"
     locate-path "^3.0.0"
 
 
-find-up@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
-  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
-  dependencies:
-    locate-path "^5.0.0"
-    path-exists "^4.0.0"
-
 findup-sync@3.0.0:
 findup-sync@3.0.0:
   version "3.0.0"
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1"
   resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1"
@@ -9553,6 +9558,15 @@ p-try@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1"
 
 
+package-installed-version-sync@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/package-installed-version-sync/-/package-installed-version-sync-2.1.0.tgz#db8d2cbee32bc91a36e100da9bda6743f956ac93"
+  integrity sha512-rhREjEXIJ0IurYS23PGmlL1T+6/wJL9Oev2WYztN+MYze6xpsFxUL3DaixlZglpHoYCPxu3tdCUO/AMoIVrCVg==
+  dependencies:
+    "@yarnpkg/lockfile" "^1.1.0"
+    find-up "4.1.0"
+    semver "^6.2.0"
+
 pako@1.0.3:
 pako@1.0.3:
   version "1.0.3"
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.3.tgz#5f515b0c6722e1982920ae8005eacb0b7ca73ccf"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.3.tgz#5f515b0c6722e1982920ae8005eacb0b7ca73ccf"
@@ -11640,7 +11654,7 @@ semver@^6.1.1:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.1.2.tgz#079960381376a3db62eb2edc8a3bfb10c7cfe318"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.1.2.tgz#079960381376a3db62eb2edc8a3bfb10c7cfe318"
   integrity sha512-z4PqiCpomGtWj8633oeAdXm1Kn1W++3T8epkZYnwiVgIYIJ0QHszhInYSJTYxebByQH7KVCEAn8R9duzZW2PhQ==
   integrity sha512-z4PqiCpomGtWj8633oeAdXm1Kn1W++3T8epkZYnwiVgIYIJ0QHszhInYSJTYxebByQH7KVCEAn8R9duzZW2PhQ==
 
 
-semver@^6.3.0:
+semver@^6.2.0, semver@^6.3.0:
   version "6.3.0"
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==