Преглед изворни кода

Merge pull request #176 from weseek/master

release v2.1.0
Yuki Takei пре 8 година
родитељ
комит
08142c3bf6
43 измењених фајлова са 1160 додато и 287 уклоњено
  1. 8 0
      CHANGES.md
  2. 1 1
      README.md
  3. 6 0
      config/env.dev.js
  4. 19 3
      lib/crowi/express-init.js
  5. 23 1
      lib/crowi/index.js
  6. 0 0
      lib/form/admin/securityGeneral.js
  7. 0 0
      lib/form/admin/securityGoogle.js
  8. 9 0
      lib/form/admin/securityMechanism.js
  9. 3 2
      lib/form/index.js
  10. 1 1
      lib/form/login.js
  11. 10 0
      lib/models/config.js
  12. 17 13
      lib/models/user.js
  13. 20 1
      lib/routes/admin.js
  14. 19 3
      lib/routes/index.js
  15. 78 0
      lib/routes/login-passport.js
  16. 14 12
      lib/routes/login.js
  17. 6 10
      lib/routes/me.js
  18. 68 0
      lib/service/passport.js
  19. 16 2
      lib/util/middlewares.js
  20. 1 108
      lib/views/admin/app.html
  21. 271 0
      lib/views/admin/security.html
  22. 1 0
      lib/views/admin/widget/menu.html
  23. 2 0
      lib/views/layout/layout.html
  24. 8 7
      lib/views/layout/single.html
  25. 12 5
      lib/views/login.html
  26. 0 2
      lib/views/not_found.html
  27. 0 2
      lib/views/page.html
  28. 0 2
      lib/views/page_list.html
  29. 20 16
      package.json
  30. 21 0
      public/images/admin/security/passport-logo.svg
  31. 6 0
      resource/css/_admin.scss
  32. 12 7
      resource/css/_search.scss
  33. 2 2
      resource/js/app.js
  34. 6 5
      resource/js/components/CopyButton.js
  35. 1 1
      resource/js/components/Page/PageBody.js
  36. 4 2
      resource/js/components/Page/RevisionUrl.js
  37. 1 1
      resource/js/components/PageCommentFormBehavior.js
  38. 2 0
      resource/js/components/PageListSearch.js
  39. 2 0
      resource/js/components/SearchPage.js
  40. 61 0
      resource/js/components/SearchPage/DeletePageListModal.js
  41. 130 3
      resource/js/components/SearchPage/SearchResult.js
  42. 1 1
      resource/js/util/CrowiRenderer.js
  43. 278 74
      yarn.lock

+ 8 - 0
CHANGES.md

@@ -1,6 +1,14 @@
 CHANGES
 ========
 
+## 2.1.0
+
+* Feat: Adopt Passport the authentication middleware
+* Feat: Selective batch deletion in search result page
+* Improvement: Ensure to be able to login with both of username or email
+* Fix: The problem that couldn't update user data in /me
+* Support: Upgrade outdated libs
+
 ## 2.0.9
 
 * Fix: Server is down when a guest user accesses to someone's private pages

+ 1 - 1
README.md

@@ -77,7 +77,7 @@ On-premise
 ### Dependencies
 
 - node 6.x (DON'T USE 7.x)
-- npm 4.x
+- npm 5.x
 - yarn
 - MongoDB 3.x
 

+ 6 - 0
config/env.dev.js

@@ -1,5 +1,6 @@
 module.exports = {
   NODE_ENV: 'development',
+  FILE_UPLOAD: 'local',
   // MATHJAX: 1,
   // REDIS_URL: 'redis://localhost:6379/crowi',
   // ELASTICSEARCH_URI: 'http://localhost:9200/crowi',
@@ -10,6 +11,11 @@ module.exports = {
   // filters for debug
   DEBUG: [
     // 'express:*',
+    // 'crowi:crowi',
+    'crowi:crowi:express-init',
+    // 'crowi:routes:login',
+    'crowi:routes:login-passport',
+    // 'crowi:service:PassportService',
     // 'crowi:*',
     // 'crowi:routes:page',
     // 'crowi:plugins:*',

+ 19 - 3
lib/crowi/express-init.js

@@ -7,6 +7,7 @@ module.exports = function(crowi, app) {
     , bodyParser     = require('body-parser')
     , cookieParser   = require('cookie-parser')
     , methodOverride = require('method-override')
+    , passport       = require('passport')
     , session        = require('express-session')
     , basicAuth      = require('basic-auth-connect')
     , flash          = require('connect-flash')
@@ -18,8 +19,10 @@ module.exports = function(crowi, app) {
     , i18nMiddleware = require('i18next-express-middleware')
     , i18nUserSettingDetector  = require('../util/i18nUserSettingDetector')
     , env            = crowi.node_env
+    , config         = crowi.getConfig()
     , middleware     = require('../util/middlewares')
 
+    , Config = crowi.model('Config')
     , User = crowi.model('User')
     ;
 
@@ -46,7 +49,6 @@ module.exports = function(crowi, app) {
   app.use(function(req, res, next) {
     var now = new Date()
       , baseUrl
-      , config = crowi.getConfig()
       , tzoffset = -(config.crowi['app:timezone'] || 9) * 60 // for datez
       , Page = crowi.model('Page')
       , User = crowi.model('User')
@@ -97,7 +99,6 @@ module.exports = function(crowi, app) {
 
   // Set basic auth middleware
   app.use(function(req, res, next) {
-    var config = crowi.getConfig();
     if (req.query.access_token || req.body.access_token) {
       return next();
     }
@@ -111,12 +112,27 @@ module.exports = function(crowi, app) {
     }
   });
 
+  // passport
+  if (Config.isEnabledPassport(config)) {
+    debug('initialize Passport')
+    app.use(passport.initialize());
+    app.use(passport.session());
+  }
+
   app.use(flash());
 
   app.use(middleware.swigFilters(app, swig));
   app.use(middleware.swigFunctions(crowi, app));
 
-  app.use(middleware.loginChecker(crowi, app));
+  app.use(middleware.csrfKeyGenerator(crowi, app));
+
+  // switch loginChecker
+  if (Config.isEnabledPassport(config)) {
+    app.use(middleware.loginCheckerForPassport(crowi, app));
+  }
+  else {
+    app.use(middleware.loginChecker(crowi, app));
+  }
 
   app.use(i18nMiddleware.handle(i18next));
 };

+ 23 - 1
lib/crowi/index.js

@@ -85,6 +85,8 @@ Crowi.prototype.init = function() {
     });
   }).then(function() {
     return self.scanRuntimeVersions();
+  }).then(function() {
+    return self.setupPassport();
   }).then(function() {
     return self.setupSearcher();
   }).then(function() {
@@ -249,6 +251,26 @@ Crowi.prototype.getInterceptorManager = function() {
   return this.interceptorManager;
 }
 
+Crowi.prototype.setupPassport = function() {
+  const config = this.getConfig();
+  const Config = this.model('Config');
+
+  if (!Config.isEnabledPassport(config)) {
+    // disabled
+    return;
+  }
+
+  debug('Passport is enabled');
+
+  const PassportService = require('../service/passport');
+
+  const passportService = new PassportService(this);
+  passportService.setupLocalStrategy();
+  passportService.setupSerializer();
+
+  return Promise.resolve();
+}
+
 Crowi.prototype.setupSearcher = function() {
   var self = this;
   var searcherUri = this.env.ELASTICSEARCH_URI
@@ -367,7 +389,7 @@ Crowi.prototype.buildServer = function() {
   var Config = this.model('Config');
   var isEnabledPlugins = Config.isEnabledPlugins(this.config);
   if (isEnabledPlugins) {
-    debug('plugins enabled');
+    debug('Plugins are enabled');
     var PluginService = require('../plugins/plugin.service');
     var pluginService = new PluginService(this, app);
     pluginService.autoDetectAndLoadPlugins();

+ 0 - 0
lib/form/admin/sec.js → lib/form/admin/securityGeneral.js


+ 0 - 0
lib/form/admin/google.js → lib/form/admin/securityGoogle.js


+ 9 - 0
lib/form/admin/securityMechanism.js

@@ -0,0 +1,9 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('settingForm[security:isEnabledPassport]').trim().toBooleanStrict()
+);
+

+ 3 - 2
lib/form/index.js

@@ -12,11 +12,12 @@ module.exports = {
   },
   admin: {
     app: require('./admin/app'),
-    sec: require('./admin/sec'),
     mail: require('./admin/mail'),
     aws: require('./admin/aws'),
-    google: require('./admin/google'),
     plugin: require('./admin/plugin'),
+    securityGeneral: require('./admin/securityGeneral'),
+    securityGoogle: require('./admin/securityGoogle'),
+    securityMechanism: require('./admin/securityMechanism'),
     markdown: require('./admin/markdown'),
     customcss: require('./admin/customcss'),
     customscript: require('./admin/customscript'),

+ 1 - 1
lib/form/login.js

@@ -4,6 +4,6 @@ var form = require('express-form')
   , field = form.field;
 
 module.exports = form(
-  field('loginForm.email').required(),
+  field('loginForm.username').required(),
   field('loginForm.password').required().is(/^[\x20-\x7F]{6,}$/)
 );

+ 10 - 0
lib/models/config.js

@@ -27,6 +27,8 @@ module.exports = function(crowi) {
     let config = getDefaultCrowiConfigs();
 
     // overwrite
+    config['app:title'] = 'crowi-plus';
+    config['security:isEnabledPassport'] = true;
     config['customize:behavior'] = 'crowi-plus';
     config['customize:layout'] = 'crowi-plus';
     config['customize:isSavedStatesOfTabChanges'] = false;
@@ -51,6 +53,8 @@ module.exports = function(crowi) {
       'security:registrationMode'      : 'Open',
       'security:registrationWhiteList' : [],
 
+      'security:isEnabledPassport' : false,
+
       'aws:bucket'          : 'crowi',
       'aws:region'          : 'ap-northeast-1',
       'aws:accessKeyId'     : '',
@@ -235,6 +239,12 @@ module.exports = function(crowi) {
       });
   };
 
+  configSchema.statics.isEnabledPassport = function(config)
+  {
+    const key = 'security:isEnabledPassport';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isUploadable = function(config)
   {
     var method = crowi.env.FILE_UPLOAD || 'aws';

+ 17 - 13
lib/models/user.js

@@ -2,6 +2,7 @@ module.exports = function(crowi) {
   var debug = require('debug')('crowi:models:user')
     , mongoose = require('mongoose')
     , mongoosePaginate = require('mongoose-paginate')
+    , uniqueValidator = require('mongoose-unique-validator')
     , crypto = require('crypto')
     , async = require('async')
     , ObjectId = mongoose.Schema.Types.ObjectId
@@ -31,7 +32,7 @@ module.exports = function(crowi) {
     googleId: String,
     name: { type: String },
     username: { type: String, index: true },
-    email: { type: String, required: true, index: true  },
+    email: { type: String, required: true, unique: true },
     introduction: { type: String },
     password: String,
     apiToken: String,
@@ -46,6 +47,7 @@ module.exports = function(crowi) {
     admin: { type: Boolean, default: 0, index: true  }
   });
   userSchema.plugin(mongoosePaginate);
+  userSchema.plugin(uniqueValidator);
 
   userEvent.on('activated', userEvent.onActivated);
 
@@ -129,16 +131,6 @@ module.exports = function(crowi) {
     return false;
   };
 
-  userSchema.methods.update = function(name, email, lang, callback) {
-    this.name = name;
-    this.email = email;
-    this.lang = lang;
-
-    this.save(function(err, userData) {
-      return callback(err, userData);
-    });
-  };
-
   userSchema.methods.updateLastLoginAt = function(lastLoginAt, callback) {
     this.lastLoginAt = lastLoginAt;
     this.save(function(err, userData) {
@@ -255,12 +247,13 @@ module.exports = function(crowi) {
     debug('Delete User', this);
 
     const now = new Date();
+    const deletedLabel = `deleted_at_${now.getTime()}`;
 
     this.status = STATUS_DELETED;
-    this.username = `deleted_at_${now.getTime()}`;
+    this.username = deletedLabel;
     this.password = '';
     this.name = '';
-    this.email = 'deleted@deleted';
+    this.email = `${deletedLabel}@deleted`;
     this.googleId = null;
     this.isGravatarEnabled = false;
     this.image = null;
@@ -465,6 +458,17 @@ module.exports = function(crowi) {
     });
   };
 
+  userSchema.statics.findUserByUsernameOrEmail = function(usernameOrEmail, password, callback) {
+    this.findOne()
+      .or([
+        {username: usernameOrEmail},
+        {email: usernameOrEmail},
+      ])
+      .exec((err, userData) => {
+        callback(err, userData);
+      });
+  };
+
   userSchema.statics.findUserByEmailAndPassword = function(email, password, callback) {
     var hashedPassword = generatePassword(password);
     this.findOne({email: email, password: hashedPassword}, function (err, userData) {

+ 20 - 1
lib/routes/admin.js

@@ -87,7 +87,15 @@ module.exports = function(crowi, app) {
   actions.app.settingUpdate = function(req, res) {
   };
 
-  // app.get('/admin/markdonw'                  , admin.markdonw.index);
+  // app.get('/admin/security'                  , admin.security.index);
+  actions.security = {};
+  actions.security.index = function(req, res) {
+    var settingForm;
+    settingForm = Config.setupCofigFormData('crowi', req.config);
+    return res.render('admin/security', { settingForm });
+  };
+
+  // app.get('/admin/markdown'                  , admin.markdown.index);
   actions.markdown = {};
   actions.markdown.index = function(req, res) {
     var config = crowi.getConfig();
@@ -497,6 +505,17 @@ module.exports = function(crowi, app) {
     }
   };
 
+  actions.api.securitySetting = function(req, res) {
+    var form = req.form.settingForm;
+
+    if (req.form.isValid) {
+      debug('form content', form);
+      return saveSetting(req, res, form);
+    } else {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+  };
+
   actions.api.customizeSetting = function(req, res) {
     var form = req.form.settingForm;
 

+ 19 - 3
lib/routes/index.js

@@ -5,6 +5,7 @@ module.exports = function(crowi, app) {
     , form      = require('../form')
     , page      = require('./page')(crowi, app)
     , login     = require('./login')(crowi, app)
+    , loginPassport = require('./login-passport')(crowi, app)
     , logout    = require('./logout')(crowi, app)
     , me        = require('./me')(crowi, app)
     , admin     = require('./admin')(crowi, app)
@@ -18,6 +19,9 @@ module.exports = function(crowi, app) {
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser(crowi, app)
     , csrf      = middleware.csrfVerify(crowi, app)
+
+    , config    = crowi.getConfig()
+    , Config    = crowi.model('Config')
     ;
 
   app.get('/'                        , middleware.applicationInstalled(), loginRequired(crowi, app, false) , page.pageListShow);
@@ -30,7 +34,15 @@ module.exports = function(crowi, app) {
   app.get('/login'                   , middleware.applicationInstalled()    , login.login);
   app.get('/login/invited'           , login.invited);
   app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
-  app.post('/login'                  , form.login                           , csrf, login.login);
+
+  // switch POST /login route
+  if (Config.isEnabledPassport(config)) {
+    app.post('/login'                , form.login                           , csrf, loginPassport.loginWithLdap, loginPassport.loginWithLocal, loginPassport.loginFailure);
+  }
+  else {
+    app.post('/login'                , form.login                           , csrf, login.login);
+  }
+
   app.post('/register'               , form.register                        , csrf, login.register);
   app.get('/register'                , middleware.applicationInstalled()    , login.register);
   app.post('/register/google'        , login.registerGoogle);
@@ -41,12 +53,16 @@ module.exports = function(crowi, app) {
   app.get('/admin'                      , loginRequired(crowi, app) , middleware.adminRequired() , admin.index);
   app.get('/admin/app'                  , loginRequired(crowi, app) , middleware.adminRequired() , admin.app.index);
   app.post('/_api/admin/settings/app'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.app, admin.api.appSetting);
-  app.post('/_api/admin/settings/sec'   , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.sec, admin.api.appSetting);
   app.post('/_api/admin/settings/mail'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.mail, admin.api.appSetting);
   app.post('/_api/admin/settings/aws'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.aws, admin.api.appSetting);
-  app.post('/_api/admin/settings/google', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.google, admin.api.appSetting);
   app.post('/_api/admin/settings/plugin', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.plugin, admin.api.appSetting);
 
+  // security admin
+  app.get('/admin/security'                     , loginRequired(crowi, app) , middleware.adminRequired() , admin.security.index);
+  app.post('/_api/admin/security/general'       , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.securityGeneral, admin.api.securitySetting);
+  app.post('/_api/admin/security/google'        , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityGoogle, admin.api.securitySetting);
+  app.post('/_api/admin/security/mechanism'     , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityMechanism, admin.api.securitySetting);
+
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
   app.post('/admin/markdown/lineBreaksSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting);

+ 78 - 0
lib/routes/login-passport.js

@@ -0,0 +1,78 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:login-passport')
+    , passport = require('passport')
+    , config = crowi.getConfig()
+    , Config = crowi.model('Config');
+
+  /**
+   * success handler
+   * @param {*} req
+   * @param {*} res
+   */
+  const loginSuccess = (req, res, user) => {
+    var jumpTo = req.session.jumpTo;
+    if (jumpTo) {
+      req.session.jumpTo = null;
+      return res.redirect(jumpTo);
+    } else {
+      return res.redirect('/');
+    }
+  };
+
+  /**
+   * failure handler
+   * @param {*} req
+   * @param {*} res
+   */
+  const loginFailure = (req, res) => {
+    req.flash('warningMessage', 'Sign in failure.');
+    return res.redirect('/login');
+  };
+
+
+  const loginWithLdap = (req, res, next) => {
+    // TODO impl with vesse/passport-ldapauth
+    return next();
+  }
+
+  /**
+   * login with LocalStrategy action
+   * @param {*} req
+   * @param {*} res
+   * @param {*} next
+   */
+  const loginWithLocal = (req, res, next) => {
+    const loginForm = req.body.loginForm;
+
+    if (!req.form.isValid) {
+      return res.render('login', {
+      });
+    }
+
+    passport.authenticate('local', (err, user, info) => {
+      debug('---authentication with LocalStrategy start---');
+      debug('user', user);
+      debug('info', info);
+
+      if (err) { return next(err); }
+      if (!user) { return next(); }
+      req.logIn(user, (err) => {
+        if (err != null) {
+          debug(err);
+          return next();
+        }
+        return loginSuccess(req, res, user);
+      });
+
+      debug('---authentication with LocalStrategy end---');
+    })(req, res, next);
+  }
+
+  return {
+    loginFailure,
+    loginWithLdap,
+    loginWithLocal,
+  };
+};

+ 14 - 12
lib/routes/login.js

@@ -4,6 +4,7 @@ module.exports = function(crowi, app) {
   var googleapis = require('googleapis')
     , debug = require('debug')('crowi:routes:login')
     , async    = require('async')
+    , passport = require('passport')
     , config = crowi.getConfig()
     , mailer = crowi.getMailer()
     , Page = crowi.model('Page')
@@ -75,21 +76,17 @@ module.exports = function(crowi, app) {
     var loginForm = req.body.loginForm;
 
     if (req.method == 'POST' && req.form.isValid) {
-      var email = loginForm.email;
+      var username = loginForm.username;
       var password = loginForm.password;
 
-      User.findUserByEmailAndPassword(email, password, function(err, userData) {
-        debug('on login findUserByEmailAndPassword', err, userData);
-        if (userData) {
-          userData.updateLastLoginAt(Date.now(), function(err, userData) {
-            if (err) {
-              debug(err);
-            }
-          });
-          loginSuccess(req, res, userData);
-        } else {
-          loginFailure(req, res);
+      // find user
+      User.findUserByUsernameOrEmail(username, password, (err, user) => {
+        if (err) { return loginFailure(req, res); }
+        // check existence and password
+        if (!user || !user.isPasswordValid(password)) {
+          return loginFailure(req, res);
         }
+        return loginSuccess(req, res, user);
       });
     } else { // method GET
       if (req.form) {
@@ -254,6 +251,11 @@ module.exports = function(crowi, app) {
                 });
               }
             } else {
+              // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
+              // cz. loginSuccess method doesn't work on it's own when using passport
+              //      because `req.login()` prepared by passport is not called.
+              req.flash('successMessage', `The user '${userData.username}' is successfully created.`);
+
               return loginSuccess(req, res, userData);
             }
           }

+ 6 - 10
lib/routes/me.js

@@ -89,26 +89,22 @@ module.exports = function(crowi, app) {
         return res.render('me/index', {});
       }
 
-      User.findOne({email: email}, (err, existingUserData) => {
-        if (existingUserData) {
-          debug('Email address was duplicated');
-          req.form.errors.push('It can not be changed to that mail address');
-          return res.render('me/index', {});
-        }
-
-        userData.update(name, email, lang, (err, userData) => {
+      User.findOneAndUpdate(
+        { email: userData.email },                  // query
+        { name, email, lang },                      // updating data
+        { runValidators: true, context: 'query' },  // for validation
+                                                    //   see https://www.npmjs.com/package/mongoose-unique-validator#find--updates -- 2017.09.24 Yuki Takei
+        (err) => {
           if (err) {
             Object.keys(err.errors).forEach((e) => {
               req.form.errors.push(err.errors[e].message);
             });
             return res.render('me/index', {});
           }
-
           req.i18n.changeLanguage(lang);
           req.flash('successMessage', req.t('Updated'));
           return res.redirect('/me');
         });
-      });
 
     } else { // method GET
       /// そのうちこのコードはいらなくなるはず

+ 68 - 0
lib/service/passport.js

@@ -0,0 +1,68 @@
+const debug = require('debug')('crowi:service:PassportService');
+const passport = require('passport');
+const LocalStrategy = require('passport-local').Strategy;
+
+/**
+ * the service class of Passport
+ */
+class PassportService {
+
+  // see '/lib/form/login.js'
+  static get USERNAME_FIELD() { return 'loginForm[username]' }
+  static get PASSWORD_FIELD() { return 'loginForm[password]' }
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * setup LocalStrategy
+   *
+   * @memberof PassportService
+   */
+  setupLocalStrategy() {
+    debug('setup LocalStrategy');
+
+    const User = this.crowi.model('User');
+
+    passport.use(new LocalStrategy(
+      {
+        usernameField: PassportService.USERNAME_FIELD,
+        passwordField: PassportService.PASSWORD_FIELD,
+      },
+      (username, password, done) => {
+        // find user
+        User.findUserByUsernameOrEmail(username, password, (err, user) => {
+          if (err) { return done(err); }
+          // check existence and password
+          if (!user || !user.isPasswordValid(password)) {
+            return done(null, false, { message: 'Incorrect credentials.' });
+          }
+          return done(null, user);
+        });
+      }
+    ));
+  }
+
+  /**
+   * setup serializer and deserializer
+   *
+   * @memberof PassportService
+   */
+  setupSerializer() {
+    debug('setup serializer and deserializer');
+
+    const User = this.crowi.model('User');
+
+    passport.serializeUser(function(user, done) {
+      done(null, user.id);
+    });
+    passport.deserializeUser(function(id, done) {
+      User.findById(id, function(err, user) {
+        done(err, user);
+      });
+    });
+  }
+}
+
+module.exports = PassportService;

+ 16 - 2
lib/util/middlewares.js

@@ -1,15 +1,22 @@
 var debug = require('debug')('crowi:lib:middlewares');
 var md5 = require('md5');
 
-exports.loginChecker = function(crowi, app) {
+exports.csrfKeyGenerator = function(crowi, app) {
   return function(req, res, next) {
-    var User = crowi.model('User');
     var csrfKey = (req.session && req.session.id) || 'anon';
 
     if (req.csrfToken === null) {
       req.csrfToken = crowi.getTokens().create(csrfKey);
     }
 
+    next();
+  }
+}
+
+exports.loginChecker = function(crowi, app) {
+  return function(req, res, next) {
+    var User = crowi.model('User');
+
     // session に user object が入ってる
     if (req.session.user && '_id' in req.session.user) {
       User.findById(req.session.user._id, function(err, userData) {
@@ -29,6 +36,13 @@ exports.loginChecker = function(crowi, app) {
   };
 };
 
+exports.loginCheckerForPassport = function(crowi, app) {
+  return function(req, res, next) {
+    res.locals.user = req.user;
+    next();
+  };
+};
+
 exports.csrfVerify = function(crowi, app) {
   return function(req, res, next) {
     var token = req.body._csrf || req.query._csrf || null;

+ 1 - 108
lib/views/admin/app.html

@@ -81,70 +81,6 @@
       </fieldset>
       </form>
 
-      <form action="/_api/admin/settings/sec" method="post" class="form-horizontal" id="secSettingForm" role="form">
-      <fieldset>
-      <legend>セキュリティ設定</legend>
-
-        <div class="form-group">
-          <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">Basic認証</label>
-          <div class="col-xs-3">
-            <label for="">ID</label>
-            <input class="form-control" type="text" name="settingForm[security:basicName]"   value="{{ settingForm['security:basicName']|default('') }}">
-          </div>
-          <div class="col-xs-3">
-            <label for="">パスワード</label>
-            <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}">
-          </div>
-          <div class="col-xs-offset-3 col-xs-9">
-            <p class="help-block">
-              Basic認証を設定すると、ページ全体に共通の認証がかかります。<br>
-              IDとパスワードは暗号化されずに送信されるのでご注意下さい。<br>
-            </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">ゲストユーザーのアクセス</label>
-          <div class="col-xs-6">
-            <select class="form-control" name="settingForm[security:restrictGuestMode]" value="{{ settingForm['security:restrictGuestMode'] }}">
-              {% for modeValue, modeLabel in consts.restrictGuestMode %}
-              <option value="{{ modeValue }}" {% if modeValue == settingForm['security:restrictGuestMode'] %}selected{% endif %} >{{ modeLabel }}</option>
-              {% endfor %}
-            </select>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">登録の制限</label>
-          <div class="col-xs-6">
-            <select class="form-control" name="settingForm[security:registrationMode]" value="{{ settingForm['security:registrationMode'] }}">
-              {% for modeValue, modeLabel in consts.registrationMode %}
-              <option value="{{ modeValue }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ modeLabel }}</option>
-              {% endfor %}
-            </select>
-            <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">登録許可メールアドレスの<br>ホワイトリスト</label>
-          <div class="col-xs-8">
-            <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="例: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
-            <p class="help-block">登録可能なメールアドレスを制限することができます。例えば、会社で使う場合、<code>@crowi.wiki</code> などと記載すると、その会社のメールアドレスを持っている人のみ登録可能になります。<br>
-            1行に1メールアドレス入力してください。</p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-3 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">更新</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
       <form action="/_api/admin/settings/mail" method="post" class="form-horizontal" id="mailSettingForm" role="form">
       <fieldset>
       <legend>メールの設定</legend>
@@ -239,49 +175,6 @@
       </fieldset>
       </form>
 
-      <form action="/_api/admin/settings/google" method="post" class="form-horizontal" id="googleSettingForm" role="form">
-      <fieldset>
-      <legend>Google 設定</legend>
-        <p class="well">
-          Google Cloud Platform の <a href="https://console.cloud.google.com/apis/credentials">API Manager</a>
-          から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。
-        </p>
-
-        <ol class="help-block">
-          <li><a href="https://console.cloud.google.com/apis/credentials">API Manager</a> へアクセス</li>
-          <li>プロジェクトを作成していない場合は作成してください</li>
-          <li>「認証情報を作成」-> OAuthクライアントID</li>
-          <ol>
-            <li>「ウェブアプリケーション」を選択</li>
-            <li>承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>
-            (<code>${crowi.host}</code>は環境に合わせて変更してください)</li>
-          </ol>
-        </ol>
-
-        <div class="form-group">
-          <label for="settingForm[google:clientId]" class="col-xs-3 control-label">Client ID</label>
-          <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[google:clientId]" value="{{ settingForm['google:clientId'] }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <label for="settingForm[google:clientSecret]" class="col-xs-3 control-label">Client Secret</label>
-          <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[google:clientSecret]" value="{{ settingForm['google:clientSecret'] }}">
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-3 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">更新</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
       <form action="/_api/admin/settings/plugin" method="post" class="form-horizontal" id="pluginSettingForm" role="form">
       <fieldset>
       <legend>プラグイン設定</legend>
@@ -317,7 +210,7 @@
   </div>
 
   <script>
-    $('#appSettingForm, #secSettingForm, #mailSettingForm, #awsSettingForm, #googleSettingForm, #pluginSettingForm').each(function() {
+    $('#appSettingForm, #mailSettingForm, #awsSettingForm, #pluginSettingForm').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

+ 271 - 0
lib/views/admin/security.html

@@ -0,0 +1,271 @@
+{% 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">
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'security'} %}
+    </div>
+    <div class="col-md-9">
+
+      {% set smessage = req.flash('successMessage') %}
+      {% if smessage.length %}
+      <div class="alert alert-success">
+        {% for e in smessage %}
+          {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      {% set emessage = req.flash('errorMessage') %}
+      {% if emessage.length %}
+      <div class="alert alert-danger">
+        {% for e in emessage %}
+        {{ e }}<br>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+      <form action="/_api/admin/security/general" method="post" class="form-horizontal" id="generalSetting" role="form">
+        <fieldset>
+        <legend>基本設定</legend>
+
+          <div class="form-group">
+            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">Basic認証</label>
+            <div class="col-xs-3">
+              <label for="">ID</label>
+              <input class="form-control" type="text" name="settingForm[security:basicName]"   value="{{ settingForm['security:basicName']|default('') }}">
+            </div>
+            <div class="col-xs-3">
+              <label for="">パスワード</label>
+              <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}">
+            </div>
+            <div class="col-xs-offset-3 col-xs-9">
+              <p class="help-block">
+                Basic認証を設定すると、ページ全体に共通の認証がかかります。<br>
+                IDとパスワードは暗号化されずに送信されるのでご注意下さい。<br>
+              </p>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">ゲストユーザーのアクセス</label>
+            <div class="col-xs-6">
+              <select class="form-control" name="settingForm[security:restrictGuestMode]" value="{{ settingForm['security:restrictGuestMode'] }}">
+                {% for modeValue, modeLabel in consts.restrictGuestMode %}
+                <option value="{{ modeValue }}" {% if modeValue == settingForm['security:restrictGuestMode'] %}selected{% endif %} >{{ modeLabel }}</option>
+                {% endfor %}
+              </select>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">登録の制限</label>
+            <div class="col-xs-6">
+              <select class="form-control" name="settingForm[security:registrationMode]" value="{{ settingForm['security:registrationMode'] }}">
+                {% for modeValue, modeLabel in consts.registrationMode %}
+                <option value="{{ modeValue }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ modeLabel }}</option>
+                {% endfor %}
+              </select>
+              <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">登録許可メールアドレスの<br>ホワイトリスト</label>
+            <div class="col-xs-8">
+              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="例: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
+              <p class="help-block">登録可能なメールアドレスを制限することができます。例えば、会社で使う場合、<code>@crowi.wiki</code> などと記載すると、その会社のメールアドレスを持っている人のみ登録可能になります。<br>
+              1行に1メールアドレス入力してください。</p>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <div class="col-xs-offset-3 col-xs-6">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-primary">更新</button>
+            </div>
+          </div>
+
+        </fieldset>
+      </form>
+
+      <form action="/_api/admin/security/mechanism" method="post" class="form-horizontal" id="mechanismSetting" role="form">
+        <fieldset>
+        <legend>認証機構設定</legend>
+          <p class="alert alert-info"><b>NOTE: </b>Reboot the server and apply the changes</p>
+          <div class="form-group">
+            <div class="col-xs-6">
+              <h4>
+                <input type="radio" name="settingForm[security:isEnabledPassport]" value="false"
+                    {% if !settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
+                Official Crowi authentication mechanism
+              </h4>
+              <ul>
+                <li>Username, E-mail and Password authentication</li>
+                <li>Google OAuth2 authentication</li>
+              </ul>
+            </div>
+            <div class="col-xs-6">
+              <h4>
+                <input type="radio" name="settingForm[security:isEnabledPassport]" value="true"
+                    {% if true === settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
+                <a href="http://passportjs.org/">
+                  <img src="/images/admin/security/passport-logo.svg" class="passport-logo"> Passport
+                </a> authentication mechanism <small class="text-success">(Recommended)</small>
+              </h4>
+              <ul>
+                <li>Username, E-mail and Password authentication</li>
+                <li class="text-muted">(TBD) <del>LDAP authentication</del></li>
+                <li class="text-muted">(TBD) <del>Google OAuth2 authentication</del></li>
+                <li class="text-muted">(TBD) <del>Facebook OAuth2 authentication</del></li>
+                <li class="text-muted">(TBD) <del>Twitter OAuth authentication</del></li>
+                <li class="text-muted">(TBD) <del>Github OAuth2 authentication</del></li>
+              </ul>
+            </div>
+          </div>
+
+          <div class="form-group">
+            <div class="col-xs-offset-5 col-xs-6">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-primary">更新</button>
+            </div>
+          </div>
+      </form>
+
+      <form action="/_api/admin/security/google" method="post" class="form-horizontal officialCrowiMechanism" id="googleSetting" role="form"
+          {% if true === settingForm['security:isEnabledPassport'] %}style="display: none;"{% endif %}>
+        <fieldset>
+          <h3>Google 設定</h3>
+          <p class="well">
+            Google Cloud Platform の <a href="https://console.cloud.google.com/apis/credentials">API Manager</a>
+            から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。
+          </p>
+
+          <ol class="help-block">
+            <li><a href="https://console.cloud.google.com/apis/credentials">API Manager</a> へアクセス</li>
+            <li>プロジェクトを作成していない場合は作成してください</li>
+            <li>「認証情報を作成」-> OAuthクライアントID</li>
+            <ol>
+              <li>「ウェブアプリケーション」を選択</li>
+              <li>承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>
+              (<code>${crowi.host}</code>は環境に合わせて変更してください)</li>
+            </ol>
+          </ol>
+
+          <div class="form-group">
+            <label for="settingForm[google:clientId]" class="col-xs-3 control-label">Client ID</label>
+            <div class="col-xs-6">
+              <input class="form-control" type="text" name="settingForm[google:clientId]" value="{{ settingForm['google:clientId'] }}">
+            </div>
+          </div>
+
+          <div class="form-group">
+            <label for="settingForm[google:clientSecret]" class="col-xs-3 control-label">Client Secret</label>
+            <div class="col-xs-6">
+              <input class="form-control" type="text" name="settingForm[google:clientSecret]" value="{{ settingForm['google:clientSecret'] }}">
+            </div>
+          </div>
+
+          <div class="form-group">
+            <div class="col-xs-offset-3 col-xs-6">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-primary">更新</button>
+            </div>
+          </div>
+
+        </fieldset>
+      </form>
+
+      <form action="/_api/admin/security/ldap" method="post" class="form-horizontal passportStrategy" id="ldapSetting" role="form"
+          {% if !settingForm['security:isEnabledPassport'] %}style="display: none;"{% endif %}>
+        <fieldset>
+          <h3>[Passport] LDAP Strategy</h3>
+          <p class="well">
+            (TBD)
+          </p>
+
+        </fieldset>
+      </form>
+
+    </div>
+  </div>
+
+  <script>
+    $('#generalSetting, #googleSetting, #mechanismSetting').each(function() {
+      $(this).submit(function()
+      {
+        function showMessage(formId, msg, status) {
+          $('#' + formId + ' > .alert').remove();
+
+          if (!status) {
+            status = 'success';
+          }
+          var $message = $('<p class="alert"></p>');
+          $message.addClass('alert-' + status);
+          $message.html(msg.replace('\n', '<br>'));
+          $message.insertAfter('#' + formId + ' legend');
+
+          if (status == 'success') {
+            setTimeout(function()
+            {
+              $message.fadeOut({
+                complete: function() {
+                  $message.remove();
+                }
+              });
+            }, 5000);
+          }
+        }
+
+        var $form = $(this);
+        var $id = $form.attr('id');
+        var $button = $('button', this);
+        $button.attr('disabled', 'disabled');
+        var jqxhr = $.post($form.attr('action'), $form.serialize(), function(data)
+          {
+            if (data.status) {
+              showMessage($id, '更新しました');
+            } else {
+              showMessage($id, data.message, 'danger');
+            }
+          })
+          .fail(function() {
+            showMessage($id, 'エラーが発生しました', 'danger');
+          })
+          .always(function() {
+            $button.prop('disabled', false);
+        });
+        return false;
+      });
+    });
+
+    // switch display according to on / off of radio buttons
+    $('input[name="settingForm[security:isEnabledPassport]"]:radio').change(function() {
+      const isEnabledPassport = ($(this).val() === "true");
+
+      if (isEnabledPassport) {
+        $('form.officialCrowiMechanism').hide(400);
+        $('form.passportStrategy').show(400);
+      }
+      else {
+        $('form.officialCrowiMechanism').show(400);
+        $('form.passportStrategy').hide(400);
+      }
+    });
+  </script>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}

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

@@ -4,6 +4,7 @@
 <ul class="nav nav-pills nav-stacked">
   <li class="{% if current == 'index'%}active{% endif %}"><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
   <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
+  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="fa fa-shield"></i> セキュリティ設定</a></li>
   <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="fa fa-pencil"></i> Markdown設定</a></li>
   <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="fa fa-object-group"></i> カスタマイズ</a></li>
   <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>

+ 2 - 0
lib/views/layout/layout.html

@@ -70,6 +70,8 @@
   data-me="{{ user._id.toString() }}"
   data-plugin-enabled="{{ isEnabledPlugins() }}"
  {% block html_base_attr %}{% endblock %}
+  data-csrftoken="{{ csrf() }}"
+  data-current-username="{% if user %}{{ user.username }}{% endif %}"
  >
 
 {% block layout_head_nav %}

+ 8 - 7
lib/views/layout/single.html

@@ -17,12 +17,13 @@
 {% endblock %} {# layout_main #}
 
 {% block footer %}
-<div id="footer-container" class="footer">
-  <footer class="">
-    <p>
-      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
-      <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
-    </p>
-  </footer>
+{% parent %}
+<div class="system-version">
+  <span>
+    <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
+  </span>
+  <span>
+    <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
+  </span>
 </div>
 {% endblock %}

+ 12 - 5
lib/views/login.html

@@ -21,10 +21,17 @@
     <h2>{{ t('Sign in') }}</h2>
 
     <div id="login-form-errors">
-      {% set message = req.flash('warningMessage') %}
-      {% if message.length %}
+      {% set success = req.flash('successMessage') %}
+      {% if success.length %}
+      <div class="alert alert-success">
+        {{ success }}
+      </div>
+      {% endif %}
+
+      {% set warn = req.flash('warningMessage') %}
+      {% if warn.length %}
       <div class="alert alert-danger">
-        {{ message }}
+        {{ warn }}
       </div>
       {% endif %}
 
@@ -40,8 +47,8 @@
     </div>
     <form role="form" action="/login" method="post">
       <div class="input-group">
-        <span class="input-group-addon"><i class="fa fa-envelope"></i></span>
-        <input type="text" class="form-control" placeholder="E-mail" name="loginForm[email]">
+        <span class="input-group-addon"><i class="fa fa-user"></i></span>
+        <input type="text" class="form-control" placeholder="Username or E-mail" name="loginForm[username]">
       </div>
 
       <div class="input-group">

+ 0 - 2
lib/views/not_found.html

@@ -38,12 +38,10 @@
   data-path-shortname="{{ path|path2name }}"
   data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-current-username="{% if user %}{{ user.username }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
-  data-csrftoken="{{ csrf() }}"
   >
 
   <ul class="nav nav-tabs hidden-print">

+ 0 - 2
lib/views/page.html

@@ -54,12 +54,10 @@
   data-path-shortname="{{ path|path2name }}"
   data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-current-username="{% if user %}{{ user.username }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
-  data-csrftoken="{{ csrf() }}"
   >
 
   {% if not page %}

+ 0 - 2
lib/views/page_list.html

@@ -77,12 +77,10 @@
   data-page-portal="{% if page and page.isPortal() %}1{% else %}0{% endif %}"
   data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-current-username="{% if user %}{{ user.username }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
-  data-csrftoken="{{ csrf() }}"
   >
 
 <div class="portal {% if not page or req.query.offset > 0 %}hide{% endif %}">

+ 20 - 16
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "2.0.9-RC",
+  "version": "2.1.0-RC",
   "description": "Enhanced Crowi",
   "tags": [
     "wiki",
@@ -44,6 +44,7 @@
     "webpack": "webpack"
   },
   "dependencies": {
+    "8fold-marked": "^0.3.7",
     "assets-webpack-plugin": "~3.5.1",
     "async": "^2.3.0",
     "aws-sdk": "^2.80.0",
@@ -63,20 +64,20 @@
     "crowi-pluginkit": "^1.1.0",
     "csrf": "~3.0.3",
     "css-loader": "^0.28.0",
-    "debug": "^3.0.0",
+    "debug": "^3.1.0",
     "diff": "^3.2.0",
     "diff2html": "^2.3.0",
     "elasticsearch": "^13.2.0",
     "emojify.js": "^1.1.0",
-    "env-cmd": "^5.0.0",
+    "env-cmd": "^7.0.0",
     "escape-string-regexp": "^1.0.5",
-    "express": "~4.15.2",
+    "express": "^4.16.1",
     "express-form": "~0.12.0",
     "express-pino-logger": "^2.0.0",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
-    "file-loader": "^0.11.1",
-    "googleapis": "^21.3.0",
+    "file-loader": "^1.1.0",
+    "googleapis": "^22.0.0",
     "graceful-fs": "^4.1.11",
     "highlight.js": "^9.10.0",
     "i18next": "^9.0.0",
@@ -85,26 +86,28 @@
     "i18next-sprintf-postprocessor": "^0.2.2",
     "inline-attachment": "~2.0.3",
     "jquery.cookie": "~1.4.1",
-    "marked": "~0.3.6",
     "md5": "^2.2.1",
-    "method-override": "~2.3.1",
+    "method-override": "^2.3.10",
     "mkdirp": "~0.5.1",
     "moment": "^2.18.0",
     "mongoose": "^4.11.1",
     "mongoose-paginate": "5.0.x",
+    "mongoose-unique-validator": "^1.0.6",
     "multer": "~1.3.0",
     "node-sass": "^4.5.0",
     "nodemailer": "^4.0.1",
     "nodemailer-ses-transport": "~1.5.0",
     "normalize-path": "^2.1.1",
     "optimize-js-plugin": "0.0.4",
+    "passport": "^0.4.0",
+    "passport-local": "^1.0.0",
     "pino-clf": "^1.0.2",
     "plantuml-encoder": "^1.2.4",
-    "react": "^15.5.0",
+    "react": "^16.0.0",
     "react-bootstrap": "^0.31.0",
-    "react-bootstrap-typeahead": "^1.3.0",
-    "react-clipboard.js": "^1.0.1",
-    "react-dom": "^15.5.0",
+    "react-bootstrap-typeahead": "^1.4.2",
+    "react-clipboard.js": "^1.1.2",
+    "react-dom": "^16.0.0",
     "redis": "^2.7.1",
     "reveal.js": "^3.5.0",
     "rimraf": "^2.6.1",
@@ -128,12 +131,13 @@
     "mocha": "^3.5.0",
     "morgan": "^1.8.2",
     "node-dev": "^3.1.3",
-    "sinon": "^3.0.0",
-    "sinon-chai": "^2.12.0"
+    "sinon": "^4.0.0",
+    "sinon-chai": "^2.13.0"
   },
   "engines": {
-    "node": "6.x",
-    "npm": "4.x"
+    "node": "6.11.3",
+    "npm": "5.4.2",
+    "yarn": "1.1.0"
   },
   "config": {
     "blanket": {

+ 21 - 0
public/images/admin/security/passport-logo.svg

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="400px" height="500px" viewBox="0 0 400 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
+    <!-- Generator: Sketch 3.3.1 (12002) - http://www.bohemiancoding.com/sketch -->
+    <title>Group</title>
+    <desc>Created with Sketch.</desc>
+    <defs/>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
+        <g id="passport_logo_final" sketch:type="MSLayerGroup" transform="translate(-400.000000, -350.000000)">
+            <g id="Group" sketch:type="MSShapeGroup">
+                <g id="Shape">
+                    <g transform="translate(400.000000, 350.000000)">
+                        <path d="M200,0 C89.5,0 0,89.5 0,200 L100,200 C100,144.8 144.8,100 200,100 L200,0 L200,0 Z" fill="#D6FF00"/>
+                        <path d="M400,200 C400,89.5 310.5,0 200,0 L200,100 C255.2,100 300,144.8 300,200 L400,200 L400,200 Z" fill="#34E27A"/>
+                        <path d="M200,400 C310.5,400 400,310.5 400,200 L300,200 C300,255.2 255.2,300 200,300 L200,400 L200,400 Z" fill="#00B9F1"/>
+                        <path d="M100,400 L100,200 L0,200 L0,500 L200,500 L200,400 L100,400 Z" fill="#FFFFFF"/>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 6 - 0
resource/css/_admin.scss

@@ -42,4 +42,10 @@
     font-family: $font-family-monospace;
   }
 
+  .passport-logo {
+    background: url("/images/admin/security/passport_logo.svg") center left no-repeat;
+    padding: 4px;
+    height: 32px;
+    background-color: black;
+  }
 } // }}}

+ 12 - 7
resource/css/_search.scss

@@ -80,26 +80,31 @@
         overflow-y: scroll;
       }
       .nav {
-
         > li {
-          padding: 0px 11px 0 8px;
+          padding: 0px 10px 0 0;
           &.active {
-            padding: 0px 8px;
+            padding-right: 7px;
             border-right: solid 3px #666;
             background: #f0f0f0;
           }
         }
       }
     }
-  }
-
-  .search-result-content {
-    padding-bottom: 32px;
 
     .search-result-meta {
       margin-bottom: 16px;
       font-weight: bold;
     }
+
+    .search-result-list-delete-checkbox {
+      margin: 0 10px 0 0;
+      vertical-align: middle;
+    }
+  }
+
+  .search-result-content {
+    padding-bottom: 32px;
+
     .search-result-page {
       > h2 {
         font-size: 20px;

+ 2 - 2
resource/js/app.js

@@ -39,8 +39,8 @@ if (mainContent !== null) {
 
 // FIXME
 const crowi = new Crowi({
-  me: $('#content-main').data('current-username'),
-  csrfToken: $('#content-main').data('csrftoken'),
+  me: $('body').data('current-username'),
+  csrfToken: $('body').data('csrftoken'),
 }, window);
 window.crowi = crowi;
 crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));

+ 6 - 5
resource/js/components/CopyButton.js

@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import ClipboardButton from 'react-clipboard.js';
 
 export default class CopyButton extends React.Component {
@@ -42,11 +43,11 @@ export default class CopyButton extends React.Component {
 }
 
 CopyButton.propTypes = {
-  text: React.PropTypes.string.isRequired,
-  buttonId: React.PropTypes.string.isRequired,
-  buttonClassName: React.PropTypes.string.isRequired,
-  buttonStyle: React.PropTypes.object,
-  iconClassName: React.PropTypes.string.isRequired,
+  text: PropTypes.string.isRequired,
+  buttonId: PropTypes.string.isRequired,
+  buttonClassName: PropTypes.string.isRequired,
+  buttonStyle: PropTypes.object,
+  iconClassName: PropTypes.string.isRequired,
 };
 CopyButton.defaultProps = {
   buttonId: 'btnCopy',

+ 1 - 1
resource/js/components/Page/PageBody.js

@@ -59,7 +59,7 @@ PageBody.propTypes = {
   page: PropTypes.object.isRequired,
   highlightKeywords: PropTypes.string,
   pageBody: PropTypes.string,
-  rendererOptions: React.PropTypes.object,
+  rendererOptions: PropTypes.object,
 };
 
 PageBody.defaultProps = {

+ 4 - 2
resource/js/components/Page/RevisionUrl.js

@@ -1,4 +1,6 @@
 import React from 'react';
+import PropTypes from 'prop-types';
+
 import CopyButton from '../CopyButton';
 
 export default class RevisionUrl extends React.Component {
@@ -31,6 +33,6 @@ export default class RevisionUrl extends React.Component {
 }
 
 RevisionUrl.propTypes = {
-  pageId: React.PropTypes.string.isRequired,
-  pagePath: React.PropTypes.string.isRequired,
+  pageId: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
 };

+ 1 - 1
resource/js/components/PageCommentFormBehavior.js

@@ -59,6 +59,6 @@ export default class PageCommentFormBehavior extends React.Component {
 }
 
 PageCommentFormBehavior.propTypes = {
-  pageComments: React.PropTypes.instanceOf(PageComments),
+  pageComments: PropTypes.instanceOf(PageComments),
   crowi: PropTypes.object.isRequired,
 };

+ 2 - 0
resource/js/components/PageListSearch.js

@@ -151,6 +151,7 @@ export default class PageListSearch extends React.Component {
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
           searchError={this.state.searchError}
+          crowi={this.props.crowi}
           />
       </div>
     );
@@ -159,6 +160,7 @@ export default class PageListSearch extends React.Component {
 
 PageListSearch.propTypes = {
   query: PropTypes.object,
+  crowi: PropTypes.object.isRequired,
 };
 PageListSearch.defaultProps = {
   //pollInterval: 1000,

+ 2 - 0
resource/js/components/SearchPage.js

@@ -104,6 +104,7 @@ export default class SearchPage extends React.Component {
           pages={this.state.searchedPages}
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
+          crowi={this.props.crowi}
           />
       </div>
     );
@@ -112,6 +113,7 @@ export default class SearchPage extends React.Component {
 
 SearchPage.propTypes = {
   query: PropTypes.object,
+  crowi: PropTypes.object.isRequired,
 };
 SearchPage.defaultProps = {
   //pollInterval: 1000,

+ 61 - 0
resource/js/components/SearchPage/DeletePageListModal.js

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Button, Modal } from 'react-bootstrap';
+import moment from 'moment';
+
+import ReactUtils from '../ReactUtils';
+
+export default class DeletePageListModal extends React.Component {
+
+  /*
+   * the threshold for omitting body
+   */
+  static get OMIT_BODY_THRES() { return 400 };
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentWillMount() {
+  }
+
+  render() {
+    if (this.props.pages === undefined || this.props.pages.length == 0) {
+      return <div></div>
+    }
+
+    const listView = this.props.pages.map((page) => {
+      return (
+        <li key={page._id}>{page.path}</li>
+      );
+    });
+
+    return (
+      <Modal show={this.props.isShown} onHide={this.props.cancel} className="page-list-delete-modal">
+        <Modal.Header closeButton>
+          <Modal.Title>Deleting pages:</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <ul>
+            {listView}
+          </ul>
+        </Modal.Body>
+        <Modal.Footer>
+          <span className="text-danger">{this.props.errorMessage}</span>&nbsp;
+          <Button onClick={this.props.cancel}>Cancel</Button>
+          <Button onClick={this.props.confirmedToDelete} className="btn-danger">Delete</Button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+DeletePageListModal.propTypes = {
+  isShown: PropTypes.bool.isRequired,
+  pages: PropTypes.array,
+  errorMessage: PropTypes.string,
+  cancel: PropTypes.func.isRequired,            // for cancel evnet handling
+  confirmedToDelete: PropTypes.func.isRequired, // for confirmed event handling
+};

+ 130 - 3
resource/js/components/SearchPage/SearchResult.js

@@ -3,9 +3,21 @@ import PropTypes from 'prop-types';
 
 import Page from '../PageList/Page';
 import SearchResultList from './SearchResultList';
+import DeletePageListModal from './DeletePageListModal';
 
 // Search.SearchResult
 export default class SearchResult extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      deletionMode : false,
+      selectedPages : new Set(),
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    }
+    this.deleteSelectedPages = this.deleteSelectedPages.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+  }
 
   isNotSearchedYet() {
     return !this.props.searchResultMeta.took;
@@ -22,6 +34,84 @@ export default class SearchResult extends React.Component {
     return false;
   }
 
+  /**
+   * toggle checkbox and add (or delete from) selected pages list
+   *
+   * @param {any} page
+   * @memberof SearchResult
+   */
+  toggleCheckbox(page) {
+    if (this.state.selectedPages.has(page)) {
+      this.state.selectedPages.delete(page);
+    } else {
+      this.state.selectedPages.add(page);
+    }
+    this.setState({isDeleteConfirmModalShown: false});
+    this.setState({selectedPages: this.state.selectedPages});
+  }
+
+  /**
+   * change deletion mode
+   *
+   * @memberof SearchResult
+   */
+  handleDeletionModeChange() {
+    this.state.selectedPages.clear();
+    this.setState({deletionMode: !this.state.deletionMode});
+  }
+
+  /**
+   * delete selected pages
+   *
+   * @memberof SearchResult
+   */
+  deleteSelectedPages() {
+    let isDeleteComplete = true;
+    Array.from(this.state.selectedPages).map((page) => {
+      const pageId = page._id;
+      const revisionId = page.revision._id;
+      this.props.crowi.apiPost('/pages.remove',
+        {page_id: pageId, revision_id: revisionId})
+      .then(res => {
+        if (res.ok) {
+          this.state.selectedPages.delete(page);
+        }
+        else {
+          isDeleteComplete = false;
+        }
+      }).catch(err => {
+        console.log(err.message);
+        isDeleteComplete = false;
+        this.setState({errorMessageForDeleting: err.message});
+      });
+    });
+
+    if ( isDeleteComplete ) {
+      window.location.reload();
+    }
+  }
+
+  /**
+   * open confirm modal for page selection delete
+   *
+   * @memberof SearchResult
+   */
+  showDeleteConfirmModal() {
+    this.setState({isDeleteConfirmModalShown: true});
+  }
+
+  /**
+   * close confirm modal for page selection delete
+   *
+   * @memberof SearchResult
+   */
+  closeDeleteConfirmModal() {
+    this.setState({
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    });
+  }
+
   render() {
     const excludePathString = this.props.tree;
 
@@ -52,6 +142,24 @@ export default class SearchResult extends React.Component {
 
     }
 
+    let deletionModeButtons = '';
+
+    if (this.state.deletionMode) {
+      deletionModeButtons =
+      <div className="btn-group">
+        <button type="button" className="btn btn-danger btn-xs" onClick={() => this.showDeleteConfirmModal()} disabled={this.state.selectedPages.size == 0}><i className="fa fa-trash-o"/> Delete</button>
+        <button type="button" className="btn btn-default btn-xs" onClick={() => this.handleDeletionModeChange()}><i className="fa fa-undo"/> Cancel</button>
+      </div>
+    }
+    else {
+      deletionModeButtons =
+      <div className="btn-group">
+        <button type="button" className="btn btn-default btn-xs" onClick={() => this.handleDeletionModeChange()}>
+          <i className="fa fa-toggle-off"/> DeletionMode
+        </button>
+      </div>
+    }
+
     const listView = this.props.pages.map((page) => {
       const pageId = "#" + page._id;
       return (
@@ -60,8 +168,14 @@ export default class SearchResult extends React.Component {
           key={page._id}
           excludePathString={excludePathString}
           >
+          { this.state.deletionMode &&
+            <input type="checkbox" className="search-result-list-delete-checkbox"
+              value={pageId}
+              checked={this.state.selectedPages.has(page)}
+              onClick={() => this.toggleCheckbox(page)} />
+            }
           <div className="page-list-option">
-            <a href={page.path}><i className="fa fa-arrow-circle-right" /></a>
+            <a href={page.path}><i className="fa fa-sign-in" /></a>
           </div>
         </Page>
       );
@@ -81,20 +195,32 @@ export default class SearchResult extends React.Component {
         <div className="search-result row" id="search-result">
           <div className="col-md-4 hidden-xs hidden-sm page-list search-result-list" id="search-result-list">
             <nav data-spy="affix" data-offset-top="120">
+              <div className="pull-right">{deletionModeButtons}</div>
+              <div className="search-result-meta">
+                <i className="fa fa-lightbulb-o" /> Found {this.props.searchResultMeta.total} pages with "{this.props.searchingKeyword}"
+              </div>
+              <div className="clearfix"></div>
               <ul className="page-list-ul page-list-ul-flat nav">
                 {listView}
               </ul>
             </nav>
           </div>
           <div className="col-md-8 search-result-content" id="search-result-content">
-            <div className="search-result-meta"><i className="fa fa-lightbulb-o" /> Found {this.props.searchResultMeta.total} pages with "{this.props.searchingKeyword}"</div>
             <SearchResultList
               pages={this.props.pages}
               searchingKeyword={this.props.searchingKeyword}
               />
           </div>
         </div>
-      </div>
+        <DeletePageListModal
+          isShown={this.state.isDeleteConfirmModalShown}
+          pages={Array.from(this.state.selectedPages)}
+          errorMessage={this.state.errorMessageForDeleting}
+          cancel={this.closeDeleteConfirmModal}
+          confirmedToDelete={this.deleteSelectedPages}
+        />
+
+      </div>//content-main
     );
   }
 }
@@ -104,6 +230,7 @@ SearchResult.propTypes = {
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
+  crowi: PropTypes.object.isRequired,
 };
 SearchResult.defaultProps = {
   tree: '',

+ 1 - 1
resource/js/util/CrowiRenderer.js

@@ -1,4 +1,4 @@
-import marked from 'marked';
+import marked from '8fold-marked';
 import hljs from 'highlight.js';
 
 import MarkdownFixer from './PreProcessor/MarkdownFixer';

+ 278 - 74
yarn.lock

@@ -2,6 +2,10 @@
 # yarn lockfile v1
 
 
+"8fold-marked@^0.3.7":
+  version "0.3.7"
+  resolved "https://registry.yarnpkg.com/8fold-marked/-/8fold-marked-0.3.7.tgz#dd8b40de0b290520782f7d5f6acd105887e5227b"
+
 "@ciscospark/common-timers@1.1.11":
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/@ciscospark/common-timers/-/common-timers-1.1.11.tgz#a30f6fccf2a151804b4a9c690b0500d19e2cc676"
@@ -198,7 +202,7 @@ accepts@1.3.3:
     mime-types "~2.1.11"
     negotiator "0.6.1"
 
-accepts@~1.3.3:
+accepts@~1.3.3, accepts@~1.3.4:
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.4.tgz#86246758c7dd6d21a6474ff084a4740ec05eb21f"
   dependencies:
@@ -509,7 +513,7 @@ autoprefixer@^6.3.1:
     postcss "^5.2.16"
     postcss-value-parser "^3.2.3"
 
-aws-sdk@^2.2.36, aws-sdk@^2.80.0:
+aws-sdk@^2.2.36:
   version "2.113.0"
   resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.113.0.tgz#8fb95f4654e851c761603157c7cf7a431af6fd6d"
   dependencies:
@@ -524,6 +528,21 @@ aws-sdk@^2.2.36, aws-sdk@^2.80.0:
     xml2js "0.4.17"
     xmlbuilder "4.2.1"
 
+aws-sdk@^2.80.0:
+  version "2.123.0"
+  resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.123.0.tgz#245283323ce73e3d39d0a166861451ce129ca8be"
+  dependencies:
+    buffer "4.9.1"
+    crypto-browserify "1.0.9"
+    events "^1.1.1"
+    jmespath "0.15.0"
+    querystring "0.2.0"
+    sax "1.2.1"
+    url "0.10.3"
+    uuid "3.0.1"
+    xml2js "0.4.17"
+    xmlbuilder "4.2.1"
+
 aws-sign2@~0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
@@ -1129,10 +1148,16 @@ basic-auth-connect@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/basic-auth-connect/-/basic-auth-connect-1.0.0.tgz#fdb0b43962ca7b40456a7c2bb48fe173da2d2122"
 
-basic-auth@^1.1.0, basic-auth@~1.1.0:
+basic-auth@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-1.1.0.tgz#45221ee429f7ee1e5035be3f51533f1cdfd29884"
 
+basic-auth@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.0.tgz#015db3f353e02e56377755f962742e8981e7bbba"
+  dependencies:
+    safe-buffer "5.1.1"
+
 bcrypt-pbkdf@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d"
@@ -1185,13 +1210,13 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
 
-body-parser@^1.17.1:
-  version "1.18.1"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.1.tgz#9c1629370bcfd42917f30641a2dcbe2ec50d4c26"
+body-parser@1.18.2, body-parser@^1.17.1:
+  version "1.18.2"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454"
   dependencies:
     bytes "3.0.0"
     content-type "~1.0.4"
-    debug "2.6.8"
+    debug "2.6.9"
     depd "~1.1.1"
     http-errors "~1.6.2"
     iconv-lite "0.4.19"
@@ -1232,8 +1257,8 @@ botkit-studio-sdk@^1.0.2:
     request "^2.67.0"
 
 botkit@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/botkit/-/botkit-0.6.0.tgz#22c69d480cfc63a40df90acb119cc98d7f8e01d6"
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/botkit/-/botkit-0.6.2.tgz#ab432c1b4c6a53fcfa9f41444ac26e908f8fe9ef"
   dependencies:
     async "^2.1.5"
     back "^1.0.1"
@@ -1861,7 +1886,7 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
-create-react-class@^15.5.2, create-react-class@^15.5.x, create-react-class@^15.6.0:
+create-react-class@^15.5.2, create-react-class@^15.5.x:
   version "15.6.0"
   resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.0.tgz#ab448497c26566e1e29413e883207d57cfe7bed4"
   dependencies:
@@ -2041,7 +2066,7 @@ debounce@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.0.2.tgz#503cc674d8d7f737099664fb75ddbd36b9626dc6"
 
-debug@*, debug@^3.0.0:
+debug@*:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.0.1.tgz#0564c612b521dc92d9f2988f0549e34f9c98db64"
   dependencies:
@@ -2053,6 +2078,18 @@ debug@2.6.8, debug@^2.2.0, debug@^2.4.1, debug@^2.4.5, debug@^2.6.6, debug@^2.6.
   dependencies:
     ms "2.0.0"
 
+debug@2.6.9:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  dependencies:
+    ms "2.0.0"
+
+debug@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+  dependencies:
+    ms "2.0.0"
+
 debug@~0.7.0:
   version "0.7.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39"
@@ -2098,7 +2135,7 @@ delegates@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
 
-depd@1.1.1, depd@~1.1.0, depd@~1.1.1:
+depd@1.1.1, depd@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
 
@@ -2313,9 +2350,9 @@ enhanced-resolve@^3.4.0:
     object-assign "^4.0.1"
     tapable "^0.2.7"
 
-env-cmd@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-5.1.0.tgz#0236db393c3f033005204fcd0a92ee40723a9c9e"
+env-cmd@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-7.0.0.tgz#d1fcfea6e0cbe6bf50b7130221d568907b6349bd"
   dependencies:
     cross-spawn "^5.0.1"
 
@@ -2478,6 +2515,10 @@ etag@~1.8.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051"
 
+etag@~1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+
 event-emitter@~0.3.5:
   version "0.3.5"
   resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
@@ -2539,24 +2580,24 @@ express-pino-logger@^2.0.0:
     pino-http "^2.0.0"
 
 express-session@~1.15.0:
-  version "1.15.5"
-  resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.15.5.tgz#f49a18227263b316f6f8544da5fee25a540259ec"
+  version "1.15.6"
+  resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.15.6.tgz#47b4160c88f42ab70fe8a508e31cbff76757ab0a"
   dependencies:
     cookie "0.3.1"
     cookie-signature "1.0.6"
     crc "3.4.4"
-    debug "2.6.8"
+    debug "2.6.9"
     depd "~1.1.1"
     on-headers "~1.0.1"
-    parseurl "~1.3.1"
-    uid-safe "~2.1.4"
-    utils-merge "1.0.0"
+    parseurl "~1.3.2"
+    uid-safe "~2.1.5"
+    utils-merge "1.0.1"
 
 express-webpack-assets@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/express-webpack-assets/-/express-webpack-assets-0.1.0.tgz#000fb3413eb0d512cbd6cd3f6a10b5e70dbe0079"
 
-express@^4.15.2, express@~4.15.2:
+express@^4.15.2:
   version "4.15.4"
   resolved "https://registry.yarnpkg.com/express/-/express-4.15.4.tgz#032e2253489cf8fce02666beca3d11ed7a2daed1"
   dependencies:
@@ -2589,6 +2630,41 @@ express@^4.15.2, express@~4.15.2:
     utils-merge "1.0.0"
     vary "~1.1.1"
 
+express@^4.16.1:
+  version "4.16.1"
+  resolved "https://registry.yarnpkg.com/express/-/express-4.16.1.tgz#6b33b560183c9b253b7b62144df33a4654ac9ed0"
+  dependencies:
+    accepts "~1.3.4"
+    array-flatten "1.1.1"
+    body-parser "1.18.2"
+    content-disposition "0.5.2"
+    content-type "~1.0.4"
+    cookie "0.3.1"
+    cookie-signature "1.0.6"
+    debug "2.6.9"
+    depd "~1.1.1"
+    encodeurl "~1.0.1"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    finalhandler "1.1.0"
+    fresh "0.5.2"
+    merge-descriptors "1.0.1"
+    methods "~1.1.2"
+    on-finished "~2.3.0"
+    parseurl "~1.3.2"
+    path-to-regexp "0.1.7"
+    proxy-addr "~2.0.2"
+    qs "6.5.1"
+    range-parser "~1.2.0"
+    safe-buffer "5.1.1"
+    send "0.16.1"
+    serve-static "1.13.1"
+    setprototypeof "1.1.0"
+    statuses "~1.3.1"
+    type-is "~1.6.15"
+    utils-merge "1.0.1"
+    vary "~1.1.2"
+
 extend@^3.0.0, extend@~3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
@@ -2619,6 +2695,18 @@ fastparse@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8"
 
+fbjs@^0.8.16:
+  version "0.8.16"
+  resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
+  dependencies:
+    core-js "^1.0.0"
+    isomorphic-fetch "^2.1.1"
+    loose-envify "^1.0.0"
+    object-assign "^4.1.0"
+    promise "^7.1.1"
+    setimmediate "^1.0.5"
+    ua-parser-js "^0.7.9"
+
 fbjs@^0.8.9:
   version "0.8.15"
   resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.15.tgz#4f0695fdfcc16c37c0b07facec8cb4c4091685b9"
@@ -2631,11 +2719,12 @@ fbjs@^0.8.9:
     setimmediate "^1.0.5"
     ua-parser-js "^0.7.9"
 
-file-loader@^0.11.1:
-  version "0.11.2"
-  resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-0.11.2.tgz#4ff1df28af38719a6098093b88c82c71d1794a34"
+file-loader@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-1.1.3.tgz#ad5d143d8b614114873df3661ab5c9b8ebd5345a"
   dependencies:
     loader-utils "^1.0.2"
+    schema-utils "^0.3.0"
 
 file-type@^3.9.0:
   version "3.9.0"
@@ -2661,6 +2750,18 @@ fill-range@^2.1.0:
     repeat-element "^1.1.2"
     repeat-string "^1.5.2"
 
+finalhandler@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.0.tgz#ce0b6855b45853e791b2fcc680046d88253dd7f5"
+  dependencies:
+    debug "2.6.9"
+    encodeurl "~1.0.1"
+    escape-html "~1.0.3"
+    on-finished "~2.3.0"
+    parseurl "~1.3.2"
+    statuses "~1.3.1"
+    unpipe "~1.0.0"
+
 finalhandler@~1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.4.tgz#18574f2e7c4b98b8ae3b230c21f201f31bdb3fb7"
@@ -2783,10 +2884,18 @@ forwarded@~0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.1.tgz#8a4e30c640b05395399a3549c730257728048961"
 
+forwarded@~0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+
 fresh@0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e"
 
+fresh@0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -2955,9 +3064,9 @@ google-p12-pem@^0.1.0:
   dependencies:
     node-forge "^0.7.1"
 
-googleapis@^21.3.0:
-  version "21.3.0"
-  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-21.3.0.tgz#043d0276574eb7930a9fa731768d5571eeac11ee"
+googleapis@^22.0.0:
+  version "22.2.0"
+  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-22.2.0.tgz#fd79c3c26e7e71a4f5a2ec1a7da5fb115d8853d2"
   dependencies:
     async "~2.3.0"
     google-auth-library "~0.10.0"
@@ -3243,6 +3352,10 @@ ipaddr.js@1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.4.0.tgz#296aca878a821816e5b85d0a285a99bcff4582f0"
 
+ipaddr.js@1.5.2:
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.5.2.tgz#d4b505bde9946987ccf0fc58d9010ff9607e3fa0"
+
 is-absolute-url@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
@@ -3810,7 +3923,11 @@ lodash.create@3.1.1:
     lodash._basecreate "^3.0.0"
     lodash._isiterateecall "^3.0.0"
 
-lodash.get@^4.4.2:
+lodash.foreach@^4.1.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53"
+
+lodash.get@^4.0.2, lodash.get@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
 
@@ -3986,7 +4103,7 @@ marked-terminal@^1.6.2:
     lodash.assign "^4.2.0"
     node-emoji "^1.4.1"
 
-marked@^0.3.6, marked@~0.3.6:
+marked@^0.3.6:
   version "0.3.6"
   resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.6.tgz#b2c6c618fccece4ef86c4fc6cb8a7cbf5aeda8d7"
 
@@ -4045,14 +4162,14 @@ merge-descriptors@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
 
-method-override@~2.3.1:
-  version "2.3.9"
-  resolved "https://registry.yarnpkg.com/method-override/-/method-override-2.3.9.tgz#bd151f2ce34cf01a76ca400ab95c012b102d8f71"
+method-override@^2.3.10:
+  version "2.3.10"
+  resolved "https://registry.yarnpkg.com/method-override/-/method-override-2.3.10.tgz#e3daf8d5dee10dd2dce7d4ae88d62bbee77476b4"
   dependencies:
-    debug "2.6.8"
+    debug "2.6.9"
     methods "~1.1.2"
-    parseurl "~1.3.1"
-    vary "~1.1.1"
+    parseurl "~1.3.2"
+    vary "~1.1.2"
 
 methods@~1.1.2:
   version "1.1.2"
@@ -4097,6 +4214,10 @@ mime@1.3.4:
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53"
 
+mime@1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
+
 mime@^1.2.11:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.0.tgz#69e9e0db51d44f2a3b56e48b7817d7d137f1a343"
@@ -4196,9 +4317,16 @@ mongoose-paginate@5.0.x:
   dependencies:
     bluebird "3.0.5"
 
+mongoose-unique-validator@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/mongoose-unique-validator/-/mongoose-unique-validator-1.0.6.tgz#fab31e68c1a5ba6f5b05da8e93842db55eb0a3b1"
+  dependencies:
+    lodash.foreach "^4.1.0"
+    lodash.get "^4.0.2"
+
 mongoose@^4.11.1:
-  version "4.11.11"
-  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.11.11.tgz#ae9baf860241e086c90b226fdda52f1afddc6a7b"
+  version "4.11.13"
+  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.11.13.tgz#0470cbfecbd0b9d3e77fe03a2ff6bde53a82507c"
   dependencies:
     async "2.1.4"
     bson "~1.0.4"
@@ -4214,12 +4342,12 @@ mongoose@^4.11.1:
     sliced "1.0.1"
 
 morgan@^1.8.2:
-  version "1.8.2"
-  resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.8.2.tgz#784ac7734e4a453a9c6e6e8680a9329275c8b687"
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.9.0.tgz#d01fa6c65859b76fcf31b3cb53a3821a311d8051"
   dependencies:
-    basic-auth "~1.1.0"
-    debug "2.6.8"
-    depd "~1.1.0"
+    basic-auth "~2.0.0"
+    debug "2.6.9"
+    depd "~1.1.1"
     on-finished "~2.3.0"
     on-headers "~1.0.1"
 
@@ -4290,14 +4418,15 @@ negotiator@0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
 
-nise@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-1.0.1.tgz#0da92b10a854e97c0f496f6c2845a301280b3eef"
+nise@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-1.1.0.tgz#37e41b9bf0041ccb83d1bf03e79440bbc0db10ad"
   dependencies:
     formatio "^1.2.0"
     just-extend "^1.1.22"
     lolex "^1.6.0"
     path-to-regexp "^1.7.0"
+    text-encoding "^0.6.4"
 
 node-dev@^3.1.3:
   version "3.1.3"
@@ -4438,8 +4567,8 @@ nodemailer-ses-transport@~1.5.0:
     aws-sdk "^2.2.36"
 
 nodemailer@^4.0.1:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.1.0.tgz#e3f76bcad7376bae44714552571f5b0674fe469f"
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.1.1.tgz#e84a65b423e5ca2b65b81ff39bb9aee71c3dc718"
 
 nopt@1.0.10:
   version "1.0.10"
@@ -4723,10 +4852,27 @@ parseuri@0.0.5:
   dependencies:
     better-assert "~1.0.0"
 
-parseurl@~1.3.1:
+parseurl@~1.3.1, parseurl@~1.3.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
 
+passport-local@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee"
+  dependencies:
+    passport-strategy "1.x.x"
+
+passport-strategy@1.x.x:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
+
+passport@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811"
+  dependencies:
+    passport-strategy "1.x.x"
+    pause "0.0.1"
+
 path-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a"
@@ -4781,6 +4927,10 @@ pathval@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
 
+pause@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d"
+
 pbkdf2@^3.0.3:
   version "3.0.14"
   resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.14.tgz#a35e13c64799b06ce15320f459c230e68e73bade"
@@ -5164,6 +5314,14 @@ prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.8:
     fbjs "^0.8.9"
     loose-envify "^1.3.1"
 
+prop-types@^15.6.0:
+  version "15.6.0"
+  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
+  dependencies:
+    fbjs "^0.8.16"
+    loose-envify "^1.3.1"
+    object-assign "^4.1.1"
+
 proxy-addr@~1.1.5:
   version "1.1.5"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.5.tgz#71c0ee3b102de3f202f3b64f608d173fcba1a918"
@@ -5171,6 +5329,13 @@ proxy-addr@~1.1.5:
     forwarded "~0.1.0"
     ipaddr.js "1.4.0"
 
+proxy-addr@~2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.2.tgz#6571504f47bb988ec8180253f85dd7e14952bdec"
+  dependencies:
+    forwarded "~0.1.2"
+    ipaddr.js "1.5.2"
+
 prr@~0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a"
@@ -5292,7 +5457,7 @@ rc@^1.1.7:
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
-react-bootstrap-typeahead@^1.3.0:
+react-bootstrap-typeahead@^1.4.2:
   version "1.4.2"
   resolved "https://registry.yarnpkg.com/react-bootstrap-typeahead/-/react-bootstrap-typeahead-1.4.2.tgz#1913e32cea4d79becd03acabbd9336116884815b"
   dependencies:
@@ -5324,21 +5489,21 @@ react-bootstrap@^0.31.0:
     uncontrollable "^4.1.0"
     warning "^3.0.0"
 
-react-clipboard.js@^1.0.1:
+react-clipboard.js@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/react-clipboard.js/-/react-clipboard.js-1.1.2.tgz#96c92c928d09873c14287fd5c75f395f1f911490"
   dependencies:
     clipboard "^1.6.1"
     prop-types "^15.5.0"
 
-react-dom@^15.5.0:
-  version "15.6.1"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.6.1.tgz#2cb0ed4191038e53c209eb3a79a23e2a4cf99470"
+react-dom@^16.0.0:
+  version "16.0.0"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.0.0.tgz#9cc3079c3dcd70d4c6e01b84aab2a7e34c303f58"
   dependencies:
-    fbjs "^0.8.9"
+    fbjs "^0.8.16"
     loose-envify "^1.1.0"
-    object-assign "^4.1.0"
-    prop-types "^15.5.10"
+    object-assign "^4.1.1"
+    prop-types "^15.6.0"
 
 react-highlighter@^0.4.0:
   version "0.4.0"
@@ -5378,15 +5543,14 @@ react-prop-types@^0.4.0:
   dependencies:
     warning "^3.0.0"
 
-react@^15.5.0:
-  version "15.6.1"
-  resolved "https://registry.yarnpkg.com/react/-/react-15.6.1.tgz#baa8434ec6780bde997cdc380b79cd33b96393df"
+react@^16.0.0:
+  version "16.0.0"
+  resolved "https://registry.yarnpkg.com/react/-/react-16.0.0.tgz#ce7df8f1941b036f02b2cca9dbd0cb1f0e855e2d"
   dependencies:
-    create-react-class "^15.6.0"
-    fbjs "^0.8.9"
+    fbjs "^0.8.16"
     loose-envify "^1.1.0"
-    object-assign "^4.1.0"
-    prop-types "^15.5.10"
+    object-assign "^4.1.1"
+    prop-types "^15.6.0"
 
 read-pkg-up@^1.0.1:
   version "1.0.1"
@@ -5734,7 +5898,7 @@ run-parallel@^1.1.4:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.6.tgz#29003c9a2163e01e2d2dfc90575f2c6c1d61a039"
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
 
@@ -5828,6 +5992,24 @@ send@0.15.4:
     range-parser "~1.2.0"
     statuses "~1.3.1"
 
+send@0.16.1:
+  version "0.16.1"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.16.1.tgz#a70e1ca21d1382c11d0d9f6231deb281080d7ab3"
+  dependencies:
+    debug "2.6.9"
+    depd "~1.1.1"
+    destroy "~1.0.4"
+    encodeurl "~1.0.1"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    fresh "0.5.2"
+    http-errors "~1.6.2"
+    mime "1.4.1"
+    ms "2.0.0"
+    on-finished "~2.3.0"
+    range-parser "~1.2.0"
+    statuses "~1.3.1"
+
 serve-static@1.12.4:
   version "1.12.4"
   resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.4.tgz#9b6aa98eeb7253c4eedc4c1f6fdbca609901a961"
@@ -5837,6 +6019,15 @@ serve-static@1.12.4:
     parseurl "~1.3.1"
     send "0.15.4"
 
+serve-static@1.13.1:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.1.tgz#4c57d53404a761d8f2e7c1e8a18a47dbf278a719"
+  dependencies:
+    encodeurl "~1.0.1"
+    escape-html "~1.0.3"
+    parseurl "~1.3.2"
+    send "0.16.1"
+
 set-blocking@^2.0.0, set-blocking@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
@@ -5853,6 +6044,10 @@ setprototypeof@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04"
 
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+
 sha.js@^2.4.0, sha.js@^2.4.8:
   version "2.4.8"
   resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.8.tgz#37068c2c476b6baf402d14a49c67f597921f634f"
@@ -5886,19 +6081,20 @@ signal-exit@^3.0.0:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
 
-sinon-chai@^2.12.0:
+sinon-chai@^2.13.0:
   version "2.13.0"
   resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.13.0.tgz#b9a42e801c20234bfc2f43b29e6f4f61b60990c4"
 
-sinon@^3.0.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-3.2.1.tgz#d8adabd900730fd497788a027049c64b08be91c2"
+sinon@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-4.0.0.tgz#a54a5f0237aa1dd2215e5e81c89b42b50c4fdb6b"
   dependencies:
     diff "^3.1.0"
     formatio "1.2.0"
+    lodash.get "^4.4.2"
     lolex "^2.1.2"
     native-promise-only "^0.8.1"
-    nise "^1.0.1"
+    nise "^1.1.0"
     path-to-regexp "^1.7.0"
     samsam "^1.1.3"
     text-encoding "0.6.4"
@@ -6254,7 +6450,7 @@ test-value@^2.1.0:
     array-back "^1.0.3"
     typical "^2.6.0"
 
-text-encoding@0.6.4:
+text-encoding@0.6.4, text-encoding@^0.6.4:
   version "0.6.4"
   resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
 
@@ -6431,7 +6627,7 @@ uid-safe@2.1.4:
   dependencies:
     random-bytes "~1.0.0"
 
-uid-safe@~2.1.4:
+uid-safe@~2.1.5:
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"
   dependencies:
@@ -6513,6 +6709,10 @@ utils-merge@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8"
 
+utils-merge@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+
 uuid@3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1"
@@ -6540,6 +6740,10 @@ vary@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37"
 
+vary@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+
 vendors@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22"
@@ -6610,8 +6814,8 @@ webpack-sources@^1.0.1:
     source-map "~0.5.3"
 
 webpack@^3.1.0:
-  version "3.5.6"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.5.6.tgz#a492fb6c1ed7f573816f90e00c8fbb5a20cc5c36"
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.6.0.tgz#a89a929fbee205d35a4fa2cc487be9cbec8898bc"
   dependencies:
     acorn "^5.0.0"
     acorn-dynamic-import "^2.0.0"