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

Merge branch 'master' into support/apply-bootstrap4

# Conflicts:
#	CHANGES.md
#	src/client/js/components/PageEditor/DrawioModal.jsx
#	src/client/js/components/PageEditorByHackmd.jsx
#	src/client/styles/scss/_layout.scss
#	src/client/styles/scss/_user.scss
yusuketk 6 лет назад
Родитель
Сommit
fbfa5094f6

+ 11 - 1
CHANGES.md

@@ -5,9 +5,19 @@
 * Support: Upgrade libs
     * bootstrap
 
-## v3.7.1-RC
+## v3.7.2-RC
+
+* 
+
+## v3.7.1
 
 * Improvement: Add an option that make it possible to choose what to send notifications
+* Improvement: Add the env var `DRAWIO_URI`
+* Improvement: Accessibility for 'spring' theme
+* Improvement: Editor scroll sync behaves strangely when using draw.io blocks
+* Fix: Coudn't upload file on Comment Editor
+    * Introduced by 3.5.8
+* I18n: HackMD integration
 
 ## v3.7.0
 

+ 1 - 0
config/env.dev.js

@@ -6,6 +6,7 @@ module.exports = {
   // NO_CDN: true,
   ELASTICSEARCH_URI: 'http://localhost:9200/growi',
   HACKMD_URI: 'http://localhost:3010',
+  // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 1 - 1
package.json

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

+ 5 - 1
resource/locales/en-US/admin/admin.json

@@ -7,7 +7,10 @@
     "list_of_installed_plugins": "List of installed plugins",
     "package_name": "Package name",
     "specified_version": "Specified version",
-    "installed_version": "Installed version"
+    "installed_version": "Installed version",
+    "list_of_env_vars":"List of environment variables",
+    "env_var_priority": "For environment variables other than security, the value of the database is obtained preferentially.",
+    "about_security": "Check <a href='/admin/security'>Securtiy Management</a> for security environment variables."
   },
   "app_setting": {
     "site_name": "Site name",
@@ -221,6 +224,7 @@
   },
   "user_management": {
     "invite_users": "Invite New Users",
+    "click_twice_same_checkbox": "You should check at least one checkbox.",
     "invite_modal": {
       "emails": "Emails",
       "invite_thru_email": "Send Invitation Email",

+ 17 - 0
resource/locales/en-US/translation.json

@@ -366,6 +366,23 @@
     "insert_image": "inserts an image",
     "open_sandbox": "Open Sandbox"
   },
+  "hackmd": {
+    "not_set_up": "HackMD is not set up.",
+    "start_to_edit": "Start to edit with HackMD",
+    "clone_page_content": "Click to clone page content and start to edit.",
+    "unsaved_draft": "HackMD has unsaved draft.",
+    "draft_outdated": "DRAFT MAY BE OUTDATED",
+    "based_on_revision": "The current draft on HackMD is based on",
+    "view_outdated_draft": "View the outdated draft on HackMD",
+    "resume_to_edit": "Resume to edit with HackMD",
+    "discard_changes": "Discard changes of HackMD",
+    "integration_failed": "HackMD Integration failed",
+    "fail_to_connect": "GROWI client failed to connect to GROWI agent for HackMD.",
+    "check_configuration": "Check your configuration following <a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
+    "not_initialized": "HackmdEditor component has not initialized",
+    "someone_editing": "Someone editing this page on HackMD",
+    "this_page_has_draft": "This page has a draft on HackMD"
+  },
   "security_setting": {
     "Security settings": "Security settings",
     "Guest Users Access": "Guest Users Access",

+ 5 - 1
resource/locales/ja/admin/admin.json

@@ -7,7 +7,10 @@
     "list_of_installed_plugins": "インストールされているプラグイン一覧",
     "package_name": "パッケージ名",
     "specified_version": "指定バージョン",
-    "installed_version": "インストールされているバージョン"
+    "installed_version": "インストールされているバージョン",
+    "list_of_env_vars":"サーバー側で設定されている環境変数一覧",
+    "env_var_priority":"セキュリティに関する環境変数を除き、データベースの値が優先的に取得されます。",
+    "about_security":"セキュリティに関する環境変数は <a href='/admin/security'>セキュリティ設定画面</a> からご確認ください。"
   },
   "app_setting": {
     "site_name": "サイト名",
@@ -221,6 +224,7 @@
   },
   "user_management": {
     "invite_users": "新規ユーザーの招待",
+    "click_twice_same_checkbox": "少なくとも一つはチェックしてください。",
     "invite_modal": {
       "emails": "メールアドレス (複数行入力で複数人招待可能)",
       "invite_thru_email": "招待をメールで送信",

+ 17 - 0
resource/locales/ja/translation.json

@@ -364,6 +364,23 @@
     "insert_image": "で画像を挿入できます",
     "open_sandbox": "Sandbox を開く"
   },
+  "hackmd":{
+    "not_set_up": "HackMD はセットアップされていません",
+    "start_to_edit": "HackMD を開始する",
+    "clone_page_content": "ページを複製して編集を開始します",
+    "unsaved_draft": "HackMD のドラフトが保存されていません",
+    "draft_outdated": "ドラフトは古くなっている可能性があります",
+    "based_on_revision": "現在のドラフトは次の revision に基づいています",
+    "view_outdated_draft": "HackMD で古いドラフトを表示する",
+    "resume_to_edit": "HackMD で編集を再開する",
+    "discard_changes": "HackMD の変更を破棄する",
+    "integration_failed": "HackMD の統合に失敗しました",
+    "fail_to_connect": "GROWI クライアントが HackMD の GROWI agent に接続できませんでした。",
+    "check_configuration": "<a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>こちらのマニュアル</a>から設定を確認してください",
+    "not_initialized": "HackMD コンポーネントは初期化されていません",
+    "someone_editing": "このページは、HackMD で編集されています。",
+    "this_page_has_draft": "このページは、HackMD のドラフトがあります。"
+  },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",

+ 12 - 1
src/client/js/components/Admin/AdminHome/AdminHome.jsx

@@ -10,6 +10,7 @@ import AppContainer from '../../../services/AppContainer';
 import AdminHomeContainer from '../../../services/AdminHomeContainer';
 import SystemInfomationTable from './SystemInfomationTable';
 import InstalledPluginTable from './InstalledPluginTable';
+import EnvVarsTable from './EnvVarsTable';
 
 const logger = loggerFactory('growi:admin');
 
@@ -29,7 +30,7 @@ class AdminHome extends React.Component {
   }
 
   render() {
-    const { t } = this.props;
+    const { t, adminHomeContainer } = this.props;
 
     return (
       <Fragment>
@@ -52,6 +53,16 @@ class AdminHome extends React.Component {
             <InstalledPluginTable />
           </div>
         </div>
+
+        <div className="row mb-5">
+          <div className="col-md-12">
+            <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
+            <p>{t('admin:admin_top.env_var_priority')}</p>
+            {/* eslint-disable-next-line react/no-danger */}
+            <p dangerouslySetInnerHTML={{ __html: t('admin:admin_top.about_security') }} />
+            {adminHomeContainer.state.envVars && <EnvVarsTable envVars={adminHomeContainer.state.envVars} />}
+          </div>
+        </div>
       </Fragment>
     );
   }

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

@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const EnvVarsTable = (props) => {
+  const envVarRows = [];
+
+  for (const [key, value] of Object.entries(props.envVars)) {
+    if (value != null) {
+      envVarRows.push(
+        <tr key={key}>
+          <th className="col-sm-4">{key}</th>
+          <td>{value.toString()}</td>
+        </tr>,
+      );
+    }
+  }
+
+  return (
+    <table className="table table-bordered">
+      <tbody>
+        {envVarRows}
+      </tbody>
+    </table>
+  );
+
+};
+
+EnvVarsTable.propTypes = {
+  envVars: PropTypes.object.isRequired,
+};
+
+export default EnvVarsTable;

+ 168 - 0
src/client/js/components/Admin/UserManagement.jsx

@@ -20,7 +20,12 @@ class UserManagement extends React.Component {
   constructor(props) {
     super();
 
+    this.state = {
+      isNotifyCommentShow: false,
+    };
+
     this.handlePage = this.handlePage.bind(this);
+    this.handleChangeSearchText = this.handleChangeSearchText.bind(this);
   }
 
   componentWillMount() {
@@ -36,6 +41,56 @@ class UserManagement extends React.Component {
     }
   }
 
+  /**
+   * For checking same check box twice
+   * @param {string} statusType
+   */
+  async handleClick(statusType) {
+    const { adminUsersContainer } = this.props;
+    if (!this.validateToggleStatus(statusType)) {
+      return this.setState({ isNotifyCommentShow: true });
+    }
+
+    if (this.state.isNotifyCommentShow) {
+      await this.setState({ isNotifyCommentShow: false });
+    }
+    adminUsersContainer.handleClick(statusType);
+  }
+
+  /**
+   * Workaround user status check box
+   * @param {string} statusType
+   */
+  validateToggleStatus(statusType) {
+    if (this.props.adminUsersContainer.isSelected(statusType)) {
+      return this.props.adminUsersContainer.state.selectedStatusList.size > 1;
+    }
+    return true;
+  }
+
+  /**
+   * Reset button
+   */
+  resetButtonClickHandler() {
+    const { adminUsersContainer } = this.props;
+    try {
+      adminUsersContainer.resetAllChanges();
+      this.searchUserElement.value = '';
+      this.state.isNotifyCommentShow = false;
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  /**
+   * Workaround increamental search
+   * @param {string} event
+   */
+  handleChangeSearchText(event) {
+    this.props.adminUsersContainer.handleChangeSearchText(event.target.value);
+  }
+
   render() {
     const { t, adminUsersContainer } = this.props;
 
@@ -50,6 +105,20 @@ class UserManagement extends React.Component {
       </div>
     );
 
+    const clearButton = (
+      adminUsersContainer.state.searchText.length > 0
+        ? (
+          <i
+            className="icon-close search-clear"
+            onClick={() => {
+              adminUsersContainer.clearSearchText();
+              this.searchUserElement.value = '';
+            }}
+          />
+        )
+        : ''
+    );
+
     return (
       <Fragment>
         {adminUsersContainer.state.userForPasswordResetModal && <PasswordResetModal />}
@@ -63,6 +132,105 @@ class UserManagement extends React.Component {
 
         <h2>{t('User_Management')}</h2>
 
+        <div className="border-top border-bottom">
+
+          <div className="d-flex justify-content-start align-items-center my-2">
+            <div>
+              <i className="icon-magnifier mr-1"></i>
+              <span className="search-typeahead">
+                <input
+                  type="text"
+                  ref={(searchUserElement) => { this.searchUserElement = searchUserElement }}
+                  onChange={this.handleChangeSearchText}
+                />
+                { clearButton }
+              </span>
+            </div>
+
+            <div className="mx-5 form-inline">
+              <div className="checkbox checkbox-primary pl-0">
+                <input
+                  type="checkbox"
+                  id="c1"
+                  checked={adminUsersContainer.isSelected('all')}
+                  onClick={() => { this.handleClick('all') }}
+                />
+                <label htmlFor="c1">
+                  <span className="label label-primary d-inline-block vt mt-1">All</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-info">
+                <input
+                  type="checkbox"
+                  id="c2"
+                  checked={adminUsersContainer.isSelected('registered')}
+                  onClick={() => { this.handleClick('registered') }}
+                />
+                <label htmlFor="c2">
+                  <span className="label label-info d-inline-block vt mt-1">Approval Pending</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-success">
+                <input
+                  type="checkbox"
+                  id="c3"
+                  checked={adminUsersContainer.isSelected('active')}
+                  onClick={() => { this.handleClick('active') }}
+                />
+                <label htmlFor="c3">
+                  <span className="label label-success d-inline-block vt mt-1">Active</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-warning">
+                <input
+                  type="checkbox"
+                  id="c4"
+                  checked={adminUsersContainer.isSelected('suspended')}
+                  onClick={() => { this.handleClick('suspended') }}
+                />
+                <label htmlFor="c4">
+                  <span className="label label-warning d-inline-block vt mt-1">Suspended</span>
+                </label>
+              </div>
+
+              <div className="checkbox checkbox-info">
+                <input
+                  type="checkbox"
+                  id="c5"
+                  checked={adminUsersContainer.isSelected('invited')}
+                  onClick={() => { this.handleClick('invited') }}
+                />
+                <label htmlFor="c5">
+                  <span className="label label-info d-inline-block vt mt-1">Invited</span>
+                </label>
+              </div>
+            </div>
+
+            <div>
+              <button
+                type="button"
+                className="btn btn-default btn-outline btn-sm"
+                onClick={() => { this.resetButtonClickHandler() }}
+              >
+                <span
+                  className="icon-refresh mr-1"
+                >
+                </span>
+                Reset
+              </button>
+            </div>
+
+            <div className="ml-5">
+              {this.state.isNotifyCommentShow && <span className="text-warning">{t('admin:user_management.click_twice_same_checkbox')}</span>}
+            </div>
+
+          </div>
+        </div>
+
+
         {pager}
         <UserTable />
         {pager}

+ 32 - 0
src/client/js/components/Admin/Users/SortIcons.jsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+const SortIcons = (props) => {
+
+  const { isSelected, isAsc } = props;
+
+  return (
+    <div className="d-flex flex-column text-center">
+      <a
+        className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
+        aria-hidden="true"
+        onClick={() => props.onClick('asc')}
+      />
+      <a
+        className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
+        aria-hidden="true"
+        onClick={() => props.onClick('desc')}
+      />
+    </div>
+  );
+};
+
+SortIcons.propTypes = {
+  onClick: PropTypes.func.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  isAsc: PropTypes.bool.isRequired,
+};
+
+
+export default withTranslation()(SortIcons);

+ 98 - 9
src/client/js/components/Admin/Users/UserTable.jsx

@@ -9,6 +9,7 @@ import UserMenu from './UserMenu';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import SortIcons from './SortIcons';
 
 class UserTable extends React.Component {
 
@@ -74,21 +75,108 @@ class UserTable extends React.Component {
     }
   }
 
+  sortIconsClickedHandler(sort, sortOrder) {
+    const isAsc = sortOrder === 'asc';
+
+    const { adminUsersContainer } = this.props;
+    adminUsersContainer.sort(sort, isAsc);
+  }
+
   render() {
     const { t, adminUsersContainer } = this.props;
 
+    const isCurrentSortOrderAsc = adminUsersContainer.state.sortOrder === 'asc';
+
     return (
       <Fragment>
         <table className="table table-default table-bordered table-user-list">
           <thead>
             <tr>
               <th width="100px">#</th>
-              <th>{t('status')}</th>
-              <th><code>username</code></th>
-              <th>{t('Name')}</th>
-              <th>{t('Email')}</th>
-              <th width="100px">{t('Created')}</th>
-              <th width="150px">{t('Last_Login')}</th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('status')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'status'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('status', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    <code>username</code>
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'username'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('username', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Name')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'name'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('name', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th>
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Email')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'email'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('email', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th width="100px">
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Created')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'createdAt'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('createdAt', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
+              <th width="150px">
+                <div className="d-flex align-items-center">
+                  <div className="mr-3">
+                    {t('Last_Login')}
+                  </div>
+                  <SortIcons
+                    isSelected={adminUsersContainer.state.sort === 'lastLoginAt'}
+                    isAsc={isCurrentSortOrderAsc}
+                    onClick={(sortOrder) => {
+                      this.sortIconsClickedHandler('lastLoginAt', sortOrder);
+                    }}
+                  />
+                </div>
+              </th>
               <th width="70px"></th>
             </tr>
           </thead>
@@ -123,9 +211,6 @@ class UserTable extends React.Component {
 
 }
 
-const UserTableWrapper = (props) => {
-  return createSubscribedElement(UserTable, props, [AppContainer, AdminUsersContainer]);
-};
 
 UserTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -134,4 +219,8 @@ UserTable.propTypes = {
 
 };
 
+const UserTableWrapper = (props) => {
+  return createSubscribedElement(UserTable, props, [AppContainer, AdminUsersContainer]);
+};
+
 export default withTranslation()(UserTableWrapper);

+ 7 - 4
src/client/js/components/Page.jsx

@@ -29,6 +29,9 @@ class Page extends React.Component {
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
+    this.handsontableModal = React.createRef();
+    this.drawioModal = React.createRef();
+
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
     this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
@@ -46,7 +49,7 @@ class Page extends React.Component {
     const markdown = this.props.pageContainer.state.markdown;
     const tableLines = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
     this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
-    this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
+    this.handsontableModal.current.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
   /**
@@ -59,7 +62,7 @@ class Page extends React.Component {
     const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber, endLineNumber);
     const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
     this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
-    this.drawioModal.show(drawioData);
+    this.drawioModal.current.show(drawioData);
   }
 
   async saveHandlerForHandsontableModal(markdownTable) {
@@ -129,8 +132,8 @@ class Page extends React.Component {
     return (
       <div className={isMobile ? 'page-mobile' : ''}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
-        <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
-        <DrawioModal ref={(c) => { this.drawioModal = c }} onSave={this.saveHandlerForDrawioModal} />
+        <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
+        <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
       </div>
     );
   }

+ 7 - 4
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -71,6 +71,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet: new Set(),
     };
 
+    this.handsontableModal = React.createRef();
+    this.drawioModal = React.createRef();
+
     this.init();
 
     this.getCodeMirror = this.getCodeMirror.bind(this);
@@ -647,11 +650,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   showHandsonTableHandler() {
-    this.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
+    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
   showDrawioHandler() {
-    this.drawioIFrame.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
+    this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
   }
 
   getNavbarItems() {
@@ -850,11 +853,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
         { this.renderCheatsheetOverlay() }
 
         <HandsontableModal
-          ref={(c) => { this.handsontableModal = c }}
+          ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
         />
         <DrawioModal
-          ref={(c) => { this.drawioIFrame = c }}
+          ref={this.drawioModal}
           onSave={(drawioData) => { return mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData) }}
         />
 

+ 27 - 10
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -7,7 +7,11 @@ import {
   ModalBody,
 } from 'reactstrap';
 
-export default class DrawioModal extends React.PureComponent {
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import EditorContainer from '../../services/EditorContainer';
+
+class DrawioModal extends React.PureComponent {
 
   constructor(props) {
     super(props);
@@ -17,15 +21,12 @@ export default class DrawioModal extends React.PureComponent {
       drawioMxFile: '',
     };
 
-    this.drawioIFrame = React.createRef();
-
     this.headerColor = '#334455';
     this.fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
 
     this.init = this.init.bind(this);
     this.cancel = this.cancel.bind(this);
     this.receiveFromDrawio = this.receiveFromDrawio.bind(this);
-    this.drawioURL = this.drawioURL.bind(this);
   }
 
   init(drawioMxFile) {
@@ -93,7 +94,10 @@ export default class DrawioModal extends React.PureComponent {
         const parser = new DOMParser();
         const dom = parser.parseFromString(event.data, 'text/xml');
         const value = dom.getElementsByTagName('diagram')[0].innerHTML;
-        this.props.onSave(value);
+
+        if (this.props.onSave != null) {
+          this.props.onSave(value);
+        }
       }
 
       window.removeEventListener('message', this.receiveFromDrawio);
@@ -112,8 +116,11 @@ export default class DrawioModal extends React.PureComponent {
     // NOTHING DONE. (Receive unknown iframe message.)
   }
 
-  drawioURL() {
-    const url = new URL('https://www.draw.io/');
+  get drawioURL() {
+    const { config } = this.props.appContainer;
+
+    const drawioUri = config.env.DRAWIO_URI || 'https://www.draw.io/';
+    const url = new URL(drawioUri);
 
     // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
     url.searchParams.append('spin', 1);
@@ -127,7 +134,6 @@ export default class DrawioModal extends React.PureComponent {
 
   render() {
     return (
-      // <Modal show={this.state.show} onHide={this.cancel} bsSize="large" dialogClassName={dialogClassName} keyboard={false}>
       <Modal isOpen={this.state.show} toggle={this.cancel} className="drawio-modal" bsSize="large" keyboard={false}>
         <ModalBody className="p-0">
           {/* Loading spinner */}
@@ -140,8 +146,7 @@ export default class DrawioModal extends React.PureComponent {
           <div className="w-100 h-100 position-absolute d-flex">
             { this.state.show && (
               <iframe
-                ref={(c) => { this.drawioIFrame = c }}
-                src={this.drawioURL()}
+                src={this.drawioURL}
                 className="border-0 flex-grow-1"
               >
               </iframe>
@@ -154,6 +159,18 @@ export default class DrawioModal extends React.PureComponent {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
+const DrawioModalWrapper = React.forwardRef((props, ref) => {
+  return createSubscribedElement(DrawioModal, Object.assign({ ref }, props), [AppContainer, EditorContainer]);
+});
+
 DrawioModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   onSave: PropTypes.func,
 };
+
+export default DrawioModalWrapper;

+ 24 - 20
src/client/js/components/PageEditorByHackmd.jsx

@@ -2,6 +2,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
+import { withTranslation } from 'react-i18next';
+
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import EditorContainer from '../services/EditorContainer';
@@ -42,8 +44,9 @@ class PageEditorByHackmd extends React.Component {
    * @return {Promise<string>}
    */
   getMarkdown() {
+    const { t } = this.props;
     if (!this.state.isInitialized) {
-      return Promise.reject(new Error('HackmdEditor component has not initialized'));
+      return Promise.reject(new Error(t('hackmd.not_initialized')));
     }
 
     return this.hackmdEditor.getValue();
@@ -212,20 +215,20 @@ class PageEditorByHackmd extends React.Component {
   }
 
   penpalErrorOccuredHandler(error) {
-    const { pageContainer } = this.props;
+    const { pageContainer, t } = this.props;
 
     pageContainer.showErrorToastr(error);
 
     this.setState({
       hasError: true,
-      errorMessage: 'GROWI client failed to connect to GROWI agent for HackMD.',
+      errorMessage: t('hackmd.fail_to_connect'),
       errorReason: error.toString(),
     });
   }
 
   renderPreInitContent() {
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer } = this.props;
+    const { pageContainer, t } = this.props;
     const {
       revisionId, revisionIdHackmdSynced, remoteRevisionId,
     } = pageContainer.state;
@@ -239,7 +242,7 @@ class PageEditorByHackmd extends React.Component {
     if (hackmdUri == null) {
       content = (
         <div>
-          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is not set up.</p>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> { t('hackmd.not_set_up')}</p>
         </div>
       );
     }
@@ -252,14 +255,14 @@ class PageEditorByHackmd extends React.Component {
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
-          <p className="text-center"><strong>HackMD has unsaved draft.</strong></p>
+          <p className="text-center"><strong>{t('hackmd.unsaved_draft')}</strong></p>
 
           { isHackmdDocumentOutdated && (
             <div className="card border-warning">
-              <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
+              <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
               <div className="card-body text-center">
-                The current draft on HackMD is based on&nbsp;
-                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.
+                {t('hackmd.based_on_revision')}&nbsp;
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>
 
                 <div className="text-center mt-3">
                   <button
@@ -268,7 +271,7 @@ class PageEditorByHackmd extends React.Component {
                     disabled={this.state.isInitializing}
                     onClick={() => { return this.resumeToEdit() }}
                   >
-                    View the outdated draft on HackMD
+                    {t('hackmd.view_outdated_draft')}
                   </button>
                 </div>
               </div>
@@ -284,7 +287,7 @@ class PageEditorByHackmd extends React.Component {
                 onClick={() => { return this.resumeToEdit() }}
               >
                 <span className="btn-label"><i className="icon-control-end"></i></span>
-                <span className="btn-text">Resume to edit with HackMD</span>
+                <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
               </button>
             </div>
           ) }
@@ -296,7 +299,7 @@ class PageEditorByHackmd extends React.Component {
               onClick={() => { return this.discardChanges() }}
             >
               <span className="btn-label"><i className="icon-control-start"></i></span>
-              <span className="btn-text">Discard changes of HackMD</span>
+              <span className="btn-text">{t('hackmd.discard_changes')}</span>
             </button>
           </div>
 
@@ -320,10 +323,10 @@ class PageEditorByHackmd extends React.Component {
               onClick={() => { return this.startToEdit() }}
             >
               <span className="btn-label"><i className="icon-paper-plane"></i></span>
-              Start to edit with HackMD
+              {t('hackmd.start_to_edit')}
             </button>
           </div>
-          <p className="text-center">Click to clone page content and start to edit.</p>
+          <p className="text-center">{t('hackmd.clone_page_content')}</p>
         </div>
       );
     }
@@ -337,7 +340,7 @@ class PageEditorByHackmd extends React.Component {
 
   render() {
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer } = this.props;
+    const { pageContainer, t } = this.props;
     const {
       markdown, pageIdOnHackmd,
     } = pageContainer.state;
@@ -374,14 +377,13 @@ class PageEditorByHackmd extends React.Component {
         { this.state.hasError && (
           <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
             <div className="white-box text-center">
-              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> HackMD Integration failed</h2>
+              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> {t('hackmd.integration_failed')}</h2>
               <h4>{this.state.errorMessage}</h4>
               <p className="well well-sm text-danger">
                 {this.state.errorReason}
               </p>
-              <p>
-                Check your configuration following <a href="https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html">the manual</a>.
-              </p>
+              {/* eslint-disable-next-line react/no-danger */}
+              <p dangerouslySetInnerHTML={{ __html: t('hackmd.check_configuration') }} />
             </div>
           </div>
         ) }
@@ -400,9 +402,11 @@ const PageEditorByHackmdWrapper = (props) => {
 };
 
 PageEditorByHackmd.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 
-export default PageEditorByHackmdWrapper;
+export default withTranslation()(PageEditorByHackmdWrapper);

+ 4 - 2
src/client/js/components/PageStatusAlert.jsx

@@ -39,10 +39,11 @@ class PageStatusAlert extends React.Component {
   }
 
   renderSomeoneEditingAlert() {
+    const { t } = this.props;
     return (
       <div className="alert-hackmd-someone-editing alert alert-success fixed-bottom p-3 mb-0">
         <i className="icon-fw icon-people"></i>
-        Someone editing this page on HackMD
+        {t('hackmd.someone_editing')}
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
@@ -54,10 +55,11 @@ class PageStatusAlert extends React.Component {
   }
 
   renderDraftExistsAlert(isRealtime) {
+    const { t } = this.props;
     return (
       <div className="alert-hackmd-draft-exists alert alert-success fixed-bottom p-3 mb-0">
         <i className="icon-fw icon-pencil"></i>
-        This page has a draft on HackMD
+        {t('hackmd.this_page_has_draft')}
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         &nbsp;

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

@@ -50,6 +50,7 @@ export default class AdminHomeContainer extends Container {
         npmVersion: adminHomeParams.npmVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         installedPlugins: adminHomeParams.installedPlugins,
+        envVars: adminHomeParams.envVars,
       });
     }
     catch (err) {

+ 89 - 2
src/client/js/services/AdminUsersContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
-
 import loggerFactory from '@alias/logger';
+import { debounce } from 'throttle-debounce';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
@@ -18,17 +18,23 @@ export default class AdminUsersContainer extends Container {
 
     this.state = {
       users: [],
+      sort: 'id',
+      sortOrder: 'asc',
       isPasswordResetModalShown: false,
       isUserInviteModalShown: false,
       userForPasswordResetModal: null,
       totalUsers: 0,
       activePage: 1,
       pagingLimit: Infinity,
+      selectedStatusList: new Set(['all']),
+      searchText: '',
     };
 
     this.showPasswordResetModal = this.showPasswordResetModal.bind(this);
     this.hidePasswordResetModal = this.hidePasswordResetModal.bind(this);
     this.toggleUserInviteModal = this.toggleUserInviteModal.bind(this);
+
+    this.handleChangeSearchTextDebouce = debounce(3000, () => this.retrieveUsersByPagingNum(1));
   }
 
   /**
@@ -38,6 +44,81 @@ export default class AdminUsersContainer extends Container {
     return 'AdminUsersContainer';
   }
 
+  /**
+   * Workaround for status list
+   */
+  isSelected(statusType) {
+    return this.state.selectedStatusList.has(statusType);
+  }
+
+  handleClick(statusType) {
+    const all = 'all';
+    if (this.isSelected(statusType)) {
+      this.deleteStatusFromList(statusType);
+    }
+    else {
+      if (statusType === all) {
+        this.clearStatusList();
+      }
+      else {
+        this.deleteStatusFromList(all);
+      }
+      this.addStatusToList(statusType);
+    }
+  }
+
+  async clearStatusList() {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.clear();
+    await this.setState({ selectedStatusList });
+  }
+
+  async addStatusToList(statusType) {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.add(statusType);
+    await this.setState({ selectedStatusList });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  async deleteStatusFromList(statusType) {
+    const { selectedStatusList } = this.state;
+    selectedStatusList.delete(statusType);
+    await this.setState({ selectedStatusList });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  /**
+   * Workaround for Increment Search Text Input
+   */
+  async handleChangeSearchText(searchText) {
+    await this.setState({ searchText });
+    this.handleChangeSearchTextDebouce();
+  }
+
+  async clearSearchText() {
+    await this.setState({ searchText: '' });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  /**
+   * Workaround for Sorting
+   */
+  async sort(sort, isAsc) {
+    const sortOrder = isAsc ? 'asc' : 'desc';
+    await this.setState({ sort, sortOrder });
+    this.retrieveUsersByPagingNum(1);
+  }
+
+  async resetAllChanges() {
+    await this.setState({
+      sort: 'id',
+      sortOrder: 'asc',
+      searchText: '',
+      selectedStatusList: new Set(['all']),
+    });
+    this.retrieveUsersByPagingNum(1);
+  }
+
   /**
    * syncUsers of selectedPage
    * @memberOf AdminUsersContainer
@@ -45,7 +126,13 @@ export default class AdminUsersContainer extends Container {
    */
   async retrieveUsersByPagingNum(selectedPage) {
 
-    const params = { page: selectedPage };
+    const params = {
+      page: selectedPage,
+      sort: this.state.sort,
+      sortOrder: this.state.sortOrder,
+      selectedStatusList: Array.from(this.state.selectedStatusList),
+      searchText: this.state.searchText,
+    };
     const { data } = await this.appContainer.apiv3.get('/users', params);
 
     if (data.paginateResult == null) {

+ 4 - 4
src/client/js/util/interceptor/drawio-interceptor.js

@@ -17,18 +17,18 @@ export class DrawioInterceptor extends BasicInterceptor {
     this.previousPreviewContext = null;
     this.appContainer = appContainer;
 
-    // draw.io の viewer.min.js から呼ばれるコールバックを定義する
+    // define callback function invoked by viewer.min.js of draw.io
     // refs: https://github.com/jgraph/drawio/blob/v12.9.1/etc/build/build.xml#L219-L232
     window.onDrawioViewerLoad = function() {
       const DrawioViewer = window.GraphViewer;
 
       if (DrawioViewer != null) {
-        // viewer.min.js の Resize による Scroll イベントを抑止するために
-        // useResizeSensor と checkVisibleState を無効化する
+        // disable useResizeSensor and checkVisibleState
+        //   for preventing resize event by viewer.min.js
         DrawioViewer.useResizeSensor = false;
         DrawioViewer.prototype.checkVisibleState = false;
 
-        // 初回レンダリング時に mxfile をレンダリングする
+        // initialize
         DrawioViewer.processElements();
       }
     };

+ 5 - 3
src/client/styles/scss/_layout.scss

@@ -90,9 +90,11 @@ header {
   }
 }
 
-.revision-toc {
-  overflow: hidden;
-  font-size: 0.9em;
+  .revision-toc {
+    // to get on the Attachment row
+    z-index: 1;
+    overflow: hidden;
+    font-size: 0.9em;
 
   .revision-toc-content {
     padding: 10px;

+ 6 - 0
src/client/styles/scss/_user.scss

@@ -31,6 +31,12 @@
 
     .user-page-username {
       font-weight: bold;
+
+      .user-page-email {
+      }
+
+      .user-page-introduction {
+      }
     }
 
     .user-page-email {

+ 12 - 0
src/lib/util/isSecurityEnv.js

@@ -0,0 +1,12 @@
+/**
+ * return whether env belongs to Security settings
+ * @param {string} key ex. 'security:passport-saml:isEnabled' is true
+ * @returns {boolean}
+ * @memberof envUtils
+ */
+const isSecurityEnv = (key) => {
+  const array = key.split(':');
+  return (array[0] === 'security');
+};
+
+module.exports = isSecurityEnv;

+ 1 - 0
src/server/models/config.js

@@ -214,6 +214,7 @@ module.exports = function(crowi) {
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
+        DRAWIO_URI: env.DRAWIO_URI || null,
         HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
         NO_CDN: env.NO_CDN || null,

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

@@ -1,5 +1,6 @@
 const express = require('express');
 const PluginUtils = require('../../plugins/plugin-utils');
+const ConfigLoader = require('../../service/config-loader');
 
 const pluginUtils = new PluginUtils();
 
@@ -70,6 +71,7 @@ module.exports = (crowi) => {
       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),
+      envVars: await ConfigLoader.getEnvVarsForDisplay(true),
     };
 
     return res.apiv3({ adminHomeParams });

+ 81 - 6
src/server/routes/apiv3/users.js

@@ -6,7 +6,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body } = require('express-validator/check');
+const { body, query } = require('express-validator/check');
 const { isEmail } = require('validator');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
@@ -77,6 +77,31 @@ module.exports = (crowi) => {
 
   const { ApiV3FormValidator } = crowi.middlewares;
 
+  const statusNo = {
+    registered: User.STATUS_REGISTERED,
+    active: User.STATUS_ACTIVE,
+    suspended: User.STATUS_SUSPENDED,
+    invited: User.STATUS_INVITED,
+  };
+
+  validator.statusList = [
+    // validate status list status array match to statusNo
+    query('selectedStatusList').custom((value) => {
+      const error = [];
+      value.forEach((status) => {
+        if (!Object.keys(statusNo)) {
+          error.push(status);
+        }
+      });
+      return (error.length === 0);
+    }),
+    // validate sortOrder : asc or desc
+    query('sortOrder').isIn(['asc', 'desc']),
+    // validate sort : what column you will sort
+    query('sort').isIn(['id', 'status', 'username', 'name', 'email', 'createdAt', 'lastLoginAt']),
+    query('page').isInt({ min: 1 }),
+  ];
+
   /**
    * @swagger
    *
@@ -86,7 +111,33 @@ module.exports = (crowi) => {
    *        tags: [Users]
    *        operationId: listUsers
    *        summary: /users
-   *        description: Get users
+   *        description: Select selected columns from users order by asc or desc
+   *        parameters:
+   *          - name: page
+   *            in: query
+   *            description: page number
+   *            schema:
+   *              type: number
+   *          - name:  selectedStatusList
+   *            in: query
+   *            description: status list
+   *            schema:
+   *              type: string
+   *          - name: searchText
+   *            in: query
+   *            description: For incremental search value from input box
+   *            schema:
+   *              type: string
+   *          - name: sortOrder
+   *            in: query
+   *            description: asc or desc
+   *            schema:
+   *              type: string
+   *          - name: sort
+   *            in: query
+   *            description: sorting column
+   *            schema:
+   *              type: string
    *        responses:
    *          200:
    *            description: users are fetched
@@ -97,14 +148,39 @@ module.exports = (crowi) => {
    *                    paginateResult:
    *                      $ref: '#/components/schemas/PaginateResult'
    */
-  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+  router.get('/', validator.statusList, ApiV3FormValidator, async(req, res) => {
+
     const page = parseInt(req.query.page) || 1;
+    // status
+    const { selectedStatusList } = req.query;
+    const statusNoList = (selectedStatusList.includes('all')) ? Object.values(statusNo) : selectedStatusList.map(element => statusNo[element]);
+
+    // Search from input
+    const searchText = req.query.searchText || '';
+    const searchWord = new RegExp(`${searchText}`);
+    // Sort
+    const { sort, sortOrder } = req.query;
+    const sortOutput = {
+      [sort]: (sortOrder === 'desc') ? -1 : 1,
+    };
 
     try {
       const paginateResult = await User.paginate(
-        { status: { $ne: User.STATUS_DELETED } },
         {
-          sort: { status: 1, username: 1, createdAt: 1 },
+          $and: [
+            { status: { $in: statusNoList } },
+            {
+              $or: [
+                { name: { $in: searchWord } },
+                { username: { $in: searchWord } },
+                { email: { $in: searchWord } },
+              ],
+            },
+          ],
+        },
+        {
+          sort: sortOutput,
           page,
           limit: PAGE_ITEMS,
         },
@@ -424,7 +500,6 @@ module.exports = (crowi) => {
     }
   });
 
-
   /**
    * @swagger
    *

+ 24 - 1
src/server/service/config-loader.js

@@ -1,6 +1,7 @@
 const debug = require('debug')('growi:service:ConfigLoader');
-
 const { envUtils } = require('growi-commons');
+const isSecurityEnv = require('../../lib/util/isSecurityEnv');
+
 
 const TYPES = {
   NUMBER:  { parse: (v) => { return parseInt(v, 10) } },
@@ -349,6 +350,28 @@ class ConfigLoader {
     return config;
   }
 
+  /**
+   * get config from the environment variables for display admin page
+   *
+   * **use this only admin home page.**
+   */
+  static getEnvVarsForDisplay(avoidSecurity = false) {
+    const config = {};
+    for (const ENV_VAR_NAME of Object.keys(ENV_VAR_NAME_TO_CONFIG_INFO)) {
+      const configInfo = ENV_VAR_NAME_TO_CONFIG_INFO[ENV_VAR_NAME];
+      if (process.env[ENV_VAR_NAME] === undefined) {
+        continue;
+      }
+      if (isSecurityEnv(configInfo.key) && avoidSecurity) {
+        continue;
+      }
+      config[ENV_VAR_NAME] = configInfo.type.parse(process.env[ENV_VAR_NAME]);
+    }
+
+    debug('ConfigLoader#getEnvVarsForDisplay', config);
+    return config;
+  }
+
 }
 
 module.exports = ConfigLoader;

+ 4 - 1
src/server/service/export.js

@@ -6,6 +6,7 @@ const mongoose = require('mongoose');
 const { Transform } = require('stream');
 const streamToPromise = require('stream-to-promise');
 const archiver = require('archiver');
+const ConfigLoader = require('../service/config-loader');
 
 const toArrayIfNot = require('../../lib/util/toArrayIfNot');
 
@@ -76,12 +77,14 @@ class ExportService {
   async createMetaJson() {
     const metaJson = path.join(this.baseDir, this.growiBridgeService.getMetaFileName());
     const writeStream = fs.createWriteStream(metaJson, { encoding: this.growiBridgeService.getEncoding() });
+    const passwordSeed = this.crowi.env.PASSWORD_SEED || null;
 
     const metaData = {
       version: this.crowi.version,
       url: this.appService.getSiteUrl(),
-      passwordSeed: this.crowi.env.PASSWORD_SEED,
+      passwordSeed,
       exportedAt: new Date(),
+      envVars: ConfigLoader.getEnvVarsForDisplay(),
     };
 
     writeStream.write(JSON.stringify(metaData));