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

Merge branch 'master' into support-i18n

Conflicts:
  lib/views/widget/page_side_header.html
Norio Suzuki 9 лет назад
Родитель
Сommit
2c8e926cf1

+ 11 - 0
CHANGES.md

@@ -1,6 +1,17 @@
 CHANGES
 ========
 
+## 1.5.2
+
+* Fix: Edit button on smartphone.
+* Fix: Avoid timeout on rebuilding search index.
+* Improve: Search screen on smartphone.
+* Improve: New page dialog.
+* Improve: Update link color.
+* Add node version for CI.
+* Changed assets loader.
+* And some fixes. (Thank you @suzuki @kdmsnr @crow-misia)
+
 ## 1.5.1
 
 * Fix: Broken corwi.min.js (thank you @Bakudankun #135)

+ 1 - 1
README.md

@@ -4,7 +4,7 @@ Crowi - The Simple & Powerful Communication Tool Based on Wiki
 ================================================================
 
 
-[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/crowi/crowi/tree/v1.5.1)
+[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/crowi/crowi/tree/v1.5.2)
 
 [![Circle CI](https://circleci.com/gh/crowi/crowi.svg?style=svg)](https://circleci.com/gh/crowi/crowi)
 [![Join the chat at https://gitter.im/crowi/general](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/crowi/general)

+ 4 - 1
circle.yml

@@ -4,7 +4,10 @@ machine:
     - mongodb
   environment:
     MONGO_URI: mongodb://127.0.0.1/crowi_test
+  node:
+      version: 4.6.2
+  post:
+    - npm install -g npm@3
 notify:
   webhooks:
     - url: https://webhooks.gitter.im/e/5a035388e3274b621d20
-

+ 13 - 10
gulpfile.js

@@ -1,5 +1,7 @@
 'use strict';
 
+var fs = require('fs');
+
 var gulp   = require('gulp');
 var sass   = require('gulp-sass');
 var cssmin = require('gulp-cssmin');
@@ -48,10 +50,10 @@ var js = {
 
   bundled:      dirs.jsDist + '/bundled.js',
   dist:         dirs.jsDist + '/crowi.js',
+  app:          dirs.jsDist + '/app.js',
   admin:        dirs.jsDist + '/admin.js',
   form:         dirs.jsDist + '/form.js',
   presentation: dirs.jsDist + '/presentation.js',
-  app:          dirs.jsDist + '/app.js',
 
   clientWatch: ['resource/js/**/*.js'],
   watch: ['test/**/*.test.js', 'app.js', 'lib/**/*.js'],
@@ -66,15 +68,16 @@ var cssIncludePaths = [
 ];
 
 gulp.task('js:del', function() {
-  var fileList = [
-    js.dist,
-    js.bundled,
-    js.admin,
-    js.form,
-    js.presentation,
-    js.app,
-  ];
-  fileList = fileList.concat(fileList.map(function(fn){ return fn.replace(/\.js/, '.min.js');}));
+  var fileList = [];
+
+  var actualFiles = fs.readdirSync(dirs.jsDist);
+  fileList = actualFiles.map(function(fn){
+    if (!fn.match(/.js(on)?$/)) {
+      return false
+    }
+    return dirs.jsDist + '/' + fn;
+  }).filter(function(v) { return v; });
+
   return del(fileList);
 });
 

+ 23 - 0
lib/crowi/index.js

@@ -4,6 +4,7 @@
 var debug = require('debug')('crowi:crowi')
   , pkg = require('../../package.json')
   , path = require('path')
+  , fs = require('fs')
   , sep = path.sep
   , Promise = require('bluebird')
 
@@ -27,6 +28,13 @@ function Crowi (rootdir, env)
   this.viewsDir  = path.join(this.libDir, 'views') + sep;
   this.mailDir   = path.join(this.viewsDir, 'mail') + sep;
 
+  this.assets    = {};
+  try {
+    this.assets = require(this.publicDir + '/js/manifest.json') || {};
+  } catch (e) {
+    // ignore
+  }
+
   this.config = {};
   this.searcher = null;
   this.mailer = {};
@@ -97,6 +105,21 @@ Crowi.prototype.getConfig = function() {
   return this.config;
 };
 
+Crowi.prototype.getAssetList = function() {
+  if (this.node_env !== 'development') {
+    return this.assets;
+  }
+
+  // reload manifest
+  try {
+    this.assets = JSON.parse(fs.readFileSync(this.publicDir + '/js/manifest.json'))|| {};
+  } catch (e) {
+    // ignore
+    debug('Failed to reload assets on development', e);
+  }
+  return this.assets;
+};
+
 // getter/setter of model instance
 //
 Crowi.prototype.model = function(name, model) {

+ 78 - 2
lib/models/user.js

@@ -11,9 +11,9 @@ module.exports = function(crowi) {
     , STATUS_SUSPENDED  = 3
     , STATUS_DELETED    = 4
     , STATUS_INVITED    = 5
-    , USER_PUBLIC_FIELDS = '_id image googleId name username email status createdAt' // TODO: どこか別の場所へ...
+    , USER_PUBLIC_FIELDS = '_id image googleId name username email introduction status createdAt admin' // TODO: どこか別の場所へ...
 
-    , PAGE_ITEMS        = 20
+    , PAGE_ITEMS        = 50
 
     , userEvent = crowi.event('user')
 
@@ -58,6 +58,19 @@ module.exports = function(crowi) {
     }
   }
 
+  function generateRandomTempPassword () {
+    var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!=-_';
+    var password = '';
+    var len = 12;
+
+    for (var i = 0; i < len; i++) {
+      var randomPoz = Math.floor(Math.random() * chars.length);
+      password += chars.substring(randomPoz, randomPoz+1);
+    }
+
+    return password;
+  }
+
   function generatePassword (password) {
     var hasher = crypto.createHash('sha256');
     hasher.update(crowi.env.PASSWORD_SEED + password);
@@ -247,6 +260,19 @@ module.exports = function(crowi) {
     return true;
   };
 
+  userSchema.statics.filterToPublicFields = function(user) {
+    var filteredUser = {};
+    var fields = USER_PUBLIC_FIELDS.split(' ');
+    for (var i = 0; i < fields.length; i++) {
+      var key = fields[i];
+      if (user[key]) {
+        filteredUser[key] = user[key];
+      }
+    }
+
+    return filteredUser;
+  };
+
   userSchema.statics.findUsers = function(options, callback) {
     var sort = options.sort || {status: 1, createdAt: 1};
 
@@ -329,6 +355,31 @@ module.exports = function(crowi) {
     }, { sortBy : sort });
   };
 
+  userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {
+    const status = options.status || null;
+    const emailPartRegExp = new RegExp(emailPart.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'));
+    const User = this;
+
+    return new Promise((resolve, reject) => {
+      const query = User.find({ email: emailPartRegExp }, USER_PUBLIC_FIELDS);
+
+      if (status) {
+        query.and({status});
+      }
+
+      query
+        .limit(PAGE_ITEMS + 1)
+        .exec((err, userData) => {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(userData);
+        });
+    });
+  };
+
+
   userSchema.statics.findUserByUsername = function(username) {
     var User = this;
     return new Promise(function(resolve, reject) {
@@ -431,6 +482,30 @@ module.exports = function(crowi) {
     });
   };
 
+  userSchema.statics.resetPasswordByRandomString = function(id) {
+    var User = this;
+
+    return new Promise(function(resolve, reject) {
+      User.findById(id, function (err, userData) {
+        if (!userData) {
+          return reject(new Error('User not found'));
+        }
+
+        // is updatable check
+        // if (userData.isUp
+        var newPassword = generateRandomTempPassword();
+        userData.setPassword(newPassword);
+        userData.save(function(err, userData) {
+          if (err) {
+            return reject(err);
+          }
+
+          resolve({user: userData, newPassword: newPassword});
+        });
+      });
+    });
+  };
+
   userSchema.statics.createUsersByInvitation = function(emailList, toSendEmail, callback) {
     var User = this
       , createdUserList = []
@@ -577,6 +652,7 @@ module.exports = function(crowi) {
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED = STATUS_INVITED;
   userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
+  userSchema.statics.PAGE_ITEMS = PAGE_ITEMS;
 
   return mongoose.model('User', userSchema);
 };

+ 31 - 0
lib/routes/admin.js

@@ -337,6 +337,21 @@ module.exports = function(crowi, app) {
     });
   };
 
+  // app.post('/_api/admin/users.resetPassword' , admin.api.usersResetPassword);
+  actions.user.resetPassword = function(req, res) {
+    const id = req.body.user_id;
+    const User = crowi.model('User');
+
+    User.resetPasswordByRandomString(id)
+    .then(function(data) {
+      data.user = User.filterToPublicFields(data.user);
+      return res.json(ApiResponse.success(data));
+    }).catch(function(err) {
+      debug('Error on reseting password', err);
+      return res.json(ApiResponse.error('Error'));
+    });
+  }
+
   actions.api = {};
   actions.api.appSetting = function(req, res) {
     var form = req.form.settingForm;
@@ -399,6 +414,22 @@ module.exports = function(crowi, app) {
     });
   };
 
+  // app.get('/_api/admin/users.search' , admin.api.userSearch);
+  actions.api.usersSearch = function(req, res) {
+    const User = crowi.model('User');
+    const email =req.query.email;
+
+    User.findUsersByPartOfEmail(email, {})
+    .then(users => {
+      const result = {
+        data: users
+      };
+      return res.json(ApiResponse.success(result));
+    }).catch(err => {
+      return res.json(ApiResponse.error());
+    });
+  };
+
   function saveSetting(req, res, form)
   {
     Config.updateNamespaceByArray('crowi', form, function(err, config) {

+ 3 - 0
lib/routes/index.js

@@ -54,6 +54,7 @@ module.exports = function(crowi, app) {
   app.get('/admin/notification/slackAuth'    , loginRequired(crowi, app) , middleware.adminRequired() , admin.notification.slackAuth);
   app.post('/_api/admin/notification.add'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationAdd);
   app.post('/_api/admin/notification.remove' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationRemove);
+  app.get('/_api/admin/users.search'         , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.usersSearch);
 
   app.get('/admin/users'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.index);
   app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.invite);
@@ -63,6 +64,8 @@ module.exports = function(crowi, app) {
   app.post('/admin/user/:id/suspend'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.suspend);
   app.post('/admin/user/:id/remove'     , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.remove);
   app.post('/admin/user/:id/removeCompletely' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.removeCompletely);
+  // new route patterns from here:
+  app.post('/_api/admin/users.resetPassword'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.resetPassword);
 
   app.get('/me'                       , loginRequired(crowi, app) , me.index);
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);

+ 1 - 1
lib/util/mailer.js

@@ -68,7 +68,7 @@ module.exports = function(crowi) {
       return;
     }
 
-    if (config.crowi['mail:smtpUser'] && config.crowi['mail:smtpPassword'] && config.crowi['mail:smtpHost'] && config.crowi['mail:smtpPort']
+    if (config.crowi['mail:smtpHost'] && config.crowi['mail:smtpPort']
       ) {
       // SMTP 設定がある場合はそれを優先
       mailer = createSMTPClient();

+ 4 - 1
lib/util/search.js

@@ -226,7 +226,10 @@ SearchClient.prototype.addAllPages = function()
       // all done
 
       // 最後に送信
-      self.client.bulk({ body: body, })
+      self.client.bulk({
+        body: body,
+        requestTimeout: Infinity,
+      })
       .then(function(res) {
         debug('Reponse from es:', res);
         return resolve(res);

+ 14 - 0
lib/util/swigFunctions.js

@@ -10,6 +10,20 @@ module.exports = function(crowi, app, req, locals) {
     return req.csrfToken;
   };
 
+  locals.assets = function(file) {
+    var assetList = crowi.getAssetList();
+    var baseName = file.match(/\/([^\/]+)$/)[1];
+
+    var baseNameWithHash = '';
+    if (assetList[baseName]) {
+      baseNameWithHash = assetList[baseName];
+    } else {
+      return file;
+    }
+
+    return file.replace(baseName, baseNameWithHash);
+  };
+
   locals.googleLoginEnabled = function() {
     var config = crowi.getConfig()
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];

+ 1 - 1
lib/views/_form.html

@@ -61,4 +61,4 @@
   <div class="file-module hidden">
   </div>
 </div>
-<script src="/js/form.js"></script>
+<script src="{{ assets('/js/form.js') }}"></script>

+ 67 - 0
lib/views/admin/users.html

@@ -76,6 +76,62 @@
       </div><!-- /.modal -->
       {% endif %}
 
+      {# FIXME とりあえずクソ実装。React化はやくしたいなー(チラッチラッ #}
+      <div class="modal fade" id="admin-password-reset-modal">
+        <div class="modal-dialog">
+          <div class="modal-content">
+            <div class="modal-header">
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+              <h4 class="modal-title">パスワードを新規発行しますか?</h4>
+            </div>
+
+            <div class="modal-body">
+              <p>
+              新規発行したパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。<br>
+              <span class="text-danger">新規発行したパスワードを、対象ユーザーへ連絡してください。</span>
+              </p>
+              <p>
+              Reset user: <code id="admin-password-reset-user"></code>
+              </p>
+
+              <form method="post" id="admin-users-reset-password">
+                <input type="hidden" name="user_id" value="">
+                <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                <button type="submit" value="" class="btn btn-primary">
+                  実行
+                </button>
+              </form>
+
+            </div>
+
+          </div><!-- /.modal-content -->
+        </div><!-- /.modal-dialog -->
+      </div>
+      <div class="modal fade" id="admin-password-reset-modal-done">
+        <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">Password reset!</h4>
+            </div>
+
+            <div class="modal-body">
+              <p class="alert alert-danger">Let the user know the new password below and strongly recommend to change another one immediately. </p>
+              <p>
+              Reset user: <code id="admin-password-reset-done-user"></code>
+              </p>
+              <p>
+              New passwrod: <code id="admin-password-reset-done-password"></code>
+              </p>
+            </div>
+            <div class="modal-footer">
+              <button class="btn btn-primary" data-dismiss="modal">OK</button>
+            </div>
+          </div><!-- /.modal-content -->
+        </div><!-- /.modal-dialog -->
+      </div>
+
       <h2>ユーザー一覧</h2>
 
       <table class="table table-hover table-striped table-bordered">
@@ -121,6 +177,16 @@
                   <li class="dropdown-header">編集メニュー</li>
                   <li>
                     <a href="">編集</a>
+
+                  </li>
+                  <li class="dropdown-button">
+                    <a href="#"
+                      data-user-id="{{ sUser._id.toString() }}"
+                      data-user-email="{{ sUser.email }}"
+                      data-target="#admin-password-reset-modal"
+                      data-toggle="modal" class="btn btn-block btn-default">
+                      パスワードの再発行
+                    </a>
                   </li>
                   <li class="divider"></li>
                   <li class="dropdown-header">ステータス</li>
@@ -132,6 +198,7 @@
                   </form>
                   {% endif  %}
                   {% if sUser.status == 2 %}
+
                   <form action="/admin/user/{{ sUser._id.toString() }}/suspend" method="post">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                     <button type="submit" class="btn btn-block btn-warning">アカウント停止</button>

+ 1 - 1
lib/views/layout/admin.html

@@ -1,6 +1,6 @@
 {% extends '2column.html' %}
 
 {% block footer %}
-  <script src="/js/admin.js"></script>
+  <script src="{{ assets('/js/admin.js') }}"></script>
 {% endblock footer %}
 

+ 6 - 5
lib/views/layout/layout.html

@@ -12,7 +12,8 @@
   <meta name="viewport" content="width=device-width,initial-scale=1">
 
   <link rel="stylesheet" href="/css/crowi{% if env  == 'production' %}.min{% endif %}.css">
-  <script src="/js/bundled.js"></script>
+
+  <script src="{{ assets('/js/bundled.js') }}"></script>
   <link href='//fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
 </head>
 {% endblock %}
@@ -30,10 +31,10 @@
   <div class="navbar-header">
     <a class="navbar-brand" href="/">
       <img alt="Crowi" src="/logo/32x32.png" width="16">
-      {% block title %}{{ config.crowi['app:title']|default('Crowi') }}{% endblock %}
+      <span class="hidden-xs">{% block title %}{{ config.crowi['app:title']|default('Crowi') }}{% endblock %}</span>
     </a>
   {% if searchConfigured() %}
-  <div class="navbar-form navbar-left search-top visible-lg visible-md" role="search" id="search-top">
+  <div class="navbar-form navbar-left search-top" role="search" id="search-top">
   </div>
   {% endif %}
   </div>
@@ -130,6 +131,6 @@
 </body>
 {% endblock %}
 
-<script src="/js/crowi.js"></script>
-<script src="/js/app.js"></script>
+<script src="{{ assets('/js/crowi.js') }}"></script>
+<script src="{{ assets('/js/app.js') }}"></script>
 </html>

+ 14 - 3
lib/views/page.html

@@ -13,10 +13,21 @@
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
 
-    {% if page %}
-    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    <div class="flex-title-line">
+      <h1 class="title flex-item-title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
+      {% if page %}
+      <div class="flex-item-action">
+        <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-csrftoken="{{ csrf() }}" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+      </div>
+      <div class="flex-item-action visible-xs visible-sm">
+        <button
+            data-csrftoken="{{ csrf() }}"
+            data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
+            class="like-button btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
+        ><i class="fa fa-thumbs-o-up"></i></button>
+      </div>
+    </div>
     {% endif %}
-    <h1 class="title" id="revision-path">{{ path|insertSpaceToEachSlashes }}</h1>
   </header>
   {% else %}
   {# trash/* #}

+ 2 - 2
lib/views/page_presentation.html

@@ -10,7 +10,7 @@
     <link rel="stylesheet" type="text/css" href="/css/crowi-reveal{% if env  == 'production' %}.min{% endif %}.css">
     <link rel="stylesheet" type="text/css" href="/js/reveal/lib/css/zenburn.css">
 
-    <script src="/js/bundled.js"></script>
+    <script src="{{ assets('/js/bundled.js') }}"></script>
 
     <title>{{ path|path2name }} | {{ path }}</title>
   </head>
@@ -29,6 +29,6 @@
       </div>
     </div>
 
-    <script src="/js/presentation.js"></script>
+    <script src="{{ assets('/js/presentation.js') }}"></script>
   </body>
 </html>

+ 2 - 2
lib/views/widget/page_side_header.html

@@ -35,8 +35,8 @@
         <button
           data-csrftoken="{{ csrf() }}"
           data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
-          class="btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
-          id="like-button"><i class="fa fa-thumbs-o-up"></i> {{ t('Like!') }}</button>
+          class="like-button btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
+          ><i class="fa fa-thumbs-o-up"></i> {{ t('Like!') }}</button>
         </p>
         <p id="liker-list" class="liker-list" data-likers="{{ page.liker|default([])|join(',') }}">
         </p>

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi",
-  "version": "1.5.1",
+  "version": "1.5.2",
   "description": "The simple & powerful Wiki",
   "tags": [
     "wiki",
@@ -99,6 +99,7 @@
     "time": "~0.11.0",
     "vinyl-source-stream": "~1.1.0",
     "webpack": "~1.13.0",
+    "webpack-manifest-plugin": "~1.0.1",
     "webpack-stream": "~3.1.0"
   },
   "devDependencies": {

+ 8 - 2
resource/css/_form.scss

@@ -99,7 +99,7 @@
     width: 100%;
     left: 0;
     padding: 8px;
-    height: 50px;
+    min-height: 50px;
     background: rgba(255,255,255,.8);
     border-top: solid 1px #ccc;
     margin-bottom: 0;
@@ -150,9 +150,15 @@ input:-moz-placeholder {
     .form-group.form-submit-group {
       select.form-control {
         display: inline-block;
-        width: auto;
+        max-width: 50%;
       }
     }
   }
 
 } // }}}
+
+@media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+  #edit-form-submit {
+    float: right;
+  }
+} // }}}

+ 19 - 2
resource/css/_page.scss

@@ -54,7 +54,6 @@
 
     article header { // not affixed
       .bookmark-link {
-        float: right;
         color: #e6b422;
         font-size: 2em;
         &.bookmarked {
@@ -62,6 +61,25 @@
         }
       }
 
+      .flex-title-line {
+        display: -webkit-flex;
+        display: flex;
+        -webkit-align-items: center;
+        align-items:         center;
+
+        .flex-item-title {
+          -webkit-flex-basis: auto;
+          flex-basis: auto;
+          margin-right: auto;
+        }
+        .flex-item-action {
+          -webkit-flex-basis: 2em;
+          flex-basis: 2em;
+          text-align: center;
+          padding: 0 2px;
+        }
+      }
+
       h1 {
         font-size: 28px;
         margin-top: 0;
@@ -298,4 +316,3 @@
   }
 
 } // }}}
-

+ 55 - 0
resource/css/_search.scss

@@ -126,3 +126,58 @@
     }
   }
 }
+
+// Smartphone and Tablet
+@media (max-width: $screen-sm-max) {
+  .search-top {
+    margin-top: 4px 0 0 0;
+    padding: 0;
+    border-style: none !important;
+    box-shadow: none !important;
+    -webkit-box-shadow: none !important;
+
+    .search-form {
+      width: 76%;
+    }
+
+    .search-top-input-group {
+      .search-top-input {
+        width: 100%;
+      }
+      .btn {
+        z-index: 10;
+      }
+    }
+  }
+
+  .search-result {
+    .search-result-content {
+      .search-result-page {
+        .wiki {
+          h1, h2, h3, h4, h5, h6 {
+            font-size: medium;
+          }
+          height: 250px;
+          overflow: scroll;
+        }
+      }
+    }
+  }
+}
+
+// Smartphone
+@media (max-width: $screen-xs-max) {
+  .search-top {
+    .search-form {
+      min-width: 40%;
+      max-width: 50%;
+      width: 50%;
+    }
+    .search-box {
+      .search-suggest {
+        left: 2%;
+        width: 94%;
+      }
+    }
+  }
+}

+ 11 - 0
resource/css/_wiki.scss

@@ -8,6 +8,7 @@ div.body {
   border: solid 1px #aaa;
   border-radius: 5px;
   max-width: 250px;
+  overflow: hidden;
 
   .revision-toc-head {
     display: inline-block;
@@ -97,6 +98,16 @@ div.body {
     font-size: .9em;
   }
 
+  a {
+    text-decoration: underline;
+    color: #38ad9e;
+
+    &:hover {
+      text-decoration: none;
+      color: darken(#38ad9e, 10%);
+    }
+  }
+
   pre {
     line-height: 1.4em;
     font-size: .9em;

+ 16 - 0
resource/css/crowi.scss

@@ -109,6 +109,22 @@ footer {
     }
 
     form {
+
+      input.form-control {
+        border: none;
+        box-shadow: none;
+        border-bottom: dotted 1px #444;
+        border-radius: 0;
+        padding: 6px;
+        height: 34px;
+        font-weight: bold;
+        background: #f0f0f0;
+
+        &:focus {
+          background: #ddd;
+        }
+      }
+
       .page-name-addons {
         position: absolute;
         top: 7px;

+ 0 - 2
resource/js/app.js

@@ -33,5 +33,3 @@ Object.keys(componentMappings).forEach((key) => {
     ReactDOM.render(componentMappings[key], elem);
   }
 });
-
-

+ 1 - 2
resource/js/components/SearchPage/SearchResult.js

@@ -78,7 +78,7 @@ export default class SearchResult extends React.Component {
     return (
       <div className="content-main">
         <div className="search-result row" id="search-result">
-          <div className="col-md-4 page-list search-result-list" id="search-result-list">
+          <div className="col-md-4 hidden-xs hidden-sm page-list search-result-list" id="search-result-list">
             <nav data-spy="affix" data-offset-top="120">
               <ul className="page-list-ul nav">
                 {listView}
@@ -111,4 +111,3 @@ SearchResult.defaultProps = {
   searchResultMeta: {},
   searchError: null,
 };
-

+ 29 - 0
resource/js/crowi-admin.js

@@ -23,4 +23,33 @@ $(function() {
   });
 
   $('#createdUserModal').modal('show');
+
+  $('#admin-password-reset-modal').on('show.bs.modal', function(button) {
+    var data = $(button.relatedTarget);
+    var userId = data.data('user-id');
+    var email = data.data('user-email');
+
+    $('#admin-password-reset-user').text(email);
+    $('#admin-users-reset-password input[name=user_id]').val(userId);
+  });
+
+  $('form#admin-users-reset-password').on('submit', function(e) {
+    $.post('/_api/admin/users.resetPassword', $(this).serialize(), function(res) {
+      if (res.ok) {
+        // TODO Fix
+        //location.reload();
+        $('#admin-password-reset-modal').modal('hide');
+        $('#admin-password-reset-modal-done').modal('show');
+
+        $("#admin-password-reset-done-user").text(res.user.email);
+        $("#admin-password-reset-done-password").text(res.newPassword);
+        return ;
+      }
+
+      // fixme
+      alert('Failed to reset password');
+    });
+
+    return false;
+  });
 });

+ 44 - 6
resource/js/crowi-form.js

@@ -101,6 +101,39 @@ $(function() {
     crowi.clearDraft(pagePath);
   });
 
+  // This is a temporary implementation until porting to React.
+  var insertText = function(start, end, newText, mode) {
+    var editor = document.querySelector('#form-body');
+    mode = mode || 'after';
+
+    switch (mode) {
+    case 'before':
+      editor.setSelectionRange(start, start);
+      break;
+    case 'replace':
+      editor.setSelectionRange(start, end);
+      break;
+    case 'after':
+    default:
+      editor.setSelectionRange(end, end);
+    }
+
+    editor.focus();
+
+    var inserted = false;
+    try {
+      // Chrome, Safari
+      inserted = document.execCommand('insertText', false, newText);
+    } catch (e) {
+      inserted = false;
+    }
+
+    if (!inserted) {
+      // Firefox
+      editor.value = editor.value.substr(0, start) + newText + editor.value.substr(end);
+    }
+  };
+
   var getCurrentLine = function(event) {
     var $target = $(event.target);
 
@@ -220,7 +253,8 @@ $(function() {
           listMark = listMark.replace(/\s*\d+/, indent + (num +1));
         }
       }
-      $target.selection('insert', {text: "\n" + listMark, mode: 'before'});
+      //$target.selection('insert', {text: "\n" + listMark, mode: 'before'});
+      insertText(currentLine.start, currentLine.end, "\n" + listMark, 'after');
     } else if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) )/)) {
       // remove list
       $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
@@ -241,10 +275,12 @@ $(function() {
       }
       var prevLine = getPrevLine(event);
       if (!prevLine || (!currentLine.text.match(/---/) && !prevLine.text.match(/\|/g))) {
-        $target.selection('insert', {text: "\n" + row.join(' --- ') + "\n" + row.join('  '), mode: 'before'});
+        //$target.selection('insert', {text: "\n" + row.join(' --- ') + "\n" + row.join('  '), mode: 'before'});
+        insertText(currentLine.start, currentLine.end, "\n" + row.join(' --- ') + "\n" + row.join('  '), 'after');
         $target.selection('setPos', {start: currentLine.caret + 6 * row.length - 1, end: currentLine.caret + 6 * row.length - 1});
       } else {
-        $target.selection('insert', {text: "\n" + row.join('  '), mode: 'before'});
+        //$target.selection('insert', {text: "\n" + row.join('  '), mode: 'before'});
+        insertText(currentLine.start, currentLine.end, "\n" + row.join('  '), 'after');
         $target.selection('setPos', {start: currentLine.caret + 3, end: currentLine.caret + 3});
       }
     }
@@ -275,7 +311,8 @@ $(function() {
       var checkMark = (match[3] == ' ') ? 'x' : ' ';
       var replaceTo = match[1] + match[2] + ' [' + checkMark + '] ' + match[4];
       $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
-      $target.selection('replace', {text: replaceTo, mode: 'keep'});
+      //$target.selection('replace', {text: replaceTo, mode: 'keep'});
+      insertText(currentLine.start, currentLine.end, replaceTo, 'replace');
       $target.selection('setPos', {start: currentLine.caret, end: currentLine.caret});
       $target.trigger('input');
     }
@@ -317,9 +354,10 @@ $(function() {
       }
     }
 
-    $target.selection('insert', {text: pasteText, mode: 'after'});
+    //$target.selection('insert', {text: pasteText, mode: 'after'});
+    insertText(currentLine.caret, currentLine.caret, pasteText, 'replace');
 
-    var newPos = currentLine.end + pasteText.length;
+    var newPos = currentLine.caret + pasteText.length;
     $target.selection('setPos', {start: newPos, end: newPos});
 
     return true;

+ 3 - 3
resource/js/crowi.js

@@ -215,12 +215,12 @@ $(function() {
 
   $('#create-page').on('shown.bs.modal', function (e) {
 
-    var input2Width = $('#create-page-today .page-today-input2').outerWidth();
+    var input2Width = $('#create-page-today .col-xs-10').outerWidth();
     var newWidth = input2Width
       - $('#create-page-today .page-today-prefix').outerWidth()
       - $('#create-page-today .page-today-input1').outerWidth()
       - $('#create-page-today .page-today-suffix').outerWidth()
-      - 10
+      - 40
       ;
     $('#create-page-today .form-control.page-today-input2').css({width: newWidth}).focus();
 
@@ -619,7 +619,7 @@ $(function() {
     }
 
     // Like
-    var $likeButton = $('#like-button');
+    var $likeButton = $('.like-button');
     var $likeCount = $('#like-count');
     $likeButton.click(function() {
       var liked = $likeButton.data('liked');

+ 12 - 0
test/models/user.test.js

@@ -29,6 +29,18 @@ describe('User', function () {
           done();
         });
       });
+
+      it('should be found by findUsersByPartOfEmail', function(done) {
+
+        User.findUsersByPartOfEmail('ao', {})
+        .then(function(userData) {
+          expect(userData).to.be.array;
+          expect(userData[0]).to.instanceof(User);
+          expect(userData[0].email).to.equal('aoi@example.com');
+          done();
+        });
+
+      });
     });
   });
 

+ 10 - 5
webpack.config.js

@@ -1,17 +1,19 @@
 var path = require('path');
 var webpack = require('webpack');
 
+var ManifestPlugin = require('webpack-manifest-plugin');
+
 var config = {
   entry: {
-    app: './resource/js/app.js',
-    crowi: './resource/js/crowi.js',
+    app:          './resource/js/app.js',
+    crowi:        './resource/js/crowi.js',
     presentation: './resource/js/crowi-presentation.js',
-    form: './resource/js/crowi-form.js',
-    admin: './resource/js/crowi-admin.js',
+    form:         './resource/js/crowi-form.js',
+    admin:        './resource/js/crowi-admin.js',
   },
   output: {
     path: path.join(__dirname + "/public/js"),
-    filename: "[name].js"
+    filename: "[name].[hash].js"
   },
   resolve: {
     modulesDirectories: [
@@ -47,4 +49,7 @@ if (process.env && process.env.NODE_ENV !== 'development') {
     }),
   ];
 }
+
+config.plugins.push(new ManifestPlugin());
+
 module.exports = config;