Kaynağa Gözat

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

Imprv/reactify admin user groups detail
Sou Mizobuchi 6 yıl önce
ebeveyn
işleme
0764961ee2
26 değiştirilmiş dosya ile 1057 ekleme ve 328 silme
  1. 4 3
      resource/locales/en-US/translation.json
  2. 4 2
      resource/locales/ja/translation.json
  3. 14 0
      src/client/js/app.jsx
  4. 1 2
      src/client/js/components/Admin/UserGroup/UserGroupPage.jsx
  5. 51 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  6. 106 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  7. 87 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  8. 83 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  9. 43 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  10. 116 0
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  11. 1 1
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  12. 1 1
      src/client/js/components/PaginationWrapper.jsx
  13. 0 8
      src/client/js/legacy/crowi-admin.js
  14. 3 3
      src/client/js/services/AppContainer.js
  15. 135 0
      src/client/js/services/UserGroupDetailContainer.js
  16. 5 1
      src/server/middlewares/ApiV3FormValidator.js
  17. 2 0
      src/server/models/page.js
  18. 2 2
      src/server/models/user-group.js
  19. 1 111
      src/server/routes/admin.js
  20. 371 11
      src/server/routes/apiv3/user-group.js
  21. 0 5
      src/server/routes/index.js
  22. 18 0
      src/server/util/express-validator/sanitizer.js
  23. 2 0
      src/server/util/express-validator/validator.js
  24. 5 176
      src/server/views/admin/user-group-detail.html
  25. 1 1
      src/server/views/me/external-accounts.html
  26. 1 1
      src/server/views/widget/passport/ldap-association-tester.html

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

@@ -20,6 +20,7 @@
   "New": "New",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",
+  "add": "Add",
   "Undo": "Undo",
   "Article": "Article",
   "Page": "Page",
@@ -29,7 +30,6 @@
   "status":"Status",
   "account_id": "Account Id",
 
-
   "Update": "Update",
   "Update Page": "Update Page",
   "Warning": "Warning",
@@ -49,6 +49,7 @@
   "History": "History",
   "Presentation Mode": "Presentation",
 
+  "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
   "Last_Login": "Last Login",
@@ -705,6 +706,8 @@
   "user_group_management": {
     "group_list": "Group List",
     "back_to_list": "Go Back to Group List",
+    "basic_info": "Basic Info",
+    "user_list": "User List",
     "create_group": "Create New Group",
     "group_example": "e.g. : Group1",
     "created_group": "Group was created",
@@ -720,8 +723,6 @@
     "select_group": "Select a group",
     "no_groups": "No groups to select",
     "no_pages": "There are no pages the group has view permission",
-    "how_to_add1": "Enter a username to add",
-    "how_to_add2": "Select a user from user list",
     "remove_from_group": "Remove this user"
   },
 

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

@@ -20,6 +20,7 @@
   "New": "作成",
   "Shortcuts": "ショートカット",
   "eg": "例:",
+  "add": "追加",
   "Undo": "元に戻す",
   "Article": "記事",
   "Page": "ページ",
@@ -48,6 +49,7 @@
   "History": "更新履歴",
   "Presentation Mode": "プレゼンテーション",
 
+  "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
   "Last_Login": "最終ログイン",
@@ -688,6 +690,8 @@
   "user_group_management": {
     "group_list": "グループ一覧",
     "back_to_list": "グループ一覧に戻る",
+    "basic_info": "基本情報",
+    "user_list": "ユーザー一覧",
     "create_group": "新規グループの作成",
     "group_example": "例: Group1",
     "created_group": "グループを作成しました",
@@ -704,8 +708,6 @@
     "select_group": "グループを選択してください",
     "no_groups": "グループがありません",
     "no_pages": "グループが閲覧権限を保有するページはありません",
-    "how_to_add1": "ユーザー名を入力して追加",
-    "how_to_add2": "ユーザーを下のリストから選択",
     "remove_from_group": "グループから外す"
   },
 

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

@@ -32,6 +32,7 @@ import StaffCredit from './components/StaffCredit/StaffCredit';
 import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 
+import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import CustomCssEditor from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
 import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
@@ -46,6 +47,7 @@ import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
+import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 
 const logger = loggerFactory('growi:app');
@@ -148,6 +150,18 @@ Object.keys(componentMappings).forEach((key) => {
 });
 
 // render for admin
+const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
+if (adminUserGroupDetailElem != null) {
+  const userGroupDetailContainer = new UserGroupDetailContainer(appContainer);
+  ReactDOM.render(
+    <Provider inject={[userGroupDetailContainer]}>
+      <I18nextProvider i18n={i18n}>
+        <UserGroupDetailPage />
+      </I18nextProvider>
+    </Provider>,
+    adminUserGroupDetailElem,
+  );
+}
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {
   // get input[type=hidden] element

+ 1 - 2
src/client/js/components/Admin/UserGroup/UserGroupPage.jsx

@@ -155,8 +155,7 @@ class UserGroupPage extends React.Component {
           changePage={this.handlePage}
           totalItemsCount={this.state.totalUserGroups}
           pagingLimit={this.state.pagingLimit}
-        >
-        </PaginationWrapper>
+        />
         <UserGroupDeleteModal
           userGroups={this.state.userGroups}
           deleteUserGroup={this.state.selectedUserGroup}

+ 51 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx

@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import UserGroupEditForm from './UserGroupEditForm';
+import UserGroupUserTable from './UserGroupUserTable';
+import UserGroupUserModal from './UserGroupUserModal';
+import UserGroupPageList from './UserGroupPageList';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+
+class UserGroupDetailPage extends React.Component {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div>
+        <a href="/admin/user-groups" className="btn btn-default">
+          <i className="icon-fw ti-arrow-left" aria-hidden="true"></i>
+          {t('user_group_management.back_to_list')}
+        </a>
+        <div className="m-t-20 form-box">
+          <UserGroupEditForm />
+        </div>
+        <legend className="m-t-20">{ t('user_group_management.user_list') }</legend>
+        <UserGroupUserTable />
+        <UserGroupUserModal />
+        <legend className="m-t-20">{ t('Page') }</legend>
+        <div className="page-list">
+          <UserGroupPageList />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+UserGroupDetailPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupDetailPageWrapper = (props) => {
+  return createSubscribedElement(UserGroupDetailPage, props, [AppContainer]);
+};
+
+export default withTranslation()(UserGroupDetailPageWrapper);

+ 106 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx

@@ -0,0 +1,106 @@
+import React 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';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupEditForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: props.userGroupDetailContainer.state.userGroup.name,
+      nameCache: props.userGroupDetailContainer.state.userGroup.name, // cache for name. update every submit
+    };
+
+    this.xss = window.xss;
+
+    this.changeUserGroupName = this.changeUserGroupName.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeUserGroupName(event) {
+    this.setState({
+      name: event.target.value,
+    });
+  }
+
+  async handleSubmit(e) {
+    e.preventDefault();
+
+    try {
+      const res = await this.props.userGroupDetailContainer.updateUserGroup({
+        name: this.state.name,
+      });
+
+      toastSuccess(`Updated the group name to "${this.xss.process(res.data.userGroup.name)}"`);
+      this.setState({ nameCache: this.state.name });
+    }
+    catch (err) {
+      toastError(new Error('Unable to update the group name'));
+    }
+  }
+
+  validateForm() {
+    return (
+      this.state.name !== this.state.nameCache
+      && this.state.name !== ''
+    );
+  }
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <form className="form-horizontal" onSubmit={this.handleSubmit}>
+        <fieldset>
+          <legend>{ t('user_group_management.basic_info') }</legend>
+          <div className="form-group">
+            <label htmlFor="name" className="col-sm-2 control-label">{ t('Name') }</label>
+            <div className="col-sm-4">
+              <input className="form-control" type="text" name="name" value={this.state.name} onChange={this.changeUserGroupName} />
+            </div>
+          </div>
+          <div className="form-group">
+            <label className="col-sm-2 control-label">{ t('Created') }</label>
+            <div className="col-sm-4">
+              <input
+                type="text"
+                className="form-control"
+                value={dateFnsFormat(new Date(userGroupDetailContainer.state.userGroup.createdAt), 'YYYY-MM-DD')}
+                disabled
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <div className="col-sm-offset-2 col-sm-10">
+              <button type="submit" className="btn btn-primary" disabled={!this.validateForm()}>{ t('Update') }</button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    );
+  }
+
+}
+
+UserGroupEditForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupEditFormWrapper = (props) => {
+  return createSubscribedElement(UserGroupEditForm, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupEditFormWrapper);

+ 87 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -0,0 +1,87 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import Page from '../../PageList/Page';
+import PaginationWrapper from '../../PaginationWrapper';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastError } from '../../../util/apiNotification';
+
+class UserGroupPageList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      currentPages: [],
+      activePage: 1,
+      total: 0,
+      pagingLimit: 10,
+    };
+
+    this.handlePageChange = this.handlePageChange.bind(this);
+  }
+
+  async componentDidMount() {
+    await this.handlePageChange(this.state.activePage);
+  }
+
+  async handlePageChange(pageNum) {
+    const limit = this.state.pagingLimit;
+    const offset = (pageNum - 1) * limit;
+
+    try {
+      const res = await this.props.appContainer.apiv3.get(`/user-groups/${this.props.userGroupDetailContainer.state.userGroup._id}/pages`, {
+        limit,
+        offset,
+      });
+      const { total, pages } = res.data;
+
+      this.setState({
+        total,
+        activePage: pageNum,
+        currentPages: pages,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <Fragment>
+        <ul className="page-list-ul page-list-ul-flat">
+          {this.state.currentPages.map((page) => { return <Page key={page._id} page={page} /> })}
+        </ul>
+        {userGroupDetailContainer.state.relatedPages.length === 0 ? <p>{ t('user_group_management.no_pages') }</p> : null}
+        <PaginationWrapper
+          activePage={this.state.activePage}
+          changePage={this.handlePageChange}
+          totalItemsCount={this.state.total}
+          pagingLimit={this.state.pagingLimit}
+        />
+      </Fragment>
+    );
+  }
+
+}
+
+UserGroupPageList.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupPageListWrapper = (props) => {
+  return createSubscribedElement(UserGroupPageList, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupPageListWrapper);

+ 83 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -0,0 +1,83 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupUserFormByInput extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      username: '',
+    };
+
+    this.xss = window.xss;
+
+    this.changeUsername = this.changeUsername.bind(this);
+    this.addUserBySubmit = this.addUserBySubmit.bind(this);
+    this.validateForm = this.validateForm.bind(this);
+  }
+
+  changeUsername(e) {
+    this.setState({ username: e.target.value });
+  }
+
+  async addUserBySubmit(e) {
+    e.preventDefault();
+    const { username } = this.state;
+
+    try {
+      await this.props.userGroupDetailContainer.addUserByUsername(username);
+      toastSuccess(`Added "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
+      this.setState({ username: '' });
+    }
+    catch (err) {
+      toastError(new Error(`Unable to add "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
+    }
+  }
+
+  validateForm() {
+    return this.state.username !== '';
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <form className="form-inline" onSubmit={this.addUserBySubmit}>
+        <div className="form-group">
+          <input
+            type="text"
+            name="username"
+            className="form-control input-sm"
+            placeholder={t('username')}
+            value={this.state.username}
+            onChange={this.changeUsername}
+          />
+        </div>
+        <button type="submit" className="btn btn-sm btn-success" disabled={!this.validateForm()}>{ t('add') }</button>
+      </form>
+    );
+  }
+
+}
+
+UserGroupUserFormByInput.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserFormByInputWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserFormByInput, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserFormByInputWrapper);

+ 43 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import Modal from 'react-bootstrap/es/Modal';
+
+import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+
+class UserGroupUserModal extends React.Component {
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <Modal show={userGroupDetailContainer.state.isUserGroupUserModalOpen} onHide={userGroupDetailContainer.closeUserGroupUserModal}>
+        <Modal.Header closeButton>
+          <Modal.Title>{ t('user_group_management.add_user') }</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <UserGroupUserFormByInput />
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
+}
+
+UserGroupUserModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserModalWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserModal, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserModalWrapper);

+ 116 - 0
src/client/js/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import UserPicture from '../../User/UserPicture';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AppContainer from '../../../services/AppContainer';
+import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+class UserGroupUserTable extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.xss = window.xss;
+
+    this.removeUser = this.removeUser.bind(this);
+  }
+
+  async removeUser(username) {
+    try {
+      await this.props.userGroupDetailContainer.removeUserByUsername(username);
+      toastSuccess(`Removed "${this.xss.process(username)}" from "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
+    }
+    catch (err) {
+      // eslint-disable-next-line max-len
+      toastError(new Error(`Unable to remove "${this.xss.process(username)}" from "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
+    }
+  }
+
+  render() {
+    const { t, userGroupDetailContainer } = this.props;
+
+    return (
+      <table className="table table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="100px">#</th>
+            <th>
+              { t('username') }
+            </th>
+            <th>{ t('Name') }</th>
+            <th width="100px">{ t('Created') }</th>
+            <th width="160px">{ t('Last_Login')}</th>
+            <th width="70px"></th>
+          </tr>
+        </thead>
+        <tbody>
+          {userGroupDetailContainer.state.userGroupRelations.map((sRelation) => {
+              const { relatedUser } = sRelation;
+
+              return (
+                <tr key={sRelation._id}>
+                  <td>
+                    <UserPicture user={relatedUser} className="picture img-circle" />
+                  </td>
+                  <td>
+                    <strong>{relatedUser.username}</strong>
+                  </td>
+                  <td>{relatedUser.name}</td>
+                  <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'YYYY-MM-DD') : ''}</td>
+                  <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'YYYY-MM-DD HH:mm:ss') : ''}</td>
+                  <td>
+                    <div className="btn-group admin-user-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 onClick={() => { return this.removeUser(relatedUser.username) }}>
+                            <i className="icon-fw icon-user-unfollow"></i> { t('user_group_management.remove_from_group')}
+                          </a>
+                        </li>
+                      </ul>
+                    </div>
+                  </td>
+                </tr>
+              );
+            })}
+
+          <tr>
+            <td></td>
+            <td className="text-center">
+              <button className="btn btn-default" type="button" onClick={userGroupDetailContainer.openUserGroupUserModal}>
+                <i className="ti-plus"></i>
+              </button>
+            </td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+          </tr>
+
+        </tbody>
+      </table>
+    );
+  }
+
+}
+
+UserGroupUserTable.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  userGroupDetailContainer: PropTypes.instanceOf(UserGroupDetailContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const UserGroupUserTableWrapper = (props) => {
+  return createSubscribedElement(UserGroupUserTable, props, [AppContainer, UserGroupDetailContainer]);
+};
+
+export default withTranslation()(UserGroupUserTableWrapper);

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

@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 import Modal from 'react-bootstrap/es/Modal';
 
-import toastError from '../../../util/apiNotification';
+import { toastError } from '../../../util/apiNotification';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 

+ 1 - 1
src/client/js/components/PaginationWrapper.jsx

@@ -42,7 +42,7 @@ class PaginationWrapper extends React.Component {
 
     let paginationStart = activePage - 2;
     let maxViewPageNum = activePage + 2;
-    // pagiNation Number area size = 5 , pageNuber calculate in here
+    // if pagiNation Number area size = 5 , pageNumber is calculated here
     // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
     if (paginationStart < 1) {
       const diff = 1 - paginationStart;

+ 0 - 8
src/client/js/legacy/crowi-admin.js

@@ -61,14 +61,6 @@ $(() => {
     return false;
   });
 
-  $('form#user-group-relation-create').on('submit', function(e) {
-    $.post('/admin/user-group-relation/create', $(this).serialize(), (res) => {
-      $('#admin-add-user-group-relation-modal').modal('hide');
-      return;
-    });
-  });
-
-
   $('#pictureUploadForm input[name=userGroupPicture]').on('change', function() {
     const $form = $('#pictureUploadForm');
     const fd = new FormData($form[0]);

+ 3 - 3
src/client/js/services/AppContainer.js

@@ -332,7 +332,7 @@ export default class AppContainer extends Container {
     return this.apiv3Request('get', path, { params });
   }
 
-  async apiv3Post(path, params) {
+  async apiv3Post(path, params = {}) {
     if (!params._csrf) {
       params._csrf = this.csrfToken;
     }
@@ -340,7 +340,7 @@ export default class AppContainer extends Container {
     return this.apiv3Request('post', path, params);
   }
 
-  async apiv3Put(path, params) {
+  async apiv3Put(path, params = {}) {
     if (!params._csrf) {
       params._csrf = this.csrfToken;
     }
@@ -348,7 +348,7 @@ export default class AppContainer extends Container {
     return this.apiv3Request('put', path, params);
   }
 
-  async apiv3Delete(path, params) {
+  async apiv3Delete(path, params = {}) {
     if (!params._csrf) {
       params._csrf = this.csrfToken;
     }

+ 135 - 0
src/client/js/services/UserGroupDetailContainer.js

@@ -0,0 +1,135 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:UserGroupDetailContainer');
+
+/**
+ * Service container for admin user group detail page (UserGroupDetailPage.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class UserGroupDetailContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      // TODO: [SPA] get userGroup from props
+      userGroup: JSON.parse(document.getElementById('admin-user-group-detail').getAttribute('data-user-group')),
+      userGroupRelations: [],
+      relatedPages: [],
+      isUserGroupUserModalOpen: false,
+    };
+
+    this.init();
+
+    this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
+    this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
+    this.addUserByUsername = this.addUserByUsername.bind(this);
+    this.removeUserByUsername = this.removeUserByUsername.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'UserGroupDetailContainer';
+  }
+
+  /**
+   * retrieve user group data
+   */
+  async init() {
+    try {
+      const [
+        userGroupRelations,
+        relatedPages,
+      ] = await Promise.all([
+        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
+        this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
+      ]);
+
+      await this.setState({
+        userGroupRelations,
+        relatedPages,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+  /**
+   * update user group
+   *
+   * @memberOf UserGroupDetailContainer
+   * @param {object} param update param for user group
+   * @return {object} response object
+   */
+  async updateUserGroup(param) {
+    const res = await this.appContainer.apiv3.put(`/user-groups/${this.state.userGroup._id}`, param);
+    const { userGroup } = res.data;
+
+    await this.setState({ userGroup });
+
+    return res;
+  }
+
+  /**
+   * open a modal
+   *
+   * @memberOf UserGroupDetailContainer
+   */
+  async openUserGroupUserModal() {
+    await this.setState({ isUserGroupUserModalOpen: true });
+  }
+
+  /**
+   * close a modal
+   *
+   * @memberOf UserGroupDetailContainer
+   */
+  async closeUserGroupUserModal() {
+    await this.setState({ isUserGroupUserModalOpen: false });
+  }
+
+  /**
+   * update user group
+   *
+   * @memberOf UserGroupDetailContainer
+   * @param {string} username username of the user to be added to the group
+   */
+  async addUserByUsername(username) {
+    const res = await this.appContainer.apiv3.post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+    const { userGroupRelation } = res.data;
+
+    this.setState((prevState) => {
+      return {
+        userGroupRelations: [...prevState.userGroupRelations, userGroupRelation],
+      };
+    });
+  }
+
+  /**
+   * update user group
+   *
+   * @memberOf UserGroupDetailContainer
+   * @param {string} username username of the user to be removed from the group
+   */
+  async removeUserByUsername(username) {
+    const res = await this.appContainer.apiv3.delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+
+    this.setState((prevState) => {
+      return {
+        userGroupRelations: prevState.userGroupRelations.filter((u) => { return u._id !== res.data.userGroupRelation._id }),
+      };
+    });
+  }
+
+}

+ 5 - 1
src/server/middlewares/ApiV3FormValidator.js

@@ -7,13 +7,17 @@ class ApiV3FormValidator {
     const { ErrorV3 } = crowi.models;
 
     return (req, res, next) => {
+      logger.debug('req.query', req.query);
+      logger.debug('req.params', req.params);
+      logger.debug('req.body', req.body);
+
       const errObjArray = validationResult(req);
       if (errObjArray.isEmpty()) {
         return next();
       }
 
       const errs = errObjArray.array().map((err) => {
-        logger.error(`${err.param} in ${err.location}: ${err.msg}`);
+        logger.error(`${err.location}.${err.param}: ${err.value} - ${err.msg}`);
         return new ErrorV3(`${err.param}: ${err.msg}`, 'validation_failed');
       });
 

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

@@ -6,6 +6,7 @@
 const debug = require('debug')('growi:models:page');
 const nodePath = require('path');
 const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate');
 const uniqueValidator = require('mongoose-unique-validator');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
@@ -64,6 +65,7 @@ const pageSchema = new mongoose.Schema({
   toObject: { getters: true },
 });
 // apply plugins
+pageSchema.plugin(mongoosePaginate);
 pageSchema.plugin(uniqueValidator);
 
 

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

@@ -118,10 +118,10 @@ class UserGroup {
   }
 
   // グループ名の更新
-  updateName(name) {
+  async updateName(name) {
     // 名前を設定して更新
     this.name = name;
-    return this.save();
+    await this.save();
   }
 
 }

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

@@ -693,124 +693,14 @@ module.exports = function(crowi, app) {
   // グループ詳細
   actions.userGroup.detail = async function(req, res) {
     const userGroupId = req.params.id;
-    const renderVar = {
-      userGroup: null,
-      userGroupRelations: [],
-      notRelatedusers: [],
-      relatedPages: [],
-    };
-
     const userGroup = await UserGroup.findOne({ _id: userGroupId });
 
     if (userGroup == null) {
       logger.error('no userGroup is exists. ', userGroupId);
-      req.flash('errorMessage', 'グループがありません');
       return res.redirect('/admin/user-groups');
     }
-    renderVar.userGroup = userGroup;
-
-    const resolves = await Promise.all([
-      // get all user and group relations
-      UserGroupRelation.findAllRelationForUserGroup(userGroup),
-      // get all not related users for group
-      UserGroupRelation.findUserByNotRelatedGroup(userGroup),
-      // get all related pages
-      Page.find({ grant: Page.GRANT_USER_GROUP, grantedGroup: { $in: [userGroup] } }),
-    ]);
-    renderVar.userGroupRelations = resolves[0];
-    renderVar.notRelatedusers = resolves[1];
-    renderVar.relatedPages = resolves[2];
-
-    return res.render('admin/user-group-detail', renderVar);
-  };
-
-  //
-  actions.userGroup.update = function(req, res) {
-    const userGroupId = req.params.userGroupId;
-    const name = crowi.xss.process(req.body.name);
-
-    UserGroup.findById(userGroupId)
-      .then((userGroupData) => {
-        if (userGroupData == null) {
-          req.flash('errorMessage', 'グループの検索に失敗しました。');
-          return new Promise();
-        }
-
-        // 名前存在チェック
-        return UserGroup.isRegisterableName(name)
-          .then((isRegisterableName) => {
-          // 既に存在するグループ名に更新しようとした場合はエラー
-            if (!isRegisterableName) {
-              req.flash('errorMessage', 'グループ名が既に存在します。');
-            }
-            else {
-              return userGroupData.updateName(name)
-                .then(() => {
-                  req.flash('successMessage', 'グループ名を更新しました。');
-                })
-                .catch((err) => {
-                  req.flash('errorMessage', 'グループ名の更新に失敗しました。');
-                });
-            }
-          });
-      })
-      .then(() => {
-        return res.redirect(`/admin/user-group-detail/${userGroupId}`);
-      });
-  };
-
-  actions.userGroupRelation = {};
-  actions.userGroupRelation.index = function(req, res) {
 
-  };
-
-  actions.userGroupRelation.create = function(req, res) {
-    const User = crowi.model('User');
-    const UserGroup = crowi.model('UserGroup');
-    const UserGroupRelation = crowi.model('UserGroupRelation');
-
-    // req params
-    const userName = req.body.user_name;
-    const userGroupId = req.body.user_group_id;
-
-    let user = null;
-    let userGroup = null;
-
-    Promise.all([
-      // ユーザグループをIDで検索
-      UserGroup.findById(userGroupId),
-      // ユーザを名前で検索
-      User.findUserByUsername(userName),
-    ])
-      .then((resolves) => {
-        userGroup = resolves[0];
-        user = resolves[1];
-        // Relation を作成
-        UserGroupRelation.createRelation(userGroup, user);
-      })
-      .then((result) => {
-        return res.redirect(`/admin/user-group-detail/${userGroup.id}`);
-      })
-      .catch((err) => {
-        debug('Error on create user-group relation', err);
-        req.flash('errorMessage', 'Error on create user-group relation');
-        return res.redirect(`/admin/user-group-detail/${userGroup.id}`);
-      });
-  };
-
-  actions.userGroupRelation.remove = function(req, res) {
-    const UserGroupRelation = crowi.model('UserGroupRelation');
-    const userGroupId = req.params.id;
-    const relationId = req.params.relationId;
-
-    UserGroupRelation.removeById(relationId)
-      .then(() => {
-        return res.redirect(`/admin/user-group-detail/${userGroupId}`);
-      })
-      .catch((err) => {
-        debug('Error on remove user-group-relation', err);
-        req.flash('errorMessage', 'グループのユーザ削除に失敗しました。');
-      });
+    return res.render('admin/user-group-detail', { userGroup });
   };
 
   // Importer management

+ 371 - 11
src/server/routes/apiv3/user-group.js

@@ -7,9 +7,14 @@ const express = require('express');
 const router = express.Router();
 
 const { body, param, query } = require('express-validator/check');
+const { sanitizeQuery } = require('express-validator/filter');
 
 const validator = {};
 
+const { ObjectId } = require('mongoose').Types;
+
+const { toPagingLimit, toPagingOffset } = require('../../util/express-validator/sanitizer');
+
 /**
  * @swagger
  *  tags:
@@ -17,7 +22,13 @@ const validator = {};
  */
 
 module.exports = (crowi) => {
-  const { ErrorV3, UserGroup, UserGroupRelation } = crowi.models;
+  const {
+    ErrorV3,
+    UserGroup,
+    UserGroupRelation,
+    User,
+    Page,
+  } = crowi.models;
   const { ApiV3FormValidator } = crowi.middlewares;
 
   const {
@@ -33,7 +44,7 @@ module.exports = (crowi) => {
    *    /_api/v3/user-groups:
    *      get:
    *        tags: [UserGroup]
-   *        description: Gets usergroups
+   *        description: Get usergroups
    *        produces:
    *          - application/json
    *        responses:
@@ -63,7 +74,7 @@ module.exports = (crowi) => {
   });
 
   validator.create = [
-    body('name', 'Group name is required').trim().exists(),
+    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
   ];
 
   /**
@@ -113,8 +124,8 @@ module.exports = (crowi) => {
   });
 
   validator.delete = [
-    param('id').trim().exists(),
-    query('actionName').trim().exists(),
+    param('id').trim().exists({ checkFalsy: true }),
+    query('actionName').trim().exists({ checkFalsy: true }),
     query('transferToUserGroupId').trim(),
   ];
 
@@ -175,18 +186,75 @@ module.exports = (crowi) => {
   // router.get('/:id', async(req, res) => {
   // });
 
-  // update one group with the id
-  // router.put('/:id/update', async(req, res) => {
-  // });
+  validator.update = [
+    body('name', 'Group name is required').trim().exists({ checkFalsy: true }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}:
+   *      put:
+   *        tags: [UserGroup]
+   *        description: Update userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *        responses:
+   *          200:
+   *            description: userGroup is updated
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroup:
+   *                      type: object
+   *                      description: A result of `UserGroup.updateName`
+   */
+  router.put('/:id', loginRequired(), adminRequired, csrf, validator.update, ApiV3FormValidator, async(req, res) => {
+    const { id } = req.params;
+    const { name } = req.body;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      if (userGroup == null) {
+        throw new Error('The group does not exist');
+      }
+
+      // check if the new group name is available
+      const isRegisterableName = await UserGroup.isRegisterableName(name);
+      if (!isRegisterableName) {
+        throw new Error('The group name is already taken');
+      }
+
+      await userGroup.updateName(name);
+
+      res.apiv3({ userGroup });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating a user group name';
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-update-failed'));
+    }
+  });
+
+  validator.users = {};
 
   /**
    * @swagger
    *
    *  paths:
-   *    /_api/v3/user-groups/{:id/users}:
+   *    /_api/v3/user-groups/{:id}/users:
    *      get:
    *        tags: [UserGroup]
-   *        description: Gets the users related to the userGroup
+   *        description: Get users related to the userGroup
    *        produces:
    *          - application/json
    *        parameters:
@@ -224,7 +292,299 @@ module.exports = (crowi) => {
     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 res.apiv3Err(new ErrorV3(msg, 'user-group-user-list-fetch-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/unrelated-users:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get users unrelated to the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *        responses:
+   *          200:
+   *            description: users are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    users:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: user objects
+   */
+  router.get('/:id/unrelated-users', loginRequired(), adminRequired, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const users = await UserGroupRelation.findUserByNotRelatedGroup(userGroup);
+
+      return res.apiv3({ users });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching unrelated users for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-unrelated-user-list-fetch-failed'));
+    }
+  });
+
+  validator.users.post = [
+    param('id').trim().exists({ checkFalsy: true }),
+    param('username').trim().exists({ checkFalsy: true }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/users:
+   *      post:
+   *        tags: [UserGroup]
+   *        description: Add a user to the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *          - name: username
+   *            in: path
+   *            description: id of user
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: a user is added
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    user:
+   *                      type: object
+   *                      description: the user added to the group
+   *                    userGroup:
+   *                      type: object
+   *                      description: the group to which a user was added
+   *                    userGroupRelation:
+   *                      type: object
+   *                      description: the associative entity between user and userGroup
+   */
+  router.post('/:id/users/:username', loginRequired(), adminRequired, validator.users.post, ApiV3FormValidator, async(req, res) => {
+    const { id, username } = req.params;
+
+    try {
+      const [userGroup, user] = await Promise.all([
+        UserGroup.findById(id),
+        User.findUserByUsername(username),
+      ]);
+
+      const userGroupRelation = await UserGroupRelation.createRelation(userGroup, user);
+      await userGroupRelation.populate('relatedUser', User.USER_PUBLIC_FIELDS).execPopulate();
+
+      return res.apiv3({ user, userGroup, userGroupRelation });
+    }
+    catch (err) {
+      const msg = `Error occurred in adding the user "${username}" to group "${id}"`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-add-user-failed'));
+    }
+  });
+
+  validator.users.delete = [
+    param('id').trim().exists({ checkFalsy: true }),
+    param('username').trim().exists({ checkFalsy: true }),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/users:
+   *      delete:
+   *        tags: [UserGroup]
+   *        description: remove a user from the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *          - name: username
+   *            in: path
+   *            description: id of user
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: a user was removed
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    user:
+   *                      type: object
+   *                      description: the user removed from the group
+   *                    userGroup:
+   *                      type: object
+   *                      description: the group from which a user was removed
+   *                    userGroupRelation:
+   *                      type: object
+   *                      description: the associative entity between user and userGroup
+   */
+  router.delete('/:id/users/:username', loginRequired(), adminRequired, validator.users.delete, ApiV3FormValidator, async(req, res) => {
+    const { id, username } = req.params;
+
+    try {
+      const [userGroup, user] = await Promise.all([
+        UserGroup.findById(id),
+        User.findUserByUsername(username),
+      ]);
+
+      const userGroupRelation = await UserGroupRelation.findOne({ relatedUser: new ObjectId(user._id), relatedGroup: new ObjectId(userGroup._id) });
+      if (userGroupRelation == null) {
+        throw new Error(`Group "${id}" does not exist or user "${username}" does not belong to group "${id}"`);
+      }
+
+      await userGroupRelation.remove();
+
+      return res.apiv3({ user, userGroup, userGroupRelation });
+    }
+    catch (err) {
+      const msg = `Error occurred in removing the user "${username}" from group "${id}"`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-remove-user-failed'));
+    }
+  });
+
+  validator.userGroupRelations = {};
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/user-group-relations:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get the user group relations for the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *        responses:
+   *          200:
+   *            description: user group relations are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userGroupRelations:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: userGroupRelation objects
+   */
+  router.get('/:id/user-group-relations', loginRequired(), adminRequired, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userGroup = await UserGroup.findById(id);
+      const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
+
+      return res.apiv3({ userGroupRelations });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching user group relations for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-user-group-relation-list-fetch-failed'));
+    }
+  });
+
+  validator.pages = {};
+
+  validator.pages.get = [
+    param('id').trim().exists({ checkFalsy: true }),
+    sanitizeQuery('limit').customSanitizer(toPagingLimit),
+    sanitizeQuery('offset').customSanitizer(toPagingOffset),
+  ];
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /_api/v3/user-groups/{:id}/pages:
+   *      get:
+   *        tags: [UserGroup]
+   *        description: Get closed pages for the userGroup
+   *        produces:
+   *          - application/json
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            description: id of userGroup
+   *            schema:
+   *              type: ObjectId
+   *        responses:
+   *          200:
+   *            description: pages are fetched
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    pages:
+   *                      type: array
+   *                      items:
+   *                        type: object
+   *                      description: page objects
+   */
+  router.get('/:id/pages', loginRequired(), adminRequired, validator.pages.get, ApiV3FormValidator, async(req, res) => {
+    const { id } = req.params;
+    const { limit, offset } = req.query;
+
+    try {
+      const { docs, total } = await Page.paginate({
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: { $in: [id] },
+      }, {
+        offset,
+        limit,
+        populate: {
+          path: 'lastUpdateUser',
+          select: User.USER_PUBLIC_FIELDS,
+        },
+      });
+
+      const current = offset / limit + 1;
+
+      // TODO: create a common moudule for paginated response
+      return res.apiv3({ total, current, pages: docs });
+    }
+    catch (err) {
+      const msg = `Error occurred in fetching pages for group: ${id}`;
+      logger.error(msg, err);
+      return res.apiv3Err(new ErrorV3(msg, 'user-group-page-list-fetch-failed'));
     }
   });
 

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

@@ -134,11 +134,6 @@ module.exports = function(crowi, app) {
   // user-groups admin
   app.get('/admin/user-groups'                    , loginRequired(), adminRequired, admin.userGroup.index);
   app.get('/admin/user-group-detail/:id'          , loginRequired(), adminRequired, admin.userGroup.detail);
-  app.post('/admin/user-group/:userGroupId/update', loginRequired(), adminRequired, csrf, admin.userGroup.update);
-
-  // user-group-relations admin
-  app.post('/admin/user-group-relation/create', loginRequired(), adminRequired, csrf, admin.userGroupRelation.create);
-  app.post('/admin/user-group-relation/:id/remove-relation/:relationId', loginRequired(), adminRequired, csrf, admin.userGroupRelation.remove);
 
   // importer management for admin
   app.get('/admin/importer'                , loginRequired() , adminRequired , admin.importer.index);

+ 18 - 0
src/server/util/express-validator/sanitizer.js

@@ -0,0 +1,18 @@
+// custom sanitizers not covered by express-validator
+// https://github.com/validatorjs/validator.js#sanitizers
+
+const sanitizers = {};
+
+sanitizers.toPagingLimit = (_value) => {
+  const value = parseInt(_value);
+  // eslint-disable-next-line no-restricted-globals
+  return !isNaN(value) && isFinite(value) ? value : 20;
+};
+
+sanitizers.toPagingOffset = (_value) => {
+  const value = parseInt(_value);
+  // eslint-disable-next-line no-restricted-globals
+  return !isNaN(value) && isFinite(value) ? value : 0;
+};
+
+module.exports = sanitizers;

+ 2 - 0
src/server/util/express-validator/validator.js

@@ -0,0 +1,2 @@
+// custom validators not covered by express-validator
+// https://github.com/validatorjs/validator.js#validators

+ 5 - 176
src/server/views/admin/user-group-detail.html

@@ -12,184 +12,15 @@
 
 {% block 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="col-md-3">
       {% include './widget/menu.html' with {current: 'user-group'} %}
     </div>
-
-    <div class="col-md-9">
-      <a href="/admin/user-groups" class="btn btn-default">
-        <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
-        {{ t('user_group_management.back_to_list') }}
-      </a>
-
-      <div class="modal fade" id="admin-add-user-group-relation-modal">
-        <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.add_user') }}
-              </h4>
-            </div>
-
-            <div class="modal-body">
-              <p>
-                <strong>{{ t('Method') }}1.</strong> {{ t('user_group_management.how_to_add1') }}
-              </p>
-              <form class="form-inline" role="form" action="/admin/user-group-relation/create" method="post">
-                <div class="form-group">
-                  <input type="text" name="user_name" class="form-control input-sm" id="inputRelatedUserName" placeholder="{{ t('User Name')}}">
-                </div>
-                <input type="hidden" name="user_group_id" value="{{userGroup.id}}">
-                <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" class="btn btn-sm btn-success">{{ t('Add') }}</button>
-              </form>
-
-              {% if 0 < notRelatedusers.length %}
-              <hr>
-              <p>
-                <strong>{{ t('Method') }}2.</strong> {{ t('user_group_management.how_to_add2') }}
-              </p>
-
-              <ul class="list-inline">
-                {% for sUser in notRelatedusers %}
-                <li>
-                  <form role="form" action="/admin/user-group-relation/create" method="post">
-                    <!-- <input type="hidden" name="user_name" value="{{sUser.username}}"> -->
-                    <input type="hidden" name="user_group_id" value="{{userGroup.id}}">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                    <button type="submit" name="user_name" value="{{sUser.username}}" class="btn btn-xs btn-primary">{{sUser.username}}</button>
-                  </form>
-                </li>
-                {% endfor %}
-              </ul>
-              {% endif %}
-
-            </div>
-
-          </div>
-          <!-- /.modal-content -->
-        </div>
-        <!-- /.modal-dialog -->
-      </div>
-
-      <div class="m-t-20 form-box">
-        <form action="/admin/user-group/{{userGroup.id}}/update" method="post" class="form-horizontal" role="form">
-          <fieldset>
-            <legend>{{ t('Basic Settings') }}</legend>
-            <div class="form-group">
-              <label for="name" class="col-sm-2 control-label">{{ t('Name') }}</label>
-              <div class="col-sm-4">
-                <input class="form-control" type="text" name="name" value="{{ userGroup.name }}" required>
-              </div>
-            </div>
-            <div class="form-group">
-              <label class="col-sm-2 control-label">{{ t('Created') }}</label>
-              <div class="col-sm-4">
-                <input class="form-control" type="text" disabled value="{{userGroup.createdAt|datetz('Y-m-d') }}">
-              </div>
-            </div>
-            <div class="form-group">
-              <div class="col-sm-offset-2 col-sm-10">
-                <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-              </div>
-            </div>
-          </fieldset>
-        </form>
-      </div>
-
-      <legend class="m-t-20">{{ t('User List') }}</legend>
-
-      <table class="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th width="100px">#</th>
-            <th>
-              {{ t('User') }}
-            </th>
-            <th>{{ t('Name') }}</th>
-            <th width="100px">{{ t('Created') }}</th>
-            <th width="160px">{{ t('Last Login')}}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {% for sRelation in userGroupRelations %}
-          {% set sUser = sRelation.relatedUser%}
-          <tr>
-            <td>
-              <img src="{{ sRelation.relatedUser|picture }}" class="picture img-circle" />
-            </td>
-            <td>
-              <strong>{{ sRelation.relatedUser.username }}</strong>
-            </td>
-            <td>{{ sRelation.relatedUser.name }}</td>
-            <td>{{ sRelation.relatedUser.createdAt|datetz('Y-m-d') }}</td>
-            <td>
-              {% if sRelation.relatedUser.lastLoginAt %} {{ sRelation.relatedUser.lastLoginAt|datetz('Y-m-d H:i:s') }} {% endif %}
-            </td>
-            <td>
-              <div class="btn-group admin-user-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">
-                  <form id="form_removeFromGroup_{{ loop.index }}" action="/admin/user-group-relation/{{userGroup._id.toString()}}/remove-relation/{{ sRelation._id.toString() }}" method="post">
-                    <input type="hidden" name="_csrf" value="{{ csrf() }}">
-                  </form>
-                  <li>
-                    <a href="javascript:form_removeFromGroup_{{ loop.index }}.submit()">
-                      <i class="icon-fw icon-user-unfollow"></i> {{ t('user_group_management.remove_from_group')}}
-                    </a>
-                  </li>
-                </ul>
-              </div>
-            </td>
-          </tr>
-          {% endfor %}
-
-          {% if 0 < notRelatedusers.length %}
-          <tr>
-            <td></td>
-            <td class="text-center">
-              <button class="btn btn-default" data-target="#admin-add-user-group-relation-modal" data-toggle="modal">
-                <i class="ti-plus"></i>
-              </button>
-            </td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-          </tr>
-          {% endif %}
-        </tbody>
-      </table>
-
-      <!-- {% include '../widget/pager.html' with {path: "/admin/user-group-detail", pager: pager} %} -->
-
-      <legend class="m-t-20">{{ t('Page') }}</legend>
-
-      <div class="page-list">
-        {% if relatedPages.length == 0 %}<p>{{ t('user_group_management.no_pages') }}</p>{% endif %}
-        {% include '../widget/page_list.html' with { pages: relatedPages } %}
-      </div>
-
+    <div
+      id="admin-user-group-detail"
+      class="col-md-9"
+      data-user-group="{{ userGroup|json }}"
+    >
     </div>
   </div>
 </div>
@@ -197,5 +28,3 @@
 
 {% block content_footer %}
 {% endblock content_footer %}
-
-

+ 1 - 1
src/server/views/me/external-accounts.html

@@ -143,7 +143,7 @@
                 <div class="clearfix">
                   <button type="button" class="btn btn-info pull-right" onclick="associateLdap()">
                     <i class="fa fa-plus-circle" aria-hidden="true"></i>
-                    {{ t('Add') }}
+                    {{ t('add') }}
                   </button>
                 </div>
               </div>

+ 1 - 1
src/server/views/widget/passport/ldap-association-tester.html

@@ -2,7 +2,7 @@
   <div class="alert-container"></div>
   <fieldset>
     <div class="form-group">
-      <label for="username" class="col-xs-3 control-label">{{ t('Username') }}</label>
+      <label for="username" class="col-xs-3 control-label">{{ t('username') }}</label>
       <div class="col-xs-6">
         <input class="form-control" name="loginForm[username]">
       </div>