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

Merge pull request #980 from weseek/imprv/reactify-admin-user-groups

Imprv/reactify admin user groups
Sou Mizobuchi 6 лет назад
Родитель
Сommit
c82365d9a3

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

@@ -715,7 +715,6 @@
     "created_group": "Group was created",
     "created_group": "Group was created",
     "add_user": "Add a User to the Created Group",
     "add_user": "Add a User to the Created Group",
     "deny_create_group": "You can't create a new group with the current settings",
     "deny_create_group": "You can't create a new group with the current settings",
-    "is_loading_data": "Loading data...",
     "choose_action": "Choose an action for private pages",
     "choose_action": "Choose an action for private pages",
     "delete_group": "Delete Group",
     "delete_group": "Delete Group",
     "group_name": "Group Name",
     "group_name": "Group Name",

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

@@ -716,7 +716,6 @@
     "created_group": "グループを作成しました",
     "created_group": "グループを作成しました",
     "add_user": "グループへのユーザー追加",
     "add_user": "グループへのユーザー追加",
     "deny_create_group": "現在の設定では新規グループの作成はできません。",
     "deny_create_group": "現在の設定では新規グループの作成はできません。",
-    "is_loading_data": "データを取得中です...",
     "choose_action": "削除するグループの限定公開ページの処理を選択してください",
     "choose_action": "削除するグループの限定公開ページの処理を選択してください",
     "delete_group": "グループの削除",
     "delete_group": "グループの削除",
     "group_name": "グループ名",
     "group_name": "グループ名",

+ 19 - 9
src/client/js/app.js

@@ -38,7 +38,7 @@ import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
-import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
+import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 
 
 import AppContainer from './services/AppContainer';
 import AppContainer from './services/AppContainer';
 import PageContainer from './services/PageContainer';
 import PageContainer from './services/PageContainer';
@@ -327,15 +327,25 @@ if (adminRebuildSearchElem != null) {
     adminRebuildSearchElem,
     adminRebuildSearchElem,
   );
   );
 }
 }
-const adminGrantSelectorElem = document.getElementById('admin-delete-user-group-modal');
-if (adminGrantSelectorElem != null) {
+
+const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
+if (adminUserGroupPageElem != null) {
+  const userGroups = JSON.parse(adminUserGroupPageElem.getAttribute('data-user-groups'));
+  const userGroupRelations = JSON.parse(adminUserGroupPageElem.getAttribute('data-user-group-relations'));
+  const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
+
   ReactDOM.render(
   ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <GroupDeleteModal
-        crowi={appContainer}
-      />
-    </I18nextProvider>,
-    adminGrantSelectorElem,
+    <Provider inject={[]}>
+      <I18nextProvider i18n={i18n}>
+        <UserGroupPage
+          crowi={appContainer}
+          userGroups={userGroups}
+          userGroupRelations={userGroupRelations}
+          isAclEnabled={isAclEnabled}
+        />
+      </I18nextProvider>
+    </Provider>,
+    adminUserGroupPageElem,
   );
   );
 }
 }
 
 

+ 120 - 0
src/client/js/components/Admin/UserGroup/UserGroupCreateForm.jsx

@@ -0,0 +1,120 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupCreateForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: '',
+    };
+
+    this.xss = window.xss;
+
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  handleChange(event) {
+    const target = event.target;
+    const value = target.type === 'checkbox' ? target.checked : target.value;
+    const name = target.name;
+
+    this.setState({
+      [name]: value,
+    });
+  }
+
+  async handleSubmit(e) {
+    e.preventDefault();
+
+    try {
+      const res = await this.props.appContainer.apiv3.post('/user-groups', {
+        name: this.state.name,
+      });
+
+      const userGroup = res.data.userGroup;
+      const userGroupId = userGroup._id;
+
+      const res2 = await this.props.appContainer.apiv3.get(`/user-groups/${userGroupId}/users`);
+
+      const { users } = res2.data;
+
+      this.props.onCreate(userGroup, users);
+
+      this.setState({ name: '' });
+
+      toastSuccess(`Created a user group "${this.xss.process(userGroup.name)}"`);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  validateForm() {
+    return this.state.name !== '';
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div>
+        <p>
+          {this.props.isAclEnabled
+            ? (
+              <button type="button" data-toggle="collapse" className="btn btn-default" href="#createGroupForm">
+                { t('user_group_management.create_group') }
+              </button>
+            )
+            : (
+              t('user_group_management.deny_create_group')
+            )
+          }
+        </p>
+        <form onSubmit={this.handleSubmit}>
+          <div id="createGroupForm" className="collapse">
+            <div className="form-group">
+              <label htmlFor="name">{ t('user_group_management.group_name') }</label>
+              <textarea
+                id="name"
+                name="name"
+                className="form-control"
+                placeholder={t('user_group_management.group_example')}
+                value={this.state.name}
+                onChange={this.handleChange}
+              >
+              </textarea>
+            </div>
+            <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{ t('Create') }</button>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupCreateFormWrapper = (props) => {
+  return createSubscribedElement(UserGroupCreateForm, props, [AppContainer]);
+};
+
+UserGroupCreateForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  isAclEnabled: PropTypes.bool,
+  onCreate: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(UserGroupCreateFormWrapper);

+ 206 - 0
src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx

@@ -0,0 +1,206 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+/**
+ * Delete User Group Select component
+ *
+ * @export
+ * @class GrantSelector
+ * @extends {React.Component}
+ */
+class UserGroupDeleteModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { t } = this.props;
+
+    // actionName master constants
+    this.actionForPages = {
+      public: 'public',
+      delete: 'delete',
+      transfer: 'transfer',
+    };
+
+    this.availableOptions = [
+      {
+        id: 1, actionForPages: this.actionForPages.public, iconClass: 'icon-people', styleClass: '', label: t('user_group_management.publish_pages'),
+      },
+      {
+        id: 2, actionForPages: this.actionForPages.delete, iconClass: 'icon-trash', styleClass: 'text-danger', label: t('user_group_management.delete_pages'),
+      },
+      {
+        id: 3, actionForPages: this.actionForPages.transfer, iconClass: 'icon-options', styleClass: '', label: t('user_group_management.transfer_pages'),
+      },
+    ];
+
+    this.initialState = {
+      actionName: '',
+      transferToUserGroupId: '',
+    };
+
+    this.state = this.initialState;
+
+    this.xss = window.xss;
+
+    this.onHide = this.onHide.bind(this);
+    this.handleActionChange = this.handleActionChange.bind(this);
+    this.handleGroupChange = this.handleGroupChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
+    this.renderGroupSelector = this.renderGroupSelector.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  onHide() {
+    this.setState(this.initialState);
+    this.props.onHide();
+  }
+
+  handleActionChange(e) {
+    const actionName = e.target.value;
+    this.setState({ actionName });
+  }
+
+  handleGroupChange(e) {
+    const transferToUserGroupId = e.target.value;
+    this.setState({ transferToUserGroupId });
+  }
+
+  handleSubmit(e) {
+    e.preventDefault();
+
+    this.props.onDelete({
+      deleteGroupId: this.props.deleteUserGroup._id,
+      actionName: this.state.actionName,
+      transferToUserGroupId: this.state.transferToUserGroupId,
+    });
+  }
+
+  renderPageActionSelector() {
+    const { t } = this.props;
+
+    const optoins = this.availableOptions.map((opt) => {
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
+      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
+    });
+
+    return (
+      <select
+        name="actionName"
+        className="form-control"
+        placeholder="select"
+        value={this.state.actionName}
+        onChange={this.handleActionChange}
+      >
+        <option value="" disabled>{t('user_group_management.choose_action')}</option>
+        {optoins}
+      </select>
+    );
+  }
+
+  renderGroupSelector() {
+    const { t } = this.props;
+
+    const groups = this.props.userGroups.filter((group) => {
+      return group._id !== this.props.deleteUserGroup._id;
+    });
+
+    const options = groups.map((group) => {
+      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.xss.process(group.name)}`;
+      return <option key={group._id} value={group._id} data-content={dataContent}>{this.xss.process(group.name)}</option>;
+    });
+
+    const defaultOptionText = groups.length === 0 ? t('user_group_management.no_groups') : t('user_group_management.select_group');
+
+    return (
+      <select
+        name="transferToUserGroupId"
+        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
+        value={this.state.transferToUserGroupId}
+        onChange={this.handleGroupChange}
+      >
+        <option value="" disabled>{defaultOptionText}</option>
+        {options}
+      </select>
+    );
+  }
+
+  validateForm() {
+    let isValid = true;
+
+    if (this.state.actionName === '') {
+      isValid = false;
+    }
+    else if (this.state.actionName === this.actionForPages.transfer) {
+      isValid = this.state.transferToUserGroupId !== '';
+    }
+
+    return isValid;
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isShow} onHide={this.onHide}>
+        <Modal.Header className="modal-header bg-danger" closeButton>
+          <Modal.Title>
+            <i className="icon icon-fire"></i> {t('user_group_management.delete_group')}
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <div>
+            <span className="font-weight-bold">{t('user_group_management.group_name')}</span> : &quot;{this.props.deleteUserGroup.name}&quot;
+          </div>
+          <div className="text-danger mt-5">
+            {t('user_group_management.group_and_pages_not_retrievable')}
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <form className="d-flex justify-content-between" onSubmit={this.handleSubmit}>
+            <div className="d-flex">
+              {this.renderPageActionSelector()}
+              {this.renderGroupSelector()}
+            </div>
+            <button type="submit" value="" className="btn btn-sm btn-danger" disabled={!this.validateForm()}>
+              <i className="icon icon-fire"></i> {t('Delete')}
+            </button>
+          </form>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDeleteModalWrapper = (props) => {
+  return createSubscribedElement(UserGroupDeleteModal, props, [AppContainer]);
+};
+
+UserGroupDeleteModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
+  deleteUserGroup: PropTypes.object,
+  onDelete: PropTypes.func.isRequired,
+  isShow: PropTypes.bool.isRequired,
+  onShow: PropTypes.func.isRequired,
+  onHide: PropTypes.func.isRequired,
+};
+
+UserGroupDeleteModal.defaultProps = {
+  deleteUserGroup: {},
+};
+
+export default withTranslation()(UserGroupDeleteModalWrapper);

+ 161 - 0
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -0,0 +1,161 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+
+import UserGroupTable from './UserGroupTable';
+import UserGroupCreateForm from './UserGroupCreateForm';
+import UserGroupDeleteModal from './UserGroupDeleteModal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupPage extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      userGroups: props.userGroups,
+      userGroupRelations: props.userGroupRelations,
+      selectedUserGroup: undefined, // not null but undefined (to use defaultProps in UserGroupDeleteModal)
+      isDeleteModalShow: false,
+    };
+
+    this.xss = window.xss;
+
+    this.showDeleteModal = this.showDeleteModal.bind(this);
+    this.hideDeleteModal = this.hideDeleteModal.bind(this);
+    this.addUserGroup = this.addUserGroup.bind(this);
+    this.deleteUserGroupById = this.deleteUserGroupById.bind(this);
+  }
+
+  async showDeleteModal(group) {
+    try {
+      await this.syncUserGroupAndRelations();
+
+      this.setState({
+        selectedUserGroup: group,
+        isDeleteModalShow: true,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  hideDeleteModal() {
+    this.setState({
+      selectedUserGroup: undefined,
+      isDeleteModalShow: false,
+    });
+  }
+
+  addUserGroup(userGroup, users) {
+    this.setState((prevState) => {
+      const userGroupRelations = Object.assign(prevState.userGroupRelations, {
+        [userGroup._id]: users,
+      });
+
+      return {
+        userGroups: [...prevState.userGroups, userGroup],
+        userGroupRelations,
+      };
+    });
+  }
+
+  async deleteUserGroupById({ deleteGroupId, actionName, transferToUserGroupId }) {
+    try {
+      const res = await this.props.appContainer.apiv3.delete(`/user-groups/${deleteGroupId}`, {
+        actionName,
+        transferToUserGroupId,
+      });
+
+      this.setState((prevState) => {
+        const userGroups = prevState.userGroups.filter((userGroup) => {
+          return userGroup._id !== deleteGroupId;
+        });
+
+        delete prevState.userGroupRelations[deleteGroupId];
+
+        return {
+          userGroups,
+          userGroupRelations: prevState.userGroupRelations,
+          selectedUserGroup: undefined,
+          isDeleteModalShow: false,
+        };
+      });
+
+      toastSuccess(`Deleted a group "${this.xss.process(res.data.userGroup.name)}"`);
+    }
+    catch (err) {
+      toastError(new Error('Unable to delete the group'));
+    }
+  }
+
+  async syncUserGroupAndRelations() {
+    let userGroups = [];
+    let userGroupRelations = {};
+
+    try {
+      const responses = await Promise.all([
+        this.props.appContainer.apiv3.get('/user-groups'),
+        this.props.appContainer.apiv3.get('/user-group-relations'),
+      ]);
+
+      const [userGroupsRes, userGroupRelationsRes] = responses;
+      userGroups = userGroupsRes.data.userGroups;
+      userGroupRelations = userGroupRelationsRes.data.userGroupRelations;
+
+      this.setState({
+        userGroups,
+        userGroupRelations,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    return (
+      <Fragment>
+        <UserGroupCreateForm
+          isAclEnabled={this.props.isAclEnabled}
+          onCreate={this.addUserGroup}
+        />
+        <UserGroupTable
+          userGroups={this.state.userGroups}
+          userGroupRelations={this.state.userGroupRelations}
+          isAclEnabled={this.props.isAclEnabled}
+          onDelete={this.showDeleteModal}
+        />
+        <UserGroupDeleteModal
+          userGroups={this.state.userGroups}
+          deleteUserGroup={this.state.selectedUserGroup}
+          onDelete={this.deleteUserGroupById}
+          isShow={this.state.isDeleteModalShow}
+          onShow={this.showDeleteModal}
+          onHide={this.hideDeleteModal}
+        />
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupPageWrapper = (props) => {
+  return createSubscribedElement(UserGroupPage, props, [AppContainer]);
+};
+
+UserGroupPage.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
+  userGroupRelations: PropTypes.object.isRequired,
+  isAclEnabled: PropTypes.bool,
+};
+
+export default UserGroupPageWrapper;

+ 122 - 0
src/client/js/components/Admin/UserGroup/UserGroupTable.jsx

@@ -0,0 +1,122 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class UserGroupTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.xss = window.xss;
+
+    this.onDelete = this.onDelete.bind(this);
+  }
+
+  onDelete(e) {
+    const { target } = e;
+    const groupId = target.getAttribute('data-user-group-id');
+    const group = this.props.userGroups.find((group) => {
+      return group._id === groupId;
+    });
+
+    this.props.onDelete(group);
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <h2>{t('user_group_management.group_list')}</h2>
+
+        <table className="table table-bordered table-user-list">
+          <thead>
+            <tr>
+              <th>{ t('Name') }</th>
+              <th>{ t('User') }</th>
+              <th width="100px">{ t('Created') }</th>
+              <th width="70px"></th>
+            </tr>
+          </thead>
+          <tbody>
+            {this.props.userGroups.map((group) => {
+              return (
+                <tr key={group._id}>
+                  {this.props.isAclEnabled
+                    ? (
+                      <td><a href={`/admin/user-group-detail/${group._id}`}>{this.xss.process(group.name)}</a></td>
+                    )
+                    : (
+                      <td>{this.xss.process(group.name)}</td>
+                    )
+                  }
+                  <td>
+                    <ul className="list-inline">
+                      {this.props.userGroupRelations[group._id].map((user) => {
+                        return <li key={user._id} className="list-inline-item badge badge-primary">{this.xss.process(user.username)}</li>;
+                      })}
+                    </ul>
+                  </td>
+                  <td>{dateFnsFormat(new Date(group.createdAt), 'YYYY-MM-DD')}</td>
+                  {this.props.isAclEnabled
+                    ? (
+                      <td>
+                        <div className="btn-group admin-group-menu">
+                          <button type="button" className="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                            <i className="icon-settings"></i> <span className="caret"></span>
+                          </button>
+                          <ul className="dropdown-menu" role="menu">
+                            <li>
+                              <a href={`/admin/user-group-detail/${group._id}`}>
+                                <i className="icon-fw icon-note"></i> { t('Edit') }
+                              </a>
+                            </li>
+
+                            <li>
+                              <a href="#" onClick={this.onDelete} data-user-group-id={group._id}>
+                                <i className="icon-fw icon-fire text-danger"></i> { t('Delete') }
+                              </a>
+                            </li>
+
+                          </ul>
+                        </div>
+                      </td>
+                    )
+                    : (
+                      <td></td>
+                    )
+                  }
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupTableWrapper = (props) => {
+  return createSubscribedElement(UserGroupTable, props, [AppContainer]);
+};
+
+
+UserGroupTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  userGroups: PropTypes.arrayOf(PropTypes.object).isRequired,
+  userGroupRelations: PropTypes.object.isRequired,
+  isAclEnabled: PropTypes.bool,
+  onDelete: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(UserGroupTableWrapper);

+ 0 - 260
src/client/js/components/GroupDeleteModal/GroupDeleteModal.jsx

@@ -1,260 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import * as toastr from 'toastr';
-
-/**
- * Delete User Group Select component
- *
- * @export
- * @class GrantSelector
- * @extends {React.Component}
- */
-class GroupDeleteModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    const { t } = this.props;
-
-    // actionName master constants
-    this.actionForPages = {
-      public: 'public',
-      delete: 'delete',
-      transfer: 'transfer',
-    };
-
-    this.availableOptions = [
-      {
-        id: 1, actionForPages: this.actionForPages.public, iconClass: 'icon-people', styleClass: '', label: t('user_group_management.publish_pages'),
-      },
-      {
-        id: 2, actionForPages: this.actionForPages.delete, iconClass: 'icon-trash', styleClass: 'text-danger', label: t('user_group_management.delete_pages'),
-      },
-      {
-        id: 3, actionForPages: this.actionForPages.transfer, iconClass: 'icon-options', styleClass: '', label: t('user_group_management.transfer_pages'),
-      },
-    ];
-
-    this.initialState = {
-      deleteGroupId: '',
-      deleteGroupName: '',
-      groups: [],
-      actionName: '',
-      selectedGroupId: '',
-      isFetching: false,
-    };
-
-    this.state = this.initialState;
-
-    // logger
-    this.logger = require('@alias/logger')('growi:GroupDeleteModal:GroupDeleteModal');
-
-    // retrieve xss library from window
-    this.xss = window.xss;
-
-    this.getGroupName = this.getGroupName.bind(this);
-    this.changeActionHandler = this.changeActionHandler.bind(this);
-    this.changeGroupHandler = this.changeGroupHandler.bind(this);
-    this.renderPageActionSelector = this.renderPageActionSelector.bind(this);
-    this.renderGroupSelector = this.renderGroupSelector.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  componentDidMount() {
-    // bootstrap and this jQuery opens/hides the modal.
-    // let React handle it in the future.
-    $('#admin-delete-user-group-modal').on('show.bs.modal', async(button) => {
-      this.setState({ isFetching: true });
-
-      const groups = await this.fetchAllGroups();
-
-      const data = $(button.relatedTarget);
-      const deleteGroupId = data.data('user-group-id');
-      const deleteGroupName = data.data('user-group-name');
-
-      this.setState({
-        groups,
-        deleteGroupId,
-        deleteGroupName,
-        isFetching: false,
-      });
-    });
-
-    $('#admin-delete-user-group-modal').on('hide.bs.modal', (button) => {
-      this.setState(this.initialState);
-    });
-  }
-
-  getGroupName(group) {
-    return this.xss.process(group.name);
-  }
-
-  async fetchAllGroups() {
-    let groups = [];
-
-    try {
-      const res = await this.props.crowi.apiGet('/admin/user-groups');
-      if (res.ok) {
-        groups = res.userGroups;
-      }
-      else {
-        throw new Error('Unable to fetch groups from server');
-      }
-    }
-    catch (err) {
-      this.handleError(err);
-    }
-
-    return groups;
-  }
-
-  handleError(err) {
-    this.logger.error(err);
-    toastr.error(err, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
-  changeActionHandler(e) {
-    const actionName = e.target.value;
-    this.setState({ actionName });
-  }
-
-  changeGroupHandler(e) {
-    const selectedGroupId = e.target.value;
-    this.setState({ selectedGroupId });
-  }
-
-  renderPageActionSelector() {
-    const { t } = this.props;
-
-    const optoins = this.availableOptions.map((opt) => {
-      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="action-name ${opt.styleClass}">${t(opt.label)}</span>`;
-      return <option key={opt.id} value={opt.actionForPages} data-content={dataContent}>{t(opt.label)}</option>;
-    });
-
-    return (
-      <select
-        name="actionName"
-        className="form-control"
-        placeholder="select"
-        value={this.state.actionName}
-        onChange={this.changeActionHandler}
-      >
-        <option value="" disabled>{t('user_group_management.choose_action')}</option>
-        {optoins}
-      </select>
-    );
-  }
-
-  renderGroupSelector() {
-    const { t } = this.props;
-
-    const groups = this.state.groups.filter((group) => {
-      return group._id !== this.state.deleteGroupId;
-    });
-
-    const options = groups.map((group) => {
-      const dataContent = `<i class="icon icon-fw icon-organization"></i> ${this.getGroupName(group)}`;
-      return <option key={group._id} value={group._id} data-content={dataContent}>{this.getGroupName(group)}</option>;
-    });
-
-    const defaultOptionText = groups.length === 0 ? t('user_group_management.no_groups') : t('user_group_management.select_group');
-
-    return (
-      <select
-        name="selectedGroupId"
-        className={`form-control ${this.state.actionName === this.actionForPages.transfer ? '' : 'd-none'}`}
-        value={this.state.selectedGroupId}
-        onChange={this.changeGroupHandler}
-      >
-        <option value="" disabled>{defaultOptionText}</option>
-        {options}
-      </select>
-    );
-  }
-
-  validateForm() {
-    let isValid = true;
-
-    if (this.state.actionName === '') {
-      isValid = false;
-    }
-    else if (this.state.actionName === this.actionForPages.transfer) {
-      isValid = this.state.selectedGroupId !== '';
-    }
-
-    return isValid;
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="modal-dialog">
-        <div className="modal-content">
-          <div className="modal-header bg-danger">
-            <button type="button" className="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-            <div className="modal-title">
-              <i className="icon icon-fire"></i> {t('user_group_management.delete_group')}
-            </div>
-          </div>
-
-          <div className="modal-body">
-            <div>
-              <span className="font-weight-bold">{t('user_group_management.group_name')}</span> : &quot;{this.state.deleteGroupName}&quot;
-            </div>
-            {this.state.isFetching
-              ? (
-                <div className="mt-5">
-                  {t('user_group_management.is_loading_data')}
-                </div>
-              )
-              : (
-                <div className="text-danger mt-5">
-                  {t('user_group_management.group_and_pages_not_retrievable')}
-                </div>
-              )
-            }
-          </div>
-
-          {this.state.isFetching
-            ? (
-              null
-            )
-            : (
-              <div className="modal-footer">
-                <form action="/admin/user-group.remove" method="post" id="admin-user-groups-delete" className="d-flex justify-content-between">
-                  <div className="d-flex">
-                    {this.renderPageActionSelector()}
-                    {this.renderGroupSelector()}
-                  </div>
-                  <input type="hidden" id="deleteGroupId" name="deleteGroupId" value={this.state.deleteGroupId} onChange={() => {}} />
-                  <input type="hidden" name="_csrf" defaultValue={this.props.crowi.csrfToken} />
-                  <button type="submit" value="" className="btn btn-sm btn-danger" disabled={!this.validateForm()}>
-                    <i className="icon icon-fire"></i> {t('Delete')}
-                  </button>
-                </form>
-              </div>
-            )
-          }
-        </div>
-      </div>
-    );
-  }
-
-}
-
-GroupDeleteModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-};
-
-export default withTranslation()(GroupDeleteModal);

+ 49 - 0
src/client/js/services/AppContainer.js

@@ -1,6 +1,7 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import axios from 'axios';
 import axios from 'axios';
+import urljoin from 'url-join';
 
 
 import InterceptorManager from '@commons/service/interceptor-manager';
 import InterceptorManager from '@commons/service/interceptor-manager';
 
 
@@ -13,6 +14,7 @@ import {
 } from '../util/interceptor/detach-code-blocks';
 } from '../util/interceptor/detach-code-blocks';
 
 
 import i18nFactory from '../util/i18n';
 import i18nFactory from '../util/i18n';
+import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
 
 
 /**
 /**
  * Service container related to options for Application
  * Service container related to options for Application
@@ -68,6 +70,14 @@ export default class AppContainer extends Container {
     this.apiGet = this.apiGet.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiPost = this.apiPost.bind(this);
     this.apiPost = this.apiPost.bind(this);
     this.apiRequest = this.apiRequest.bind(this);
     this.apiRequest = this.apiRequest.bind(this);
+
+    this.apiv3Root = '/_api/v3';
+    this.apiv3 = {
+      get: this.apiv3Get.bind(this),
+      post: this.apiv3Post.bind(this),
+      put: this.apiv3Put.bind(this),
+      delete: this.apiv3Delete.bind(this),
+    };
   }
   }
 
 
   initPlugins() {
   initPlugins() {
@@ -338,4 +348,43 @@ export default class AppContainer extends Container {
     });
     });
   }
   }
 
 
+  async apiv3Request(method, path, params) {
+    try {
+      const res = await axios[method](urljoin(this.apiv3Root, path), params);
+      return res.data;
+    }
+    catch (err) {
+      const errors = apiv3ErrorHandler(err);
+      throw errors;
+    }
+  }
+
+  async apiv3Get(path, params) {
+    return this.apiv3Request('get', path, { params });
+  }
+
+  async apiv3Post(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('post', path, params);
+  }
+
+  async apiv3Put(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('put', path, params);
+  }
+
+  async apiv3Delete(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiv3Request('delete', path, { params });
+  }
+
 }
 }

+ 37 - 0
src/client/js/util/apiNotification.js

@@ -0,0 +1,37 @@
+// show API error/sucess toastr
+
+import * as toastr from 'toastr';
+import toArrayIfNot from '../../../lib/util/toArrayIfNot';
+
+const toastrOption = {
+  error: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+  success: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+};
+
+// accepts both a single error and an array of errors
+export const toastError = (err, header = 'Error', option = toastrOption.error) => {
+  const errs = toArrayIfNot(err);
+
+  for (const err of errs) {
+    toastr.error(err.message, header, option);
+  }
+};
+
+// only accepts a single item
+export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
+  toastr.success(body, header, option);
+};

+ 20 - 0
src/client/js/util/apiv3ErrorHandler.js

@@ -0,0 +1,20 @@
+// API v3 sends an array of errors in res.data.errors.
+// API v3 errors need to extracted from an error object in order to properly handle them.
+
+import toArrayIfNot from '../../../lib/util/toArrayIfNot';
+
+const logger = require('@alias/logger')('growi:apiv3');
+
+const apiv3ErrorHandler = (_err, header = 'Error') => {
+  // extract api errors from general 400 err
+  const err = _err.response ? _err.response.data.errors : _err;
+  const errs = toArrayIfNot(err);
+
+  for (const err of errs) {
+    logger.error(err.message);
+  }
+
+  return errs;
+};
+
+export default apiv3ErrorHandler;

+ 15 - 0
src/lib/util/toArrayIfNot.js

@@ -0,0 +1,15 @@
+// converts non-array item to array
+
+const toArrayIfNot = (item) => {
+  if (item == null) {
+    return [];
+  }
+
+  if (Array.isArray(item)) {
+    return item;
+  }
+
+  return [item];
+};
+
+module.exports = toArrayIfNot;

+ 8 - 0
src/server/crowi/index.js

@@ -14,6 +14,7 @@ const sep = path.sep;
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 
 
 const models = require('../models');
 const models = require('../models');
+const initMiddlewares = require('../middlewares');
 
 
 function Crowi(rootdir) {
 function Crowi(rootdir) {
   const self = this;
   const self = this;
@@ -46,6 +47,7 @@ function Crowi(rootdir) {
   this.tokens = null;
   this.tokens = null;
 
 
   this.models = {};
   this.models = {};
+  this.middlewares = {};
 
 
   this.env = process.env;
   this.env = process.env;
   this.node_env = this.env.NODE_ENV || 'development';
   this.node_env = this.env.NODE_ENV || 'development';
@@ -72,6 +74,7 @@ function getMongoUrl(env) {
 Crowi.prototype.init = async function() {
 Crowi.prototype.init = async function() {
   await this.setupDatabase();
   await this.setupDatabase();
   await this.setupModels();
   await this.setupModels();
+  await this.setupMiddlewares();
   await this.setupSessionConfig();
   await this.setupSessionConfig();
   await this.setupAppConfig();
   await this.setupAppConfig();
   await this.setupConfigManager();
   await this.setupConfigManager();
@@ -212,6 +215,11 @@ Crowi.prototype.setupModels = function() {
   }));
   }));
 };
 };
 
 
+Crowi.prototype.setupMiddlewares = async function() {
+  // const self = this;
+  this.middlewares = await initMiddlewares(this);
+};
+
 Crowi.prototype.getIo = function() {
 Crowi.prototype.getIo = function() {
   return this.io;
   return this.io;
 };
 };

+ 26 - 0
src/server/middlewares/ApiV3FormValidator.js

@@ -0,0 +1,26 @@
+const logger = require('@alias/logger')('growi:middlewares:ApiV3FormValidator');
+const { validationResult } = require('express-validator/check');
+
+class ApiV3FormValidator {
+
+  constructor(crowi) {
+    const { ErrorV3 } = crowi.models;
+
+    return (req, res, next) => {
+      const errObjArray = validationResult(req);
+      if (errObjArray.isEmpty()) {
+        return next();
+      }
+
+      const errs = errObjArray.array().map((err) => {
+        logger.error(`${err.param} in ${err.location}: ${err.msg}`);
+        return new ErrorV3(`${err.param}: ${err.msg}`, 'validation_failed');
+      });
+
+      return res.apiv3Err(errs);
+    };
+  }
+
+}
+
+module.exports = ApiV3FormValidator;

+ 21 - 0
src/server/middlewares/index.js

@@ -0,0 +1,21 @@
+const fs = require('fs');
+const path = require('path');
+
+const initMiddlewares = (crowi) => {
+  const basename = path.basename(__filename);
+  const middlewares = {};
+
+  fs
+    .readdirSync(__dirname)
+    .filter((file) => {
+      return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
+    })
+    .forEach((file) => {
+      const Middleware = require(path.join(__dirname, file));
+      middlewares[file.slice(0, -3)] = new Middleware(crowi);
+    });
+
+  return middlewares;
+};
+
+module.exports = initMiddlewares;

+ 13 - 0
src/server/models/ErrorV3.js

@@ -0,0 +1,13 @@
+class ErrorV3 extends Error {
+
+  constructor(message = '', code = '') {
+    super(); // do not provide message to the super constructor
+    this.message = message;
+    this.code = code;
+  }
+
+}
+
+module.exports = function(crowi) {
+  return ErrorV3;
+};

+ 3 - 0
src/server/models/index.js

@@ -15,4 +15,7 @@ module.exports = {
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationSetting: require('./GlobalNotificationSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
   GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
+
+  // non-persistent models
+  ErrorV3: require('./ErrorV3'),
 };
 };

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

@@ -1277,7 +1277,7 @@ module.exports = function(crowi) {
     return pageData;
     return pageData;
   };
   };
 
 
-  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, selectedGroupId) {
+  pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {
     const Page = mongoose.model('Page');
     const Page = mongoose.model('Page');
 
 
     const pages = await this.find({ grantedGroup: deletedGroup });
     const pages = await this.find({ grantedGroup: deletedGroup });
@@ -1295,7 +1295,7 @@ module.exports = function(crowi) {
         break;
         break;
       case 'transfer':
       case 'transfer':
         await Promise.all(pages.map((page) => {
         await Promise.all(pages.map((page) => {
-          return Page.transferPageToGroup(page, selectedGroupId);
+          return Page.transferPageToGroup(page, transferToUserGroupId);
         }));
         }));
         break;
         break;
       default:
       default:
@@ -1309,17 +1309,17 @@ module.exports = function(crowi) {
     await page.save();
     await page.save();
   };
   };
 
 
-  pageSchema.statics.transferPageToGroup = async function(page, selectedGroupId) {
+  pageSchema.statics.transferPageToGroup = async function(page, transferToUserGroupId) {
     const UserGroup = mongoose.model('UserGroup');
     const UserGroup = mongoose.model('UserGroup');
 
 
     // check page existence
     // check page existence
-    const isExist = await UserGroup.count({ _id: selectedGroupId }) > 0;
+    const isExist = await UserGroup.count({ _id: transferToUserGroupId }) > 0;
     if (isExist) {
     if (isExist) {
-      page.grantedGroup = selectedGroupId;
+      page.grantedGroup = transferToUserGroupId;
       await page.save();
       await page.save();
     }
     }
     else {
     else {
-      throw new Error('Cannot find the group to which private pages belong to. _id: ', selectedGroupId);
+      throw new Error('Cannot find the group to which private pages belong to. _id: ', transferToUserGroupId);
     }
     }
   };
   };
 
 

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

@@ -90,7 +90,7 @@ class UserGroup {
   }
   }
 
 
   // グループの完全削除
   // グループの完全削除
-  static async removeCompletelyById(deleteGroupId, action, selectedGroupId) {
+  static async removeCompletelyById(deleteGroupId, action, transferToUserGroupId) {
     const PageGroupRelation = mongoose.model('PageGroupRelation');
     const PageGroupRelation = mongoose.model('PageGroupRelation');
     const UserGroupRelation = mongoose.model('UserGroupRelation');
     const UserGroupRelation = mongoose.model('UserGroupRelation');
     const Page = mongoose.model('Page');
     const Page = mongoose.model('Page');
@@ -104,7 +104,7 @@ class UserGroup {
     await Promise.all([
     await Promise.all([
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
       UserGroupRelation.removeAllByUserGroup(deletedGroup),
       PageGroupRelation.removeAllByUserGroup(deletedGroup),
       PageGroupRelation.removeAllByUserGroup(deletedGroup),
-      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, selectedGroupId),
+      Page.handlePrivatePagesForDeletedGroup(deletedGroup, action, transferToUserGroupId),
     ]);
     ]);
 
 
     return deletedGroup;
     return deletedGroup;

+ 9 - 53
src/server/routes/admin.js

@@ -667,7 +667,12 @@ module.exports = function(crowi, app) {
           return new Promise((resolve, reject) => {
           return new Promise((resolve, reject) => {
             UserGroupRelation.findAllRelationForUserGroup(userGroup)
             UserGroupRelation.findAllRelationForUserGroup(userGroup)
               .then((relations) => {
               .then((relations) => {
-                return resolve([userGroup, relations]);
+                return resolve({
+                  id: userGroup._id,
+                  relatedUsers: relations.map((relation) => {
+                    return relation.relatedUser;
+                  }),
+                });
               });
               });
           });
           });
         });
         });
@@ -676,7 +681,9 @@ module.exports = function(crowi, app) {
         return Promise.all(allRelationsPromise);
         return Promise.all(allRelationsPromise);
       })
       })
       .then((relations) => {
       .then((relations) => {
-        renderVar.userGroupRelations = new Map(relations);
+        for (const relation of relations) {
+          renderVar.userGroupRelations[relation.id] = relation.relatedUsers;
+        }
         debug('in findUserGroupsWithPagination findAllRelationForUserGroupResult', renderVar.userGroupRelations);
         debug('in findUserGroupsWithPagination findAllRelationForUserGroupResult', renderVar.userGroupRelations);
         return res.render('admin/user-groups', renderVar);
         return res.render('admin/user-groups', renderVar);
       })
       })
@@ -720,29 +727,6 @@ module.exports = function(crowi, app) {
     return res.render('admin/user-group-detail', renderVar);
     return res.render('admin/user-group-detail', renderVar);
   };
   };
 
 
-  // グループの生成
-  actions.userGroup.create = function(req, res) {
-    const form = req.form.createGroupForm;
-    if (req.form.isValid) {
-      const userGroupName = crowi.xss.process(form.userGroupName);
-
-      UserGroup.createGroupByName(userGroupName)
-        .then((newUserGroup) => {
-          req.flash('successMessage', newUserGroup.name);
-          req.flash('createdUserGroup', newUserGroup);
-          return res.redirect('/admin/user-groups');
-        })
-        .catch((err) => {
-          debug('create userGroup error:', err);
-          req.flash('errorMessage', '同じグループ名が既に存在します。');
-        });
-    }
-    else {
-      req.flash('errorMessage', req.form.errors.join('\n'));
-      return res.redirect('/admin/user-groups');
-    }
-  };
-
   //
   //
   actions.userGroup.update = function(req, res) {
   actions.userGroup.update = function(req, res) {
     const userGroupId = req.params.userGroupId;
     const userGroupId = req.params.userGroupId;
@@ -778,23 +762,6 @@ module.exports = function(crowi, app) {
       });
       });
   };
   };
 
 
-
-  // app.post('/_api/admin/user-group/delete' , admin.userGroup.removeCompletely);
-  actions.userGroup.removeCompletely = async(req, res) => {
-    const { deleteGroupId, actionName, selectedGroupId } = req.body;
-
-    try {
-      await UserGroup.removeCompletelyById(deleteGroupId, actionName, selectedGroupId);
-      req.flash('successMessage', '削除しました');
-    }
-    catch (err) {
-      debug('Error while removing userGroup.', err, deleteGroupId);
-      req.flash('errorMessage', '完全な削除に失敗しました。');
-    }
-
-    return res.redirect('/admin/user-groups');
-  };
-
   actions.userGroupRelation = {};
   actions.userGroupRelation = {};
   actions.userGroupRelation.index = function(req, res) {
   actions.userGroupRelation.index = function(req, res) {
 
 
@@ -1327,17 +1294,6 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
 
 
-  actions.api.userGroups = async(req, res) => {
-    try {
-      const userGroups = await UserGroup.find();
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.json(ApiResponse.error('Error'));
-    }
-  };
-
   /**
   /**
    * save settings, update config cache, and response json
    * save settings, update config cache, and response json
    *
    *

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

@@ -7,6 +7,15 @@ const express = require('express');
 const router = express.Router();
 const router = express.Router();
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
+
+  // add custom functions to express response
+  require('./response')(express, crowi);
+
   router.use('/healthcheck', require('./healthcheck')(crowi));
   router.use('/healthcheck', require('./healthcheck')(crowi));
+
+  router.use('/user-groups', require('./user-group')(crowi));
+
+  router.use('/user-group-relations', require('./user-group-relation')(crowi));
+
   return router;
   return router;
 };
 };

+ 36 - 0
src/server/routes/apiv3/response.js

@@ -0,0 +1,36 @@
+const toArrayIfNot = require('../../../lib/util/toArrayIfNot');
+
+const addCustomFunctionToResponse = (express, crowi) => {
+  const { ErrorV3 } = crowi.models;
+
+  express.response.apiv3 = function(obj) { // not arrow function
+    // obj must be object
+    if (typeof obj !== 'object' || obj instanceof Array) {
+      throw new Error('invalid value supplied to res.apiv3');
+    }
+
+    this.json({ data: obj });
+  };
+
+  express.response.apiv3Err = function(_err, status = 400) { // not arrow function
+    if (!Number.isInteger(status)) {
+      throw new Error('invalid status supplied to res.apiv3Err');
+    }
+
+    let errors = toArrayIfNot(_err);
+    errors = errors.map((e) => {
+      if (e instanceof ErrorV3) {
+        return e;
+      }
+      if (typeof e === 'string') {
+        return { message: e };
+      }
+
+      throw new Error('invalid error supplied to res.apiv3Err');
+    });
+
+    this.status(status).json({ errors });
+  };
+};
+
+module.exports = addCustomFunctionToResponse;

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

@@ -0,0 +1,106 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:user-group-relation'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+const {
+  loginRequired,
+  adminRequired,
+} = require('../../util/middlewares');
+
+
+module.exports = (crowi) => {
+  const { ErrorV3, UserGroup, UserGroupRelation } = crowi.models;
+
+  router.get('/', loginRequired(crowi), adminRequired(), async(req, res) => {
+    // TODO: filter with querystring? or body
+    try {
+      const page = parseInt(req.query.page) || 1;
+      const result = await UserGroup.findUserGroupsWithPagination({ page });
+      // const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+      const userGroups = result.docs;
+
+      const userGroupRelationsObj = {};
+      await Promise.all(userGroups.map(async(userGroup) => {
+        const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+        userGroupRelationsObj[userGroup._id] = userGroupRelations.map((userGroupRelation) => {
+          return userGroupRelation.relatedUser;
+        });
+      }));
+
+      const data = {
+        userGroupRelations: userGroupRelationsObj,
+      };
+
+      return res.apiv3(data);
+    }
+    catch (err) {
+      const msg = 'Error occurred in fetching user group relations';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-relation-list-fetch-failed'));
+    }
+  });
+
+  return router;
+};
+
+// const MAX_PAGE_LIST = 50;
+
+// function createPager(total, limit, page, pagesCount, maxPageList) {
+//   const pager = {
+//     page,
+//     pagesCount,
+//     pages: [],
+//     total,
+//     previous: null,
+//     previousDots: false,
+//     next: null,
+//     nextDots: false,
+//   };
+
+//   if (page > 1) {
+//     pager.previous = page - 1;
+//   }
+
+//   if (page < pagesCount) {
+//     pager.next = page + 1;
+//   }
+
+//   let pagerMin = Math.max(1, Math.ceil(page - maxPageList / 2));
+//   let pagerMax = Math.min(pagesCount, Math.floor(page + maxPageList / 2));
+//   if (pagerMin === 1) {
+//     if (MAX_PAGE_LIST < pagesCount) {
+//       pagerMax = MAX_PAGE_LIST;
+//     }
+//     else {
+//       pagerMax = pagesCount;
+//     }
+//   }
+//   if (pagerMax === pagesCount) {
+//     if ((pagerMax - MAX_PAGE_LIST) < 1) {
+//       pagerMin = 1;
+//     }
+//     else {
+//       pagerMin = pagerMax - MAX_PAGE_LIST;
+//     }
+//   }
+
+//   pager.previousDots = null;
+//   if (pagerMin > 1) {
+//     pager.previousDots = true;
+//   }
+
+//   pager.nextDots = null;
+//   if (pagerMax < pagesCount) {
+//     pager.nextDots = true;
+//   }
+
+//   for (let i = pagerMin; i <= pagerMax; i++) {
+//     pager.pages.push(i);
+//   }
+
+//   return pager;
+// }

+ 107 - 0
src/server/routes/apiv3/user-group.js

@@ -0,0 +1,107 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body, param, query } = require('express-validator/check');
+
+const {
+  csrfVerify,
+  loginRequired,
+  adminRequired,
+} = require('../../util/middlewares');
+
+const validator = {};
+
+module.exports = (crowi) => {
+  const { ErrorV3, UserGroup, UserGroupRelation } = crowi.models;
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  router.get('/', loginRequired(crowi), adminRequired(), async(req, res) => {
+    // TODO: filter with querystring
+    try {
+      const userGroups = await UserGroup.find();
+      return res.apiv3({ userGroups });
+    }
+    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'));
+    }
+  });
+
+  validator.create = [
+    body('name', 'Group name is required').trim().exists(),
+  ];
+
+  router.post('/', loginRequired(crowi), adminRequired(), csrfVerify(crowi), validator.create, ApiV3FormValidator, async(req, res) => {
+    const { name } = req.body;
+
+    try {
+      const userGroupName = crowi.xss.process(name);
+      const userGroup = await UserGroup.createGroupByName(userGroupName);
+
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in creating a user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-create-failed'));
+    }
+  });
+
+  validator.delete = [
+    param('id').trim().exists(),
+    query('actionName').trim().exists(),
+    query('transferToUserGroupId').trim(),
+  ];
+
+  router.delete('/:id', loginRequired(crowi), adminRequired(), csrfVerify(crowi), validator.delete, ApiV3FormValidator, async(req, res) => {
+    const { id: deleteGroupId } = req.params;
+    const { actionName, transferToUserGroupId } = req.query;
+
+    try {
+      const userGroup = await UserGroup.removeCompletelyById(deleteGroupId, actionName, transferToUserGroupId);
+
+      return res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in deleting a user group';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-delete-failed'));
+    }
+  });
+
+  // return one group with the id
+  // router.get('/:id', async(req, res) => {
+  // });
+
+  // update one group with the id
+  // router.put('/:id/update', async(req, res) => {
+  // });
+
+  router.get('/:id/users', loginRequired(crowi), adminRequired(), async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+
+      const users = userGroupRelations.map((userGroupRelation) => {
+        return userGroupRelation.relatedUser;
+      });
+
+      return res.apiv3({ users });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching users for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-fetch-failed'));
+    }
+  });
+
+  return router;
+};

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

@@ -140,10 +140,7 @@ module.exports = function(crowi, app) {
   // user-groups admin
   // user-groups admin
   app.get('/admin/user-groups'             , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.index);
   app.get('/admin/user-groups'             , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.index);
   app.get('/admin/user-group-detail/:id'          , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.detail);
   app.get('/admin/user-group-detail/:id'          , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.detail);
-  app.post('/admin/user-group/create'      , form.admin.userGroupCreate, loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.create);
   app.post('/admin/user-group/:userGroupId/update', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.update);
   app.post('/admin/user-group/:userGroupId/update', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.update);
-  app.post('/admin/user-group.remove' , loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.removeCompletely);
-  app.get('/_api/admin/user-groups', loginRequired(crowi, app), middleware.adminRequired(), admin.api.userGroups);
 
 
   // user-group-relations admin
   // user-group-relations admin
   app.post('/admin/user-group-relation/create', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.create);
   app.post('/admin/user-group-relation/create', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.create);

+ 3 - 1
src/server/util/middlewares.js

@@ -4,7 +4,6 @@ const pathUtils = require('growi-commons').pathUtils;
 const md5 = require('md5');
 const md5 = require('md5');
 const entities = require('entities');
 const entities = require('entities');
 
 
-
 exports.csrfKeyGenerator = function(crowi, app) {
 exports.csrfKeyGenerator = function(crowi, app) {
   return function(req, res, next) {
   return function(req, res, next) {
     const csrfKey = (req.session && req.session.id) || 'anon';
     const csrfKey = (req.session && req.session.id) || 'anon';
@@ -318,3 +317,6 @@ exports.awsEnabled = function() {
     return next();
     return next();
   };
   };
 };
 };
+
+// don't add any more middlewares to this file.
+// all new middlewares should be an independent file under /server/routes/middlewares

+ 10 - 122
src/server/views/admin/user-groups.html

@@ -12,132 +12,20 @@
 
 
 {% block content_main %}
 {% block content_main %}
 <div class="content-main">
 <div class="content-main">
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
-  <div class="alert alert-success">
-    {{ smessage }}
-  </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
-  <div class="alert alert-danger">
-    {{ emessage }}
-  </div>
-  {% endif %}
-
   <div class="row">
   <div class="row">
     <div class="col-md-3">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'user-group'} %}
       {% include './widget/menu.html' with {current: 'user-group'} %}
     </div>
     </div>
-
-    <div class="col-md-9">
-      <p>
-        {% if isAclEnabled %}
-          <button  data-toggle="collapse" class="btn btn-default" href="#createGroupForm">{{ t('user_group_management.create_group') }}</button>
-        {% else %}
-          {{ t('user_group_management.deny_create_group')}}
-        {% endif %}
-      </p>
-      <form role="form" action="/admin/user-group/create" method="post">
-        <div id="createGroupForm" class="collapse">
-          <div class="form-group">
-            <label for="createGroupForm[userGroupName]">{{ t('user_group_management.group_name') }}</label>
-            <textarea class="form-control" name="createGroupForm[userGroupName]" placeholder="{{t('user_group_management.group_example')}}"></textarea>
-          </div>
-          <button type="submit" class="btn btn-primary">{{ t('Create') }}</button>
-        </div>
-        <input type="hidden" name="_csrf" value="{{ csrf() }}">
-      </form>
-
-      {% set createdUserGroup = req.flash('createdUserGroup') %}
-      {% if createdUserGroup.length %}
-      <div class="modal fade in" id="createdGroupModal">
-        <div class="modal-dialog">
-          <div class="modal-content">
-
-            <div class="modal-header">
-              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-              <h4 class="modal-title">{{ t('user_group_management.created_group') }}</h4>
-            </div>
-
-            <div class="modal-body">
-              <p>
-                {{ t('user_group_management.add_user') }}
-              </p>
-
-              <pre>{{ createdUserGroup.name }}</pre>
-            </div>
-
-          </div><!-- /.modal-content -->
-        </div><!-- /.modal-dialog -->
-      </div><!-- /.modal -->
-      {% endif %}
-
-      <div class="modal fade" id="admin-delete-user-group-modal"></div>
-
-      <h2>{{ t('user_group_management.group_list') }}</h2>
-
-      <table class="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th>{{ t('Name') }}</th>
-            <th>{{ t('User') }}</th>
-            <th width="100px">{{ t('Created') }}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for sGroup in userGroups %}
-          {% set sGroupDetailPageUrl = '/admin/user-group-detail/' + sGroup._id.toString() %}
-          <tr>
-            {% if isAclEnabled %}
-              <td><a href="{{ sGroupDetailPageUrl }}">{{ sGroup.name | preventXss }}</a></td>
-            {% else %}
-              <td>{{ sGroup.name | preventXss }}</td>
-            {% endif %}
-            <td><ul class="list-inline">
-              {% for relation in userGroupRelations.get(sGroup) %}
-              <li class="list-inline-item badge badge-primary">{{relation.relatedUser.username}}</li>
-              {% endfor %}
-            </ul></td>
-            <td>{{ sGroup.createdAt|date('Y-m-d', sGroup.createdAt.getTimezoneOffset()) }}</td>
-            {% if isAclEnabled %}
-            <td>
-              <div class="btn-group admin-group-menu">
-                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
-                  <i class="icon-settings"></i> <span class="caret"></span>
-                </button>
-                <ul class="dropdown-menu" role="menu">
-                  <li>
-                    <a href="{{ sGroupDetailPageUrl }}">
-                      <i class="icon-fw icon-note"></i> {{ t('Edit') }}
-                    </a>
-                  </li>
-
-                  <li>
-                    <a href="#"
-                        data-user-group-id="{{ sGroup._id.toString() }}"
-                        data-user-group-name="{{ sGroup.name.toString() | encodeHTML }}"
-                        data-target="#admin-delete-user-group-modal"
-                        data-toggle="modal">
-                      <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
-                    </a>
-                  </li>
-
-                </ul>
-              </div>
-            </td>
-            {% else %}
-              <td></td>
-            {% endif %}
-          </tr>
-          {% endfor %}
-        </tbody>
-      </table>
-
-      {% include '../widget/pager.html' with {path: "/admin/user-groups", pager: pager} %}
-
+    <div
+      id ="admin-user-group-page"
+      class="col-md-9"
+      data-user-groups="{{ userGroups|json }}"
+      data-user-group-relations="{{ userGroupRelations|json }}"
+      data-isAclEnabled="{{ isAclEnabled }}"
+    >
+      <!-- Reactify Paginator start -->
+      <!-- {% include '../widget/pager.html' with {path: "/admin/user-groups", pager: pager} %} -->
+      <!-- Reactify Paginator end -->
     </div>
     </div>
   </div>
   </div>
 </div>
 </div>