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

Merge remote-tracking branch 'origin/master' into support/apply-bootstrap4

# Conflicts:
#	src/client/styles/scss/_layout.scss
#	src/server/views/admin/Users_reserve.html
#	src/server/views/layout/layout.html
Yuki Takei 6 лет назад
Родитель
Сommit
615495ae9c
38 измененных файлов с 577 добавлено и 348 удалено
  1. 9 2
      CHANGES.md
  2. 3 3
      package.json
  3. 7 3
      resource/locales/en-US/translation.json
  4. 7 3
      resource/locales/ja/translation.json
  5. 6 6
      src/client/js/app.jsx
  6. 0 0
      src/client/js/components/Admin/ExportData/ExportTableMenu.jsx
  7. 0 0
      src/client/js/components/Admin/ExportData/ExportZipFormModal.jsx
  8. 0 0
      src/client/js/components/Admin/ExportData/ZipFileTable.jsx
  9. 11 11
      src/client/js/components/Admin/ExportDataPage.jsx
  10. 0 181
      src/client/js/components/Admin/Import/GrowiZipImportForm.jsx
  11. 364 0
      src/client/js/components/Admin/ImportData/GrowiZipImportForm.jsx
  12. 6 11
      src/client/js/components/Admin/ImportData/GrowiZipImportSection.jsx
  13. 1 1
      src/client/js/components/Admin/ImportData/GrowiZipUploadForm.jsx
  14. 6 6
      src/client/js/components/Admin/ImportDataPage.jsx
  15. 37 20
      src/client/js/components/Admin/UserManagement.jsx
  16. 1 3
      src/client/js/components/Admin/Users/UserTable.jsx
  17. 1 0
      src/client/js/components/Page/RevisionBody.jsx
  18. 1 0
      src/client/js/components/PageHistory/RevisionDiff.jsx
  19. 7 5
      src/client/js/services/AdminUsersContainer.js
  20. 1 1
      src/server/models/external-account.js
  21. 48 0
      src/server/models/openapi/paginate-result.js
  22. 1 1
      src/server/models/page-tag-relation.js
  23. 1 1
      src/server/models/page.js
  24. 1 1
      src/server/models/tag.js
  25. 1 1
      src/server/models/user-group-relation.js
  26. 1 1
      src/server/models/user-group.js
  27. 4 18
      src/server/models/user.js
  28. 1 22
      src/server/routes/admin.js
  29. 1 1
      src/server/routes/apiv3/user-group-relation.js
  30. 10 10
      src/server/routes/apiv3/user-group.js
  31. 16 9
      src/server/routes/apiv3/users.js
  32. 0 11
      src/server/service/acl.js
  33. 10 1
      src/server/service/config-loader.js
  34. 1 1
      src/server/service/export.js
  35. 1 1
      src/server/util/search.js
  36. 2 3
      src/server/views/admin/users.html
  37. 6 1
      src/server/views/layout/layout.html
  38. 4 9
      yarn.lock

+ 9 - 2
CHANGES.md

@@ -1,10 +1,17 @@
 # CHANGES
 
-## 3.5.17-RC
+## 3.5.18-RC
+
+* Improvement: Optimize handling promise of stream when exporting
+* Improvement: Optimize handling promise of stream when building indices
+
+## 3.5.17
 
 * Feature: Upload to GCS (Google Cloud Storage)
 * Feature: Statistics API
-* Improvement: Export progress bar
+* Improvement: Optimize exporting
+* Improvement: Show progress bar when exporting
+* Improvement: Validate collection combinations when importing
 * Improvement: Reactify admin pages
 * Fix: Use HTTP PlantUML URL in default
     * Introduced by 3.5.12

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.17-RC",
+  "version": "3.5.18-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -20,7 +20,7 @@
     "url": "https://github.com/weseek/growi/issues"
   },
   "scripts": {
-    "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/routes/apiv3/**/*.js",
+    "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/**/*.js",
     "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:dll": "webpack --config config/webpack.dev.dll.js",
@@ -114,7 +114,7 @@
     "module-alias": "^2.0.6",
     "mongoose": "5.4.4",
     "mongoose-gridfs": "^1.2.2",
-    "mongoose-paginate": "^5.0.3",
+    "mongoose-paginate-v2": "^1.3.2",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",

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

@@ -753,12 +753,17 @@
     "import_form_growi": "Import from GROWI",
     "growi_settings": {
       "overwrite_documents": "Imported documents will overwrite existing documents",
-      "zip_file": "Zip File",
+      "zip_file": "Exported Zip File",
       "uploaded_data": "Uploaded Data",
       "extracted_file": "Extracted File",
       "collection": "Collection",
       "upload": "Upload",
-      "discard": "Discard Uploaded Data"
+      "discard": "Discard Uploaded Data",
+      "errors": {
+        "at_least_one": "Select one or more collections.",
+        "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
+        "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
+      }
     },
     "esa_settings": {
       "team_name": "Team name",
@@ -783,7 +788,6 @@
     "rebuild_description_3":"This may take a while."
   },
   "export_management": {
-    "beta_warning": "This function is Beta.",
     "exporting_data_list": "Exporting Data List",
     "exported_data_list": "Exported Data List",
     "export_collections": "Export Collections",

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

@@ -738,12 +738,17 @@
     "import_form_growi": "GROWIからインポート",
     "growi_settings": {
       "overwrite_documents": "インポートされたドキュメントは既存のドキュメントを上書きします",
-      "zip_file": "Zip ファイル",
+      "zip_file": "エクスポートされた Zip ファイル",
       "uploaded_data": "アップロードされたデータ",
       "extracted_file": "展開されたファイル",
       "collection": "コレクション",
       "upload": "アップロード",
-      "discard": "アップロードしたデータを破棄する"
+      "discard": "アップロードしたデータを破棄する",
+      "errors": {
+        "at_least_one": "コレクションが選択されていません",
+        "page_and_revision": "'Pages' と 'Revisions' はセットでインポートする必要があります",
+        "depends": "'{{condition}}' をインポートする場合は、'{{target}}' を一緒に選択する必要があります"
+      }
     },
     "esa_settings": {
       "team_name": "チーム名",
@@ -768,7 +773,6 @@
     "rebuild_description_3":""
   },
   "export_management": {
-    "beta_warning": "この機能はベータ版です",
     "exporting_data_list": "エクスポート中のデータ",
     "exported_data_list": "エクスポートデータリスト",
     "export_collections": "コレクションのエクスポート",

+ 6 - 6
src/client/js/app.jsx

@@ -39,13 +39,13 @@ import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
-import Users from './components/Admin/Users/Users';
+import UserManagement from './components/Admin/UserManagement';
 import ManageExternalAccount from './components/Admin/Users/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
-import Importer from './components/Admin/Importer';
+import ImportDataPage from './components/Admin/ImportDataPage';
+import ExportDataPage from './components/Admin/ExportDataPage';
 import FullTextSearchManagement from './components/Admin/FullTextSearchManagement';
-import ExportPage from './components/Admin/Export/ExportPage';
 
 import AppContainer from './services/AppContainer';
 import PageContainer from './services/PageContainer';
@@ -112,7 +112,7 @@ let componentMappings = {
   'admin-external-account-setting': <ManageExternalAccount />,
 
   'staff-credit': <StaffCredit />,
-  'admin-importer': <Importer />,
+  'admin-importer': <ImportDataPage />,
 };
 
 // additional definitions if data exists
@@ -164,7 +164,7 @@ if (adminUsersElem != null) {
   ReactDOM.render(
     <Provider inject={[injectableContainers, adminUsersContainer]}>
       <I18nextProvider i18n={i18n}>
-        <Users />
+        <UserManagement />
       </I18nextProvider>
     </Provider>,
     adminUsersElem,
@@ -250,7 +250,7 @@ if (adminExportPageElem != null) {
   ReactDOM.render(
     <Provider inject={[appContainer, websocketContainer]}>
       <I18nextProvider i18n={i18n}>
-        <ExportPage
+        <ExportDataPage
           crowi={appContainer}
         />
       </I18nextProvider>

+ 0 - 0
src/client/js/components/Admin/Export/ExportTableMenu.jsx → src/client/js/components/Admin/ExportData/ExportTableMenu.jsx


+ 0 - 0
src/client/js/components/Admin/Export/ExportZipFormModal.jsx → src/client/js/components/Admin/ExportData/ExportZipFormModal.jsx


+ 0 - 0
src/client/js/components/Admin/Export/ZipFileTable.jsx → src/client/js/components/Admin/ExportData/ZipFileTable.jsx


+ 11 - 11
src/client/js/components/Admin/Export/ExportPage.jsx → src/client/js/components/Admin/ExportDataPage.jsx

@@ -4,17 +4,17 @@ import { withTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
 
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
-import WebsocketContainer from '../../../services/WebsocketContainer';
+import ProgressBar from './Common/ProgressBar';
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import WebsocketContainer from '../../services/WebsocketContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
-import ProgressBar from '../Common/ProgressBar';
 
-import ExportZipFormModal from './ExportZipFormModal';
-import ZipFileTable from './ZipFileTable';
+import ExportZipFormModal from './ExportData/ExportZipFormModal';
+import ZipFileTable from './ExportData/ZipFileTable';
 
-class ExportPage extends React.Component {
+class ExportDataPage extends React.Component {
 
   constructor(props) {
     super(props);
@@ -235,7 +235,7 @@ class ExportPage extends React.Component {
 
 }
 
-ExportPage.propTypes = {
+ExportDataPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   websocketContainer: PropTypes.instanceOf(WebsocketContainer).isRequired,
@@ -244,8 +244,8 @@ ExportPage.propTypes = {
 /**
  * Wrapper component for using unstated
  */
-const ExportPageFormWrapper = (props) => {
-  return createSubscribedElement(ExportPage, props, [AppContainer, WebsocketContainer]);
+const ExportDataPageFormWrapper = (props) => {
+  return createSubscribedElement(ExportDataPage, props, [AppContainer, WebsocketContainer]);
 };
 
-export default withTranslation()(ExportPageFormWrapper);
+export default withTranslation()(ExportDataPageFormWrapper);

+ 0 - 181
src/client/js/components/Admin/Import/GrowiZipImportForm.jsx

@@ -1,181 +0,0 @@
-import React, { Fragment } from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import * as toastr from 'toastr';
-
-import { createSubscribedElement } from '../../UnstatedUtils';
-import AppContainer from '../../../services/AppContainer';
-// import { toastSuccess, toastError } from '../../../util/apiNotification';
-
-class GrowiImportForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.initialState = {
-      collections: new Set(),
-      schema: {
-        pages: {},
-        revisions: {},
-        // ...
-      },
-    };
-
-    this.state = this.initialState;
-
-    this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.import = this.import.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  toggleCheckbox(e) {
-    const { target } = e;
-    const { name, checked } = target;
-
-    this.setState((prevState) => {
-      const collections = new Set(prevState.collections);
-      if (checked) {
-        collections.add(name);
-      }
-      else {
-        collections.delete(name);
-      }
-      return { collections };
-    });
-  }
-
-  async import(e) {
-    e.preventDefault();
-
-    try {
-      // TODO: use appContainer.apiv3.post
-      const { results } = await this.props.appContainer.apiPost('/v3/import', {
-        fileName: this.props.fileName,
-        collections: Array.from(this.state.collections),
-        schema: this.state.schema,
-      });
-
-      this.setState(this.initialState);
-      this.props.onPostImport();
-
-      // TODO: toastSuccess, toastError
-      toastr.success(undefined, 'Imported documents', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '1200',
-        extendedTimeOut: '150',
-      });
-
-      for (const { collectionName, failedIds } of results) {
-        if (failedIds.length > 0) {
-          toastr.error(`failed to insert ${failedIds.join(', ')}`, collectionName, {
-            closeButton: true,
-            progressBar: true,
-            newestOnTop: false,
-            timeOut: '30000',
-          });
-        }
-      }
-    }
-    catch (err) {
-      // TODO: toastSuccess, toastError
-      toastr.error(err, 'Error', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
-      });
-    }
-  }
-
-  validateForm() {
-    return this.state.collections.size > 0;
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <form className="row" onSubmit={this.import}>
-        <div className="col-xs-12">
-          <table className="table table-bordered table-mapping">
-            <caption>{t('importer_management.growi_settings.uploaded_data')}</caption>
-            <thead>
-              <tr>
-                <th></th>
-                <th>{t('importer_management.growi_settings.extracted_file')}</th>
-                <th>{t('importer_management.growi_settings.collection')}</th>
-              </tr>
-            </thead>
-            <tbody>
-              {this.props.fileStats.map((fileStat) => {
-                  const { fileName, collectionName } = fileStat;
-                  const checked = this.state.collections.has(collectionName);
-                  return (
-                    <Fragment key={collectionName}>
-                      <tr>
-                        <td>
-                          <input
-                            type="checkbox"
-                            id={collectionName}
-                            name={collectionName}
-                            className="form-check-input"
-                            value={collectionName}
-                            checked={checked}
-                            onChange={this.toggleCheckbox}
-                          />
-                        </td>
-                        <td>{fileName}</td>
-                        <td className="text-capitalize">{collectionName}</td>
-                      </tr>
-                      {checked && (
-                        <tr>
-                          <td className="text-muted" colSpan="3">
-                            TBD: define how {collectionName} are imported
-                            {/* TODO: create a component for each collection to modify this.state.schema */}
-                          </td>
-                        </tr>
-                      )}
-                    </Fragment>
-                  );
-                })}
-            </tbody>
-          </table>
-        </div>
-        <div className="col-xs-12 text-center">
-          <button type="submit" className="btn btn-primary mx-1" disabled={!this.validateForm()}>
-            { t('importer_management.import') }
-          </button>
-          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
-            { t('importer_management.growi_settings.discard') }
-          </button>
-        </div>
-      </form>
-    );
-  }
-
-}
-
-GrowiImportForm.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  fileName: PropTypes.string,
-  fileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
-  onDiscard: PropTypes.func.isRequired,
-  onPostImport: PropTypes.func.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiImportFormWrapper = (props) => {
-  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
-};
-
-export default withTranslation()(GrowiImportFormWrapper);

+ 364 - 0
src/client/js/components/Admin/ImportData/GrowiZipImportForm.jsx

@@ -0,0 +1,364 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+// import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+
+class GrowiImportForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.initialState = {
+      collectionNameToFileNameMap: {},
+      selectedCollections: new Set(),
+      schema: {
+        pages: {},
+        revisions: {},
+        // ...
+      },
+
+      canImport: false,
+      errorsForPageGroups: [],
+      errorsForUserGroups: [],
+      errorsForConfigGroups: [],
+      errorsForOtherGroups: [],
+    };
+
+    this.props.fileStats.forEach((fileStat) => {
+      const { fileName, collectionName } = fileStat;
+      this.initialState.collectionNameToFileNameMap[collectionName] = fileName;
+    });
+
+    this.state = this.initialState;
+
+    this.toggleCheckbox = this.toggleCheckbox.bind(this);
+    this.checkAll = this.checkAll.bind(this);
+    this.uncheckAll = this.uncheckAll.bind(this);
+    this.validate = this.validate.bind(this);
+    this.import = this.import.bind(this);
+  }
+
+  get allCollectionNames() {
+    return Object.keys(this.state.collectionNameToFileNameMap);
+  }
+
+  async toggleCheckbox(e) {
+    const { target } = e;
+    const { name, checked } = target;
+
+    await this.setState((prevState) => {
+      const selectedCollections = new Set(prevState.selectedCollections);
+      if (checked) {
+        selectedCollections.add(name);
+      }
+      else {
+        selectedCollections.delete(name);
+      }
+      return { selectedCollections };
+    });
+
+    this.validate();
+  }
+
+  async checkAll() {
+    await this.setState({ selectedCollections: new Set(this.allCollectionNames) });
+    this.validate();
+  }
+
+  async uncheckAll() {
+    await this.setState({ selectedCollections: new Set() });
+    this.validate();
+  }
+
+  async validate() {
+    // init errors
+    await this.setState({
+      errorsForPageGroups: [],
+      errorsForUserGroups: [],
+      errorsForConfigGroups: [],
+      errorsForOtherGroups: [],
+    });
+
+    await this.validateCollectionSize();
+    await this.validatePagesCollectionPairs();
+    await this.validateExternalAccounts();
+    await this.validateUserGroups();
+    await this.validateUserGroupRelations();
+
+    const errors = [
+      ...this.state.errorsForPageGroups,
+      ...this.state.errorsForUserGroups,
+      ...this.state.errorsForConfigGroups,
+      ...this.state.errorsForOtherGroups,
+    ];
+    const canImport = errors.length === 0;
+
+    this.setState({ canImport });
+  }
+
+  async validateCollectionSize(validationErrors) {
+    const { t } = this.props;
+    const { errorsForOtherGroups, selectedCollections } = this.state;
+
+    if (selectedCollections.size === 0) {
+      errorsForOtherGroups.push(t('importer_management.growi_settings.errors.at_least_one'));
+    }
+
+    this.setState({ errorsForOtherGroups });
+  }
+
+  async validatePagesCollectionPairs() {
+    const { t } = this.props;
+    const { errorsForPageGroups, selectedCollections } = this.state;
+
+    const pageRelatedCollectionsLength = ['pages', 'revisions'].filter((collectionName) => {
+      return selectedCollections.has(collectionName);
+    }).length;
+
+    // MUST be included both or neither when importing
+    if (pageRelatedCollectionsLength !== 0 && pageRelatedCollectionsLength !== 2) {
+      errorsForPageGroups.push(t('importer_management.growi_settings.errors.page_and_revision'));
+    }
+
+    this.setState({ errorsForPageGroups });
+  }
+
+  async validateExternalAccounts() {
+    const { t } = this.props;
+    const { errorsForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'users' if 'externalaccounts' is selected
+    if (selectedCollections.has('externalaccounts')) {
+      if (!selectedCollections.has('users')) {
+        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Externalaccounts' }));
+      }
+    }
+
+    this.setState({ errorsForUserGroups });
+  }
+
+  async validateUserGroups() {
+    const { t } = this.props;
+    const { errorsForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'users' if 'usergroups' is selected
+    if (selectedCollections.has('usergroups')) {
+      if (!selectedCollections.has('users')) {
+        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Users', condition: 'Usergroups' }));
+      }
+    }
+
+    this.setState({ errorsForUserGroups });
+  }
+
+  async validateUserGroupRelations() {
+    const { t } = this.props;
+    const { errorsForUserGroups, selectedCollections } = this.state;
+
+    // MUST include also 'usergroups' if 'usergrouprelations' is selected
+    if (selectedCollections.has('usergrouprelations')) {
+      if (!selectedCollections.has('usergroups')) {
+        errorsForUserGroups.push(t('importer_management.growi_settings.errors.depends', { target: 'Usergroups', condition: 'Usergrouprelations' }));
+      }
+    }
+
+    this.setState({ errorsForUserGroups });
+  }
+
+  async import() {
+    try {
+      // TODO: use appContainer.apiv3.post
+      const { results } = await this.props.appContainer.apiPost('/v3/import', {
+        fileName: this.props.fileName,
+        collections: Array.from(this.state.selectedCollections),
+        schema: this.state.schema,
+      });
+
+      this.setState(this.initialState);
+      this.props.onPostImport();
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Imported documents', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      for (const { collectionName, failedIds } of results) {
+        if (failedIds.length > 0) {
+          toastr.error(`failed to insert ${failedIds.join(', ')}`, collectionName, {
+            closeButton: true,
+            progressBar: true,
+            newestOnTop: false,
+            timeOut: '30000',
+          });
+        }
+      }
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }
+
+  renderWarnForGroups(errors, key) {
+    if (errors.length === 0) {
+      return null;
+    }
+
+    return (
+      <div key={key} className="alert alert-warning">
+        <ul>
+          { errors.map((error, index) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`${key}-${index}`}>{error}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }
+
+  renderGroups(groupList, groupName, errors, { wellContent, color } = {}) {
+    const collectionNames = groupList.filter((collectionName) => {
+      return this.allCollectionNames.includes(collectionName);
+    });
+
+    if (collectionNames.length === 0) {
+      return null;
+    }
+
+    return (
+      <div className="mt-4">
+        <legend>{groupName} Collections</legend>
+        { wellContent != null && (
+          <div className="well well-sm small">
+            <ul>
+              <li>{wellContent}</li>
+            </ul>
+          </div>
+        ) }
+        { this.renderCheckboxes(collectionNames, color) }
+        { this.renderWarnForGroups(errors, `warnFor${groupName}`) }
+      </div>
+    );
+  }
+
+  renderOthers() {
+    const collectionNames = this.allCollectionNames.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    return this.renderGroups(collectionNames, 'Other', this.state.errorsForOtherGroups);
+  }
+
+  renderCheckboxes(collectionNames, color) {
+    const checkboxColor = color ? `checkbox-${color}` : 'checkbox-info';
+
+    return (
+      <div className={`row checkbox ${checkboxColor}`}>
+        {collectionNames.map((collectionName) => {
+          return (
+            <div className="col-xs-6 my-1" key={collectionName}>
+              <input
+                type="checkbox"
+                id={collectionName}
+                name={collectionName}
+                className="form-check-input"
+                value={collectionName}
+                checked={this.state.selectedCollections.has(collectionName)}
+                onChange={this.toggleCheckbox}
+              />
+              <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
+                {collectionName}
+              </label>
+            </div>
+          );
+        })}
+      </div>
+    );
+  }
+
+
+  render() {
+    const { t } = this.props;
+    const { errorsForPageGroups, errorsForUserGroups, errorsForConfigGroups } = this.state;
+
+    return (
+      <>
+        <form className="form-inline">
+          <div className="form-group">
+            <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.checkAll}>
+              <i className="fa fa-check-square-o"></i> {t('export_management.check_all')}
+            </button>
+          </div>
+          <div className="form-group">
+            <button type="button" className="btn btn-sm btn-default mr-2" onClick={this.uncheckAll}>
+              <i className="fa fa-square-o"></i> {t('export_management.uncheck_all')}
+            </button>
+          </div>
+        </form>
+
+        { this.renderGroups(GROUPS_PAGE, 'Page', errorsForPageGroups, { wellContent: t('importer_management.growi_settings.overwrite_documents') }) }
+        { this.renderGroups(GROUPS_USER, 'User', errorsForUserGroups) }
+        { this.renderGroups(GROUPS_CONFIG, 'Config', errorsForConfigGroups) }
+        { this.renderOthers() }
+
+        <div className="mt-5 text-center">
+          <button type="button" className="btn btn-default mx-1" onClick={this.props.onDiscard}>
+            { t('importer_management.growi_settings.discard') }
+          </button>
+          <button type="button" className="btn btn-primary mx-1" onClick={this.import} disabled={!this.state.canImport}>
+            { t('importer_management.import') }
+          </button>
+        </div>
+      </>
+    );
+  }
+
+}
+
+GrowiImportForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  fileName: PropTypes.string,
+  fileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
+  onDiscard: PropTypes.func.isRequired,
+  onPostImport: PropTypes.func.isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiImportFormWrapper = (props) => {
+  return createSubscribedElement(GrowiImportForm, props, [AppContainer]);
+};
+
+export default withTranslation()(GrowiImportFormWrapper);

+ 6 - 11
src/client/js/components/Admin/Import/GrowiZipImportSection.jsx → src/client/js/components/Admin/ImportData/GrowiZipImportSection.jsx

@@ -3,12 +3,13 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import * as toastr from 'toastr';
 
-import GrowiZipUploadForm from './GrowiZipUploadForm';
-import GrowiZipImportForm from './GrowiZipImportForm';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 // import { toastSuccess, toastError } from '../../../util/apiNotification';
 
+import GrowiZipUploadForm from './GrowiZipUploadForm';
+import GrowiZipImportForm from './GrowiZipImportForm';
+
 class GrowiZipImportSection extends React.Component {
 
   constructor(props) {
@@ -72,27 +73,21 @@ class GrowiZipImportSection extends React.Component {
 
     return (
       <Fragment>
-        <legend>{t('importer_management.import_form_growi')}</legend>
+        <h2>{t('importer_management.import_form_growi')}</h2>
 
         <div className="alert alert-warning">
           <i className="icon-exclamation"></i> { t('importer_management.beta_warning') }
         </div>
 
-        <div className="well well-sm small">
-          <ul>
-            <li>{t('importer_management.growi_settings.overwrite_documents')}</li>
-          </ul>
-        </div>
-
         {this.state.fileName ? (
-          <Fragment>
+          <div className="px-4">
             <GrowiZipImportForm
               fileName={this.state.fileName}
               fileStats={this.state.fileStats}
               onDiscard={this.discardData}
               onPostImport={this.resetState}
             />
-          </Fragment>
+          </div>
         ) : (
           <GrowiZipUploadForm
             onUpload={this.handleUpload}

+ 1 - 1
src/client/js/components/Admin/Import/GrowiZipUploadForm.jsx → src/client/js/components/Admin/ImportData/GrowiZipUploadForm.jsx

@@ -51,7 +51,7 @@ class GrowiZipUploadForm extends React.Component {
     return (
       <form className="form-horizontal" onSubmit={this.uploadZipFile}>
         <fieldset>
-          <div className="form-group d-flex align-items-center">
+          <div className="form-group">
             <label htmlFor="file" className="col-xs-3 control-label">{t('importer_management.growi_settings.zip_file')}</label>
             <div className="col-xs-6">
               <input

+ 6 - 6
src/client/js/components/Admin/Importer.jsx → src/client/js/components/Admin/ImportDataPage.jsx

@@ -8,11 +8,11 @@ import { toastSuccess, toastError } from '../../util/apiNotification';
 
 import AppContainer from '../../services/AppContainer';
 
-import GrowiZipImportSection from './Import/GrowiZipImportSection';
+import GrowiZipImportSection from './ImportData/GrowiZipImportSection';
 
 const logger = loggerFactory('growi:importer');
 
-class Importer extends React.Component {
+class ImportDataPage extends React.Component {
 
   constructor(props) {
     super(props);
@@ -332,13 +332,13 @@ class Importer extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const ImporterWrapper = (props) => {
-  return createSubscribedElement(Importer, props, [AppContainer]);
+const ImportDataPageWrapper = (props) => {
+  return createSubscribedElement(ImportDataPage, props, [AppContainer]);
 };
 
-Importer.propTypes = {
+ImportDataPage.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   t: PropTypes.func.isRequired, // i18next
 };
 
-export default withTranslation()(ImporterWrapper);
+export default withTranslation()(ImportDataPageWrapper);

+ 37 - 20
src/client/js/components/Admin/Users/Users.jsx → src/client/js/components/Admin/UserManagement.jsx

@@ -2,18 +2,20 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import PasswordResetModal from './PasswordResetModal';
-import PaginationWrapper from '../../PaginationWrapper';
-import InviteUserControl from './InviteUserControl';
-import UserTable from './UserTable';
+import PaginationWrapper from '../PaginationWrapper';
 
-import { createSubscribedElement } from '../../UnstatedUtils';
-import { toastError } from '../../../util/apiNotification';
 
-import AppContainer from '../../../services/AppContainer';
-import AdminUsersContainer from '../../../services/AdminUsersContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
+import { toastError } from '../../util/apiNotification';
 
-class UserPage extends React.Component {
+import AppContainer from '../../services/AppContainer';
+import AdminUsersContainer from '../../services/AdminUsersContainer';
+
+import PasswordResetModal from './Users/PasswordResetModal';
+import InviteUserControl from './Users/InviteUserControl';
+import UserTable from './Users/UserTable';
+
+class UserManagement extends React.Component {
 
   constructor(props) {
     super();
@@ -21,6 +23,10 @@ class UserPage extends React.Component {
     this.handlePage = this.handlePage.bind(this);
   }
 
+  componentWillMount() {
+    this.handlePage(1);
+  }
+
   async handlePage(selectedPage) {
     try {
       await this.props.adminUsersContainer.retrieveUsersByPagingNum(selectedPage);
@@ -33,6 +39,17 @@ class UserPage extends React.Component {
   render() {
     const { t, adminUsersContainer } = this.props;
 
+    const pager = (
+      <div className="pull-right">
+        <PaginationWrapper
+          activePage={adminUsersContainer.state.activePage}
+          changePage={this.handlePage}
+          totalItemsCount={adminUsersContainer.state.totalUsers}
+          pagingLimit={adminUsersContainer.state.pagingLimit}
+        />
+      </div>
+    );
+
     return (
       <Fragment>
         {adminUsersContainer.state.userForPasswordResetModal && <PasswordResetModal />}
@@ -43,28 +60,28 @@ class UserPage extends React.Component {
             { t('user_management.external_account') }
           </a>
         </p>
+
+        <h2>{ t('User_Management') }</h2>
+
+        {pager}
         <UserTable />
-        <PaginationWrapper
-          activePage={adminUsersContainer.state.activePage}
-          changePage={this.handlePage}
-          totalItemsCount={adminUsersContainer.state.totalUsers}
-          pagingLimit={adminUsersContainer.state.pagingLimit}
-        />
+        {pager}
+
       </Fragment>
     );
   }
 
 }
 
-const UserPageWrapper = (props) => {
-  return createSubscribedElement(UserPage, props, [AppContainer, AdminUsersContainer]);
-};
 
-UserPage.propTypes = {
+UserManagement.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
+};
 
+const UserManagementWrapper = (props) => {
+  return createSubscribedElement(UserManagement, props, [AppContainer, AdminUsersContainer]);
 };
 
-export default withTranslation()(UserPageWrapper);
+export default withTranslation()(UserManagementWrapper);

+ 1 - 3
src/client/js/components/Admin/Users/UserTable.jsx

@@ -66,14 +66,12 @@ class UserTable extends React.Component {
 
     return (
       <Fragment>
-        <h2>{ t('User_Management') }</h2>
-
         <table className="table table-default table-bordered table-user-list">
           <thead>
             <tr>
               <th width="100px">#</th>
               <th>{ t('status') }</th>
-              <th><code>{ t('User') }</code></th>
+              <th><code>username</code></th>
               <th>{ t('Name') }</th>
               <th>{ t('Email') }</th>
               <th width="100px">{ t('Created') }</th>

+ 1 - 0
src/client/js/components/Page/RevisionBody.jsx

@@ -53,6 +53,7 @@ export default class RevisionBody extends React.Component {
           }
         }}
         className={`wiki ${additionalClassName}`}
+        // eslint-disable-next-line react/no-danger
         dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}
       />
     );

+ 1 - 0
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -34,6 +34,7 @@ export default class RevisionDiff extends React.Component {
     }
 
     const diffView = { __html: diffViewHTML };
+    // eslint-disable-next-line react/no-danger
     return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
   }
 

+ 7 - 5
src/client/js/services/AdminUsersContainer.js

@@ -17,7 +17,7 @@ export default class AdminUsersContainer extends Container {
     this.appContainer = appContainer;
 
     this.state = {
-      users: JSON.parse(document.getElementById('admin-user-page').getAttribute('users')) || [],
+      users: [],
       isPasswordResetModalShown: false,
       isUserInviteModalShown: false,
       userForPasswordResetModal: null,
@@ -46,11 +46,13 @@ export default class AdminUsersContainer extends Container {
   async retrieveUsersByPagingNum(selectedPage) {
 
     const params = { page: selectedPage };
-    const response = await this.appContainer.apiv3.get('/users', params);
+    const { data } = await this.appContainer.apiv3.get('/users', params);
 
-    const users = response.data.users;
-    const totalUsers = response.data.totalUsers;
-    const pagingLimit = response.data.pagingLimit;
+    if (data.paginateResult == null) {
+      throw new Error('data must conclude \'paginateResult\' property.');
+    }
+
+    const { docs: users, totalDocs: totalUsers, limit: pagingLimit } = data.paginateResult;
 
     this.setState({
       users,

+ 1 - 1
src/server/models/external-account.js

@@ -3,7 +3,7 @@
 
 const debug = require('debug')('growi:models:external-account');
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
+const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;

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

@@ -0,0 +1,48 @@
+/**
+ * @see https://www.npmjs.com/package/mongoose-paginate-v2
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      PaginateResult:
+ *        type: object
+ *        properties:
+ *          docs:
+ *            type: array
+ *            description: Array of documents
+ *            items:
+ *              type: object
+ *          totalDocs:
+ *            type: number
+ *            description: Total number of documents in collection that match a query
+ *          limit:
+ *            type: number
+ *            description: Limit that was used
+ *          hasPrevPage:
+ *            type: number
+ *            description: Availability of prev page.
+ *          hasNextPage:
+ *            type: number
+ *            description: Availability of next page.
+ *          page:
+ *            type: number
+ *            description: Current page number
+ *          totalPages:
+ *            type: number
+ *            description: Total number of pages.
+ *          offset:
+ *            type: number
+ *            description: Only if specified or default page/offset values were used
+ *          prefPage:
+ *            type: number
+ *            description: Previous page number if available or NULL
+ *          nextPage:
+ *            type: number
+ *            description: Next page number if available or NULL
+ *          pagingCounter:
+ *            type: number
+ *            description: The starting sl. number of first document.
+ *          meta:
+ *            type: number
+ *            description: Object of pagination meta data (Default false).
+ */

+ 1 - 1
src/server/models/page-tag-relation.js

@@ -4,7 +4,7 @@
 const flatMap = require('array.prototype.flatmap');
 
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
+const mongoosePaginate = require('mongoose-paginate-v2');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 

+ 1 - 1
src/server/models/page.js

@@ -7,7 +7,7 @@ const debug = require('debug')('growi:models:page');
 const nodePath = require('path');
 const urljoin = require('url-join');
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
+const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const { pathUtils } = require('growi-commons');

+ 1 - 1
src/server/models/tag.js

@@ -2,7 +2,7 @@
 /* eslint-disable no-return-await */
 
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
+const mongoosePaginate = require('mongoose-paginate-v2');
 
 /*
  * define schema

+ 1 - 1
src/server/models/user-group-relation.js

@@ -1,6 +1,6 @@
 const debug = require('debug')('growi:models:userGroupRelation');
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
+const mongoosePaginate = require('mongoose-paginate-v2');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 

+ 1 - 1
src/server/models/user-group.js

@@ -1,6 +1,6 @@
 const debug = require('debug')('growi:models:userGroup');
 const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate');
+const mongoosePaginate = require('mongoose-paginate-v2');
 
 
 /*

+ 4 - 18
src/server/models/user.js

@@ -3,9 +3,9 @@
 const debug = require('debug')('growi:models:user');
 const logger = require('@alias/logger')('growi:models:user');
 const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate-v2');
 const path = require('path');
 const uniqueValidator = require('mongoose-unique-validator');
-const mongoosePaginate = require('mongoose-paginate');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const crypto = require('crypto');
@@ -426,17 +426,6 @@ module.exports = function(crowi) {
       });
   };
 
-  userSchema.statics.findUsersWithPagination = async function(options) {
-    const defaultOptions = {
-      sort: { status: 1, username: 1, createdAt: 1 },
-      page: 1,
-      limit: PAGE_ITEMS,
-    };
-    const mergedOptions = Object.assign(defaultOptions, options);
-
-    return this.paginate({ status: { $ne: STATUS_DELETED } }, mergedOptions);
-  };
-
   userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {
     const status = options.status || null;
     const emailPartRegExp = new RegExp(emailPart.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'));
@@ -504,15 +493,12 @@ module.exports = function(crowi) {
   };
 
   userSchema.statics.isUserCountExceedsUpperLimit = async function() {
-    const { aclService } = crowi;
+    const { configManager } = crowi;
 
-    const userUpperLimit = aclService.userUpperLimit();
-    if (userUpperLimit === 0) {
-      return false;
-    }
+    const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
 
     const activeUsers = await this.countListByStatus(STATUS_ACTIVE);
-    if (userUpperLimit !== 0 && userUpperLimit <= activeUsers) {
+    if (userUpperLimit <= activeUsers) {
       return true;
     }
 

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

@@ -445,27 +445,7 @@ module.exports = function(crowi, app) {
 
   actions.user = {};
   actions.user.index = async function(req, res) {
-    const activeUsers = await User.countListByStatus(User.STATUS_ACTIVE);
-    const userUpperLimit = aclService.userUpperLimit();
-    const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
-
-    const page = parseInt(req.query.page) || 1;
-
-    const result = await User.findUsersWithPagination({
-      page,
-      select: `${User.USER_PUBLIC_FIELDS} lastLoginAt`,
-      populate: User.IMAGE_POPULATION,
-    });
-
-    const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
-
-    return res.render('admin/users', {
-      users: result.docs,
-      pager,
-      activeUsers,
-      userUpperLimit,
-      isUserCountExceedsUpperLimit,
-    });
+    return res.render('admin/users');
   };
 
   // これやったときの relation の挙動未確認
@@ -1057,7 +1037,6 @@ module.exports = function(crowi, app) {
     const { validationResult } = require('express-validator');
     const errors = validationResult(req);
     if (!errors.isEmpty()) {
-      console.log('validator', errors);
       return res.json(ApiResponse.error('Qiita form is blank'));
     }
 

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

@@ -21,7 +21,7 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *  paths:
-   *    /_api/v3/user-group-relations:
+   *    /user-group-relations:
    *      get:
    *        tags: [UserGroupRelation]
    *        description: Gets the user group relations

+ 10 - 10
src/server/routes/apiv3/user-group.js

@@ -39,7 +39,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups:
+   *    /user-groups:
    *      get:
    *        tags: [UserGroup]
    *        description: Get usergroups
@@ -77,7 +77,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups:
+   *    /user-groups:
    *      post:
    *        tags: [UserGroup]
    *        description: Adds userGroup
@@ -127,7 +127,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}:
+   *    /user-groups/{id}:
    *      delete:
    *        tags: [UserGroup]
    *        description: Deletes userGroup
@@ -187,7 +187,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}:
+   *    /user-groups/{id}:
    *      put:
    *        tags: [UserGroup]
    *        description: Update userGroup
@@ -242,7 +242,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}/users:
+   *    /user-groups/{id}/users:
    *      get:
    *        tags: [UserGroup]
    *        description: Get users related to the userGroup
@@ -290,7 +290,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}/unrelated-users:
+   *    /user-groups/{id}/unrelated-users:
    *      get:
    *        tags: [UserGroup]
    *        description: Get users unrelated to the userGroup
@@ -339,7 +339,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}/users:
+   *    /user-groups/{id}/users:
    *      post:
    *        tags: [UserGroup]
    *        description: Add a user to the userGroup
@@ -398,7 +398,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}/users:
+   *    /user-groups/{id}/users:
    *      delete:
    *        tags: [UserGroup]
    *        description: remove a user from the userGroup
@@ -458,7 +458,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}/user-group-relations:
+   *    /user-groups/{id}/user-group-relations:
    *      get:
    *        tags: [UserGroup]
    *        description: Get the user group relations for the userGroup
@@ -510,7 +510,7 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{id}/pages:
+   *    /user-groups/{id}/pages:
    *      get:
    *        tags: [UserGroup]
    *        description: Get closed pages for the userGroup

+ 16 - 9
src/server/routes/apiv3/users.js

@@ -9,6 +9,8 @@ const router = express.Router();
 const { body } = require('express-validator/check');
 const { isEmail } = require('validator');
 
+const PAGE_ITEMS = 50;
+
 const validator = {};
 
 /**
@@ -17,7 +19,6 @@ const validator = {};
  *    name: Users
  */
 
-
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
   const adminRequired = require('../../middleware/admin-required')(crowi);
@@ -47,21 +48,27 @@ module.exports = (crowi) => {
    *              application/json:
    *                schema:
    *                  properties:
-   *                    users:
-   *                      type: object
-   *                      description: a result of `Users.find`
+   *                    paginateResult:
+   *                      $ref: '#/components/schemas/PaginateResult'
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const page = parseInt(req.query.page) || 1;
+
     try {
-      const page = parseInt(req.query.page) || 1;
-      const result = await User.findUsersWithPagination({ page });
-      const { docs: users, total: totalUsers, limit: pagingLimit } = result;
-      return res.apiv3({ users, totalUsers, pagingLimit });
+      const paginateResult = await User.paginate(
+        { status: { $ne: User.STATUS_DELETED } },
+        {
+          sort: { status: 1, username: 1, createdAt: 1 },
+          page,
+          limit: PAGE_ITEMS,
+        },
+      );
+      return res.apiv3({ paginateResult });
     }
     catch (err) {
       const msg = 'Error occurred in fetching user group list';
       logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'));
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-list-fetch-failed'), 500);
     }
   });
 

+ 0 - 11
src/server/service/acl.js

@@ -79,17 +79,6 @@ class AclService {
     return labels;
   }
 
-  userUpperLimit() {
-    // const limit = this.configManager.getConfig('crowi', 'USER_UPPER_LIMIT');
-    const limit = process.env.USER_UPPER_LIMIT;
-
-    if (limit) {
-      return Number(limit);
-    }
-
-    return 0;
-  }
-
 }
 
 module.exports = AclService;

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

@@ -140,7 +140,10 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     ns:      'crowi',
     key:     'gridfs:totalLimit',
     type:    TYPES.NUMBER,
-    default: null,
+    default: null, // set null in default for backward compatibility
+    //                cz: Newer system respects FILE_UPLOAD_TOTAL_LIMIT.
+    //                    If the default value of MONGO_GRIDFS_TOTAL_LIMIT is Infinity,
+    //                      the system can't distinguish between "not specified" and "Infinity is specified".
   },
   FORCE_WIKI_MODE: {
     ns:      'crowi',
@@ -148,6 +151,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: undefined,
   },
+  USER_UPPER_LIMIT: {
+    ns:      'crowi',
+    key:     'security:userUpperLimit',
+    type:    TYPES.NUMBER,
+    default: Infinity,
+  },
   LOCAL_STRATEGY_ENABLED: {
     ns:      'crowi',
     key:     'security:passport-local:isEnabled',

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

@@ -208,7 +208,7 @@ class ExportService {
       .pipe(transformStream)
       .pipe(writeStream);
 
-    await streamToPromise(readStream);
+    await streamToPromise(writeStream);
 
     return writeStream.path;
   }

+ 1 - 1
src/server/util/search.js

@@ -441,7 +441,7 @@ SearchClient.prototype.updateOrInsertPages = async function(queryFactory, isEmit
     .pipe(appendTagNamesStream)
     .pipe(writeStream);
 
-  return streamToPromise(readStream);
+  return streamToPromise(writeStream);
 
 };
 

+ 2 - 3
src/server/views/admin/users.html

@@ -30,9 +30,8 @@
     {% include './widget/menu.html' with {current: 'user'} %}
   </div>
   <div
-  class="col-md-9"
-  id ="admin-user-page"
-  users= "{{ users | json }}"
+    class="col-md-9"
+    id ="admin-user-page"
   >
   </div>
 </div>

+ 6 - 1
src/server/views/layout/layout.html

@@ -75,7 +75,7 @@
 <div id="wrapper">
   <!-- Navigation -->
   {% block layout_head_nav %}
-  <nav class="navbar grw-navbar navbar-expand-lg navbar-dark mb-0 p-0">
+  <nav class="navbar grw-navbar navbar-expand-sm navbar-dark mb-0 p-0">
     <!-- 1 GROWI logo -->
     <div class="navbar-brand">
       <a class="logo d-block" href="/">
@@ -131,6 +131,11 @@
           <span class="d-none d-md-inline-block">{{ t('New') }}</span>
         </a>
       </li>
+      <li class="nav-item">
+        <a class="nav-link" href="https://docs.growi.org/" target="_blank">
+          <i class="icon-question"></i><span class="d-none d-md-inline-block">{{ t('Help') }}</span><span class="text-muted small"><i class="icon-share-alt"></i></span>
+        </a>
+      </li>
       <li class="nav-item dropdown">
         <a type="button" class="nav-link dropdown-toggle waves-effect waves-light" data-toggle="dropdown">
           <img src="{{ user|picture }}" class="picture rounded-circle" width="25" />

+ 4 - 9
yarn.lock

@@ -2137,10 +2137,6 @@ block-stream@*:
   dependencies:
     inherits "~2.0.0"
 
-bluebird@3.0.5:
-  version "3.0.5"
-  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.0.5.tgz#2ff9d07c9b3edb29d6d280fe07528365e7ecd392"
-
 bluebird@3.5.1, bluebird@^3.5.1:
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
@@ -8474,11 +8470,10 @@ mongoose-legacy-pluralize@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"
 
-mongoose-paginate@^5.0.3:
-  version "5.0.3"
-  resolved "https://registry.yarnpkg.com/mongoose-paginate/-/mongoose-paginate-5.0.3.tgz#d7ae49ed5bf64f1f7af7620ea865b67058c55371"
-  dependencies:
-    bluebird "3.0.5"
+mongoose-paginate-v2@^1.3.2:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/mongoose-paginate-v2/-/mongoose-paginate-v2-1.3.2.tgz#4a6077255156c555879c857eb0350b16272ed113"
+  integrity sha512-z8fmLaUjJ8u6Q/zxd/6JEbwKB+MY7lp2NahWlFdPYqiVHGVuL2cOpW99t4JA+EgW59V2zxwv8ZSoN0mFDaVrqw==
 
 mongoose-schema-jsonschema@>=1.2.1:
   version "1.2.1"