Răsfoiți Sursa

Merge remote-tracking branch 'origin/feat/grouping-users' into rc/3.0.0

# Conflicts:
#	lib/views/admin/widget/menu.html
#	resource/css/_admin.scss
#	resource/js/legacy/crowi-admin.js
Yuki Takei 8 ani în urmă
părinte
comite
8e7dafba32

+ 8 - 0
lib/form/admin/userGroupCreate.js

@@ -0,0 +1,8 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('createGroupForm[userGroupName]', '新規グループ名').trim().required()
+);

+ 1 - 0
lib/form/index.js

@@ -32,5 +32,6 @@ module.exports = {
     userInvite: require('./admin/userInvite'),
     slackIwhSetting: require('./admin/slackIwhSetting'),
     slackSetting: require('./admin/slackSetting'),
+    userGroupCreate: require('./admin/userGroupCreate'),
   },
 };

+ 2 - 0
lib/locales/en-US/translation.json

@@ -76,10 +76,12 @@
 
   "Table of Contents": "Table of Contents",
 
+  "UserGroup management": "UserGroup management",
   "Public": "Public",
   "Anyone with the link": "Anyone with the link",
   "Specified users only": "Specified users only",
   "Just me": "Just me",
+  "Only inside the group": "Only inside the group",
   "Shareable link": "Shareable link",
 
   "Show latest": "Show latest",

+ 2 - 0
lib/locales/ja/translation.json

@@ -81,6 +81,7 @@
   "Customize": "カスタマイズ",
   "Notification settings": "通知設定",
   "User management": "ユーザー管理",
+  "UserGroup management": "グループ管理",
   "Basic settings": "基本設定",
   "Basic authentication": "Basic認証",
   "Guest users access": "ゲストユーザーのアクセス",
@@ -90,6 +91,7 @@
   "Anyone with the link": "リンクを知っている人のみ",
   "Specified users": "特定ユーザーのみ",
   "Just me": "自分のみ",
+  "Only inside the group": "特定グループのみ",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Selecting authentication mechanism": "認証機構選択",

+ 3 - 0
lib/models/index.js

@@ -2,8 +2,11 @@
 
 module.exports = {
   Page: require('./page'),
+  PageGroupRelation: require('./page-group-relation'),
   User: require('./user'),
   ExternalAccount: require('./external-account'),
+  UserGroup: require('./user-group'),
+  UserGroupRelation: require('./user-group-relation'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
   Comment: require('./comment'),

+ 280 - 0
lib/models/page-group-relation.js

@@ -0,0 +1,280 @@
+const debug = require('debug')('crowi:models:pageGroupRelation');
+const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate');
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema({
+  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
+  targetPage: { type: ObjectId, ref: 'Page', required: true },
+  createdAt: { type: Date, default: Date.now },
+}, {
+  toJSON: { getters: true },
+  toObject: { getters: true }
+});
+// apply plugins
+schema.plugin(mongoosePaginate);
+
+
+/**
+ * PageGroupRelation Class
+ *
+ * @class PageGroupRelation
+ */
+class PageGroupRelation {
+
+    /**
+   * limit items num for pagination
+   *
+   * @readonly
+   * @static
+   * @memberof PageGroupRelation
+   */
+   static get PAGE_ITEMS() {
+     return 50;
+    }
+
+  static set crowi(crowi) {
+    this._crowi = crowi;
+  }
+
+  static get crowi() {
+    return this._crowi;
+  }
+
+  /**
+   * find all page and group relation
+   *
+   * @static
+   * @returns {Promise<PageGroupRelation[]>}
+   * @memberof PageGroupRelation
+   */
+  static findAllRelation() {
+
+    return this
+      .find()
+      .populate('targetPage')
+      .exec();
+  }
+
+  /**
+   * find all page and group relation for UserGroup
+   *
+   * @static
+   * @param {UserGroup} userGroup
+   * @returns {Promise<PageGroupRelation[]>}
+   * @memberof PageGroupRelation
+   */
+  static findAllRelationForUserGroup(userGroup) {
+    debug('findAllRelationForUserGroup is called', userGroup);
+
+    return this
+      .find({ relatedGroup: userGroup.id })
+      .populate('targetPage')
+      .exec();
+  }
+
+  /**
+   * find all entities with pagination
+   *
+   * @see https://github.com/edwardhotchkiss/mongoose-paginate
+   *
+   * @static
+   * @param {UserGroup} userGroup
+   * @param {any} opts mongoose-paginate options object
+   * @returns {Promise<any>} mongoose-paginate result object
+   * @memberof UserGroupRelation
+   */
+  static findPageGroupRelationsWithPagination(userGroup, opts) {
+    const query = { relatedGroup: userGroup };
+    const options = Object.assign({}, opts);
+    if (options.page == null) {
+      options.page = 1;
+    }
+    if (options.limit == null) {
+      options.limit = UserGroupRelation.PAGE_ITEMS;
+    }
+
+    return this.paginate(query, options)
+      .catch((err) => {
+        debug('Error on pagination:', err);
+      });
+  }
+
+  /**
+   * find the relation or create(if not exists) for page and group
+   *
+   * @static
+   * @param {Page} page
+   * @param {UserGroup} userGroup
+   * @returns {Promise<PageGroupRelation>}
+   * @memberof PageGroupRelation
+   */
+  static findOrCreateRelationForPageAndGroup(page, userGroup) {
+    const query = { targetPage: page.id, relatedGroup: userGroup.id };
+
+    return this
+      .count(query)
+      .then((count) => {
+        // return (0 < count);
+        if (0 < count) {
+          return this.find(query).exec();
+        }
+        else {
+          return this.createRelation(userGroup, page);
+        }
+      })
+      .catch((err) => {
+        debug('An Error occured.', err);
+        return reject(err);
+      });
+  }
+
+  /**
+   * find page and group relation for Page
+   *
+   * @static
+   * @param {Page} page
+   * @returns {Promise<PageGroupRelation[]>}
+   * @memberof PageGroupRelation
+   */
+  static findByPage(page) {
+
+    return this
+      .find({ targetPage: page.id })
+      .populate('relatedGroup')
+      .exec();
+  }
+
+  /**
+   * get is exists granted group for relatedPage and relatedUser
+   *
+   * @static
+   * @param {any} pageData relatedPage
+   * @param {any} userData relatedUser
+   * @returns is exists granted group(or not)
+   * @memberof PageGroupRelation
+   */
+  static isExistsGrantedGroupForPageAndUser(pageData, userData) {
+    var UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
+
+    return this.findByPage(pageData)
+      .then((pageRelations) => {
+        return pageRelations.map((pageRelation) => {
+          return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup)
+        });
+      })
+      .then((checkPromises) => {
+        return Promise.all(checkPromises)
+      })
+      .then((checkResults) => {
+        var checkResult = false;
+        checkResults.map((result) => {
+          if (result) {
+            checkResult = true;
+          }
+        });
+        return checkResult;
+      })
+      .catch((err) => {
+        return reject(err);
+      });
+  }
+
+  /**
+   * create page and group relation
+   *
+   * @static
+   * @param {any} userGroup
+   * @param {any} page
+   * @returns
+   * @memberof PageGroupRelation
+   */
+  static createRelation(userGroup, page) {
+    return this.create({
+      relatedGroup: userGroup.id,
+      targetPage: page.id,
+    });
+  };
+
+  /**
+   * remove all relation for UserGroup
+   *
+   * @static
+   * @param {UserGroup} userGroup related group for remove
+   * @returns {Promise<any>}
+   * @memberof PageGroupRelation
+   */
+  static removeAllByUserGroup(userGroup) {
+
+    return this.findAllRelationForUserGroup(userGroup)
+      .then((relations) => {
+        if (relations == null) {
+          return;
+        }
+        else {
+          relations.map((relation) => {
+            relation.remove();
+          });
+        }
+      });
+  }
+
+  /**
+   * remove all relation for Page
+   *
+   * @static
+   * @param {Page} page related page for remove
+   * @returns {Promise<any>}
+   * @memberof PageGroupRelation
+   */
+  static removeAllByPage(page) {
+
+    return this.findByPage(page)
+      .then((relations) => {
+        debug('remove relations are ', relations);
+        if (relations == null) {
+          return;
+        }
+        else {
+          relations.map((relation) => {
+            relation.remove();
+          });
+        }
+      });
+  }
+
+  /**
+   * remove relation by id
+   *
+   * @static
+   * @param {ObjectId} id for remove
+   * @returns {Promise<any>}
+   * @memberof PageGroupRelation
+   */
+  static removeById(id) {
+
+    return this.findById(id)
+      .then((relationData) => {
+        if (relationData == null) {
+          throw new Exception('PageGroupRelation data is not exists. id:', id);
+        }
+        else {
+          relationData.remove();
+        }
+      })
+      .catch((err) => {
+        debug('Error on find a removing page-group-relation', err);
+        return reject(err);
+      });
+  }
+}
+
+module.exports = function (crowi) {
+  PageGroupRelation.crowi = crowi;
+  schema.loadClass(PageGroupRelation);
+  return mongoose.model('PageGroupRelation', schema);
+}

+ 75 - 11
lib/models/page.js

@@ -7,6 +7,7 @@ module.exports = function(crowi) {
     , GRANT_RESTRICTED = 2
     , GRANT_SPECIFIED = 3
     , GRANT_OWNER = 4
+    , GRANT_USER_GROUP = 5
     , PAGE_GRANT_ERROR = 1
 
     , STATUS_WIP        = 'wip'
@@ -340,6 +341,7 @@ module.exports = function(crowi) {
     grantLabels[GRANT_PUBLIC]     = 'Public'; // 公開
     grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
     //grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
+    grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
     grantLabels[GRANT_OWNER]      = 'Just me'; // 自分のみ
 
     return grantLabels;
@@ -454,15 +456,26 @@ module.exports = function(crowi) {
 
   pageSchema.statics.findPageByIdAndGrantedUser = function(id, userData) {
     var Page = this;
+    var PageGroupRelation = crowi.model('PageGroupRelation');
+    var pageData = null;
 
     return new Promise(function(resolve, reject) {
       Page.findPageById(id)
-      .then(function(pageData) {
+      .then(function(result) {
+        pageData = result;
         if (userData && !pageData.isGrantedFor(userData)) {
+          return PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData);
+        }
+        else {
+          return true;
+        }
+      }).then((checkResult) => {
+        if (checkResult) {
+          return resolve(pageData);
+        }
+        else  {
           return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
         }
-
-        return resolve(pageData);
       }).catch(function(err) {
         return reject(err);
       });
@@ -472,6 +485,7 @@ module.exports = function(crowi) {
   // find page and check if granted user
   pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
     var self = this;
+    var PageGroupRelation = crowi.model('PageGroupRelation');
 
     return new Promise(function(resolve, reject) {
       self.findOne({path: path}, function(err, pageData) {
@@ -490,10 +504,22 @@ module.exports = function(crowi) {
         }
 
         if (!pageData.isGrantedFor(userData)) {
-          return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
+          PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData)
+            .then(function (checkResult) {
+              if (!checkResult) {
+                return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
+              } else {
+                // return resolve(pageData);
+                self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
+              }
+            })
+            .catch(function (err) {
+              return reject(err);
+            });
+        }
+        else {
+          self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
         }
-
-        self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
       });
     });
   };
@@ -758,12 +784,15 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.statics.updateGrant = function(page, grant, userData) {
+  pageSchema.statics.updateGrant = function (page, grant, userData, grantUserGroupId) {
     var Page = this;
 
+    if (grant == GRANT_USER_GROUP && grantUserGroupId == null) {
+      throw new Error('grant userGroupId is not specified');
+    }
     return new Promise(function(resolve, reject) {
       page.grant = grant;
-      if (grant == GRANT_PUBLIC) {
+      if (grant == GRANT_PUBLIC || grant == GRANT_USER_GROUP) {
         page.grantedUsers = [];
       } else {
         page.grantedUsers = [];
@@ -776,11 +805,38 @@ module.exports = function(crowi) {
           return reject(err);
         }
 
-        return resolve(data);
+        Page.updateGrantUserGroup(page, grant, grantUserGroupId, userData)
+        .then(() => {
+          return resolve(data);
+        });
       });
     });
   };
 
+  pageSchema.statics.updateGrantUserGroup = function (page, grant, grantUserGroupId, userData) {
+    var UserGroupRelation = crowi.model('UserGroupRelation');
+    var PageGroupRelation = crowi.model('PageGroupRelation');
+
+    // グループの場合
+    if (grant == GRANT_USER_GROUP) {
+      debug('grant is usergroup', grantUserGroupId);
+      return UserGroupRelation.findByGroupIdAndUser(grantUserGroupId, userData)
+      .then((relation) => {
+        if (relation == null) {
+          return reject(new Error('no relations were exist for group and user.'));
+        }
+        return PageGroupRelation.findOrCreateRelationForPageAndGroup(page, relation.relatedGroup);
+      })
+      .catch((err) => {
+        return reject(new Error('No UserGroup is exists. userGroupId : ', grantUserGroupId));
+      });
+    }
+    else {
+      return PageGroupRelation.removeAllByPage(page);
+    }
+
+  };
+
   // Instance method でいいのでは
   pageSchema.statics.pushToGrantedUsers = function(page, userData) {
 
@@ -837,7 +893,8 @@ module.exports = function(crowi) {
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
       , grant = options.grant || GRANT_PUBLIC
-      , redirectTo = options.redirectTo || null;
+      , redirectTo = options.redirectTo || null
+      , grantUserGroupId = options.grantUserGroupId || null;
 
     // force public
       if (isPortalPath(path)) {
@@ -867,6 +924,12 @@ module.exports = function(crowi) {
               return reject(err);
             }
 
+            if (newPage.grant == Page.GRANT_USER_GROUP && grantUserGroupId != null) {
+              Page.updateGrantUserGroup(newPage, grant, grantUserGroupId, user)
+              .catch((err) => {
+                return reject(err);
+              });
+            }
             var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
             Page.pushRevision(newPage, newRevision, user).then(function(data) {
               resolve(data);
@@ -884,6 +947,7 @@ module.exports = function(crowi) {
     var Page = this
       , Revision = crowi.model('Revision')
       , grant = options.grant || null
+      , grantUserGroupId = options.grantUserGroupId || null
       ;
     // update existing page
     var newRevision = Revision.prepareRevision(pageData, body, user);
@@ -892,7 +956,7 @@ module.exports = function(crowi) {
       Page.pushRevision(pageData, newRevision, user)
       .then(function(revision) {
         if (grant != pageData.grant) {
-          return Page.updateGrant(pageData, grant, user).then(function(data) {
+          return Page.updateGrant(pageData, grant, user, grantUserGroupId).then(function(data) {
             debug('Page grant update:', data);
             resolve(data);
             pageEvent.emit('update', data, user);

+ 279 - 0
lib/models/user-group-relation.js

@@ -0,0 +1,279 @@
+const debug = require('debug')('crowi:models:userGroupRelation');
+const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate');
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema({
+  relatedGroup: { type: ObjectId, ref: 'UserGroup', required: true },
+  relatedUser: { type: ObjectId, ref: 'User', required: true },
+  createdAt: { type: Date, default: Date.now, required: true },
+});
+schema.plugin(mongoosePaginate);
+
+/**
+ * UserGroupRelation Class
+ *
+ * @class UserGroupRelation
+ */
+class UserGroupRelation {
+
+  /**
+   * limit items num for pagination
+   *
+   * @readonly
+   * @static
+   * @memberof UserGroupRelation
+   */
+  static get PAGE_ITEMS() {
+    return 50;
+  }
+
+  static set crowi(crowi) {
+    this._crowi = crowi;
+  }
+
+  static get crowi() {
+    return this._crowi;
+  }
+
+  /**
+   * find all user and group relation
+   *
+   * @static
+   * @returns {Promise<UserGroupRelation[]>}
+   * @memberof UserGroupRelation
+   */
+  static findAllRelation() {
+
+    return this
+      .find()
+      .populate('relatedUser')
+      .populate('relatedGroup')
+      .exec();
+  };
+
+  /**
+   * find all user and group relation of UserGroup
+   *
+   * @static
+   * @param {UserGroup} userGroup
+   * @returns {Promise<UserGroupRelation[]>}
+   * @memberof UserGroupRelation
+   */
+  static findAllRelationForUserGroup(userGroup) {
+    debug('findAllRelationForUserGroup is called', userGroup);
+    var UserGroupRelation = this;
+
+    return this
+      .find({ relatedGroup: userGroup })
+      .populate('relatedUser')
+      .exec();
+  }
+
+  /**
+   * find all user and group relation of UserGroups
+   *
+   * @static
+   * @param {UserGroup[]} userGroups
+   * @returns {Promise<UserGroupRelation[]>}
+   * @memberof UserGroupRelation
+   */
+  static findAllRelationForUserGroups(userGroups) {
+
+    return this
+      .find({ relatedGroup: { $in: userGroups } })
+      .populate('relatedUser')
+      .exec();
+  }
+
+  /**
+   * find all user and group relation of User
+   *
+   * @static
+   * @param {User} user
+   * @returns {Promise<UserGroupRelation[]>}
+   * @memberof UserGroupRelation
+   */
+  static findAllRelationForUser(user) {
+
+    return this
+      .find({ relatedUser: user.id })
+      .populate('relatedGroup')
+      .exec();
+  }
+
+  /**
+   * find all entities with pagination
+   *
+   * @see https://github.com/edwardhotchkiss/mongoose-paginate
+   *
+   * @static
+   * @param {UserGroup} userGroup
+   * @param {any} opts mongoose-paginate options object
+   * @returns {Promise<any>} mongoose-paginate result object
+   * @memberof UserGroupRelation
+   */
+  static findUserGroupRelationsWithPagination(userGroup, opts) {
+    const query = { relatedGroup: userGroup };
+    const options = Object.assign({}, opts);
+    if (options.page == null) {
+      options.page = 1;
+    }
+    if (options.limit == null) {
+      options.limit = UserGroupRelation.PAGE_ITEMS;
+    }
+
+    return this.paginate(query, options)
+      .catch((err) => {
+        debug('Error on pagination:', err);
+      });
+  }
+
+  /**
+   * find one result by related group id and related user
+   *
+   * @static
+   * @param {string} userGroupId find query param for relatedGroup
+   * @param {User} userData find query param for relatedUser
+   * @returns {Promise<UserGroupRelation>}
+   * @memberof UserGroupRelation
+   */
+  static findByGroupIdAndUser(userGroupId, userData) {
+    const query = {
+      relatedGroup: userGroupId,
+      relatedUser: userData.id
+    }
+
+    return this
+      .findOne(query)
+      .populate('relatedUser')
+      .populate('relatedGroup')
+      .exec();
+  }
+
+  /**
+   * find all "not" related user for UserGroup
+   *
+   * @static
+   * @param {UserGroup} userGroup for find users not related
+   * @returns {Promise<User>}
+   * @memberof UserGroupRelation
+   */
+  static findUserByNotRelatedGroup(userGroup) {
+    const User = UserGroupRelation.crowi.model('User');
+
+    return this.findAllRelationForUserGroup(userGroup)
+      .then((relations) => {
+        const relatedUserIds = relations.map((relation) => {
+          return relation.relatedUser.id;
+        });
+        const query = { _id: { $nin: relatedUserIds }, status: User.STATUS_ACTIVE };
+
+        debug("findUserByNotRelatedGroup ", query);
+        return User.find(query).exec();
+      });
+  }
+
+  /**
+   * get if the user has relation for group
+   *
+   * @static
+   * @param {User} userData
+   * @param {UserGroup} userGroup
+   * @returns {Promise<boolean>} is user related for group(or not)
+   * @memberof UserGroupRelation
+   */
+  static isRelatedUserForGroup(userData, userGroup) {
+    const query = {
+      relatedGroup: userGroup.id,
+      relatedUser: userData.id
+    }
+
+    return this
+      .count(query)
+      .exec()
+      .then((count) => {
+        // return true or false of the relation is exists(not count)
+        return (0 < count);
+      })
+      .catch((err) => {
+        debug('An Error occured.', err);
+        reject(err);
+      });
+  }
+
+  /**
+   * create user and group relation
+   *
+   * @static
+   * @param {UserGroup} userGroup
+   * @param {User} user
+   * @returns {Promise<UserGroupRelation>} created relation
+   * @memberof UserGroupRelation
+   */
+  static createRelation(userGroup, user) {
+    return this.create({
+      relatedGroup: userGroup.id,
+      relatedUser: user.id
+    });
+  }
+
+  /**
+   * remove all relation for UserGroup
+   *
+   * @static
+   * @param {UserGroup} userGroup related group for remove
+   * @returns {Promise<any>}
+   * @memberof UserGroupRelation
+   */
+  static removeAllByUserGroup(userGroup) {
+
+    return this.findAllRelationForUserGroup(userGroup)
+      .then((relations) => {
+        if (relations == null) {
+          return;
+        }
+        else {
+          relations.map((relation) => {
+            relation.remove();
+          });
+        }
+      });
+  }
+
+  /**
+   * remove relation by id
+   *
+   * @static
+   * @param {ObjectId} id
+   * @returns {Promise<any>}
+   * @memberof UserGroupRelation
+   */
+  static removeById(id) {
+
+    return this.findById(id)
+      .then((relationData) => {
+        if (relationData == null) {
+          throw new Exception('UserGroupRelation data is not exists. id:', id);
+        }
+        else {
+          relationData.remove();
+        }
+      })
+      .catch((err) => {
+        debug('Error on find a removing user-group-relation', err);
+        reject(err);
+      });
+  }
+
+}
+
+module.exports = function (crowi) {
+  UserGroupRelation.crowi = crowi;
+  schema.loadClass(UserGroupRelation);
+  return mongoose.model('UserGroupRelation', schema);
+}

+ 151 - 0
lib/models/user-group.js

@@ -0,0 +1,151 @@
+const debug = require('debug')('crowi:models:userGroup');
+const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate');
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema({
+  userGroupId: String,
+  image: String,
+  name: { type: String, required: true, unique: true },
+  createdAt: { type: Date, default: Date.now },
+});
+schema.plugin(mongoosePaginate);
+
+class UserGroup {
+
+  /**
+   * public fields for UserGroup model
+   *
+   * @readonly
+   * @static
+   * @memberof UserGroup
+   */
+  static get USER_GROUP_PUBLIC_FIELDS() {
+    return '_id image name createdAt';
+  }
+
+  /**
+   * limit items num for pagination
+   *
+   * @readonly
+   * @static
+   * @memberof UserGroup
+   */
+  static get PAGE_ITEMS() {
+    return 10;
+  }
+
+  /*
+   * model static methods
+   */
+
+   // グループ画像パスの生成
+  static createUserGroupPictureFilePath(userGroup, name) {
+    var ext = '.' + name.match(/(.*)(?:\.([^.]+$))/)[2];
+
+    return 'userGroup/' + userGroup._id + ext;
+  };
+
+  // すべてのグループを取得(オプション指定可)
+  static findAllGroups(option) {
+
+    return this.find().exec();
+  };
+
+  /**
+   * find all entities with pagination
+   *
+   * @see https://github.com/edwardhotchkiss/mongoose-paginate
+   *
+   * @static
+   * @param {any} opts mongoose-paginate options object
+   * @returns {Promise<any>} mongoose-paginate result object
+   * @memberof UserGroup
+   */
+  static findUserGroupsWithPagination(opts) {
+    const query = {};
+    const options = Object.assign({}, opts);
+    if (options.page == null) {
+      options.page = 1;
+    }
+    if (options.limit == null) {
+      options.limit = UserGroup.PAGE_ITEMS;
+    }
+
+    return this.paginate(query, options)
+      .catch((err) => {
+        debug('Error on pagination:', err);
+      });
+  };
+
+  // TBD: グループ名によるグループ検索
+  static findUserGroupByName(name) {
+    const query = { name: name };
+    return this.findOne(query);
+  };
+
+  // 登録可能グループ名確認
+  static isRegisterableName(name) {
+    const query = { name: name };
+
+    return this.findOne(query)
+      .then((userGroupData) => {
+        return (userGroupData == null);
+      });
+  };
+
+  // グループの完全削除
+  static removeCompletelyById(id) {
+
+    return this.findById(id)
+      .then((userGroupData) => {
+        if (userGroupData == null) {
+          throw new Exception('UserGroup data is not exists. id:', id);
+        }
+        else {
+          userGroupData.remove();
+        }
+      });
+  }
+
+  // グループ生成(名前が要る)
+  static createGroupByName(name) {
+
+    return this.create({name: name});
+  }
+
+  /*
+   * instance methods
+   */
+
+  // グループ画像の更新
+  updateImage(image) {
+    this.image = image;
+    return this.save();
+  }
+
+  // グループ画像の削除
+  deleteImage() {
+    return this.updateImage(null);
+  }
+
+  // グループ名の更新
+  updateName(name) {
+    // 名前を設定して更新
+    this.name = name;
+    return this.save();
+  }
+
+}
+
+
+module.exports = function (crowi) {
+  UserGroup.crowi = crowi;
+  schema.loadClass(UserGroup);
+  return mongoose.model('UserGroup', schema);
+}
+

+ 313 - 1
lib/routes/admin.js

@@ -2,16 +2,20 @@ module.exports = function(crowi, app) {
   'use strict';
 
   var debug = require('debug')('crowi:routes:admin')
+    , fs = require('fs')
     , models = crowi.models
     , Page = models.Page
+    , PageGroupRelation = models.PageGroupRelation
     , User = models.User
     , ExternalAccount = models.ExternalAccount
+    , UserGroup = models.UserGroup
+    , UserGroupRelation = models.UserGroupRelation
     , Config = models.Config
     , PluginUtils = require('../plugins/plugin-utils')
     , pluginUtils = new PluginUtils()
     , ApiResponse = require('../util/apiResponse')
 
-    , MAX_PAGE_LIST = 5
+    , MAX_PAGE_LIST = 50
     , actions = {};
 
   function createPager(total, limit, page, pagesCount, maxPageList) {
@@ -527,6 +531,314 @@ module.exports = function(crowi, app) {
       });
   };
 
+  actions.userGroup = {};
+  actions.userGroup.index = function (req, res) {
+    var page = parseInt(req.query.page) || 1;
+    var renderVar = {
+      userGroups : [],
+      userGroupRelations : new Map(),
+      pager : null,
+    }
+
+    UserGroup.findUserGroupsWithPagination({ page: page })
+      .then((result) => {
+        const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+        var userGroups = result.docs;
+        renderVar.userGroups = userGroups;
+        renderVar.pager = pager;
+        return userGroups.map((userGroup) => {
+          return new Promise((resolve, reject) => {
+            UserGroupRelation.findAllRelationForUserGroup(userGroup)
+            .then((relations) => {
+              return resolve([userGroup, relations]);
+            });
+          });
+        });
+      })
+      .then((allRelationsPromise) => {
+        return Promise.all(allRelationsPromise)
+      })
+      .then((relations) => {
+        renderVar.userGroupRelations = new Map(relations);
+        debug("in findUserGroupsWithPagination findAllRelationForUserGroupResult", renderVar.userGroupRelations);
+        return res.render('admin/user-groups', renderVar);
+      })
+      .catch( function(err) {
+          debug('Error on find all relations', err);
+          return res.json(ApiResponse.error('Error'));
+      });
+  };
+
+  // グループ詳細
+  actions.userGroup.detail = function (req, res) {
+    var name = req.params.name;
+    var renderVar = {
+      userGroup: null,
+      userGroupRelations: [],
+      pageGroupRelations: [],
+      notRelatedusers: []
+    }
+    var targetUserGroup = null;
+    UserGroup.findUserGroupByName(name)
+      .then(function (userGroup) {
+        targetUserGroup = userGroup;
+        if (targetUserGroup == null) {
+          req.flash('errorMessage', 'グループがありません');
+          throw new Error('no userGroup is exists. ', name);
+        }
+        else {
+          renderVar.userGroup = targetUserGroup;
+
+          return Promise.all([
+            // get all user and group relations
+            UserGroupRelation.findAllRelationForUserGroup(targetUserGroup),
+            // get all page and group relations
+            PageGroupRelation.findAllRelationForUserGroup(targetUserGroup),
+            // get all not related users for group
+            UserGroupRelation.findUserByNotRelatedGroup(targetUserGroup),
+          ]);
+        }
+      })
+      .then((resolves) => {
+        renderVar.userGroupRelations = resolves[0];
+        renderVar.pageGroupRelations = resolves[1];
+        renderVar.notRelatedusers = resolves[2];
+        debug('notRelatedusers', renderVar.notRelatedusers);
+
+        return res.render('admin/user-group-detail', renderVar);
+      })
+      .catch((err) => {
+        req.flash('errorMessage', 'ユーザグループの検索に失敗しました');
+        debug('Error on get userGroupDetail', err);
+        return res.redirect('/admin/user-groups');
+      });
+  }
+
+  //グループの生成
+  actions.userGroup.create = function (req, res) {
+    var form = req.form.createGroupForm;
+    if (req.form.isValid) {
+      UserGroup.createGroupByName(form.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) {
+
+    var userGroupId = req.params.userGroupId;
+    var name = req.body.name;
+
+    UserGroup.findById(userGroupId)
+    .then((userGroupData) => {
+      if (userGroupData == null) {
+        req.flash('errorMessage', 'グループの検索に失敗しました。');
+        return new Promise();
+      }
+      else {
+        // 名前存在チェック
+        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/' + name);
+    });
+  };
+
+  actions.userGroup.uploadGroupPicture = function (req, res) {
+    var fileUploader = require('../util/fileUploader')(crowi, app);
+    //var storagePlugin = new pluginService('storage');
+    //var storage = require('../service/storage').StorageService(config);
+
+    var userGroupId = req.params.userGroupId;
+
+    var tmpFile = req.file || null;
+    if (!tmpFile) {
+      return res.json({
+        'status': false,
+        'message': 'File type error.'
+      });
+    }
+
+    UserGroup.findById(userGroupId, function (err, userGroupData) {
+      if (!userGroupData) {
+        return res.json({
+          'status': false,
+          'message': 'UserGroup error.'
+        });
+      }
+
+      var tmpPath = tmpFile.path;
+      var filePath = UserGroup.createUserGroupPictureFilePath(userGroupData, tmpFile.filename + tmpFile.originalname);
+      var acceptableFileType = /image\/.+/;
+
+      if (!tmpFile.mimetype.match(acceptableFileType)) {
+        return res.json({
+          'status': false,
+          'message': 'File type error. Only image files is allowed to set as user picture.',
+        });
+      }
+
+      var tmpFileStream = fs.createReadStream(tmpPath, { flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true });
+
+      fileUploader.uploadFile(filePath, tmpFile.mimetype, tmpFileStream, {})
+        .then(function (data) {
+          var imageUrl = fileUploader.generateUrl(filePath);
+          userGroupData.updateImage(imageUrl)
+          .then(() => {
+            fs.unlink(tmpPath, function (err) {
+              if (err) {
+                debug('Error while deleting tmp file.', err);
+              }
+
+              return res.json({
+                'status': true,
+                'url': imageUrl,
+                'message': '',
+              });
+            });
+          });
+        }).catch(function (err) {
+          debug('Uploading error', err);
+
+          return res.json({
+            'status': false,
+            'message': 'Error while uploading to ',
+          });
+        });
+    });
+
+  };
+
+  actions.userGroup.deletePicture = function (req, res) {
+
+    var userGroupId = req.params.userGroupId;
+    let userGroupName = null;
+
+    UserGroup.findById(userGroupId)
+    .then((userGroupData) => {
+      if (userGroupData == null) {
+        return Promise.reject();
+      }
+      else {
+        userGroupName = userGroupData.name;
+        return userGroupData.deleteImage();
+      }
+    })
+    .then((updated) => {
+      req.flash('successMessage', 'Deleted group picture');
+
+      return res.redirect('/admin/user-group-detail/' + userGroupName);
+    })
+    .catch((err) => {
+      debug('An error occured.', err);
+
+      req.flash('errorMessage', 'Error while deleting group picture');
+      if (userGroupName == null) {
+        return res.redirect('/admin/user-groups/');
+      }
+      else {
+        return res.redirect('/admin/user-group-detail/' + userGroupName);
+      }
+    });
+  };
+
+  // app.post('/_api/admin/user-group/delete' , admin.userGroup.removeCompletely);
+  actions.userGroup.removeCompletely = function (req, res) {
+    const id = req.body.user_group_id;
+
+    UserGroup.removeCompletelyById(id, function (err, removed) {
+      if (err) {
+        debug('Error while removing userGroup.', err, id);
+        req.flash('errorMessage', '完全な削除に失敗しました。');
+      } else {
+        req.flash('successMessage', '削除しました');
+      }
+      return res.redirect('/admin/user-groups');
+    });
+  }
+
+  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.name);
+    }).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.name);
+    });
+  }
+
+  actions.userGroupRelation.remove = function (req, res) {
+    const UserGroupRelation = crowi.model('UserGroupRelation');
+    var name = req.params.name;
+    var relationId = req.params.relationId;
+
+    debug(name, relationId);
+    UserGroupRelation.removeById(relationId)
+    .then(() =>{
+      return res.redirect('/admin/user-group-detail/' + name);
+    })
+    .catch((err) => {
+      debug('Error on remove user-group-relation', err);
+      req.flash('errorMessage', 'グループのユーザ削除に失敗しました。');
+    });
+
+  }
+
   actions.api = {};
   actions.api.appSetting = function(req, res) {
     var form = req.form.settingForm;

+ 13 - 0
lib/routes/index.js

@@ -109,6 +109,19 @@ module.exports = function(crowi, app) {
   app.get('/admin/users/external-accounts'               , loginRequired(crowi, app) , middleware.adminRequired() , admin.externalAccount.index);
   app.post('/admin/users/external-accounts/:id/remove'   , loginRequired(crowi, app) , middleware.adminRequired() , admin.externalAccount.remove);
 
+  // user-groups admin
+  app.get('/admin/user-groups'             , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.index);
+  app.get('/admin/user-group-detail/:name'          , 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/picture/delete', loginRequired(crowi, app), admin.userGroup.deletePicture);
+  app.post('/_api/admin/user-group.remove' , loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroup.removeCompletely);
+  app.post('/_api/admin/user-group/:userGroupId/picture/upload', loginRequired(crowi, app), uploads.single('userGroupPicture'), admin.userGroup.uploadGroupPicture);
+
+  // 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/:name/remove-relation/:relationId', loginRequired(crowi, app), middleware.adminRequired(), csrf, admin.userGroupRelation.remove)
+
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
   app.get('/me/apiToken'              , loginRequired(crowi, app) , me.apiToken);

+ 20 - 3
lib/routes/page.js

@@ -8,6 +8,7 @@ module.exports = function(crowi, app) {
     , config   = crowi.getConfig()
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
+    , UserGroupRelation = crowi.model('UserGroupRelation')
     , ApiResponse = require('../util/apiResponse')
     , interceptorManager = crowi.getInterceptorManager()
 
@@ -233,6 +234,7 @@ module.exports = function(crowi, app) {
       author: false,
       pages: [],
       tree: [],
+      userRelatedGroups: [],
     };
 
     var pageTeamplate = 'customlayout-selector/page';
@@ -333,6 +335,15 @@ module.exports = function(crowi, app) {
           debug('Error on rendering pageListShowForCrowiPlus', err);
         });
       }
+    })
+    .then(function() {
+      return UserGroupRelation.findAllRelationForUser(req.user);
+    }).then(function (groupRelations) {
+      debug('findPage : relatedGroups ', groupRelations);
+      renderVars.userRelatedGroups = groupRelations.map(relation => relation.relatedGroup);
+      debug('findPage : groups ', renderVars.userRelatedGroups);
+
+      return Promise.resolve();
     });
   }
 
@@ -550,6 +561,7 @@ module.exports = function(crowi, app) {
     var currentRevision = pageForm.currentRevision;
     var grant = pageForm.grant;
     var path = pageForm.path;
+    var grantUserGroupId = pageForm.grantUserGroupId
 
     // TODO: make it pluggable
     var notify = pageForm.notify || {};
@@ -588,11 +600,11 @@ module.exports = function(crowi, app) {
 
       if (data) {
         previousRevision = data.revision;
-        return Page.updatePage(data, body, req.user, {grant: grant});
+        return Page.updatePage(data, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
       } else {
         // new page
         updateOrCreate = 'create';
-        return Page.create(path, body, req.user, {grant: grant});
+        return Page.create(path, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
       }
     }).then(function(data) {
       // data is a saved page data.
@@ -790,6 +802,7 @@ module.exports = function(crowi, app) {
     var body = req.body.body || null;
     var pagePath = req.body.path || null;
     var grant = req.body.grant || null;
+    var grantUserGroupId = req.body.grantUserGroupId || null;
 
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
@@ -802,7 +815,7 @@ module.exports = function(crowi, app) {
         throw new Error('Page exists');
       }
 
-      return Page.create(pagePath, body, req.user, {grant: grant});
+      return Page.create(pagePath, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
     }).then(function(data) {
       if (!data) {
         throw new Error('Failed to create page.');
@@ -837,6 +850,7 @@ module.exports = function(crowi, app) {
     var pageId = req.body.page_id || null;
     var revisionId = req.body.revision_id || null;
     var grant = req.body.grant || null;
+    var grantUserGroupId = req.body.grantUserGroupId || null;
 
     if (pageId === null || pageBody === null) {
       return res.json(ApiResponse.error('page_id and body are required.'));
@@ -852,6 +866,9 @@ module.exports = function(crowi, app) {
       if (grant !== null) {
         grantOption.grant = grant;
       }
+      if (grantUserGroupId != null) {
+        grantOption.grantUserGroupId = grantUserGroupId;
+      }
       return Page.updatePage(pageData, pageBody, req.user, grantOption);
     }).then(function(pageData) {
       var result = {

+ 8 - 1
lib/views/_form.html

@@ -50,7 +50,14 @@
       {% else %}
       <select name="pageForm[grant]" class="m-r-5 selectpicker btn-group-sm">
         {% for grantId, grantLabel in consts.pageGrants %}
-        <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ t(grantLabel) }}</option>
+        <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %} {% if grantId == 5 && userRelatedGroups.length == 0 %}disabled{% endif %}>{{ t(grantLabel) }}</option>
+        {% endfor %}
+      </select>
+      {% endif %}
+      {% if userRelatedGroups.length != 0 %}
+      <select name="pageForm[grantUserGroupId]" class="form-control">
+        {% for userGroup in userRelatedGroups %}
+        <option value="{{ userGroup.id }}">{{ userGroup.name }}</option>
         {% endfor %}
       </select>
       {% endif %}

+ 242 - 0
lib/views/admin/user-group-detail.html

@@ -0,0 +1,242 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}グループ管理 · {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">グループ管理(グループ詳細)</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% 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"><i class="fa fa-arrow-left"></i> グループ一覧に戻る</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">
+                グループにユーザを追加します</h4>
+            </div>
+
+            <div class="modal-body">
+              <p>
+                ユーザ名を入力してください。
+              </p>
+              <form class="form-inline" role="form" action="/admin/user-group-relation/create" method="post">
+                <div class="form-group">
+                  <label for="inputRelatedUserName">Add related user:</label>
+                  <input type="text" name="user_name" class="form-control input-sm" id="inputRelatedUserName" placeholder="username">
+                </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-danger">実行</button>
+              </form>
+
+              {% if 0 < notRelatedusers.length %}
+              <hr>
+              <p>
+                ユーザ名を選択してください。
+              </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="form-box">
+        <form action="/admin/user-group/{{userGroup.id}}/update" method="post" class="form-horizontal" role="form">
+          <fieldset>
+            <legend>基本情報</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|date('Y-m-d', sRelation.relatedUser.createdAt.getTimezoneOffset()) }}">
+              </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>
+
+      <div class="form-box">
+        <fieldset>
+          <legend>グループ画像の設定</legend>
+          <div class="form-group col-sm-8">
+            <h4>
+              {{ t('Upload Image') }}
+            </h4>
+            <div class="form-group">
+              <div id="pictureUploadFormMessage"></div>
+              <label for="" class="col-sm-4 control-label">
+                {{ t('Current Image') }}
+              </label>
+              <div class="col-sm-8">
+                <p>
+                  <img src="{{ userGroup|uploadedpicture }}" width="64" id="settingUserPicture">
+                  <br>
+                </p>
+                <p>
+                  {% if userGroup.image %}
+                  <form action="/admin/user-group/{{userGroup.id}}/picture/delete" method="post" class="form-horizontal" role="form" onsubmit="return window.confirm('{{ t('Delete this image?') }}');">
+                    <button type="submit" class="btn btn-danger">{{ t('Delete Image') }}</button>
+                  </form>
+                  {% endif %}
+                </p>
+              </div>
+            </div><!-- /.form-group -->
+
+            <div class="form-group">
+              <label for="" class="col-sm-4 control-label">
+                {{ t('Upload new image') }}
+              </label>
+              <div class="col-sm-8">
+                {% if isUploadable() %}
+                <form action="/_api/admin/user-group/{{userGroup.id}}/picture/upload" id="pictureUploadForm" method="post" class="form-horizontal" role="form" enctype="multipart/form-data">
+                  <input name="userGroupPicture" type="file" accept="image/*">
+                  <div id="pictureUploadFormProgress">
+                  </div>
+                </form>
+                {% else %} * {{ t('page_me.form_help.profile_image1') }}
+                <br> * {{ t('page_me.form_help.profile_image2') }}
+                <br> {% endif %}
+              </div>
+            </div><!-- /.form-group -->
+
+          </div><!-- /.col-sm- -->
+
+        </fieldset>
+      </div><!-- /.form-box -->
+
+      <legend>ユーザー一覧</legend>
+
+      <table class="table table-hover table-striped table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="100px">#</th>
+            <th>
+              <code>username</code>
+            </th>
+            <th>名前</th>
+            <th width="100px">作成日</th>
+            <th width="150px">最終ログイン</th>
+            <th width="90px">操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for sRelation in userGroupRelations %}
+          {% set sUser = sRelation.relatedUser%}
+          <tr>
+            <td>
+              <img src="{{ sRelation.relatedUser|picture }}" class="picture picture-rounded" />
+            </td>
+            <td>
+              <strong>{{ sRelation.relatedUser.username }}</strong>
+            </td>
+            <td>{{ sRelation.relatedUser.name }}</td>
+            <td>{{ sRelation.relatedUser.createdAt|date('Y-m-d', sRelation.relatedUser.createdAt.getTimezoneOffset()) }}</td>
+            <td>
+              {% if sRelation.relatedUser.lastLoginAt %} {{ sRelation.relatedUser.lastLoginAt|date('Y-m-d H:i', sRelation.relatedUser.createdAt.getTimezoneOffset()) }} {% endif %}
+            </td>
+            <td>
+              <div class="btn-group admin-user-menu">
+                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                  編集
+                  <span class="caret"></span>
+                </button>
+                <ul class="dropdown-menu" role="menu">
+                  <li class="dropdown-header">編集メニュー</li>
+                  <li class="divider"></li>
+                  <li class="dropdown-button">
+                    <form action="/admin/user-group-relation/{{userGroup.name}}/remove-relation/{{ sRelation._id.toString() }}" method="post">
+                      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                      <button type="submit" class="btn btn-block btn-danger">グループから外す</button>
+                    </form>
+                  </li>
+                </ul>
+              </div>
+            </td>
+          </tr>
+          {% endfor %}
+
+          {% if 0 < notRelatedusers.length %}
+          <tr>
+            <td></td>
+            <td><button type="button" class="btn btn-primary" data-target="#admin-add-user-group-relation-modal" data-toggle="modal"><i class="fa fa-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>ページ一覧</legend>
+
+      {% if pageGroupRelations.length == 0 %}<p>グループが閲覧権限を保有するページはありません</p>{% endif %}
+      {% include '../widget/page_list.html' with { pages: pageGroupRelations, pagePropertyName: 'targetPage' } %}
+
+    </div>
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+

+ 169 - 0
lib/views/admin/user-groups.html

@@ -0,0 +1,169 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}グループ管理 · {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">グループ管理</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% 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">
+      <p>
+        <button  data-toggle="collapse" class="btn btn-default" href="#createGroupForm">新規グループの作成</button>
+      </p>
+      <form role="form" action="/admin/user-group/create" method="post">
+        <div id="createGroupForm" class="collapse">
+          <div class="form-group">
+            <label for="createGroupForm[userGroupName]">グループ名</label>
+            <textarea class="form-control" name="createGroupForm[userGroupName]" placeholder="例: Group1"></textarea>
+          </div>
+          <button type="submit" class="btn btn-primary">作成する</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">グループを作成しました</h4>
+            </div>
+
+            <div class="modal-body">
+              <p>
+                作成したグループにユーザを追加してください
+              </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 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"><code id="admin-delete-user-group-name"></code>グループを削除しますか?</h4>
+            </div>
+
+            <div class="modal-body">
+              <p>
+                グループの削除を行うと元に戻すことはできませんのでご注意ください。
+              </p>
+
+              <form method="post" id="admin-user-groups-delete">
+                <input type="hidden" name="user_group_id" value="">
+                <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                <button type="submit" value="" class="btn btn-danger">
+                  実行
+                </button>
+              </form>
+
+            </div>
+
+          </div>
+          <!-- /.modal-content -->
+        </div>
+        <!-- /.modal-dialog -->
+      </div>
+
+      <h2>グループ一覧</h2>
+
+      <table class="table table-hover table-striped table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="100px">#</th>
+            <th>名前</th>
+            <th>ユーザ一覧</th>
+            <th width="100px">作成日</th>
+            <th width="90px">操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for sGroup in userGroups %}
+          <tr>
+            <td>
+              <img src="{{ sGroup|picture }}" class="picture picture-rounded" />
+            </td>
+            <td>{{ sGroup.name }}</td>
+            <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>
+            <td>
+              <div class="btn-group admin-group-menu">
+                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                  操作
+                  <span class="caret"></span>
+                </button>
+                <ul class="dropdown-menu" role="menu">
+                  <li class="dropdown-header">メニュー</li>
+                  <li class="divider"></li>
+
+                  <li class="dropdown-button">
+                    <a href="/admin/user-group-detail/{{sGroup.name}}" class="btn btn-block btn-default">詳細</a>
+                  </li>
+
+                  <li class="dropdown-button">
+                    <a href="#"
+                      data-user-group-id="{{ sGroup._id.toString() }}"
+                      data-user-group-name="{{ sGroup.name.toString() }}"
+                      data-target="#admin-delete-user-group-modal"
+                      data-toggle="modal" class="btn btn-block btn-danger">
+                      グループの削除
+                    </a>
+                  </li>
+
+                </ul>
+              </div>
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+      {% include '../widget/pager.html' with {path: "/admin/user-groups", pager: pager} %}
+
+    </div>
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+

+ 1 - 0
lib/views/admin/widget/menu.html

@@ -9,6 +9,7 @@
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="icon-fw icon-wrench"></i> {{ t('Customize') }}</a></li>
   <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="icon-fw icon-bell"></i> {{ t('Notification settings') }}</a></li>
   <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="icon-fw icon-people"></i> {{ t('User management') }}</a></li>
+  <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="fa fa-users"></i> {{ t('UserGroup management') }}</a></li>
   {% if searchConfigured() %}
   <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="icon-fw icon-magnifier"></i> 検索管理</a></li>
   {% endif %}

+ 52 - 0
resource/js/legacy/crowi-admin.js

@@ -56,6 +56,58 @@ $(function() {
     return false;
   });
 
+  $('#admin-delete-user-group-modal').on('show.bs.modal', function (button) {
+    var data = $(button.relatedTarget);
+    var userGroupId = data.data('user-group-id');
+    var userGroupName = data.data('user-group-name');
+
+    $('#admin-delete-user-group-name').text(userGroupName);
+    $('#admin-user-groups-delete input[name=user_group_id]').val(userGroupId);
+  });
+
+  $('form#admin-user-groups-delete').on('submit', function (e) {
+    $.post('/_api/admin/user-group.remove', $(this).serialize(), function (res) {});
+  });
+
+  $('form#user-group-relation-create').on('submit', function (e) {
+    $.post('/admin/user-group-relation/create', $(this).serialize(), function (res) {
+      $('#admin-add-user-group-relation-modal').modal('hide');
+      return;
+     });
+  });
+
+
+  $("#pictureUploadForm input[name=userGroupPicture]").on('change', function () {
+    var $form = $('#pictureUploadForm');
+    var fd = new FormData($form[0]);
+    if ($(this).val() == '') {
+      return false;
+    }
+
+    $('#pictureUploadFormProgress').html('<img src="/images/loading_s.gif"> アップロード中...');
+    $.ajax($form.attr("action"), {
+      type: 'post',
+      processData: false,
+      contentType: false,
+      data: fd,
+      dataType: 'json',
+      success: function (data) {
+        if (data.status) {
+          $('#settingUserPicture').attr('src', data.url + '?time=' + (new Date()));
+          $('#pictureUploadFormMessage')
+            .addClass('alert alert-success')
+            .html('変更しました');
+        } else {
+          $('#pictureUploadFormMessage')
+            .addClass('alert alert-danger')
+            .html('変更中にエラーが発生しました。');
+        }
+        $('#pictureUploadFormProgress').html('');
+      }
+    });
+    return false;
+  });
+
   // style switcher
   $('#styleOptions').styleSwitcher();
 });

+ 8 - 0
resource/styles/scss/_admin.scss

@@ -8,6 +8,14 @@
     }
   }
 
+  .admin-group-menu {
+    .dropdown-menu {
+      left: auto;
+      right: 0;
+      width: 300px;
+    }
+  }
+
   .admin-customize {
     .ss-container img {
       padding: .5em;

+ 87 - 7
test/models/page.test.js

@@ -9,21 +9,27 @@ chai.use(sinonChai);
 describe('Page', () => {
   var Page = utils.models.Page,
     User   = utils.models.User,
+    UserGroup = utils.models.UserGroup,
+    UserGroupRelation = utils.models.UserGroupRelation,
+    PageGroupRelation = utils.models.PageGroupRelation,
     conn   = utils.mongoose.connection,
     createdPages,
-    createdUsers;
+    createdUsers,
+    createdUserGroups;
 
   before(done => {
     conn.collection('pages').remove().then(() => {
       var userFixture = [
-        {name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com'},
-        {name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com'}
+        { name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com' },
+        { name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com' },
+        { name: 'Anon 2', username: 'anonymous2', email: 'anonymous2@example.com' },
       ];
 
       return testDBUtil.generateFixture(conn, 'User', userFixture);
     }).then(testUsers => {
       createdUsers = testUsers;
       var testUser0 = testUsers[0];
+      var testUser1 = testUsers[1];
 
       var fixture = [
         {
@@ -62,11 +68,55 @@ describe('Page', () => {
           creator: testUser0,
           extended: {hoge: 1}
         },
+        {
+          path: '/grant/groupacl',
+          grant: 5,
+          grantedUsers: [],
+          creator: testUser1,
+        },
+      ];
+
+      return testDBUtil.generateFixture(conn, 'Page', fixture);
+    })
+    .then(pages => {
+      createdPages = pages;
+      groupFixture = [
+        {
+          image: '',
+          name: 'TestGroup0',
+        },
+        {
+          image: '',
+          name: 'TestGroup1',
+        },
       ];
 
-      return testDBUtil.generateFixture(conn, 'Page', fixture)
-      .then(pages => {
-        createdPages = pages;
+      return testDBUtil.generateFixture(conn, 'UserGroup', groupFixture);
+    })
+    .then(userGroups => {
+      createdUserGroups = userGroups;
+      testGroup0 = createdUserGroups[0];
+      testUser0 = createdUsers[0];
+      userGroupRelationFixture = [
+        {
+          relatedGroup: testGroup0,
+          relatedUser: testUser0,
+        }
+      ];
+      return testDBUtil.generateFixture(conn, 'UserGroupRelation', userGroupRelationFixture);
+    })
+    .then(userGroupRelations => {
+      testGroup0 = createdUserGroups[0];
+      testPage = createdPages[6];
+      pageGroupRelationFixture = [
+        {
+          relatedGroup: testGroup0,
+          targetPage: testPage,
+        }
+      ];
+
+      return testDBUtil.generateFixture(conn, 'PageGroupRelation', pageGroupRelationFixture)
+      .then(pageGroupRelations => {
         done();
       });
     });
@@ -300,9 +350,12 @@ describe('Page', () => {
         const pageToFind = createdPages[0];
         const grantedUser = createdUsers[0];
         Page.findPageByIdAndGrantedUser(pageToFind._id, grantedUser)
-        .then(pageData => {
+        .then((pageData) => {
           expect(pageData.path).to.equal(pageToFind.path);
           done();
+        })
+        .catch((err) => {
+          done(err);
         });
       });
 
@@ -318,6 +371,33 @@ describe('Page', () => {
         });
       });
     });
+
+    context('findPageByIdAndGrantedUser granted userGroup', () => {
+      it('should find page', done => {
+        const pageToFind = createdPages[6];
+        const grantedUser = createdUsers[0];
+        Page.findPageByIdAndGrantedUser(pageToFind._id, grantedUser)
+        .then(pageData => {
+          expect(pageData.path).to.equal(pageToFind.path);
+          done();
+        })
+        .catch((err) => {
+          done(err);
+        });
+      });
+
+      it('should error by grant userGroup', done => {
+        const pageToFind = createdPages[6];
+        const grantedUser = createdUsers[2];
+        Page.findPageByIdAndGrantedUser(pageToFind._id, grantedUser)
+          .then(pageData => {
+            done(new Error());
+          }).catch(err => {
+            expect(err).to.instanceof(Error);
+            done();
+          });
+      });
+    });
   });
 
 });

+ 5 - 2
test/utils.js

@@ -46,12 +46,15 @@ after('Close database connection', function (done) {
 
 // Setup Models
 fs.readdirSync(MODEL_DIR).forEach(function(file) {
-  if (file.match(/^(\w+)\.js$/)) {
+  if (file.match(/^([\w-]+)\.js$/)) {
     var name = RegExp.$1;
     if (name === 'index') {
       return;
     }
-    var modelName = name.charAt(0).toUpperCase() + name.slice(1);
+    var modelName = '';
+    name.split('-').map(splitted => {
+      modelName += (splitted.charAt(0).toUpperCase() + splitted.slice(1));
+    });
     models[modelName] = require(MODEL_DIR + '/' + file)(crowi);
   }
 });