Explorar o código

Merge pull request #4 from crowi/feature/config-security-mode

登録制限に関する実装
Sotaro KARASAWA %!s(int64=11) %!d(string=hai) anos
pai
achega
34bba6db26

+ 4 - 2
CHANGES.md

@@ -1,11 +1,13 @@
 CHANGES
 CHANGES
 ========
 ========
 
 
-## 1.1.0
 
 
-* Feature: Basic auth restriction whole pages access.
+## 1.0.4
 
 
+* Feature: Basic auth restriction whole pages access.
+* Fix: Security registration whitelist is working now.
 
 
 ## 1.0.3
 ## 1.0.3
 
 
 * Initial Release.
 * Initial Release.
+

+ 11 - 1
README.md

@@ -41,9 +41,19 @@ Start Up on Local
 Crowi is designed setting up to Heroku or some PaaS, but you can start up Crowi with ENV parameter on your local.
 Crowi is designed setting up to Heroku or some PaaS, but you can start up Crowi with ENV parameter on your local.
 
 
 ```
 ```
-$ MONGOLAB_URI=mongodb://username:password@localhost/crowi node app.js
+$ PASSWORD_SEED=somesecretstring MONGOHQ_URL=mongodb://username:password@localhost/crowi node app.js
 ```
 ```
 
 
+### Environment
+
+
+* `PORT`: Server port. default: `3000`.
+* `NODE_ENV`: `production` OR `development`.
+* `MONGO_URI`: URI to connect MongoDB. This parameter is also by `MONGOHQ_URL` OR `MONGOLAB_URI`.
+* `PASSWORD_SEED`: A password seed is used by password hash generator.
+* `SECRET_TOKEN`: A secret key for verifying the integrity of signed cookies.
+
+
 License
 License
 ---------
 ---------
 
 

+ 20 - 6
app.js

@@ -19,9 +19,10 @@ var express  = require('express')
   , models
   , models
   , config
   , config
   , server
   , server
+  , sessionConfig
+  , RedisStore
   ;
   ;
 
 
-
 time.tzset('Asia/Tokyo');
 time.tzset('Asia/Tokyo');
 
 
 var app = express();
 var app = express();
@@ -35,6 +36,19 @@ var mongoUri = process.env.MONGOLAB_URI
 
 
 mongo.connect(mongoUri);
 mongo.connect(mongoUri);
 
 
+sessionConfig = {
+  rolling: true,
+  secret: process.env.SECRET_TOKEN || 'this is default session secret'
+};
+
+if (process.env.REDIS_URL) {
+  RedisStore = require('connect-redis')(express);
+  sessionConfig.store = new RedisStore({
+    prefix: 'crowi:sess:',
+    url: process.env.REDIS_URL
+  });
+}
+
 app.set('port', process.env.PORT || 3000);
 app.set('port', process.env.PORT || 3000);
 app.use(express.static(__dirname + '/public'));
 app.use(express.static(__dirname + '/public'));
 app.use(express.logger());
 app.use(express.logger());
@@ -45,10 +59,7 @@ app.set('views', __dirname + '/views');
 app.use(express.methodOverride());
 app.use(express.methodOverride());
 app.use(express.bodyParser());
 app.use(express.bodyParser());
 app.use(express.cookieParser());
 app.use(express.cookieParser());
-app.use(express.session({
-  rolling: true,
-  secret: process.env.SECRET_TOKEN || 'this is default session secret',
-}));
+app.use(express.session(sessionConfig));
 app.use(flash());
 app.use(flash());
 
 
 configModel = require('./models/config')(app);
 configModel = require('./models/config')(app);
@@ -63,6 +74,8 @@ async.series([
   }, function (next) {
   }, function (next) {
     var config = app.set('config');
     var config = app.set('config');
 
 
+    app.set('mailer', require('./lib/mailer')(app));
+
     models = require('./models')(app);
     models = require('./models')(app);
     models.Config = configModel;
     models.Config = configModel;
 
 
@@ -81,7 +94,7 @@ async.series([
       req.session.cookie.expires = new Date(Date.now() + days);
       req.session.cookie.expires = new Date(Date.now() + days);
       req.session.cookie.maxAge = days;
       req.session.cookie.maxAge = days;
 
 
-      req.baseUrl = (req.headers['x-forwarded-proto'] == 'https' ? 'https' : req.protocol) + "://" + req.get('host');
+      config.crowi['app:url'] = req.baseUrl = (req.headers['x-forwarded-proto'] == 'https' ? 'https' : req.protocol) + "://" + req.get('host');
       res.locals({
       res.locals({
         req: req,
         req: req,
         baseUrl: req.baseUrl,
         baseUrl: req.baseUrl,
@@ -93,6 +106,7 @@ async.series([
         consts: {
         consts: {
           pageGrants: models.Page.getGrantLabels(),
           pageGrants: models.Page.getGrantLabels(),
           userStatus: models.User.getUserStatusLabels(),
           userStatus: models.User.getUserStatusLabels(),
+          registrationMode: models.Config.getRegistrationModeLabels(),
         },
         },
       });
       });
 
 

+ 4 - 0
app.json

@@ -16,6 +16,10 @@
     "SECRET_TOKEN": {
     "SECRET_TOKEN": {
       "description": "A secret key for verifying the integrity of signed cookies.",
       "description": "A secret key for verifying the integrity of signed cookies.",
       "generator": "secret"
       "generator": "secret"
+    },
+    "PASSWORD_SEED": {
+      "description": "A password seed is used by password hash generator. ",
+      "generator": "secret"
     }
     }
   },
   },
   "addons": [
   "addons": [

+ 25 - 0
bin/password-hash-generator.js

@@ -0,0 +1,25 @@
+var crypto = require('crypto')
+  , cli = require('cli')
+  ;
+
+function generatePassword (seed, password) {
+  var hasher = crypto.createHash('sha256');
+  hasher.update(seed + password);
+
+  cli.debug("seed is: " + seed);
+  cli.debug("password is: " + password);
+  return hasher.digest('hex');
+}
+
+cli.parse({
+    seed:      [false, 'Password seed', 'string', ''],
+    password:  [false, 'Password raw string', 'string'],
+});
+
+cli.main(function(args, options)
+{
+  console.log("args", args);
+  console.log("options", options);
+
+  this.output(generatePassword(options.seed, options.password) + '\n');
+});

+ 14 - 0
form/admin/mail.js

@@ -0,0 +1,14 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('settingForm[mail:from]', 'メールFrom').trim(),
+  field('settingForm[mail:smtpHost]', 'SMTPホスト').trim(),
+  field('settingForm[mail:smtpPort]', 'SMTPポート').trim().toInt(),
+  field('settingForm[mail:smtpUser]', 'SMTPユーザー').trim(),
+  field('settingForm[mail:smtpPassword]', 'SMTPパスワード').trim()
+);
+
+

+ 5 - 2
form/admin/sec.js

@@ -1,12 +1,15 @@
 'use strict';
 'use strict';
 
 
 var form = require('express-form')
 var form = require('express-form')
-  , field = form.field;
+  , field = form.field
+  , stringToArray = require('../../lib/formUtil').stringToArrayFilter
+  , normalizeCRLF = require('../../lib/formUtil').normalizeCRLFFilter
+  ;
 
 
 module.exports = form(
 module.exports = form(
   field('settingForm[security:basicName]'),
   field('settingForm[security:basicName]'),
   field('settingForm[security:basicSecret]'),
   field('settingForm[security:basicSecret]'),
   field('settingForm[security:registrationMode]').required(),
   field('settingForm[security:registrationMode]').required(),
-  field('settingForm[security:registrationWhiteList]')
+  field('settingForm[security:registrationWhiteList]').custom(normalizeCRLF).custom(stringToArray)
 );
 );
 
 

+ 9 - 0
form/admin/userInvite.js

@@ -0,0 +1,9 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('inviteForm[emailList]', '招待メールアドレス').trim().required(),
+  field('inviteForm[sendEmail]').trim()
+);

+ 3 - 0
form/index.js

@@ -1,5 +1,6 @@
 exports.login = require('./login');
 exports.login = require('./login');
 exports.register = require('./register');
 exports.register = require('./register');
+exports.invited = require('./invited');
 exports.revision = require('./revision');
 exports.revision = require('./revision');
 exports.me = {
 exports.me = {
   user: require('./me/user'),
   user: require('./me/user'),
@@ -8,7 +9,9 @@ exports.me = {
 exports.admin = {
 exports.admin = {
   app: require('./admin/app'),
   app: require('./admin/app'),
   sec: require('./admin/sec'),
   sec: require('./admin/sec'),
+  mail: require('./admin/mail'),
   aws: require('./admin/aws'),
   aws: require('./admin/aws'),
   google: require('./admin/google'),
   google: require('./admin/google'),
   fb: require('./admin/fb'),
   fb: require('./admin/fb'),
+  userInvite: require('./admin/userInvite'),
 };
 };

+ 11 - 0
form/invited.js

@@ -0,0 +1,11 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('invitedForm.username').required().is(/^[\da-zA-Z\-_]+$/),
+  field('invitedForm.name').required(),
+  field('invitedForm.password').required().is(/^[\da-zA-Z@#$%-_&\+\*\?]{6,64}$/)
+);
+

+ 1 - 1
form/register.js

@@ -7,7 +7,7 @@ module.exports = form(
   field('registerForm.username').required().is(/^[\da-zA-Z\-_]+$/),
   field('registerForm.username').required().is(/^[\da-zA-Z\-_]+$/),
   field('registerForm.name').required(),
   field('registerForm.name').required(),
   field('registerForm.email').required(),
   field('registerForm.email').required(),
-  field('registerForm.password').required().is(/^[\da-zA-Z@#$%-_&\+\*\?]{6,40}$/),
+  field('registerForm.password').required().is(/^[\da-zA-Z@#$%-_&\+\*\?]{6,64}$/),
   field('registerForm.fbId').isInt(),
   field('registerForm.fbId').isInt(),
   field('registerForm.googleId').isInt()
   field('registerForm.googleId').isInt()
 );
 );

+ 18 - 0
lib/formUtil.js

@@ -0,0 +1,18 @@
+'use strict';
+
+module.exports = {
+  normalizeCRLFFilter: function(value) {
+    return value
+      .replace(/\r\n/g, '\n')
+      .replace(/\r/g, '\n')
+      ;
+  },
+  stringToArrayFilter: function(value) {
+    if (!value || value === '') {
+      return [];
+    }
+
+    return value.split('\n');
+  },
+};
+

+ 119 - 0
lib/mailer.js

@@ -0,0 +1,119 @@
+/**
+ * mailer
+ */
+
+module.exports = function(app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:lib:mailer')
+    , nodemailer = require("nodemailer")
+    , config = app.set('config')
+    , mailConfig = {}
+    , mailer = {}
+    ;
+
+
+  function createSMTPClient(option)
+  {
+    var client;
+
+    debug('createSMTPClient option', option);
+    if (!option) {
+      option = {
+        host: config.crowi['mail:smtpHost'],
+        port: config.crowi['mail:smtpPort'],
+        auth: {
+          user: config.crowi['mail:smtpUser'],
+          pass: config.crowi['mail:smtpPassword']
+        }
+      };
+      if (option.port === 465) {
+        option.secure = true;
+      }
+    }
+
+    client = nodemailer.createTransport(option);
+
+    debug('mailer setted up for SMTP', client);
+    return client;
+  }
+
+  function createSESClient(option)
+  {
+    var client;
+
+    if (!option) {
+      option = {
+        accessKeyId: config.crowi['aws:accessKeyId'],
+        secretAccessKey: config.crowi['aws:secretAccessKey']
+      };
+    }
+
+    var ses = require('nodemailer-ses-transport');
+    var client = nodemailer.createTransport(ses(option));
+
+    debug('mailer setted up for SES', client);
+    return client;
+  }
+
+  function initialize() {
+    if (!config.crowi['mail:from']) {
+      mailer = undefined;
+      return;
+    }
+
+    if (config.crowi['mail:smtpUser']
+        && config.crowi['mail:smtpPassword']
+        && config.crowi['mail:smtpHost']
+        && config.crowi['mail:smtpPort']
+      ) {
+      // SMTP 設定がある場合はそれを優先
+      mailer = createSMTPClient();
+
+    } else if (config.crowi['aws:accessKeyId']
+      && config.crowi['aws:secretAccessKey']) {
+      // AWS 設定がある場合はSESを設定
+      mailer = createSESClient();
+    } else {
+      mailer = undefined;
+    }
+
+    mailConfig.from = config.crowi['mail:from'];
+    mailConfig.subject = config.crowi['app:title'] + 'からのメール';
+
+    debug('mailer initialized');
+  }
+
+  function setupMailConfig (overrideConfig) {
+    var c = overrideConfig
+      , mc = {}
+      ;
+    mc = mailConfig;
+
+    mc.to      = c.to;
+    mc.from    = c.from || mailConfig.from;
+    mc.text    = c.text;
+    mc.subject = c.subject || mailConfig.subject;
+
+    return mc;
+  }
+
+  function send(config, callback) {
+    if (mailer) {
+      return mailer.sendMail(setupMailConfig(config), callback);
+    } else {
+      debug('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
+      return callback(new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.'), null);
+    }
+  }
+
+
+  initialize();
+
+  return {
+    createSMTPClient: createSMTPClient,
+    createSESClient: createSESClient,
+    mailer: mailer,
+    send: send,
+  };
+};

+ 30 - 7
lib/middlewares.js

@@ -79,16 +79,23 @@ exports.adminRequired = function() {
   };
   };
 };
 };
 
 
-exports.loginRequired = function() {
+exports.loginRequired = function(app) {
   return function(req, res, next) {
   return function(req, res, next) {
+    var models = app.set('models');
+
     if (req.user && '_id' in req.user) {
     if (req.user && '_id' in req.user) {
-      // TODO 移行おわったら削除
-      if (req.user.email && !req.user.password && req.route.path != '/me/password') {
-        return res.redirect('/me/password');
+      if (req.user.status === models.User.STATUS_ACTIVE) {
+        // Active の人だけ先に進める
+        return next();
+      } else if (req.user.status === models.User.STATUS_REGISTERED) {
+        return res.redirect('/login/error/registered');
+      } else if (req.user.status === models.User.STATUS_SUSPENDED) {
+        return res.redirect('/login/error/suspended');
+      } else if (req.user.status === models.User.STATUS_INVITED) {
+        return res.redirect('/login/invited');
       }
       }
-
-      return next();
     }
     }
+
     req.session.jumpTo = req.originalUrl;
     req.session.jumpTo = req.originalUrl;
     return res.redirect('/login');
     return res.redirect('/login');
   };
   };
@@ -111,10 +118,26 @@ exports.applicationInstalled = function() {
   return function(req, res, next) {
   return function(req, res, next) {
     var config = req.config;
     var config = req.config;
 
 
-    if (Object.keys(config.crowi).length == 0) {
+    if (Object.keys(config.crowi).length === 0) {
       return res.redirect('/installer');
       return res.redirect('/installer');
     }
     }
 
 
     return next();
     return next();
   };
   };
 };
 };
+
+exports.awsEnabled = function() {
+  return function (req, res, next) {
+    var config = req.config;
+    if (config.crowi['aws:region'] != ''
+        && config.crowi['aws:bucket'] != ''
+        && config.crowi['aws:accessKeyId'] != ''
+        && config.crowi['aws:secretAccessKey'] != ''
+       ) {
+      req.flash('globalError', 'AWS settings required to use this function. Please ask the administrator.');
+      return res.redirect('/');
+    }
+
+    return next();
+  };
+};

+ 2 - 0
lib/swigFunctions.js

@@ -52,6 +52,8 @@ module.exports = function(app) {
             return 'label-warning';
             return 'label-warning';
           case User.STATUS_DELETED:
           case User.STATUS_DELETED:
             return 'label-danger';
             return 'label-danger';
+          case User.STATUS_INVITED:
+            return 'label-info';
           default:
           default:
             break;
             break;
         }
         }

+ 26 - 0
models/config.js

@@ -4,6 +4,10 @@ module.exports = function(app) {
     , ObjectId = mongoose.Schema.Types.ObjectId
     , ObjectId = mongoose.Schema.Types.ObjectId
     , configSchema
     , configSchema
     , Config
     , Config
+
+    , SECURITY_REGISTRATION_MODE_OPEN = 'Open'
+    , SECURITY_REGISTRATION_MODE_RESTRICTED = 'Resricted'
+    , SECURITY_REGISTRATION_MODE_CLOSED = 'Closed'
   ;
   ;
 
 
   configSchema = new mongoose.Schema({
   configSchema = new mongoose.Schema({
@@ -26,6 +30,12 @@ module.exports = function(app) {
       'aws:accessKeyId'     : '',
       'aws:accessKeyId'     : '',
       'aws:secretAccessKey' : '',
       'aws:secretAccessKey' : '',
 
 
+      'mail:from'         : '',
+      'mail:smtpHost'     : '',
+      'mail:smtpPort'     : '',
+      'mail:smtpUser'     : '',
+      'mail:smtpPassword' : '',
+
       'searcher:url': '',
       'searcher:url': '',
 
 
       'google:clientId'     : '',
       'google:clientId'     : '',
@@ -36,6 +46,16 @@ module.exports = function(app) {
     };
     };
   }
   }
 
 
+  configSchema.statics.getRegistrationModeLabels = function()
+  {
+    var labels = {};
+    labels[SECURITY_REGISTRATION_MODE_OPEN]       = '公開 (だれでも登録可能)';
+    labels[SECURITY_REGISTRATION_MODE_RESTRICTED] = '制限 (登録完了には管理者の承認が必要)';
+    labels[SECURITY_REGISTRATION_MODE_CLOSED]     = '非公開 (登録には管理者による招待が必要)';
+
+    return labels;
+  };
+
   configSchema.statics.updateConfigCache = function(ns, config)
   configSchema.statics.updateConfigCache = function(ns, config)
   {
   {
     var originalConfig = app.set('config');
     var originalConfig = app.set('config');
@@ -76,6 +96,7 @@ module.exports = function(app) {
       if (config[ns][key]) {
       if (config[ns][key]) {
         defaultConfig[key] = config[ns][key];
         defaultConfig[key] = config[ns][key];
       }
       }
+
     });
     });
     return defaultConfig;
     return defaultConfig;
   };
   };
@@ -137,12 +158,17 @@ module.exports = function(app) {
           config[el.ns][el.key] = JSON.parse(el.value);
           config[el.ns][el.key] = JSON.parse(el.value);
         });
         });
 
 
+        debug('Config loaded', config);
         return callback(null, config);
         return callback(null, config);
       });
       });
   };
   };
 
 
 
 
   Config = mongoose.model('Config', configSchema);
   Config = mongoose.model('Config', configSchema);
+  Config.SECURITY_REGISTRATION_MODE_OPEN       = SECURITY_REGISTRATION_MODE_OPEN;
+  Config.SECURITY_REGISTRATION_MODE_RESTRICTED = SECURITY_REGISTRATION_MODE_RESTRICTED;
+  Config.SECURITY_REGISTRATION_MODE_CLOSED     = SECURITY_REGISTRATION_MODE_CLOSED;
+
 
 
   return Config;
   return Config;
 };
 };

+ 162 - 7
models/user.js

@@ -3,13 +3,16 @@ module.exports = function(app, models) {
     , mongoosePaginate = require('mongoose-paginate')
     , mongoosePaginate = require('mongoose-paginate')
     , debug = require('debug')('crowi:models:user')
     , debug = require('debug')('crowi:models:user')
     , crypto = require('crypto')
     , crypto = require('crypto')
+    , async = require('async')
     , config = app.set('config')
     , config = app.set('config')
     , ObjectId = mongoose.Schema.Types.ObjectId
     , ObjectId = mongoose.Schema.Types.ObjectId
+    , mailer = app.set('mailer')
 
 
     , STATUS_REGISTERED = 1
     , STATUS_REGISTERED = 1
     , STATUS_ACTIVE     = 2
     , STATUS_ACTIVE     = 2
     , STATUS_SUSPENDED  = 3
     , STATUS_SUSPENDED  = 3
     , STATUS_DELETED    = 4
     , STATUS_DELETED    = 4
+    , STATUS_INVITED    = 5
 
 
     , PAGE_ITEMS        = 20
     , PAGE_ITEMS        = 20
 
 
@@ -20,8 +23,8 @@ module.exports = function(app, models) {
     fbId: String, // userId
     fbId: String, // userId
     image: String,
     image: String,
     googleId: String,
     googleId: String,
-    name: { type: String, required: true },
-    username: { type: String, required: true },
+    name: { type: String },
+    username: { type: String },
     email: { type: String, required: true },
     email: { type: String, required: true },
     password: String,
     password: String,
     status: { type: Number, required: true, default: STATUS_ACTIVE },
     status: { type: Number, required: true, default: STATUS_ACTIVE },
@@ -31,11 +34,14 @@ module.exports = function(app, models) {
   userSchema.plugin(mongoosePaginate);
   userSchema.plugin(mongoosePaginate);
 
 
   function decideUserStatusOnRegistration () {
   function decideUserStatusOnRegistration () {
+    var Config = models.Config;
+
     // status decided depends on registrationMode
     // status decided depends on registrationMode
-    switch (config.crowi['security.registrationMode']) {
-      case 'Open':
+    switch (config.crowi['security:registrationMode']) {
+      case Config.SECURITY_REGISTRATION_MODE_OPEN:
         return STATUS_ACTIVE;
         return STATUS_ACTIVE;
-      case 'Restricted':
+      case Config.SECURITY_REGISTRATION_MODE_RESTRICTED:
+      case Config.SECURITY_REGISTRATION_MODE_CLOSED: // 一応
         return STATUS_REGISTERED;
         return STATUS_REGISTERED;
       default:
       default:
         return STATUS_ACTIVE; // どっちにすんのがいいんだろうな
         return STATUS_ACTIVE; // どっちにすんのがいいんだろうな
@@ -124,6 +130,15 @@ module.exports = function(app, models) {
     return this.updateGoogleId(null, callback);
     return this.updateGoogleId(null, callback);
   };
   };
 
 
+  userSchema.methods.activateInvitedUser = function(username, name, password, callback) {
+    this.setPassword(password);
+    this.name = name;
+    this.username = username;
+    this.status = STATUS_ACTIVE;
+    this.save(function(err, userData) {
+      return callback(err, userData);
+    });
+  };
 
 
   userSchema.methods.removeFromAdmin = function(callback) {
   userSchema.methods.removeFromAdmin = function(callback) {
     debug('Remove from admin', this);
     debug('Remove from admin', this);
@@ -193,13 +208,14 @@ module.exports = function(app, models) {
     userStatus[STATUS_ACTIVE] = 'Active';
     userStatus[STATUS_ACTIVE] = 'Active';
     userStatus[STATUS_SUSPENDED] = 'Suspended';
     userStatus[STATUS_SUSPENDED] = 'Suspended';
     userStatus[STATUS_DELETED] = 'Deleted';
     userStatus[STATUS_DELETED] = 'Deleted';
+    userStatus[STATUS_INVITED] = '招待済み';
 
 
     return userStatus;
     return userStatus;
   };
   };
 
 
   userSchema.statics.isEmailValid = function(email, callback) {
   userSchema.statics.isEmailValid = function(email, callback) {
-    if (Array.isArray(config.crowi['security.registrationWhiteList'])) {
-      return config.crowi['security.registrationWhiteList'].some(function(allowedEmail) {
+    if (Array.isArray(config.crowi['security:registrationWhiteList'])) {
+      return config.crowi['security:registrationWhiteList'].some(function(allowedEmail) {
         var re = new RegExp(allowedEmail + '$');
         var re = new RegExp(allowedEmail + '$');
         return re.test(email);
         return re.test(email);
       });
       });
@@ -258,6 +274,18 @@ module.exports = function(app, models) {
     });
     });
   };
   };
 
 
+  userSchema.statics.isRegisterableUsername = function(username, callback) {
+    var User = this;
+    var usernameUsable = true;
+
+    this.findOne({username: username}, function (err, userData) {
+      if (userData) {
+        usernameUsable = false;
+      }
+      return callback(usernameUsable);
+    });
+  };
+
   userSchema.statics.isRegisterable = function(email, username, callback) {
   userSchema.statics.isRegisterable = function(email, username, callback) {
     var User = this;
     var User = this;
     var emailUsable = true;
     var emailUsable = true;
@@ -284,6 +312,132 @@ module.exports = function(app, models) {
     });
     });
   };
   };
 
 
+  userSchema.statics.removeCompletelyById = function(id, callback) {
+    var User = this;
+    User.findById(id, function (err, userData) {
+      if (!userData) {
+        return callback(err, null);
+      }
+
+      debug('Removing user:', userData);
+      // 物理削除可能なのは、招待中ユーザーのみ
+      // 利用を一度開始したユーザーは論理削除のみ可能
+      if (userData.status !== STATUS_INVITED) {
+        return callback(new Error('Cannot remove completely the user whoes status is not INVITED'), null);
+      }
+
+      userData.remove(function(err) {
+        if (err) {
+          return callback(err, null);
+        }
+
+        return callback(null, 1);
+      });
+    });
+  };
+
+  userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {
+    var User = this
+      , createdUserList = [];
+
+    if (!Array.isArray(emailList)) {
+      debug('emailList is not array');
+    }
+
+    async.each(
+      emailList,
+      function(email, next) {
+        var newUser = new User()
+          ,password;
+
+        email = email.trim();
+
+        // email check
+        // TODO: 削除済みはチェック対象から外そう〜
+        User.findOne({email: email}, function (err, userData) {
+          // The user is exists
+          if (userData) {
+            createdUserList.push({
+              email: email,
+              password: null,
+              user: null,
+            });
+
+            return next();
+          }
+
+          password = Math.random().toString(36).slice(-16);
+
+          newUser.email = email;
+          newUser.setPassword(password);
+          newUser.createdAt = Date.now();
+          newUser.status = STATUS_INVITED;
+
+          newUser.save(function(err, userData) {
+            if (err) {
+              createdUserList.push({
+                email: email,
+                password: null,
+                user: null,
+              });
+              debug('save failed!! ', email);
+            } else {
+              createdUserList.push({
+                email: email,
+                password: password,
+                user: userData,
+              });
+              debug('saved!', email);
+            }
+
+            next();
+          });
+        });
+      },
+      function(err) {
+        if (err) {
+          debug('error occured while iterate email list');
+        }
+
+        if (toSendEmail) {
+          // TODO: メール送信部分のロジックをサービス化する
+          async.each(
+            createdUserList,
+            function(user, next) {
+              if (user.password === null) {
+                next();
+              }
+
+              mailer.send({
+                  to: user.email,
+                  subject: 'Invitation to ' + config.crowi['app:title'],
+                  text: 'Hi, ' + user.email + '\n\n'
+                    + 'You are invited to our Wiki, you can log in with following account:\n\n'
+                    + 'Email: ' + user.email + '\n'
+                    + 'Password: ' + user.password
+                    + '\n (This password was auto generated. Update required at the first time you logging in)\n'
+                    + '\n'
+                    + 'We are waiting for you!\n'
+                    + config.crowi['app:url']
+                },
+                function (err, s) {
+                  debug('completed to send email: ', err, s);
+                  next();
+                }
+              );
+            },
+            function(err) {
+              debug('Sending invitation email completed.', err);
+            }
+          );
+        }
+
+        debug('createdUserList!!! ', createdUserList);
+        return callback(null, createdUserList);
+      }
+    );
+  };
+
   userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, callback) {
   userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, callback) {
     var User = this
     var User = this
       , newUser = new User();
       , newUser = new User();
@@ -328,6 +482,7 @@ module.exports = function(app, models) {
   userSchema.statics.STATUS_ACTIVE = STATUS_ACTIVE;
   userSchema.statics.STATUS_ACTIVE = STATUS_ACTIVE;
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
+  userSchema.statics.STATUS_INVITED = STATUS_INVITED;
 
 
   models.User = mongoose.model('User', userSchema);
   models.User = mongoose.model('User', userSchema);
 
 

+ 15 - 11
package.json

@@ -18,32 +18,36 @@
     "npm": "1.3.x"
     "npm": "1.3.x"
   },
   },
   "dependencies": {
   "dependencies": {
-    "async": "=0.1.18",
+    "async": "~0.9.0",
     "aws-sdk": "~2.0.0-rc.19",
     "aws-sdk": "~2.0.0-rc.19",
+    "bower": "~1.3.9",
+    "cli": "~0.6.4",
     "connect-flash": "~0.1.1",
     "connect-flash": "~0.1.1",
+    "connect-redis": "^1.4.7",
     "consolidate": "=0.10.0",
     "consolidate": "=0.10.0",
     "debug": "^1.0.3",
     "debug": "^1.0.3",
     "express": "=3.4.4",
     "express": "=3.4.4",
     "express-form": "~0.10.1",
     "express-form": "~0.10.1",
     "facebook-node-sdk": "=0.1.10",
     "facebook-node-sdk": "=0.1.10",
     "googleapis": "=0.4.7",
     "googleapis": "=0.4.7",
-    "jquery.cookie": "~1.4.1",
-    "marked": "=0.2.9",
-    "mongoose": "=3.8.14",
-    "socket.io": "~0.9.16",
-    "socket.io-client": "~0.9.16",
-    "swig": "=1.3.2",
-    "time": "=0.10.0",
-    "mongoose-paginate": "~3.1.0",
     "grunt": "~0.4.1",
     "grunt": "~0.4.1",
+    "grunt-cli": "~0.1.13",
     "grunt-contrib-concat": "~0.3.0",
     "grunt-contrib-concat": "~0.3.0",
     "grunt-contrib-jshint": "^0.10.0",
     "grunt-contrib-jshint": "^0.10.0",
     "grunt-contrib-uglify": "~0.2.2",
     "grunt-contrib-uglify": "~0.2.2",
     "grunt-contrib-watch": "~0.5.3",
     "grunt-contrib-watch": "~0.5.3",
     "grunt-sass": "~0.14.1",
     "grunt-sass": "~0.14.1",
+    "jquery.cookie": "~1.4.1",
+    "marked": "=0.2.9",
+    "mongoose": "=3.8.14",
+    "mongoose-paginate": "~3.1.0",
+    "nodemailer": "~1.2.2",
+    "nodemailer-ses-transport": "~1.1.0",
     "reveal.js": "~2.6.2",
     "reveal.js": "~2.6.2",
-    "grunt-cli": "~0.1.13",
-    "bower": "~1.3.9"
+    "socket.io": "~0.9.16",
+    "socket.io-client": "~0.9.16",
+    "swig": "=1.3.2",
+    "time": "=0.10.0"
   },
   },
   "devDependencies": {},
   "devDependencies": {},
   "license": [
   "license": [

+ 8 - 0
resource/css/_form.scss

@@ -12,6 +12,14 @@ textarea.form-body-height {
   height: 300px;
   height: 300px;
 }
 }
 
 
+input::-webkit-input-placeholder {
+  color: #ccc;
+}
+input:-moz-placeholder {
+  color: #ccc;
+}
+
+
 .form-maximized {
 .form-maximized {
   position: absolute;
   position: absolute;
   background: #fff;
   background: #fff;

+ 87 - 5
routes/admin.js

@@ -94,6 +94,24 @@ module.exports = function(app) {
     });
     });
   };
   };
 
 
+  actions.user.invite = function(req, res) {
+    var form = req.form.inviteForm;
+    var toSendEmail = form.sendEmail || false;
+    if (req.form.isValid) {
+      User.createUsersByInvitation(form.emailList.split('\n'), toSendEmail, function(err, userList) {
+        if (err) {
+          req.flash('errorMessage', req.form.errors.join('\n'));
+        } else {
+          req.flash('createdUser', userList);
+        }
+        return res.redirect('/admin/users');
+      });
+    } else {
+      req.flash('errorMessage', req.form.errors.join('\n'));
+      return res.redirect('/admin/users');
+    }
+  };
+
   actions.user.makeAdmin = function(req, res) {
   actions.user.makeAdmin = function(req, res) {
     var id = req.params.id;
     var id = req.params.id;
     User.findById(id, function(err, userData) {
     User.findById(id, function(err, userData) {
@@ -155,20 +173,84 @@ module.exports = function(app) {
     });
     });
   };
   };
 
 
+  actions.user.remove= function(req, res) {
+    // 未実装
+    return res.redirect('/admin/users');
+  };
+
+  actions.user.removeCompletely = function(req, res) {
+    // ユーザーの物理削除
+    var id = req.params.id;
+
+    User.removeCompletelyById(id, function(err, removed) {
+      if (err) {
+        debug('Error while removing user.', err, id);
+        req.flash('errorMessage', '完全な削除に失敗しました。');
+      } else {
+        req.flash('successMessage', '削除しました');
+      }
+      return res.redirect('/admin/users');
+    });
+  };
+
   actions.api = {};
   actions.api = {};
   actions.api.appSetting = function(req, res) {
   actions.api.appSetting = function(req, res) {
-    var form = req.body.settingForm;
+    var form = req.form.settingForm;
 
 
     if (req.form.isValid) {
     if (req.form.isValid) {
-      Config.updateNamespaceByArray('crowi', form, function(err, config) {
-        Config.updateConfigCache('crowi', config)
-        return res.json({status: true});
-      });
+      debug('form content', form);
+
+      // mail setting ならここで validation
+      if (form['mail:from']) {
+        validateMailSetting(req, form, function(err, data) {
+          if (err) {
+            req.form.errors.push('SMTPを利用したテストメール送信に失敗しました。設定をみなおしてください。');
+            return res.json({status: false, message: req.form.errors.join('\n')});
+          }
+
+          return saveSetting(req, res, form);
+        });
+      } else {
+        return saveSetting(req, res, form);
+      }
     } else {
     } else {
       return res.json({status: false, message: req.form.errors.join('\n')});
       return res.json({status: false, message: req.form.errors.join('\n')});
     }
     }
   };
   };
 
 
+  function saveSetting(req, res, form)
+  {
+    Config.updateNamespaceByArray('crowi', form, function(err, config) {
+      Config.updateConfigCache('crowi', config)
+      return res.json({status: true});
+    });
+  }
+
+  function validateMailSetting(req, form, callback)
+  {
+    var mailer = app.set('mailer');
+    var option = {
+      host: form['mail:smtpHost'],
+      port: form['mail:smtpPort'],
+      auth: {
+        user: form['mail:smtpUser'],
+        pass: form['mail:smtpPassword'],
+      }
+    };
+    if (option.port === 465) {
+      option.secure = true;
+    }
+
+    var smtpClient = mailer.createSMTPClient(option);
+    debug('mailer setup for validate SMTP setting', smtpClient);
+
+    smtpClient.sendMail({
+      to: req.user.email,
+      subject: 'Wiki管理設定のアップデートによるメール通知',
+      text: 'このメールは、WikiのSMTP設定のアップデートにより送信されています。'
+    }, callback);
+  }
+
 
 
   return actions;
   return actions;
 };
 };

+ 39 - 32
routes/index.js

@@ -9,13 +9,16 @@ module.exports = function(app) {
     , installer = require('./installer')(app)
     , installer = require('./installer')(app)
     , user      = require('./user')(app);
     , user      = require('./user')(app);
 
 
-  app.get('/'                        , middleware.loginRequired() , page.pageListShow);
+  app.get('/'                        , middleware.loginRequired(app) , page.pageListShow);
 
 
   app.get('/installer'               , middleware.applicationNotInstalled() , installer.index);
   app.get('/installer'               , middleware.applicationNotInstalled() , installer.index);
   app.post('/installer/createAdmin'  , middleware.applicationNotInstalled() , form.register , installer.createAdmin);
   app.post('/installer/createAdmin'  , middleware.applicationNotInstalled() , form.register , installer.createAdmin);
   //app.post('/installer/user'         , middleware.applicationNotInstalled() , installer.createFirstUser);
   //app.post('/installer/user'         , middleware.applicationNotInstalled() , installer.createFirstUser);
 
 
+  app.get('/login/error/:reason'     , login.error);
   app.get('/login'                   , middleware.applicationInstalled()    , login.login);
   app.get('/login'                   , middleware.applicationInstalled()    , login.login);
+  app.get('/login/invited'           , login.invited);
+  app.post('/login/activateInvited'  , form.invited                         , login.invited);
   app.post('/login'                  , form.login                           , login.login);
   app.post('/login'                  , form.login                           , login.login);
   app.post('/register'               , form.register                        , login.register);
   app.post('/register'               , form.register                        , login.register);
   app.get('/register'                , middleware.applicationInstalled()    , login.register);
   app.get('/register'                , middleware.applicationInstalled()    , login.register);
@@ -25,45 +28,49 @@ module.exports = function(app) {
   app.get('/login/facebook'          , login.loginFacebook);
   app.get('/login/facebook'          , login.loginFacebook);
   app.get('/logout'                  , logout.logout);
   app.get('/logout'                  , logout.logout);
 
 
-  app.get('/admin'                      , middleware.loginRequired() , middleware.adminRequired() , admin.index);
-  app.get('/admin/app'                  , middleware.loginRequired() , middleware.adminRequired() , admin.app.index);
-  app.post('/_api/admin/settings/app'   , middleware.loginRequired() , middleware.adminRequired() , form.admin.app, admin.api.appSetting);
-  app.post('/_api/admin/settings/sec'   , middleware.loginRequired() , middleware.adminRequired() , form.admin.sec, admin.api.appSetting);
-  app.post('/_api/admin/settings/aws'   , middleware.loginRequired() , middleware.adminRequired() , form.admin.aws, admin.api.appSetting);
-  app.post('/_api/admin/settings/google', middleware.loginRequired() , middleware.adminRequired() , form.admin.google, admin.api.appSetting);
-  app.post('/_api/admin/settings/fb'    , middleware.loginRequired() , middleware.adminRequired() , form.admin.fb
+  app.get('/admin'                      , middleware.loginRequired(app) , middleware.adminRequired() , admin.index);
+  app.get('/admin/app'                  , middleware.loginRequired(app) , middleware.adminRequired() , admin.app.index);
+  app.post('/_api/admin/settings/app'   , middleware.loginRequired(app) , middleware.adminRequired() , form.admin.app, admin.api.appSetting);
+  app.post('/_api/admin/settings/sec'   , middleware.loginRequired(app) , middleware.adminRequired() , form.admin.sec, admin.api.appSetting);
+  app.post('/_api/admin/settings/mail'  , middleware.loginRequired(app) , middleware.adminRequired() , form.admin.mail, admin.api.appSetting);
+  app.post('/_api/admin/settings/aws'   , middleware.loginRequired(app) , middleware.adminRequired() , form.admin.aws, admin.api.appSetting);
+  app.post('/_api/admin/settings/google', middleware.loginRequired(app) , middleware.adminRequired() , form.admin.google, admin.api.appSetting);
+  app.post('/_api/admin/settings/fb'    , middleware.loginRequired(app) , middleware.adminRequired() , form.admin.fb
   , admin.api.appSetting);
   , admin.api.appSetting);
 
 
-  app.get('/admin/users'                , middleware.loginRequired() , middleware.adminRequired() , admin.user.index);
-  app.post('/admin/user/:id/makeAdmin'  , middleware.loginRequired() , middleware.adminRequired() , admin.user.makeAdmin);
-  app.post('/admin/user/:id/removeFromAdmin', middleware.loginRequired() , middleware.adminRequired() , admin.user.removeFromAdmin);
-  app.post('/admin/user/:id/activate'   , middleware.loginRequired() , middleware.adminRequired() , admin.user.activate);
-  app.post('/admin/user/:id/suspend'    , middleware.loginRequired() , middleware.adminRequired() , admin.user.suspend);
+  app.get('/admin/users'                , middleware.loginRequired(app) , middleware.adminRequired() , admin.user.index);
+  app.post('/admin/user/invite'         , form.admin.userInvite ,  middleware.loginRequired(app) , middleware.adminRequired() , admin.user.invite);
+  app.post('/admin/user/:id/makeAdmin'  , middleware.loginRequired(app) , middleware.adminRequired() , admin.user.makeAdmin);
+  app.post('/admin/user/:id/removeFromAdmin', middleware.loginRequired(app) , middleware.adminRequired() , admin.user.removeFromAdmin);
+  app.post('/admin/user/:id/activate'   , middleware.loginRequired(app) , middleware.adminRequired() , admin.user.activate);
+  app.post('/admin/user/:id/suspend'    , middleware.loginRequired(app) , middleware.adminRequired() , admin.user.suspend);
+  app.post('/admin/user/:id/remove'     , middleware.loginRequired(app) , middleware.adminRequired() , admin.user.remove);
+  app.post('/admin/user/:id/removeCompletely' , middleware.loginRequired(app) , middleware.adminRequired() , admin.user.removeCompletely);
 
 
-  app.get('/me'                      , middleware.loginRequired() , me.index);
-  app.get('/me/password'             , middleware.loginRequired() , me.password);
-  app.post('/me'                     , form.me.user               , middleware.loginRequired() , me.index);
-  app.post('/me/password'            , form.me.password           , middleware.loginRequired() , me.password);
-  app.post('/me/picture/delete'      , middleware.loginRequired() , me.deletePicture);
-  app.post('/me/auth/facebook'       , middleware.loginRequired() , me.authFacebook);
-  app.post('/me/auth/google'         , middleware.loginRequired() , me.authGoogle);
-  app.get('/me/auth/google/callback' , middleware.loginRequired() , me.authGoogleCallback);
+  app.get('/me'                      , middleware.loginRequired(app) , me.index);
+  app.get('/me/password'             , middleware.loginRequired(app) , me.password);
+  app.post('/me'                     , form.me.user               , middleware.loginRequired(app) , me.index);
+  app.post('/me/password'            , form.me.password           , middleware.loginRequired(app) , me.password);
+  app.post('/me/picture/delete'      , middleware.loginRequired(app) , me.deletePicture);
+  app.post('/me/auth/facebook'       , middleware.loginRequired(app) , me.authFacebook);
+  app.post('/me/auth/google'         , middleware.loginRequired(app) , me.authGoogle);
+  app.get('/me/auth/google/callback' , middleware.loginRequired(app) , me.authGoogleCallback);
 
 
-  app.get('/_r/:id'                  , middleware.loginRequired() , page.api.redirector);
+  app.get('/_r/:id'                  , middleware.loginRequired(app) , page.api.redirector);
   app.get('/_api/check_username'     , user.api.checkUsername);
   app.get('/_api/check_username'     , user.api.checkUsername);
-  app.post('/_api/me/picture/upload' , middleware.loginRequired() , me.api.uploadPicture);
-  app.get('/_api/user/bookmarks'     , middleware.loginRequired() , user.api.bookmarks);
-  app.post('/_api/page_rename/*'     , middleware.loginRequired() , page.api.rename);
-  app.post('/_api/page/:id/like'     , middleware.loginRequired() , page.api.like);
-  app.post('/_api/page/:id/unlike'   , middleware.loginRequired() , page.api.unlike);
-  app.get('/_api/page/:id/bookmark'  , middleware.loginRequired() , page.api.isBookmarked);
-  app.post('/_api/page/:id/bookmark' , middleware.loginRequired() , page.api.bookmark);
+  app.post('/_api/me/picture/upload' , middleware.loginRequired(app) , me.api.uploadPicture);
+  app.get('/_api/user/bookmarks'     , middleware.loginRequired(app) , user.api.bookmarks);
+  app.post('/_api/page_rename/*'     , middleware.loginRequired(app) , page.api.rename);
+  app.post('/_api/page/:id/like'     , middleware.loginRequired(app) , page.api.like);
+  app.post('/_api/page/:id/unlike'   , middleware.loginRequired(app) , page.api.unlike);
+  app.get('/_api/page/:id/bookmark'  , middleware.loginRequired(app) , page.api.isBookmarked);
+  app.post('/_api/page/:id/bookmark' , middleware.loginRequired(app) , page.api.bookmark);
   //app.get('/_api/page/*'           , user.useUserData()         , page.api.get);
   //app.get('/_api/page/*'           , user.useUserData()         , page.api.get);
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 
 
-  app.post('/*/edit'                 , form.revision              , middleware.loginRequired() , page.pageEdit);
-  app.get('/*/$'                     , middleware.loginRequired() , page.pageListShow);
-  app.get('/*'                       , middleware.loginRequired() , page.pageShow);
+  app.post('/*/edit'                 , form.revision              , middleware.loginRequired(app) , page.pageEdit);
+  app.get('/*/$'                     , middleware.loginRequired(app) , page.pageListShow);
+  app.get('/*'                       , middleware.loginRequired(app) , page.pageShow);
   //app.get('/*/edit'                , routes.edit);
   //app.get('/*/edit'                , routes.edit);
 };
 };

+ 57 - 1
routes/login.js

@@ -38,6 +38,24 @@ module.exports = function(app) {
     return res.redirect(nextAction);
     return res.redirect(nextAction);
   };
   };
 
 
+  actions.error = function(req, res) {
+    var reason = req.params.reason
+      , reasonMessage = ''
+      ;
+
+    if (reason === 'suspended') {
+      reasonMessage = 'このアカウントは停止されています。';
+    } else if (reason === 'registered') {
+      reasonMessage = '管理者の承認をお待ちください。';
+    } else {
+    }
+
+    return res.render('login/error', {
+      reason: reason,
+      reasonMessage: reasonMessage
+    });
+  };
+
   actions.login = function(req, res) {
   actions.login = function(req, res) {
     var loginForm = req.body.loginForm;
     var loginForm = req.body.loginForm;
 
 
@@ -112,7 +130,7 @@ module.exports = function(app) {
   };
   };
 
 
   actions.register = function(req, res) {
   actions.register = function(req, res) {
-    var registerForm = req.body.registerForm || {};
+    var registerForm = req.form.registerForm || {};
     var googleAuth = require('../lib/googleAuth')(app);
     var googleAuth = require('../lib/googleAuth')(app);
 
 
     // ログイン済みならさようなら
     // ログイン済みならさようなら
@@ -216,5 +234,43 @@ module.exports = function(app) {
     });
     });
   };
   };
 
 
+  actions.invited = function(req, res) {
+    if (!req.user) {
+      return res.redirect('/login');
+    }
+
+    if (req.method == 'POST' && req.form.isValid) {
+      var user = req.user;
+      var invitedForm = req.form.invitedForm || {};
+      var username = invitedForm.username;
+      var name = invitedForm.name;
+      var password = invitedForm.password;
+
+      User.isRegisterableUsername(username, function(creatable) {
+        if (creatable) {
+          user.activateInvitedUser(username, name, password, function(err, data) {
+            if (err) {
+              req.flash('warningMessage', 'アクティベートに失敗しました。');
+              return res.render('invited');
+            } else {
+              return res.redirect('/');
+            }
+          });
+        } else {
+          req.flash('warningMessage', '利用できないユーザーIDです。');
+          debug('username', username);
+          return res.render('invited');
+        }
+      });
+    } else {
+      return res.render('invited', {
+      });
+    }
+  };
+
+  actions.updateInvitedUser = function(req, res) {
+    return res.redirect('/');
+  };
+
   return actions;
   return actions;
 };
 };

+ 51 - 6
views/admin/app.html

@@ -89,9 +89,9 @@
           <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">登録の制限</label>
           <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">登録の制限</label>
           <div class="col-xs-6">
           <div class="col-xs-6">
             <select class="form-control" name="settingForm[security:registrationMode]" value="{{ settingForm['security:registrationMode'] }}">
             <select class="form-control" name="settingForm[security:registrationMode]" value="{{ settingForm['security:registrationMode'] }}">
-              <option value="Open">公開 (だれでも登録可能)</option>
-              <option value="Resricted">制限 (登録完了には管理者の承認が必要)</option>
-              {# <option value="Closed">非公開 (登録には管理者による招待が必要)</option> #}
+              {% for modeValue, modeLabel in consts.registrationMode %}
+              <option value="{{ modeValue }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ modeLabel }}</option>
+              {% endfor %}
             </select>
             </select>
             <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
             <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
           </div>
           </div>
@@ -100,7 +100,7 @@
         <div class="form-group">
         <div class="form-group">
           <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">登録許可メールアドレスの<br>ホワイトリスト</label>
           <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">登録許可メールアドレスの<br>ホワイトリスト</label>
           <div class="col-xs-8">
           <div class="col-xs-8">
-            <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="例: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('\n') }}</textarea>
+            <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>
             <p class="help-block">登録可能なメールアドレスを制限することができます。例えば、会社で使う場合、<code>@crowi.wiki</code> などと記載すると、その会社のメールアドレスを持っている人のみ登録可能になります。<br>
             1行に1メールアドレス入力してください。</p>
             1行に1メールアドレス入力してください。</p>
           </div>
           </div>
@@ -115,10 +115,55 @@
       </fieldset>
       </fieldset>
       </form>
       </form>
 
 
+      <form action="/_api/admin/settings/mail" method="post" class="form-horizontal" id="mailSettingForm" role="form">
+      <fieldset>
+      <legend>メールの設定</legend>
+      <p class="well">SMTPの設定がされている場合、それが利用されます。SMTP設定がなく、AWSの設定がある場合、SESでの送信を試みます。<br>どちらの設定もない場合、メールは送信されません。</p>
+
+        <div class="form-group">
+          <label for="settingForm[mail.from]" class="col-xs-3 control-label">Fromアドレス</label>
+          <div class="col-xs-6">
+            <input class="form-control" type="text" name="settingForm[mail:from]" placeholder="例: mail@crowi.wiki" value="{{ settingForm['mail:from'] }}">
+          </div>
+        </div>
+
+        <div class="form-group">
+          <label class="col-xs-3 control-label">SMTP設定</label>
+          <div class="col-xs-4">
+            <label for="">ホスト</label>
+            <input class="form-control" type="text" name="settingForm[mail:smtpHost]"   value="{{ settingForm['mail:smtpHost']|default('') }}">
+          </div>
+          <div class="col-xs-2">
+            <label for="">ポート</label>
+            <input class="form-control" type="text" name="settingForm[mail:smtpPort]" value="{{ settingForm['mail:smtpPort']|default('') }}">
+          </div>
+        </div>
+
+        <div class="form-group">
+          <div class="col-xs-3 col-xs-offset-3">
+            <label for="">ユーザー</label>
+            <input class="form-control" type="text" name="settingForm[mail:smtpUser]"   value="{{ settingForm['mail:smtpUser']|default('') }}">
+          </div>
+          <div class="col-xs-3">
+            <label for="">パスワード</label>
+            <input class="form-control" type="password" name="settingForm[mail:smtpPassword]" value="{{ settingForm['mail:smtpPassword']|default('') }}">
+          </div>
+        </div>
+
+        <div class="form-group">
+          <div class="col-xs-offset-3 col-xs-6">
+            <button type="submit" class="btn btn-primary">更新</button>
+          </div>
+        </div>
+
+      </fieldset>
+      </form>
+
       <form action="/_api/admin/settings/aws" method="post" class="form-horizontal" id="awsSettingForm" role="form">
       <form action="/_api/admin/settings/aws" method="post" class="form-horizontal" id="awsSettingForm" role="form">
       <fieldset>
       <fieldset>
       <legend>AWS設定</legend>
       <legend>AWS設定</legend>
-        <p class="well">S3 にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。<br>
+        <p class="well">AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。<br>
+        また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。<br>
           <br>
           <br>
 
 
           <span class="text-danger"><i class="fa fa-warning"></i> この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。</span>
           <span class="text-danger"><i class="fa fa-warning"></i> この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。</span>
@@ -223,7 +268,7 @@
   <script>
   <script>
     $(function()
     $(function()
     {
     {
-      $('#appSettingForm, #secSettingForm, #awsSettingForm, #googleSettingForm, #fbSettingForm').each(function() {
+      $('#appSettingForm, #secSettingForm, #mailSettingForm, #awsSettingForm, #googleSettingForm, #fbSettingForm').each(function() {
         $(this).submit(function()
         $(this).submit(function()
         {
         {
           function showMessage(formId, msg, status) {
           function showMessage(formId, msg, status) {

+ 58 - 3
views/admin/users.html

@@ -32,7 +32,54 @@
         <li class="active"><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
         <li class="active"><a href="/admin/users"><i class="fa fa-users"></i> ユーザー管理</a></li>
       </ul>
       </ul>
     </div>
     </div>
+
     <div class="col-md-9">
     <div class="col-md-9">
+      <p>
+        <button  data-toggle="collapse" class="btn btn-default" href="#inviteUserForm">新規ユーザーの招待</button>
+      </p>
+      <form role="form" action="/admin/user/invite" method="post">
+        <div id="inviteUserForm" class="collapse">
+          <div class="form-group">
+            <label for="inviteForm[emailList]">メールアドレス (複数行入力で複数人招待可能)</label>
+            <textarea class="form-control" name="inviteForm[emailList]" placeholder="例: user@crowi.wiki"></textarea>
+          </div>
+          <div class="checkbox">
+            <label>
+              <input type="checkbox" name="inviteForm[sendEmail]" checked> 招待をメールで送信
+            </label>
+          </div>
+          <button type="submit" class="btn btn-primary">招待する</button>
+        </div>
+      </form>
+
+      {% set createdUser = req.flash('createdUser') %}
+      {% if createdUser.length %}
+      <div class="modal fade in" id="createdUserModal">
+        <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>
+                作成したユーザーは仮パスワードが設定されています。<br>
+                仮パスワードはこの画面を閉じると二度と表示できませんのでご注意ください。<span class="text-danger">招待メールを送っていない場合、この画面で必ず仮パスワードをコピーし、招待者へ連絡してください。</span>
+              </p>
+
+              <pre>{% for cUser in createdUser %}{% if cUser.user %}{{ cUser.email }} {{ cUser.password }}<br>{% else %}{{ cUser.email }} 作成失敗<br>{% endif %}{% endfor %}</pre>
+            </div>
+
+          </div><!-- /.modal-content -->
+        </div><!-- /.modal-dialog -->
+      </div><!-- /.modal -->
+      <script>$(function() { $('#createdUserModal').modal('show'); });</script>
+      {% endif %}
+
+      <h2>ユーザー一覧</h2>
+
       <table class="table table-hover table-striped table-bordered">
       <table class="table table-hover table-striped table-bordered">
         <thead>
         <thead>
           <tr>
           <tr>
@@ -96,10 +143,18 @@
                   </form>
                   </form>
                   </li>
                   </li>
                   <li class="dropdown-button">
                   <li class="dropdown-button">
-                  <form action="/admin/user/{{ sUser._id.toString() }}/suspend" method="post">
-                    <button type="submit" class="btn btn-block btn-danger">完全に削除する</button>
+                  {# label は同じだけど、こっちは論理削除 #}
+                  <form action="/admin/user/{{ sUser._id.toString() }}/remove" method="post">
+                    <button type="submit" class="btn btn-block btn-danger">削除する</button>
                   </form>
                   </form>
                   {% endif  %}
                   {% endif  %}
+                  {% if sUser.status == 5 %}
+                  {# label は同じだけど、こっちは物理削除 #}
+                  <form action="/admin/user/{{ sUser._id.toString() }}/removeCompletely" method="post">
+                    <button type="submit" class="btn btn-block btn-danger">削除する</button>
+                  </form>
+                  {% endif  %}
+                  </li>
 
 
                   {% if sUser.status == 2 %} {# activated な人だけこのメニューを表示 #}
                   {% if sUser.status == 2 %} {# activated な人だけこのメニューを表示 #}
                   <li class="divider"></li>
                   <li class="divider"></li>
@@ -116,7 +171,7 @@
                       {% endif %}
                       {% endif %}
                     {% else %}
                     {% else %}
                       <form action="/admin/user/{{ sUser._id.toString() }}/makeAdmin" method="post">
                       <form action="/admin/user/{{ sUser._id.toString() }}/makeAdmin" method="post">
-                        <button type="submit" class="btn btn-block btn-danger">管理者にする</button>
+                        <button type="submit" class="btn btn-block btn-primary">管理者にする</button>
                       </form>
                       </form>
                     {% endif %}
                     {% endif %}
                   </li>
                   </li>

+ 110 - 0
views/invited.html

@@ -0,0 +1,110 @@
+{% extends 'layout/single-nologin.html' %}
+
+{% block html_title %}Registration · {% endblock %}
+
+{% block content_main %}
+
+<h1 class="login-page">
+  {% if config.crowi['app:title'] == 'Crowi' %}
+    <img src="/logo/135x32.png" alt="Crowi">
+  {% else %}
+    {{ config.crowi['app:title'] }}<br>
+    <img src="/logo/100x11_w.png" alt="powered by Crowi">
+  {% endif %}
+</h1>
+
+<div class="login-dialog-container flip-container col-md-5">
+
+<div class="login-dialog" id="login-dialog">
+
+  <div class="login-dialog-inner front">
+    <h2>ユーザー情報入力</h2>
+
+    <p>
+    ようこそ!<br>
+    はじめに、あなたのことを教えて下さい。
+    </p>
+
+    <div id="login-form-errors">
+      {% set message = req.flash('warningMessage') %}
+      {% if message.length %}
+      <div class="alert alert-danger">
+        {{ message }}
+      </div>
+      {% endif %}
+
+      {% if req.form.errors.length > 0 %}
+      <div class="alert alert-danger">
+        <ul>
+        {% for error in req.form.errors %}
+          <li>{{ error }}</li>
+        {% endfor %}
+        </ul>
+      </div>
+      {% endif %}
+    </div>
+    <form role="form" id="invited-form" action="/login/activateInvited" method="post">
+
+      <label>メールアドレス</label>
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-envelope"></i></span>
+        <input type="text" class="form-control" disabled value="{{ user.email }}">
+      </div>
+      <p class="help-block">
+      このメールアドレスで招待を受け取っています。
+      </p>
+
+      <label>ユーザーID</label>
+      <div class="input-group" id="input-group-username">
+        <span class="input-group-addon"><strong>@</strong></span>
+        <input type="text" class="form-control" placeholder="記入例: taroyama" name="invitedForm[username]" value="{{ req.body.invitedForm.username }}" required>
+      </div>
+      <p class="help-block">
+      <span id="help-block-username" class="text-danger"></span>
+      ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。
+      </p>
+
+      <label>名前</label>
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-user"></i></span>
+        <input type="text" class="form-control" placeholder="記入例: 山田 太郎" name="invitedForm[name]" value="{{ req.body.invitedForm.name }}" required>
+      </div>
+
+      <label>パスワード</label>
+      <div class="input-group">
+        <span class="input-group-addon"><i class="fa fa-key"></i></span>
+        <input type="password" class="form-control" placeholder="Password" name="invitedForm[password]" required>
+      </div>
+      <p class="help-block">
+      現在、仮パスワードでログインしています。新しいパスワードを決定してください。<br>
+      パスワードは6文字以上の半角英数字または記号
+      </p>
+
+      <input type="submit" class="btn btn-primary btn-lg btn-block" value="登録を完了">
+    </form>
+
+    <hr>
+
+  </div>
+</div>
+
+<script>
+$(function() {
+  $('#invited-form input[name="invitedForm[username]"]').change(function(e) {
+    var username = $(this).val();
+    $('#input-group-username').removeClass('has-error');
+    $('#help-block-username').html("");
+
+    $.getJSON('/_api/check_username', {username: username}, function(json) {
+      if (!json.valid) {
+        $('#help-block-username').html('<i class="fa fa-warning"></i>このユーザーIDは利用できません。<br>');
+        $('#input-group-username').addClass('has-error');
+      }
+    });
+  });
+});
+</script>
+</div>
+
+{% endblock %}
+

+ 5 - 0
views/layout/2column.html

@@ -29,6 +29,11 @@
         </a>
         </a>
       </li>
       </li>
       {% endif %}
       {% endif %}
+      <li id="">
+        <a href="#" id="createPage">
+          <i class="fa fa-plus"> 新規</i>
+        </a>
+      </li>
       {% if user %}
       {% if user %}
       {#
       {#
       <li id="" class="notif">
       <li id="" class="notif">

+ 2 - 2
views/layout/layout.html

@@ -5,7 +5,7 @@
   <meta charset="utf-8">
   <meta charset="utf-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
   <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
 
 
-  <title>{% block html_title %}{% endblock %} {{ config.crowi['app.title']|default('Crowi') }}</title>
+  <title>{% block html_title %}{% endblock %} {{ config.crowi['app:title']|default('Crowi') }}</title>
   <meta name="description" content="">
   <meta name="description" content="">
   <meta name="author" content="">
   <meta name="author" content="">
 
 
@@ -18,7 +18,7 @@
   {% endif %}
   {% endif %}
 
 
   <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
   <script src="//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js"></script>
-  <link href='http://fonts.googleapis.com/css?family=Maven+Pro:400,700' rel='stylesheet' type='text/css'>
+  <link href='//fonts.googleapis.com/css?family=Maven+Pro:400,700' rel='stylesheet' type='text/css'>
   {% if env  == 'development' %}
   {% if env  == 'development' %}
   <script src="/js/crowi.js"></script>
   <script src="/js/crowi.js"></script>
   {% else %}
   {% else %}

+ 3 - 3
views/login.html

@@ -73,17 +73,17 @@
       {% endif %}
       {% endif %}
     </div>
     </div>
 
 
-    {% if config.security.registrationMode != 'Closed' %}
+    {% if config.crowi['security:registrationMode'] != 'Closed' %}
     <p class="bottom-text"><a href="#register" onclick="$('#login-dialog').addClass('to-flip'); return false;"><i class="fa fa-pencil"></i> 新規登録はこちら</a></p>
     <p class="bottom-text"><a href="#register" onclick="$('#login-dialog').addClass('to-flip'); return false;"><i class="fa fa-pencil"></i> 新規登録はこちら</a></p>
     {% endif %}
     {% endif %}
   </div>
   </div>
 
 
-  {% if config.security.registrationMode != 'Closed' %}
+  {% if config.crowi['security:registrationMode'] != 'Closed' %}
   <div class="register-dialog-inner back">
   <div class="register-dialog-inner back">
 
 
     <h2>新規登録</h2>
     <h2>新規登録</h2>
 
 
-    {% if config.security.registrationMode == 'Restricted' %}
+    {% if config.crowi['security:registrationMode'] == 'Restricted' %}
     <p class="alert alert-warning">
     <p class="alert alert-warning">
     この Wiki への新規登録は制限されています。<br>
     この Wiki への新規登録は制限されています。<br>
     利用を開始するには、新規登録後、管理者による承認が必要です。
     利用を開始するには、新規登録後、管理者による承認が必要です。

+ 56 - 0
views/login/error.html

@@ -0,0 +1,56 @@
+{% extends '../layout/single-nologin.html' %}
+
+{% block html_title %}Error · {% endblock %}
+
+{% block content_main %}
+
+<h1 class="login-page">
+  {% if config.crowi['app:title'] == 'Crowi' %}
+    <img src="/logo/135x32.png" alt="Crowi">
+  {% else %}
+    {{ config.crowi['app:title'] }}<br>
+    <img src="/logo/100x11_w.png" alt="powered by Crowi">
+  {% endif %}
+</h1>
+
+<div class="login-dialog-container flip-container col-md-5">
+
+<div class="login-dialog" id="login-dialog">
+
+  <div class="login-dialog-inner front">
+    {% if reason === 'registered'%}
+
+      <h2>登録完了</h2>
+
+      <p class="text-center">
+        <i class="fa fa-smile-o fa-3x"></i>
+      </p>
+      <hr>
+      <div class="alert alert-info text-center">
+        {{ reasonMessage }}
+      </div>
+
+    {% else %}
+
+      <h2>ログインエラー</h2>
+
+      <p class="text-center">
+        <i class="fa fa-meh-o fa-3x"></i>
+      </p>
+      <hr>
+      {% if reasonMessage != '' %}
+      <div class="alert alert-danger text-center">
+        {{ reasonMessage }}
+      </div>
+      {% endif %}
+
+    {% endif %}
+
+  </div>
+
+</div>
+
+</div>
+
+{% endblock %}
+

+ 4 - 4
views/me/index.html

@@ -68,11 +68,11 @@
           </p>
           </p>
           {% endif %}
           {% endif %}
 
 
-          {% if config.crowi['security.registrationWhiteList'] && config.crowi['security.registrationWhiteList'].length %}
+          {% if config.crowi['security:registrationWhiteList'] && config.crowi['security:registrationWhiteList'].length %}
           <p class="help-block">
           <p class="help-block">
           この Wiki では以下のメールアドレスのみ登録可能です。
           この Wiki では以下のメールアドレスのみ登録可能です。
           <ul>
           <ul>
-            {% for em in config.crowi['security.registrationWhiteList'] %}
+            {% for em in config.crowi['security:registrationWhiteList'] %}
             <li><code>{{ em }}</code></li>
             <li><code>{{ em }}</code></li>
             {% endfor %}
             {% endfor %}
           </ul>
           </ul>
@@ -275,12 +275,12 @@
               <p class="help-block">
               <p class="help-block">
               Googleコネクトをすると、Googleアカウントでログイン可能になります。<br>
               Googleコネクトをすると、Googleアカウントでログイン可能になります。<br>
               </p>
               </p>
-              {% if config.crowi['security.registrationWhiteList'] && config.crowi['security.registrationWhiteList'].length %}
+              {% if config.crowi['security:registrationWhiteList'] && config.crowi['security:registrationWhiteList'].length %}
               <p class="help-block">
               <p class="help-block">
               この Wiki では、登録可能なメールアドレスが限定されているため、コネクト可能なGoogleアカウントは、以下のメールアドレスの発行できるGoogle Appsアカウントに限られます。
               この Wiki では、登録可能なメールアドレスが限定されているため、コネクト可能なGoogleアカウントは、以下のメールアドレスの発行できるGoogle Appsアカウントに限られます。
               </p>
               </p>
               <ul>
               <ul>
-                {% for em in config.crowi['security.registrationWhiteList'] %}
+                {% for em in config.crowi['security:registrationWhiteList'] %}
                 <li><code>{{ em }}</code></li>
                 <li><code>{{ em }}</code></li>
                 {% endfor %}
                 {% endfor %}
               </ul>
               </ul>

+ 2 - 2
views/widget/searcher.html

@@ -1,4 +1,4 @@
-{% if config.crowi['searcher.url'] %}
+{% if config.crowi['searcher:url'] %}
 
 
 <form id="headerSearch" class="navbar-form navbar-left form-inline" role="search">
 <form id="headerSearch" class="navbar-form navbar-left form-inline" role="search">
   <div class="form-group">
   <div class="form-group">
@@ -10,7 +10,7 @@
       function Searcher () {
       function Searcher () {
       };
       };
       Searcher.prototype = {
       Searcher.prototype = {
-        baseUrl: "{{ config.crowi['searcher.url'] }}",
+        baseUrl: "{{ config.crowi['searcher:url'] }}",
         currentQuery: "",
         currentQuery: "",
         searchData: [],
         searchData: [],
         setData: function (data) {
         setData: function (data) {