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

Merge pull request #3 from crowi/wip-v1.1.0

Version 1.1.0
Sotaro KARASAWA 11 лет назад
Родитель
Сommit
a3ead05603

+ 14 - 0
CHANGES.md

@@ -2,6 +2,20 @@ CHANGES
 ========
 
 
+## 1.1.0
+
+* Feature: Use redis for session store!
+* Feature: Mail setting and added send mail module.
+* Feature: User invitation.
+* Feature: Activate invited user self (admin).
+* Feature: Activate registered user (admin / using RESTRICTED mode).
+* Feature: User suspend (admin).
+* Feature: User delete (admin).
+* Improve: Wiki style improved.
+* Improve: Update favicon (high resolution).
+* Fix: Affix header handling.
+* Library Update: Bootstrap 3.3.1, Fontawesome 4.2.0, async 0.9.0,
+
 ## 1.0.4
 
 * Feature: Basic auth restriction whole pages access.

+ 1 - 1
README.md

@@ -59,7 +59,7 @@ License
 
 > The MIT License (MIT)
 >
-> Copyright (c) 2013 Sotaro KARASAWA <sotarok@crocos.co.jp>
+> Copyright (c) 2013 Sotaro KARASAWA <sotaro.k@gmail.com>
 >
 > Permission is hereby granted, free of charge, to any person obtaining a copy
 > of this software and associated documentation files (the "Software"), to deal

+ 38 - 11
app.js

@@ -16,16 +16,19 @@ var express  = require('express')
   , middleware = require('./lib/middlewares')
   , time     = require('time')
   , async    = require('async')
+  , session  = require('express-session')
   , models
   , config
   , server
+  , sessionConfig
+  , RedisStore
   ;
 
-
 time.tzset('Asia/Tokyo');
 
 var app = express();
 var env = app.get('env');
+var days = (1000*3600*24*30);
 
 // mongoUri = mongodb://user:password@host/dbname
 var mongoUri = process.env.MONGOLAB_URI
@@ -35,6 +38,34 @@ var mongoUri = process.env.MONGOLAB_URI
 
 mongo.connect(mongoUri);
 
+sessionConfig = {
+  rolling: true,
+  secret: process.env.SECRET_TOKEN || 'this is default session secret',
+  resave: false,
+  saveUninitialized: true,
+  cookie: {
+    maxAge: days,
+  },
+};
+var redisUrl = process.env.REDISTOGO_URL
+  || process.env.REDIS_URL
+  || null;
+
+if (redisUrl) {
+  var ru   = require("url").parse(redisUrl);
+  var redis = require("redis");
+  var redisClient = redis.createClient(ru.port, ru.hostname);
+  if (ru.auth) {
+    redisClient.auth(ru.auth.split(":")[1]);
+  }
+
+  RedisStore = require('connect-redis')(session);
+  sessionConfig.store = new RedisStore({
+    prefix: 'crowi:sess:',
+    client: redisClient,
+  });
+}
+
 app.set('port', process.env.PORT || 3000);
 app.use(express.static(__dirname + '/public'));
 app.use(express.logger());
@@ -45,10 +76,7 @@ app.set('views', __dirname + '/views');
 app.use(express.methodOverride());
 app.use(express.bodyParser());
 app.use(express.cookieParser());
-app.use(express.session({
-  rolling: true,
-  secret: process.env.SECRET_TOKEN || 'this is default session secret',
-}));
+app.use(session(sessionConfig));
 app.use(flash());
 
 configModel = require('./models/config')(app);
@@ -63,13 +91,14 @@ async.series([
   }, function (next) {
     var config = app.set('config');
 
+    app.set('mailer', require('./lib/mailer')(app));
+
     models = require('./models')(app);
     models.Config = configModel;
 
     // configure application
     app.use(function(req, res, next) {
-      var days = (1000*3600*24*30)
-        , now = new Date()
+      var now = new Date()
         , fbparams = {}
         , config = app.set('config');
 
@@ -78,10 +107,7 @@ async.series([
 
       req.config = config;
 
-      req.session.cookie.expires = new Date(Date.now() + 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({
         req: req,
         baseUrl: req.baseUrl,
@@ -93,6 +119,7 @@ async.series([
         consts: {
           pageGrants: models.Page.getGrantLabels(),
           userStatus: models.User.getUserStatusLabels(),
+          registrationMode: models.Config.getRegistrationModeLabels(),
         },
       });
 

+ 2 - 1
app.json

@@ -23,6 +23,7 @@
     }
   },
   "addons": [
-    "mongohq"
+    "mongohq",
+    "redistogo"
   ]
 }

+ 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');
+});

+ 3 - 3
bower.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi",
-  "version": "1.0.4",
+  "version": "1.1.0",
   "description": "Crocos' Wiki implementation in node.js",
   "authors": [
     "Sotaro KARASAWA <sotarok@crocos.co.jp>",
@@ -19,7 +19,7 @@
     "tests"
   ],
   "dependencies": {
-    "bootstrap-sass-official": "~3.2.0",
-    "fontawesome": "~4.1.0"
+    "bootstrap-sass-official": "~3.3.1",
+    "fontawesome": "~4.2.0"
   }
 }

+ 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()
+);
+
+

+ 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.register = require('./register');
+exports.invited = require('./invited');
 exports.revision = require('./revision');
 exports.me = {
   user: require('./me/user'),
@@ -8,7 +9,9 @@ exports.me = {
 exports.admin = {
   app: require('./admin/app'),
   sec: require('./admin/sec'),
+  mail: require('./admin/mail'),
   aws: require('./admin/aws'),
   google: require('./admin/google'),
   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.name').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.googleId').isInt()
 );

+ 133 - 0
lib/mailer.js

@@ -0,0 +1,133 @@
+/**
+ * mailer
+ */
+
+module.exports = function(app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:lib:mailer')
+    , nodemailer = require('nodemailer')
+    , swig = require('swig')
+    , config = app.set('config')
+    , mailConfig = {}
+    , mailer = {}
+    , MAIL_TEMPLATE_DIR = app.set('views') + '/mail/'
+    ;
+
+
+  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) {
+      var templateVars = config.vars || {};
+      return swig.renderFile(
+        MAIL_TEMPLATE_DIR + config.template,
+        templateVars,
+        function (err, output) {
+          if (err) {
+            throw err;
+          }
+
+          config.text = output;
+          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,
+  };
+};

+ 32 - 8
lib/middlewares.js

@@ -79,16 +79,23 @@ exports.adminRequired = function() {
   };
 };
 
-exports.loginRequired = function() {
+exports.loginRequired = function(app) {
   return function(req, res, next) {
+    var models = app.set('models');
+
     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;
     return res.redirect('/login');
   };
@@ -99,7 +106,7 @@ exports.applicationNotInstalled = function() {
   return function(req, res, next) {
     var config = req.config;
 
-    if (Object.keys(config.crowi).length !== 0) {
+    if (Object.keys(config.crowi).length !== 1) {
       return res.render('500', { error: 'Application already installed.' });
     }
 
@@ -110,11 +117,28 @@ exports.applicationNotInstalled = function() {
 exports.applicationInstalled = function() {
   return function(req, res, next) {
     var config = req.config;
+    console.log(config);
 
-    if (Object.keys(config.crowi).length == 0) {
+    if (Object.keys(config.crowi).length === 1) { // app:url is set by process
       return res.redirect('/installer');
     }
 
     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';
           case User.STATUS_DELETED:
             return 'label-danger';
+          case User.STATUS_INVITED:
+            return 'label-info';
           default:
             break;
         }

+ 24 - 0
models/config.js

@@ -4,6 +4,10 @@ module.exports = function(app) {
     , ObjectId = mongoose.Schema.Types.ObjectId
     , configSchema
     , Config
+
+    , SECURITY_REGISTRATION_MODE_OPEN = 'Open'
+    , SECURITY_REGISTRATION_MODE_RESTRICTED = 'Resricted'
+    , SECURITY_REGISTRATION_MODE_CLOSED = 'Closed'
   ;
 
   configSchema = new mongoose.Schema({
@@ -26,6 +30,12 @@ module.exports = function(app) {
       'aws:accessKeyId'     : '',
       'aws:secretAccessKey' : '',
 
+      'mail:from'         : '',
+      'mail:smtpHost'     : '',
+      'mail:smtpPort'     : '',
+      'mail:smtpUser'     : '',
+      'mail:smtpPassword' : '',
+
       'searcher:url': '',
 
       '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)
   {
     var originalConfig = app.set('config');
@@ -145,6 +165,10 @@ module.exports = function(app) {
 
 
   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;
 };

+ 170 - 6
models/user.js

@@ -3,13 +3,16 @@ module.exports = function(app, models) {
     , mongoosePaginate = require('mongoose-paginate')
     , debug = require('debug')('crowi:models:user')
     , crypto = require('crypto')
+    , async = require('async')
     , config = app.set('config')
     , ObjectId = mongoose.Schema.Types.ObjectId
+    , mailer = app.set('mailer')
 
     , STATUS_REGISTERED = 1
     , STATUS_ACTIVE     = 2
     , STATUS_SUSPENDED  = 3
     , STATUS_DELETED    = 4
+    , STATUS_INVITED    = 5
 
     , PAGE_ITEMS        = 20
 
@@ -20,8 +23,8 @@ module.exports = function(app, models) {
     fbId: String, // userId
     image: String,
     googleId: String,
-    name: { type: String, required: true },
-    username: { type: String, required: true },
+    name: { type: String },
+    username: { type: String },
     email: { type: String, required: true },
     password: String,
     status: { type: Number, required: true, default: STATUS_ACTIVE },
@@ -31,11 +34,14 @@ module.exports = function(app, models) {
   userSchema.plugin(mongoosePaginate);
 
   function decideUserStatusOnRegistration () {
+    var Config = models.Config;
+
     // 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;
-      case 'Restricted':
+      case Config.SECURITY_REGISTRATION_MODE_RESTRICTED:
+      case Config.SECURITY_REGISTRATION_MODE_CLOSED: // 一応
         return STATUS_REGISTERED;
       default:
         return STATUS_ACTIVE; // どっちにすんのがいいんだろうな
@@ -124,6 +130,15 @@ module.exports = function(app, models) {
     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) {
     debug('Remove from admin', this);
@@ -193,12 +208,14 @@ module.exports = function(app, models) {
     userStatus[STATUS_ACTIVE] = 'Active';
     userStatus[STATUS_SUSPENDED] = 'Suspended';
     userStatus[STATUS_DELETED] = 'Deleted';
+    userStatus[STATUS_INVITED] = '招待済み';
 
     return userStatus;
   };
 
   userSchema.statics.isEmailValid = function(email, callback) {
-    if (Array.isArray(config.crowi['security:registrationWhiteList'])) {
+    var whitelist = config.crowi['security:registrationWhiteList'];
+    if (Array.isArray(whitelist) && whitelist.length > 0) {
       return config.crowi['security:registrationWhiteList'].some(function(allowedEmail) {
         var re = new RegExp(allowedEmail + '$');
         return re.test(email);
@@ -220,6 +237,15 @@ module.exports = function(app, models) {
 
   };
 
+  userSchema.statics.findAdmins = function(callback) {
+    var User = this;
+    this.find({admin: true})
+      .exec(function(err, admins) {
+        debug('Admins: ', admins);
+        callback(err, admins);
+      });
+  };
+
   userSchema.statics.findUsersWithPagination = function(options, callback) {
     var sort = options.sort || {status: 1, username: 1, createdAt: 1};
 
@@ -258,6 +284,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) {
     var User = this;
     var emailUsable = true;
@@ -284,6 +322,131 @@ 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) {
+                return next();
+              }
+
+              mailer.send({
+                  to: user.email,
+                  subject: 'Invitation to ' + config.crowi['app:title'],
+                  template: 'admin/userInvitation.txt',
+                  vars: {
+                    email: user.email,
+                    password: user.password,
+                    url: config.crowi['app:url'],
+                    appTitle: config.crowi['app:title'],
+                  }
+                },
+                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) {
     var User = this
       , newUser = new User();
@@ -328,6 +491,7 @@ module.exports = function(app, models) {
   userSchema.statics.STATUS_ACTIVE = STATUS_ACTIVE;
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
+  userSchema.statics.STATUS_INVITED = STATUS_INVITED;
 
   models.User = mongoose.model('User', userSchema);
 

+ 20 - 14
package.json

@@ -1,11 +1,11 @@
 {
   "name": "crowi",
-  "version": "1.0.4",
+  "version": "1.1.0",
   "description": "The simple & powerful Wiki",
   "tags": [
-    "wiki"
+    "wiki", "communication", "documentation", "collaboration"
   ],
-  "author": "Sotaro KARASAWA <sotarok@crocos.co.jp>",
+  "author": "Sotaro KARASAWA <sotaro.k@gmail.com>",
   "contributors": [
     "Keisuke SATO <riaf@me.com> (http://riaf.jp)"
   ],
@@ -18,32 +18,38 @@
     "npm": "1.3.x"
   },
   "dependencies": {
-    "async": "=0.1.18",
+    "async": "~0.9.0",
     "aws-sdk": "~2.0.0-rc.19",
+    "bower": "~1.3.9",
+    "cli": "~0.6.4",
     "connect-flash": "~0.1.1",
+    "connect-redis": "^2.1.0",
     "consolidate": "=0.10.0",
     "debug": "^1.0.3",
     "express": "=3.4.4",
     "express-form": "~0.10.1",
     "facebook-node-sdk": "=0.1.10",
     "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-cli": "~0.1.13",
     "grunt-contrib-concat": "~0.3.0",
     "grunt-contrib-jshint": "^0.10.0",
     "grunt-contrib-uglify": "~0.2.2",
     "grunt-contrib-watch": "~0.5.3",
     "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",
-    "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",
+    "redis": "~0.12.1",
+    "express-session": "~1.9.3"
   },
   "devDependencies": {},
   "license": [

BIN
public/favicon.ico


+ 8 - 0
resource/css/_form.scss

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

+ 73 - 18
resource/css/_layout.scss

@@ -7,7 +7,7 @@
   &.main-container { // {{{
 
     .crowi-header { // {{{
-      z-index: 1050;
+      z-index: 1040;
       background: $crowiHeaderBackground;
       height: $crowiHeaderHeight;
       border-radius: 0;
@@ -70,7 +70,7 @@
       border-right: none;
       background: $crowiAsideBackground;
       border-radius: 5px 0 0 5px;
-      z-index: 1055;
+      z-index: 1039;
       font-size: .8em;
 
       color: darken($link-color, 15%);
@@ -85,7 +85,7 @@
 
 
     aside.sidebar { // {{{
-      z-index: 1040;
+      z-index: 1030;
       position: fixed;
       padding: 65px 0 0 0;
       margin-bottom: $crowiFooterHeight;
@@ -186,7 +186,7 @@
             list-style: none;
 
             a {
-              color: darken($link-color, 50%);
+              color: #666;
               padding: 3px 5px 3px 40px;
               display: block;
 
@@ -245,13 +245,10 @@
           top: 0;
           left: 0;
           padding: 5px 20px;
-          z-index: 1041;
-          background: #fff;
+          z-index: 1039;
+          background: rgba(255, 255, 255, .9);
           box-shadow: 0 0px 2px #999;
 
-          transition: .5s ease;
-          -webkit-transition: .5s ease;
-
           h1 {
             font-size: 1.8em;
           }
@@ -271,10 +268,12 @@
           }
         }
       }
+
       &.col-md-12 article header.affix {
         width: 100%;
       }
 
+
       article header h1 {
         margin-top: 0;
 
@@ -345,24 +344,80 @@
   } // }}}
 
   // override bootstrap modals
-  .modal-backdrop {
-    z-index: 1052;
-  }
-  .modal {
-    z-index: 1055;
-  }
+  //.modal-backdrop {
+  //  z-index: 1052;
+  //}
+  //.modal {
+  //  z-index: 1055;
+  //}
 } // }}}
 
 .crowi.main-container .main {
   .wiki-content {
   }
 
-  .tab-content {
-    margin-top: 30px;
-    .form-box {
+  .content-main {
+    .tab-content {
       margin-top: 30px;
     }
   }
+  // on-edit
+  .content-main.on-edit {
+    position: fixed;
+    z-index: 1060;
+    background: #fff;
+    top: 0;
+    left: 0;
+    height: 100%;
+    width: 100%;
+    padding: 16px;
+
+    .nav {
+      margin-top: 8px;
+      max-height: 8%;
+    }
+
+    .tab-content {
+      margin-top: 1%;
+      height: 83%;
+
+      .edit-form {
+        height: 100%;
+        .row {
+          height: 100%;
+          .col-md-6 {
+            height: 100%;
+
+            form {
+              height: 100%;
+              textarea {
+                height: 100%;
+              }
+            }
+
+            .preview-body {
+              height: 100%;
+              padding-top: 5px;
+              padding-bottom: 5px;
+              overflow: scroll;
+            }
+          }
+        }
+      }
+    }
+
+    .form-group.form-submit-group {
+      position: fixed;
+      bottom: 0;
+      width: 100%;
+      left: 0;
+      padding: 8px;
+      max-height: 8%;
+      background: rgba(255,255,255,.8);
+      border-top: solid 1px #ccc;
+      margin-bottom: 0;
+    }
+  }
 }
 
 .crowi.single { // {{{

+ 31 - 6
resource/css/_wiki.scss

@@ -32,6 +32,7 @@ div.body {
     }
   }
   .revision-toc-content {
+    background: #fcfcfc;
     padding: 10px;
 
     > ul {
@@ -56,6 +57,7 @@ div.body {
 
 .wiki {
   line-height: 1.6em;
+  font-size: 15px;
 
   h1, h2, h3, h4, h5, h6 {
     margin-top: 1.6em;
@@ -67,23 +69,28 @@ div.body {
   }
 
   h1 {
-    font-size: 2.2em;
+    padding-bottom: 0.3em;
+    font-size: 2.3em;
     font-weight: bold;
+    border-bottom: solid 1px #ccc;
   }
   h2 {
+    padding-bottom: 0.3em;
     font-size: 1.8em;
+    line-height: 1.225;
     font-weight: bold;
+    border-bottom: 1px solid #eee;
   }
   h3 {
-    font-size: 1.6em;
+    font-size: 1.5em;
     font-weight: bold;
   }
   h4 {
-    font-size: 1.4em;
+    font-size: 1.3em;
     font-weight: normal;
   }
   h5 {
-    font-size: 1.2em;
+    font-size: 1.1em;
     font-weight: normal;
   }
 
@@ -92,7 +99,12 @@ div.body {
     margin-bottom: 9px;
   }
   blockquote {
-    font-size: 12px;
+    font-size: .9em;
+  }
+
+  pre {
+    line-height: 1.4em;
+    font-size: .9em;
   }
 
   img {
@@ -102,7 +114,20 @@ div.body {
   }
 
   ul, ol {
-    padding-left: 18px;
+    padding-left: 30px;
+
+    li {
+      line-height: 1.8em;
+
+      p {
+        margin-top: 10px;
+        margin-bottom: 0;
+
+        &:first-child {
+          margin-top: 0;
+        }
+      }
+    }
   }
 
   // {{{ table (copied from bootstrap .table

+ 0 - 8
resource/css/crowi.scss

@@ -47,14 +47,6 @@ footer, aside {
 }
 
 
-.preview-body {
-  border-top: solid 1px #ccc;
-  padding-top: 5px;
-  padding-bottom: 5px;
-  max-height: 500px;
-  overflow: scroll;
-}
-
 .form-element {
   margin-bottom: 1em;
 }

+ 85 - 4
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) {
     var id = req.params.id;
     User.findById(id, function(err, userData) {
@@ -155,21 +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.appSetting = function(req, res) {
     var form = req.form.settingForm;
 
     if (req.form.isValid) {
       debug('form content', form);
-      Config.updateNamespaceByArray('crowi', form, function(err, config) {
-        Config.updateConfigCache('crowi', config)
-        return res.json({status: true});
-      });
+
+      // 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 {
       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;
 };

+ 39 - 32
routes/index.js

@@ -9,13 +9,16 @@ module.exports = function(app) {
     , installer = require('./installer')(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.post('/installer/createAdmin'  , middleware.applicationNotInstalled() , form.register , installer.createAdmin);
   //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/invited'           , login.invited);
+  app.post('/login/activateInvited'  , form.invited                         , login.invited);
   app.post('/login'                  , form.login                           , login.login);
   app.post('/register'               , form.register                        , 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('/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);
 
-  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.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/revision/:id'     , user.useUserData()         , revision.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);
 };

+ 92 - 1
routes/login.js

@@ -3,10 +3,13 @@ module.exports = function(app) {
 
   var googleapis = require('googleapis')
     , debug = require('debug')('crowi:routes:login')
+    , async    = require('async')
     , models = app.set('models')
     , config = app.set('config')
+    , mailer = app.set('mailer')
     , Page = models.Page
     , User = models.User
+    , Config = models.Config
     , Revision = models.Revision
     , actions = {};
 
@@ -38,6 +41,24 @@ module.exports = function(app) {
     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) {
     var loginForm = req.body.loginForm;
 
@@ -121,7 +142,7 @@ module.exports = function(app) {
     }
 
     // config で closed ならさよなら
-    if (config.crowi['security:registrationMode'] == 'Closed') {
+    if (config.crowi['security:registrationMode'] == Config.SECURITY_REGISTRATION_MODE_CLOSED) {
       return res.redirect('/');
     }
 
@@ -161,6 +182,38 @@ module.exports = function(app) {
             req.flash('registerWarningMessage', 'ユーザー登録に失敗しました。');
             return res.redirect('/login?register=1');
           } else {
+
+            // 作成後、承認が必要なモードなら、管理者に通知する
+            if (config.crowi['security:registrationMode'] === Config.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+              // TODO send mail
+              User.findAdmins(function(err, admins) {
+                async.each(
+                  admins,
+                  function(adminUser, next) {
+                    mailer.send({
+                        to: adminUser.email,
+                        subject: '[' + config.crowi['app:title'] + ':admin] A New User Created and Waiting for Activation',
+                        template: 'admin/userWaitingActivation.txt',
+                        vars: {
+                          createdUser: userData,
+                          adminUser: adminUser,
+                          url: config.crowi['app:url'],
+                          appTitle: config.crowi['app:title'],
+                        }
+                      },
+                      function (err, s) {
+                        debug('completed to send email: ', err, s);
+                        next();
+                      }
+                    );
+                  },
+                  function(err) {
+                    debug('Sending invitation email completed.', err);
+                  }
+                );
+              });
+            }
+
             if (facebookId || googleId) {
               userData.updateGoogleIdAndFacebookId(googleId, facebookId, function(err, userData) {
                 if (err) { // TODO
@@ -216,5 +269,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;
 };

+ 52 - 74
views/_form.html

@@ -8,87 +8,65 @@
   </ul>
 </div>
 {% endif %}
-<div id="form-box">
-    <div class="row">
-      <div class="col-md-8">
-        <a href="javascript:;" id="form-box-full">最大化切り換え</a>
-        <form action="{{ path }}/edit" method="post" class="">
-          <div class="form-group">
-            <textarea name="pageForm[body]" class="form-control form-body-height" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not revision.body %}# {{ path|path2name }}{% else %}{{ revision.body }}{% endif %}</textarea>
-          </div>
-          <input type="hidden" name="pageForm[format]" value="markdown" id="form-format">
-          <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(revision._id.toString()) }}">
-          <div class="form-group form-inline">
-            <select name="pageForm[grant]" class="form-control">
-              {% for grantId, grantLabel in consts.pageGrants %}
-              <option value="{{ grantId }}" {% if (pageForm.grant && grantId == pageForm.grant) || (page.grant == grantId ) %}selected{% endif %}>{{ grantLabel }}</option>
-              {% endfor %}
-            </select>
+<div id="form-box" class="row">
+  <div class="col-md-6">
+    <form action="{{ path }}/edit" method="post" class="">
+      <textarea name="pageForm[body]" class="form-control form-body-height" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not revision.body %}# {{ path|path2name }}{% else %}{{ revision.body }}{% endif %}</textarea>
 
-            <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
-          </div>
-        </form>
-      </div>
-      <div class="col-md-4">
-        <div id="preview-body" class="wiki preview-body">
-        </div>
+      <input type="hidden" name="pageForm[format]" value="markdown" id="form-format">
+      <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(revision._id.toString()) }}">
+      <div class="form-submit-group form-group form-inline">
+        <select name="pageForm[grant]" class="form-control">
+          {% for grantId, grantLabel in consts.pageGrants %}
+          <option value="{{ grantId }}" {% if (pageForm.grant && grantId == pageForm.grant) || (page.grant == grantId ) %}selected{% endif %}>{{ grantLabel }}</option>
+          {% endfor %}
+        </select>
+
+        <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
       </div>
+    </form>
+  </div>
+  <div class="col-md-6">
+    <div id="preview-body" class="wiki preview-body">
     </div>
-    <script type="text/javascript">
-    $(function() {
-      // preview watch
-      var prevContent = "";
-      var watchTimer = setInterval(function() {
-        $('#preview-body').height($('#form-body').height() + 'px');
-        var content = $('#form-body').val();
-        if (prevContent != content) {
-          var renderer = new Crowi.renderer('#form-body', $('#form-format').val(), '#preview-body');
-          renderer.render();
-
-          prevContent = content;
-        }
-      }, 1000);
+  </div>
+  <script type="text/javascript">
+  $(function() {
+    // preview watch
+    var prevContent = "";
+    var watchTimer = setInterval(function() {
+      var content = $('#form-body').val();
+      if (prevContent != content) {
+        var renderer = new Crowi.renderer('#form-body', $('#form-format').val(), '#preview-body');
+        renderer.render();
 
-      function maximizeFormBox()
-      {
-        $('#form-box').addClass('form-maximized');
-        $('#form-body').height($(window).height() - 150 + 'px');
+        prevContent = content;
       }
-      function minimizeFormBox()
-      {
-        $('#form-box').removeClass('form-maximized');
-        $('#form-body').height('300px');
-      }
-      $('#form-box-full').toggle(function()
-      {
-        maximizeFormBox();
-      }, function() {
-        minimizeFormBox();
-      });
+    }, 1000);
 
-      // tabs handle
-      $('textarea#form-body').on('keydown', function(event){
-        var self  = $(this)
-            start = this.selectionStart,
-            end   = this.selectionEnd
-            val   = self.val();
+    // tabs handle
+    $('textarea#form-body').on('keydown', function(event){
+      var self  = $(this)
+          start = this.selectionStart,
+          end   = this.selectionEnd
+          val   = self.val();
 
-        if (event.keyCode === 9) {
-          // tab
-          event.preventDefault();
-          self.val(
-            val.substring(0, start)
-            + '    '
-            + val.substring(end, val.length)
-          );
-          this.selectionStart = start + 4;
-          this.selectionEnd   = start + 4;
-        } else if (event.keyCode === 27) {
-          // escape
-          self.blur();
-        }
-      });
+      if (event.keyCode === 9) {
+        // tab
+        event.preventDefault();
+        self.val(
+          val.substring(0, start)
+          + '    '
+          + val.substring(end, val.length)
+        );
+        this.selectionStart = start + 4;
+        this.selectionEnd   = start + 4;
+      } else if (event.keyCode === 27) {
+        // escape
+        self.blur();
+      }
     });
+  });
 
-    </script>
+  </script>
 </div>

+ 50 - 5
views/admin/app.html

@@ -89,9 +89,9 @@
           <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'] }}">
-              <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>
             <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
           </div>
@@ -115,10 +115,55 @@
       </fieldset>
       </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">
       <fieldset>
       <legend>AWS設定</legend>
-        <p class="well">S3 にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。<br>
+        <p class="well">AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。<br>
+        また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。<br>
           <br>
 
           <span class="text-danger"><i class="fa fa-warning"></i> この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。</span>
@@ -223,7 +268,7 @@
   <script>
     $(function()
     {
-      $('#appSettingForm, #secSettingForm, #awsSettingForm, #googleSettingForm, #fbSettingForm').each(function() {
+      $('#appSettingForm, #secSettingForm, #mailSettingForm, #awsSettingForm, #googleSettingForm, #fbSettingForm').each(function() {
         $(this).submit(function()
         {
           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>
       </ul>
     </div>
+
     <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">
         <thead>
           <tr>
@@ -96,10 +143,18 @@
                   </form>
                   </li>
                   <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>
                   {% 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 な人だけこのメニューを表示 #}
                   <li class="divider"></li>
@@ -116,7 +171,7 @@
                       {% endif %}
                     {% else %}
                       <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>
                     {% endif %}
                   </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 %}
+

+ 8 - 1
views/layout/2column.html

@@ -29,6 +29,13 @@
         </a>
       </li>
       {% endif %}
+      {#
+      <li id="">
+        <a href="#" id="createPage">
+          <i class="fa fa-plus"> 新規</i>
+        </a>
+      </li>
+      #}
       {% if user %}
       {#
       <li id="" class="notif">
@@ -84,7 +91,7 @@
 
 {% block layout_sidebar %}
 
-<a href="" class="layout-control" id="toggle-sidebar"><i class="fa fa-chevron-right"></i> <span class="hide-on-affix-top"></span></a>
+<a href="" class=" hidden-xs hidden-sm layout-control" id="toggle-sidebar"><i class="fa fa-chevron-right"></i> <span class="hide-on-affix-top"></span></a>
 <script>
   $(function() {
     $('#toggle-sidebar').click(function(e) {

+ 1 - 1
views/layout/layout.html

@@ -5,7 +5,7 @@
   <meta charset="utf-8">
   <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="author" content="">
 

+ 3 - 3
views/login.html

@@ -73,17 +73,17 @@
       {% endif %}
     </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>
     {% endif %}
   </div>
 
-  {% if config.security.registrationMode != 'Closed' %}
+  {% if config.crowi['security:registrationMode'] != 'Closed' %}
   <div class="register-dialog-inner back">
 
     <h2>新規登録</h2>
 
-    {% if config.security.registrationMode == 'Restricted' %}
+    {% if config.crowi['security:registrationMode'] == 'Restricted' %}
     <p class="alert alert-warning">
     この 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 %}
+

+ 14 - 0
views/mail/admin/userInvitation.txt

@@ -0,0 +1,14 @@
+Hi, {{ email }}
+
+You are invited to our Wiki, you can log in with following account:
+
+Email: {{ email }}
+Password: {{ password }}
+(This password was auto generated. Update required at the first time you logging in)
+
+We are waiting for you!
+{{ url }}
+
+--
+{{ appTitle }}
+{{ url }}

+ 21 - 0
views/mail/admin/userWaitingActivation.txt

@@ -0,0 +1,21 @@
+Hi, {{ adminUser.name }}
+
+A user registered to {{ appTitle }}.
+
+
+====
+Created user:
+
+Name: {{ createdUser.name }}
+User Name: {{ createdUser.username }}
+Email: {{ createdUser.email }}
+====
+
+Please do some action with following URL:
+{{ url }}/admin/user
+
+
+--
+{{ appTitle }}
+{{ url }}
+

+ 4 - 4
views/me/index.html

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

+ 33 - 19
views/page.html

@@ -3,20 +3,30 @@
 {% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
 
 {% block content_head %}
-  <header data-spy="affix" data-offset-top="80" id="page-header">
+<div class="header-wrap">
+  <header id="page-header">
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
     <h1 class="title" id="revision-path">{{ path }}</h1>
   </header>
+</div>
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
+<div class="content-main {% if not page %}on-edit{% endif %}">
 
   {% if not page %}
-    <h2>ページを作成する</h2>
-
+  <ul class="nav nav-tabs hidden-print">
+    <li><a>ページ作成: {{ path|path2name }}</a></li>
+    <li class="dropdown pull-right">
+      <a href="/"><i class="fa fa-times"></i> キャンセル</a>
+    </li>
+  </ul>
+  <div class="tab-content">
+    <div class="edit-form">
     {% include '_form.html' %}
+    </div>
+  </div>
 
   {% else %}
 
@@ -49,7 +59,6 @@
 
   {% include 'modal/widget_rename.html' %}
 
-
   <div class="tab-content wiki-content">
   {% if req.query.renamed %}
   <div class="alert alert-info">
@@ -90,7 +99,7 @@
     </div>
 
     {# edit form #}
-    <div class="tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
+    <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' %}
     </div>
   </div>
@@ -101,26 +110,33 @@
         Crowi.correctHeaders('#revision-body-content');
         Crowi.revisionToc('#revision-body-content', '#revision-toc');
 
+        $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
+          $('.content-main').addClass('on-edit');
+        });
+        $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
+          $('.content-main').removeClass('on-edit');
+        });
+
         $('#edit-form').submit(function()
         {
           //console.log('save');
           //return false;
         });
 
-        var topMargin = $('#page-header').outerHeight() + 20;
-        $('#page-header').on('affixed.bs.affix', function(e) {
-          $('.content-main').css({'padding-top': topMargin});
-        });
-        $('#page-header').on('affixed-top.bs.affix', function(e) {
-          $('.content-main').css({'padding-top': 0});
+        //data-spy="affix" data-offset-top="80"
+        var headerHeight = $('#page-header').outerHeight(true);
+        $('.header-wrap').css({height: headerHeight + 'px'});
+        $('#page-header').affix({
+          offset: {
+            top: function() {
+              return headerHeight + 74; // (54 header + 20 padding-top)
+            }
+          }
         });
         $('[data-affix-disable]').on('click', function(e) {
           $elm = $($(this).data('affix-disable'));
-          $elm.removeClass('affix')
-            .addClass('affix-top')
-            .removeAttr('data-spy');
-          $('.content-main').css({'padding-top': 0});
-
+          $(window).off('.affix');
+          $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
           return false;
         });
     });
@@ -230,13 +246,11 @@ $(function() {
   $('#bookmarkButton').click(function() {
     var pageId = {{page._id|json|safe}};
     $.post('/_api/page/{{ page._id.toString() }}/bookmark', function(data) {
-      console.log(data);
     });
   });
   $('#pageLikeButton').click(function() {
     var pageId = {{page._id|json|safe}};
     $.post('/_api/page/{{ page._id.toString() }}/like', function(data) {
-      console.log(data);
     });
   });
 });