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

Merge branch 'feat/grouping-users' into imprv/admin-user-group-managemnt

# Conflicts:
#	lib/models/index.js
#	lib/routes/index.js
#	lib/views/_form.html
#	lib/views/admin/widget/menu.html
#	resource/css/_admin.scss
Tatsuya Ise 8 лет назад
Родитель
Сommit
f0d1f8f626
100 измененных файлов с 3966 добавлено и 1003 удалено
  1. 123 5
      CHANGES.md
  2. 1 6
      LICENSE
  3. 42 42
      README.md
  4. 80 0
      THIRD-PARTY-NOTICES.md
  5. 1 1
      app.json
  6. 30 0
      bin/shrink-emojione-strategy.js
  7. 5 3
      config/env.dev.js
  8. 6 0
      config/env.prod.js
  9. 13 6
      config/webpack.common.js
  10. 32 0
      lib/crowi/dev.js
  11. 34 28
      lib/crowi/index.js
  12. 1 1
      lib/form/admin/app.js
  13. 9 0
      lib/form/admin/customheader.js
  14. 23 0
      lib/form/admin/securityPassportLdap.js
  15. 2 0
      lib/form/index.js
  16. 67 14
      lib/locales/en-US/translation.json
  17. 86 18
      lib/locales/ja/translation.json
  18. 32 1
      lib/models/config.js
  19. 148 0
      lib/models/external-account.js
  20. 1 0
      lib/models/index.js
  21. 13 0
      lib/models/revision.js
  22. 69 31
      lib/models/user.js
  23. 93 0
      lib/routes/admin.js
  24. 0 5
      lib/routes/attachment.js
  25. 13 1
      lib/routes/index.js
  26. 160 15
      lib/routes/login-passport.js
  27. 1 0
      lib/routes/login.js
  28. 138 0
      lib/routes/me.js
  29. 1 0
      lib/routes/page.js
  30. 183 2
      lib/service/passport.js
  31. 6 6
      lib/util/googleAuth.js
  32. 6 8
      lib/util/i18nUserSettingDetector.js
  33. 19 3
      lib/util/middlewares.js
  34. 27 6
      lib/util/search.js
  35. 41 3
      lib/util/swigFunctions.js
  36. 28 0
      lib/util/xss.js
  37. 54 53
      lib/views/_form.html
  38. 22 22
      lib/views/admin/app.html
  39. 56 54
      lib/views/admin/customize.html
  40. 131 0
      lib/views/admin/external-accounts.html
  41. 9 10
      lib/views/admin/index.html
  42. 129 61
      lib/views/admin/security.html
  43. 5 1
      lib/views/admin/users.html
  44. 7 7
      lib/views/admin/widget/menu.html
  45. 6 0
      lib/views/admin/widget/passport/facebook.html
  46. 6 0
      lib/views/admin/widget/passport/github.html
  47. 82 0
      lib/views/admin/widget/passport/google-oauth.html
  48. 311 0
      lib/views/admin/widget/passport/ldap.html
  49. 6 0
      lib/views/admin/widget/passport/twitter.html
  50. 1 8
      lib/views/crowi-plus/base/not_found_nosidebar.html
  51. 1 8
      lib/views/crowi-plus/base/page_list_nosidebar.html
  52. 1 8
      lib/views/crowi-plus/base/page_nosidebar.html
  53. 4 9
      lib/views/crowi-plus/base/user_page_nosidebar.html
  54. 3 1
      lib/views/crowi-plus/page.html
  55. 6 1
      lib/views/crowi-plus/page_list.html
  56. 1 1
      lib/views/crowi-plus/widget/comments.html
  57. 1 1
      lib/views/crowi-plus/widget/page_list_container.html
  58. 24 0
      lib/views/crowi-plus/widget/system-version.html
  59. 1 1
      lib/views/layout/2column.html
  60. 41 4
      lib/views/layout/layout.html
  61. 17 1
      lib/views/layout/single.html
  62. 44 3
      lib/views/login.html
  63. 1 0
      lib/views/me/api_token.html
  64. 251 0
      lib/views/me/external-accounts.html
  65. 3 2
      lib/views/me/index.html
  66. 3 2
      lib/views/me/password.html
  67. 7 3
      lib/views/modal/create_page.html
  68. 0 113
      lib/views/modal/help.html
  69. 79 0
      lib/views/modal/shortcuts.html
  70. 0 5
      lib/views/not_found.html
  71. 3 4
      lib/views/page.html
  72. 2 5
      lib/views/page_list.html
  73. 18 2
      lib/views/page_presentation.html
  74. 72 0
      lib/views/widget/passport/ldap-association-tester.html
  75. 41 23
      package.json
  76. 32 26
      resource/css/_admin.scss
  77. 10 3
      resource/css/_comment_crowi-plus.scss
  78. 192 66
      resource/css/_form.scss
  79. 11 11
      resource/css/_layout_crowi-plus.scss
  80. 2 2
      resource/css/_page.scss
  81. 13 5
      resource/css/_search.scss
  82. 66 0
      resource/css/_shortcuts.scss
  83. 2 2
      resource/css/_user_crowi-plus.scss
  84. 26 75
      resource/css/_wiki.scss
  85. 1 1
      resource/css/_wiki_crowi-plus.scss
  86. 1 1
      resource/css/crowi-reveal.scss
  87. 20 22
      resource/css/crowi.scss
  88. 124 11
      resource/js/app.js
  89. 61 0
      resource/js/components/Admin/CustomCssEditor.js
  90. 59 0
      resource/js/components/Admin/CustomHeaderEditor.js
  91. 61 0
      resource/js/components/Admin/CustomScriptEditor.js
  92. 2 2
      resource/js/components/Common/UserDate.js
  93. 18 79
      resource/js/components/HeaderSearchBox/SearchForm.js
  94. 75 0
      resource/js/components/NewPageNameInputter.js
  95. 131 0
      resource/js/components/Page.js
  96. 0 70
      resource/js/components/Page/PageBody.js
  97. 66 0
      resource/js/components/Page/RevisionBody.js
  98. 2 2
      resource/js/components/Page/RevisionUrl.js
  99. 6 5
      resource/js/components/PageAttachment/DeleteAttachmentModal.js
  100. 2 2
      resource/js/components/PageComment/Comment.js

+ 123 - 5
CHANGES.md

@@ -1,9 +1,127 @@
 CHANGES
 ========
 
-## 2.2.2
+## 2.4.2-RC
+
+* Improvement: Ensure to set absolute url from root when attaching files when `FILE_UPLOAD=local`
+
+## 2.4.1
 
+* Feature: Custom Header HTML
+* Improvement: Add highlight.js languages
+    * dockerfile, go, gradle, json, less, scss, typescript, yaml
+* Fix: Couldn't connect to PLANTUML_URI
+    * Introduced by 2.4.0
+* Fix: Couldn't render UML which includes CJK
+    * Introduced by 2.4.0
+* Support: Upgrade libs
+    * axios
+    * diff2html
+
+## 2.4.0
+
+* Feature: Support Footnotes
+* Feature: Support Task lists
+* Feature: Support Table with CSV
+* Feature: Enable to render UML diagrams with public plantuml.com server
+* Feature: Enable to switch whether rendering MathJax in realtime or not
+* Improvement: Replace markdown parser with markdown-it
+* Improvement: Generate anchor of headers with header strings
+* Improvement: Enhanced Scroll Sync on Markdown Editor/Preview
+* Improvement: Update `#revision-body` tab contents after saving with `Ctrl-S`
+* Fix: 500 Internal Server Error occures when basic-auth configuration is set
+
+## 2.3.9
+
+* Fix: `Ctrl-/` doesn't work on Chrome
+* Fix: Close Shortcuts help with `Ctrl-/`, ESC key
+* Fix: Jump to last line wrongly when `.revision-head-edit-button` clicked
+* Support: Upgrade libs
+    * googleapis
+
+## 2.3.8
+
+* Feature: Suggest page path when creating pages
+* Improvement: Prevent keyboard shortcuts when modal is opened
+* Improvement: PageHistory UI
+* Improvement: Ensure to scroll when edit button of section clicked
+* Improvement: Enabled to toggle the style for active line
+* Support: Upgrade libs
+    * style-loader
+    * react-codemirror2
+
+## 2.3.7
+
+* Fix: Open popups when `Ctrl+C` pressed
+    * Introduced by 2.3.5
+
+## 2.3.6
+
+* Feature: Theme Selector for Editor
+* Improvement: Remove unportalize button from crowi-plus layout
+* Fix: CSS for admin pages
+* Support: Shrink the size of libraries to include
+
+## 2.3.5
+
+* Feature: Enhanced Editor by CodeMirror
+* Feature: Emoji AutoComplete
+* Feature: Add keyboard shortcuts
+* Improvement: Attaching file with Dropzone.js
+* Improvement: Show shortcuts help with `Ctrl-/`
+* Fix: DOMs that has `.alert-info` class don't be displayed
+* Support: Switch and upgrade libs
+    * 8fold-marked -> marked
+    * react-bootstrap
+    * googleapis
+    * mongoose
+    * mongoose-unique-validator
+    * etc..
+
+## 2.3.4 (Missing number)
+
+## 2.3.3
+
+* Fix: The XSS Library escapes inline code blocks
+    * Degraded by 2.3.0
+* Fix: NPE occurs on Elasticsearch when initial access
+* Fix: Couldn't invite users(failed to create)
+
+## 2.3.2
+
+* Improvement: Add LDAP group search options
+
+## 2.3.1
+
+* Fix: Blockquote doesn't work
+    * Degraded by 2.3.0
+* Fix: Couldn't create user with first LDAP logging in
+
+## 2.3.0
+
+* Feature: LDAP Authentication
+* Improvement: Prevent XSS
+* Fix: node versions couldn't be shown
+* Support: Upgrade libs
+    * express-pino-logger
+
+## 2.2.4
+
+* Fix: googleapis v23.0.0 lost the function `oauth2Client.setCredentials`
+    * Degraded by 2.2.2 updates
+* Fix: HeaderSearchBox didn't append 'q=' param when searching
+    * Degraded by 2.2.3 updates
+
+## 2.2.3
+
+* Fix: The server responds anything when using passport
+    * Degraded by 2.2.2 updates
 * Fix: Update `lastLoginAt` when login is success
+* Support: Replace moment with date-fns
+* Support: Upgrade react-bootstrap-typeahead
+* Improvement: Replace emojify.js with emojione
+
+## 2.2.2 (Missing number)
 
 ## 2.2.1
 
@@ -178,8 +296,8 @@ CHANGES
 
 ## 1.1.8
 
-* Fix: Depth of dropdown-menu when '.on-edit'
-* Fix: Error occured on saveing with Ctrl+S
+* Fix: Depth of dropdown-menu when `.on-edit`
+* Fix: Error occured on saveing with `Ctrl-S`
 * Fix: Guest users browsing
 
 ## 1.1.7
@@ -208,11 +326,11 @@ CHANGES
 ## 1.1.2
 
 * Imprv: Brushup fonts and styles
-* Fix: Ensure to specity revision id when saving with Ctrl+S
+* Fix: Ensure to specity revision id when saving with `Ctrl-S`
 
 ## 1.1.1
 
-* Feature: Save with Ctrl+S
+* Feature: Save with `Ctrl-S`
 * Imprv: Brushup fonts and styles
 
 ## 1.1.0

+ 1 - 6
LICENSE

@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2017 WESEEK, Inc.
+Copyright (c) 2018 WESEEK, Inc.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -19,8 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
-
-----
-
-This software is copied and modified from https://github.com/crowi/crowi,
-released under the MIT license, Copyright (c) 2013 Sotaro KARASAWA <sotaro.k@gmail.com>

+ 42 - 42
README.md

@@ -13,40 +13,42 @@ crowi-plus [![Chat on Slack](https://crowi-plus-slackin.weseek.co.jp/badge.svg)]
 [![wercker status](https://app.wercker.com/status/39cdc49d067d65c39cb35d52ceae6dc1/s/master "wercker status")](https://app.wercker.com/project/byKey/39cdc49d067d65c39cb35d52ceae6dc1)
 [![dependencies status](https://david-dm.org/weseek/crowi-plus.svg)](https://david-dm.org/weseek/crowi-plus)
 [![devDependencies Status](https://david-dm.org/weseek/crowi-plus/dev-status.svg)](https://david-dm.org/weseek/crowi-plus?type=dev)
+[![docker pulls](https://img.shields.io/docker/pulls/weseek/crowi-plus.svg)](https://hub.docker.com/r/weseek/crowi-plus/)
 [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 
-**crowi-plus** is a fork of [Crowi][crowi] that is [perfectly compatible with the official project](https://github.com/weseek/crowi-plus/wiki/Correspondence-table-with-official-version).
+**crowi-plus** is a fork of [Crowi][crowi] which is [perfectly compatible with the official project](https://github.com/weseek/crowi-plus/wiki/Correspondence-table-with-Crowi-version).
 
 
 Why crowi-plus?
 ================
 
 * **Pluggable**
-  * Find plugins from [npm](https://www.npmjs.com/browse/keyword/crowi-plugin) or [github](https://github.com/search?q=topic%3Acrowi-plugin)!
-* **Faster**
+  * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/crowi-plugin) or [github](https://github.com/search?q=topic%3Acrowi-plugin)!
+* **Fast**
   * Optimize client-side code chunks by Webpack
-  * Optimize cache settings for static assets
-  * Adopt the fastest logger [pino](https://github.com/pinojs/pino)
+  * Optimize the performance when live preview
+  * Adopt faster libs([date-fns](https://github.com/date-fns/date-fns), [pino](https://github.com/pinojs/pino))
   * Using CDN
 * **Secure**
-  * Upgrade jQuery to 3.x
-  * Upgrade other insecure libs
+  * Prevent XSS (Cross Site Scripting)
+  * Upgrade jQuery to 3.x and other insecure libs
   * The official Crowi status is [![dependencies Status](https://david-dm.org/crowi/crowi/status.svg)](https://david-dm.org/crowi/crowi) [![devDependencies Status](https://david-dm.org/crowi/crowi/dev-status.svg)](https://david-dm.org/crowi/crowi?type=dev)
 * **Convenient**
-  * Easy to integrate with Slack using Incoming Webhooks
-  * [Added miscellaneous features](https://github.com/weseek/crowi-plus/wiki/Additional-Features)
+  * Support Authentication with LDAP / Active Directory 
+  * Slack Incoming Webhooks Integration
+  * [Miscellaneous features](https://github.com/weseek/crowi-plus/wiki/Additional-Features)
 * **[Docker Ready][dockerhub]**
 * **[Docker Compose Ready][docker-compose]**
   * [Multiple sites example](https://github.com/weseek/crowi-plus-docker-compose/tree/master/examples/multi-app)
   * [HTTPS(with Let's Encrypt) proxy integration example](https://github.com/weseek/crowi-plus-docker-compose/tree/master/examples/https-portal)
-* Support for IE11 (Experimental)
+* Support IE11 (Experimental)
 * **Developer-friendly**
   * Less compile time
   * LiveReload separately available by server/client code change
   * Exclude Environment-dependency (confirmed to be developable on Win/Mac/Linux)
 
-Check it out all additional features from [**here**](https://github.com/weseek/crowi-plus/wiki/Additional-Features).
+Check out all additional features from [**here**](https://github.com/weseek/crowi-plus/wiki/Additional-Features).
 
 
 Quick Start for Production
@@ -91,7 +93,7 @@ See [confirmed versions](https://github.com/weseek/crowi-plus/wiki/Developers-Gu
       - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
       - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)
 
-### Start
+### How to start
 
 #### Build and run the app
 
@@ -102,7 +104,7 @@ yarn
 MONGO_URI=mongodb://MONGO_HOST:MONGO_PORT/crowi npm start
 ```
 
-**DON'T USE `npm install`**, use `yarn` instead.
+**DO NOT USE `npm install`**, use `yarn` instead.
 
 If you launch crowi-plus with Redis and ElasticSearch, add environment variables before `npm start` like following:
 
@@ -113,7 +115,7 @@ export ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/crowi
 npm start
 ```
 
-For more info, check [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) and [the official documents](https://github.com/crowi/crowi/wiki/Install-and-Configuration#env-parameters).
+For more info, see [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) and [the official documents](https://github.com/crowi/crowi/wiki/Install-and-Configuration#env-parameters).
 
 #### Command details
 
@@ -123,7 +125,7 @@ For more info, check [Developers Guide](https://github.com/weseek/crowi-plus/wik
 |`npm run server:prod`|Launch the server|
 |`npm start`|Invoke `npm run build:prod` and `npm run server:prod`|
 
-### Upgrade
+### How to upgrade
 
 ```bash
 git pull
@@ -131,14 +133,14 @@ yarn
 npm start
 ```
 
-### Install plugins
+### How to install plugins
 
-* Stop server if running
+* Stop server if server is running
 * `yarn add` to install plugin or `npm install --save`
   * **Don't forget `--save` option if you use npm** or crowi-plus doesn't detect plugins
 * `npm start` to build client app and start server
 
-#### Example
+#### Examples
 
 ```bash
 yarn add crowi-plugin-lsx
@@ -146,20 +148,8 @@ npm start
 ```
 
 
-Getting Started to Develop
-==========================
 
-## Build and Run the app
-
-1. `clone` this repository
-1. `yarn global add npm@4` to install required global dependencies
-1. `yarn` to install all dependencies
-    * DON'T USE `npm install`
-1. `npm run build` to build client app
-1. `npm run server` to start the dev server
-1. Access to `http://0.0.0.0:3000`
-
-For more info, read [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) on Wiki.
+For more info, see [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) on Wiki.
 
 
 Documentation
@@ -170,13 +160,26 @@ Documentation
   * [Migration Guide from Official Crowi](https://github.com/weseek/crowi-plus/wiki/Migration-Guide-from-Official-Crowi)
   * [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide)
 
-Contributing
+Contribution
 ============
 
+For development
+-------------
+
+### Build and Run the app
+
+1. `clone` this repository
+1. `yarn global add npm@4` to install required global dependencies
+1. `yarn` to install all dependencies
+    * DO NOT USE `npm install`
+1. `npm run build` to build client app
+1. `npm run server` to start the dev server
+1. Access `http://0.0.0.0:3000`
+
 Found a Bug?
 -------------
 
-If you find a bug in the source code, you can help us by
+If you found a bug in the source code, you can help us by
 [submitting an issue][issues] to our [GitHub Repository][crowi-plus]. Even better, you can
 [submit a Pull Request][pulls] with a fix.
 
@@ -184,24 +187,21 @@ Missing a Feature?
 -------------------
 
 You can *request* a new feature by [submitting an issue][issues] to our GitHub
-Repository. If you would like to *implement* a new feature, please submit an issue with
-a proposal for your work first, to be sure that we can use it.
-Please consider what kind of change it is:
+Repository. If you would like to *implement* a new feature, firstly please submit the issue with your proposal to make sure we can confirm it. Please clarify what kind of change you would like to propose.
 
-* For a **Major Feature**, first open an issue and outline your proposal so that it can be
-discussed. This will also allow us to better coordinate our efforts, prevent duplication of work,
-and help you to craft the change so that it is successfully accepted into the project.
-* **Small Features** can be crafted and directly [submitted as a Pull Request][pulls].
+* For a **Major Feature**, firstly open an issue and outline your proposal so it can be discussed. 
+It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
+* **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 
 Language
 ---------
 
-Write issues and PRs in English or Japanese.
+You can write issues and PRs in English or Japanese.
 
 Discussion
 -----------
 
-If you have something to ask or want to discuss, [join to our Slack team][slackin] and talk about anything, anytime.
+If you have questions or suggestions, you can [join our Slack team][slackin] and talk about anything, anytime.
 
 
 License

+ 80 - 0
THIRD-PARTY-NOTICES.md

@@ -0,0 +1,80 @@
+crowi-plus uses third-party libraries or other resources that may  
+be distributed under licenses different than the crowi-plus software.
+
+In the event that we accidentally failed to list a required notice,  
+please bring it to our attention through any of the ways detailed here :
+
+    info@weseek.co.jp
+
+The attached notices are provided for information only.
+
+For any licenses that require disclosure of source, sources are available at  
+https://github.com/weseek/crowi-plus.
+
+
+1. crowi/crowi (https://github.com/crowi/crowi)
+2. Microsoft/vscode (https://github.com/Microsoft/vscode)
+
+
+
+License Notice for Crowi
+-------------------------
+
+https://github.com/crowi/crowi/blob/master/LICENSE
+
+```
+The MIT License (MIT)
+
+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
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+```
+
+
+License Notice for Visual Studio Code
+-------------------------------------
+
+https://github.com/Microsoft/vscode/blob/master/LICENSE.txt
+
+```
+MIT License
+
+Copyright (c) 2015 - present Microsoft Corporation
+
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+```

+ 1 - 1
app.json

@@ -26,7 +26,7 @@
     "mongolab",
     "redistogo",
     {
-      "plan": "bonsai:sandbox-10",
+      "plan": "bonsai:sandbox-6",
       "options": {
         "version": "5.1"
       }

+ 30 - 0
bin/shrink-emojione-strategy.js

@@ -0,0 +1,30 @@
+/**
+ * the tool to shrink emojione/emoji_strategy.json and output
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+const fs = require('graceful-fs');
+const normalize = require('normalize-path');
+const helpers = require('../config/helpers');
+
+const OUT = helpers.root('tmp/emoji_strategy_shrinked.json');
+
+const emojiStrategy = require('emojione/emoji_strategy.json');
+const markdownItEmojiFull = require('markdown-it-emoji/lib/data/full.json');
+
+let shrinkedMap = {};
+for (let unicode in emojiStrategy) {
+  const data = emojiStrategy[unicode];
+  const shortname = data.shortname.replace(/\:/g, '');
+
+  // ignore if it isn't included in markdownItEmojiFull
+  if (markdownItEmojiFull[shortname] == null) {
+    continue;
+  }
+
+  // add
+  shrinkedMap[unicode] = data;
+}
+
+// write
+fs.writeFileSync(OUT, JSON.stringify(shrinkedMap));

+ 5 - 3
config/env.dev.js

@@ -11,12 +11,14 @@ module.exports = {
   // filters for debug
   DEBUG: [
     // 'express:*',
-    // 'crowi:crowi',
+    // 'crowi:*',
+    'crowi:crowi',
+    'crowi:crowi:dev',
     'crowi:crowi:express-init',
+    'crowi:models:external-account',
     // 'crowi:routes:login',
     'crowi:routes:login-passport',
-    // 'crowi:service:PassportService',
-    // 'crowi:*',
+    'crowi:service:PassportService',
     // 'crowi:routes:page',
     // 'crowi:plugins:*',
     // 'crowi:InterceptorManager',

+ 6 - 0
config/env.prod.js

@@ -0,0 +1,6 @@
+module.exports = {
+  // logging settings
+  DEBUG: [
+    'crowi:crowi',
+  ].join(),
+}

+ 13 - 6
config/webpack.common.js

@@ -10,6 +10,7 @@ const helpers = require('./helpers');
  */
 const AssetsPlugin = require('assets-webpack-plugin');
 const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');
+const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');;
 
 /*
  * Webpack configuration
@@ -32,7 +33,9 @@ module.exports = function (options) {
     externals: {
       // require("jquery") is external and available
       //  on the global var jQuery
-      "jquery": "jQuery"
+      "jquery": "jQuery",
+      "emojione": "emojione",
+      "hljs": "hljs",
     },
     resolve: {
       extensions: ['.js', '.json'],
@@ -45,6 +48,9 @@ module.exports = function (options) {
           exclude: /node_modules/,
           use: [{
             loader: 'babel-loader?cacheDirectory',
+            options: {
+              plugins: ['lodash'],
+            }
           }]
         },
         {
@@ -96,15 +102,16 @@ module.exports = function (options) {
         chunks: ['commons', 'plugin'],
       }),
 
-      new webpack.ProvidePlugin({
+      new LodashModuleReplacementPlugin,
+
+      // ignore
+      new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
+
+      new webpack.ProvidePlugin({ // refs externals
         jQuery: "jquery",
         $: "jquery",
-        hljs: "reveal.js/plugin/highlight/highlight",
       }),
 
-      // omit moment/locale/*.js
-      new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /ja/),
-
     ]
   };
 }

+ 32 - 0
lib/crowi/dev.js

@@ -1,9 +1,11 @@
 const debug = require('debug')('crowi:crowi:dev');
+const fs = require('fs');
 const path = require('path');
 const webpack = require('webpack');
 const helpers = require('./helpers');
 
 const swig = require('swig-templates');
+const onHeaders = require('on-headers')
 const LRWebSocketServer = require('livereload-server/lib/server');
 
 class CrowiDev {
@@ -19,6 +21,8 @@ class CrowiDev {
   }
 
   init() {
+    this.requireForLiveReload();
+
     this.initPromiseRejectionWarningHandler();
     this.initSwig();
     this.hackLRWebSocketServer();
@@ -33,6 +37,19 @@ class CrowiDev {
     swig.setDefaults({ cache: false });
   }
 
+  /**
+   * require files for live reloading
+   */
+  requireForLiveReload() {
+    // environment file
+    require(path.join(this.crowi.rootDir, 'config', 'env.dev.js'));
+
+    // load all json files for live reloading
+    fs.readdirSync(this.crowi.localeDir).map((dirname) => {
+      require(path.join(this.crowi.localeDir, dirname, 'translation.json'));
+    });
+  }
+
   /**
    * prevent to crash socket with:
    * -------------------------------------------------
@@ -68,14 +85,28 @@ class CrowiDev {
    * @memberOf CrowiDev
    */
   setup(server, app) {
+    this.setupHeaderDebugger(app);
     this.setupEasyLiveReload(app);
   }
 
+  setupHeaderDebugger(app) {
+    debug('setupHeaderDebugger');
+
+    app.use((req, res, next) => {
+      onHeaders(res, () => {
+        debug('HEADERS GOING TO BE WRITTEN');
+      });
+      next();
+    });
+  }
+
   setupEasyLiveReload(app) {
     if (!helpers.hasProcessFlag('livereload')) {
       return;
     }
 
+    debug('setupEasyLiveReload');
+
     const livereload = require('easy-livereload');
     app.use(livereload({
       watchDirs: [
@@ -93,6 +124,7 @@ class CrowiDev {
         && process.env.PLUGIN_NAMES_TOBE_LOADED.length > 0) {
 
       const pluginNames = process.env.PLUGIN_NAMES_TOBE_LOADED.split(',');
+      debug('loading Plugins for development', pluginNames);
 
       // merge and remove duplicates
       if (pluginNames.length > 0) {

+ 34 - 28
lib/crowi/index.js

@@ -36,6 +36,7 @@ function Crowi (rootdir, env)
   this.searcher = null;
   this.mailer = {};
   this.interceptorManager = {};
+  this.passportService = null;
 
   this.tokens = null;
 
@@ -70,19 +71,7 @@ Crowi.prototype.init = function() {
   }).then(function() {
     return self.setupSessionConfig();
   }).then(function() {
-    return new Promise(function(resolve, reject) {
-      self.model('Config', require('../models/config')(self));
-      var Config = self.model('Config');
-      Config.loadAllConfig(function(err, doc) {
-        if (err) {
-          return reject();
-        }
-
-        self.setConfig(doc);
-
-        return resolve();
-      });
-    });
+    return self.setupAppConfig();
   }).then(function() {
     return self.scanRuntimeVersions();
   }).then(function() {
@@ -154,16 +143,13 @@ Crowi.prototype.setupDatabase = function() {
     ((process.env.NODE_ENV === 'test') ? 'mongodb://localhost/crowi_test' : 'mongodb://localhost/crowi')
     ;
 
-  return new Promise(function(resolve, reject) {
-    mongoose.connect(mongoUri, { useMongoClient: true }, function(e) {
-      if (e) {
-        debug('DB Connect Error: ', e);
-        debug('DB Connect Error: ', mongoUri);
-        return reject(new Error('Cann\'t connect to Database Server.'));
-      }
-      return resolve();
-    });
-  });
+  return mongoose.connect(mongoUri).then(
+    () => {},
+    err => {
+      debug('DB Connect Error: ', err);
+      debug('DB Connect Error: ', mongoUri);
+    }
+  );
 };
 
 Crowi.prototype.setupSessionConfig = function() {
@@ -206,6 +192,22 @@ Crowi.prototype.setupSessionConfig = function() {
   });
 };
 
+Crowi.prototype.setupAppConfig = function() {
+  return new Promise((resolve, reject) => {
+    this.model('Config', require('../models/config')(this));
+    var Config = this.model('Config');
+    Config.loadAllConfig((err, doc) => {
+      if (err) {
+        return reject();
+      }
+
+      this.setConfig(doc);
+
+      return resolve();
+    });
+  });
+}
+
 Crowi.prototype.setupModels = function() {
   var self = this
     ;
@@ -262,11 +264,15 @@ Crowi.prototype.setupPassport = function() {
 
   debug('Passport is enabled');
 
+  // initialize service
   const PassportService = require('../service/passport');
-
-  const passportService = new PassportService(this);
-  passportService.setupLocalStrategy();
-  passportService.setupSerializer();
+  if (this.passportService == null) {
+    this.passportService = new PassportService(this);
+  }
+  this.passportService.setupSerializer();
+  // setup strategies
+  this.passportService.setupLocalStrategy();
+  this.passportService.setupLdapStrategy();
 
   return Promise.resolve();
 }
@@ -357,7 +363,7 @@ Crowi.prototype.start = function() {
     })
     .then(function(app) {
       server = http.createServer(app).listen(self.port, function() {
-        console.log(`[${self.node_env}] Express server listening on port ${self.port}`);
+        debug(`[${self.node_env}] Express server listening on port ${self.port}`);
       });
 
       // setup

+ 1 - 1
lib/form/admin/app.js

@@ -6,6 +6,6 @@ var form = require('express-form')
 module.exports = form(
   field('settingForm[app:title]').required(),
   field('settingForm[app:confidential]'),
-  field('settingForm[app:fileUpload]').isInt()
+  field('settingForm[app:fileUpload]').trim().toBooleanStrict()
 );
 

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

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

+ 23 - 0
lib/form/admin/securityPassportLdap.js

@@ -0,0 +1,23 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-ldap:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-ldap:serverUrl]').trim()
+      // https://regex101.com/r/E0UL6D/1
+      .is(/^ldaps?:\/\/([^\/\s]+)\/([^\/\s]+)$/, 'Server URL is invalid. <small><a href="https://regex101.com/r/E0UL6D/1">&gt;&gt; Regex</a></small>'),
+  field('settingForm[security:passport-ldap:isUserBind]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-ldap:bindDN]').trim()
+      // https://regex101.com/r/jK8lpO/1
+      .is(/^(,?[^,=\s]+=[^,=\s]+){1,}$/, 'Bind DN is invalid. <small><a href="https://regex101.com/r/jK8lpO/1">&gt;&gt; Regex</a></small>'),
+  field('settingForm[security:passport-ldap:bindDNPassword]'),
+  field('settingForm[security:passport-ldap:searchFilter]'),
+  field('settingForm[security:passport-ldap:attrMapUsername]'),
+  field('settingForm[security:passport-ldap:groupSearchBase]'),
+  field('settingForm[security:passport-ldap:groupSearchFilter]'),
+  field('settingForm[security:passport-ldap:groupDnProperty]')
+);
+

+ 2 - 0
lib/form/index.js

@@ -18,9 +18,11 @@ module.exports = {
     securityGeneral: require('./admin/securityGeneral'),
     securityGoogle: require('./admin/securityGoogle'),
     securityMechanism: require('./admin/securityMechanism'),
+    securityPassportLdap: require('./admin/securityPassportLdap'),
     markdown: require('./admin/markdown'),
     customcss: require('./admin/customcss'),
     customscript: require('./admin/customscript'),
+    customheader: require('./admin/customheader'),
     custombehavior: require('./admin/custombehavior'),
     customlayout: require('./admin/customlayout'),
     customfeatures: require('./admin/customfeatures'),

+ 67 - 14
lib/locales/en-US/translation.json

@@ -12,6 +12,7 @@
   "Create": "Create",
   "Admin": "Admin",
   "New": "New",
+  "Shortcuts": "Shortcuts",
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -38,6 +39,8 @@
   "Share Link": "Share Link",
   "Markdown Link": "Markdown Link",
 
+  "Management Wiki": "Management Wiki",
+
   "Unportalize": "Unportalize",
 
   "View this version": "View this version",
@@ -126,7 +129,7 @@
   "Current password": "Current password",
   "New password": "New password",
   "Re-enter new password": "Re-enter new password",
-  "Please set a password": "Please set a password",
+  "Password is not set": "Password is not set",
   "You can sign in with email and password": "You can sign in with <code>%s</code> and password",
 
   "API Settings": "API Settings",
@@ -134,6 +137,9 @@
   "Current API Token": "Current API Token",
   "Update API Token": "Update API Token",
 
+  "Security settings": "Security settings",
+
+
   "page_page": {
       "notice": {
           "version": "This is not the current version.",
@@ -189,19 +195,66 @@
     }
   },
 
-  "modal_help": {
-      "basic": {
-          "title": "Basics",
-          "body1": "There are 2 types of pages: List pages (showing lists of links to other pages) and normal pages.",
-          "body2": "Pages that end with a slash / are List pages for anything following the slash.",
-          "body3": "You can view older versions of a page from the History tab. Any changes made will be stored here."
+  "modal_shortcuts": {
+      "global": {
+          "title": "Global shortcuts",
+          "Open/Close shortcut help": "Open/Close shortcut help",
+          "Edit Page": "Edit Page",
+          "Create Page": "Create Page"
       },
-      "tips": {
-          "title": "Quick Tips on Editing",
-          "body1": "Use sections and subsections to make it easier for your friends to read your pages."
-      },
-      "markdown": {
-          "title": "Markdown Rules"
-      }
+      "editor": {
+          "title": "Editor shortcuts",
+          "Indent": "Indent",
+          "Outdent": "Outdent",
+          "Save Page": "Save Page",
+          "Delete Line": "Delete Line"
+
+              }
+  },
+            
+  "admin_top": { 
+    "Management Wiki": "Management Wiki",
+    "System Information": "System Information",
+    "wiki_administrator": "Only Wiki administrator can access this page",
+    "assign_administrator": "You can assign administrator from Assign administrator button in the User management page",
+    "List of installed plugins": "List of installed plugins",
+    "Package name": "Package name",
+    "Specified version": "Specified version",
+    "Installed version": "Installed version"
+    
+  },
+
+  "app_setting": {
+    "Wiki name": "Wiki name",
+    "wiki_change": "You can change Wiki name which is used for header and HTML title.",
+    "header_content": "The contents entered here will be shown in the header etc.",
+    "Confidential name": "Confidential name",
+    "ex): internal use only":"ex): internal use only",
+    "enable_files_except_image": "Enable file upload other than image files.",
+    "attach_enable": "You can attach files other than image files if you enable this option.",
+    "Reload": "Reload",
+    "Mail settings": "Mail settings",
+    "SMTP_used": "If you have SMTP settings, it will be used.",
+    "SMTP_but_AWS": "If you do not have SMTP settings but AWS settings,  e-mails will be sent by SES.",
+    "neihter_of": "If you do not of neither of these, e-mails will not be sent.",
+    "From e-mail address": "From e-mail address",
+    "SMTP settings": "SMTP settings"  , 
+    "Host": "Host",
+    "Port": "Port",
+    "User": "User",
+    "AWS settings": "AWS settings",
+    "AWS_access": "This is for AWS settings. If you complete AWS settings, file upload function, progile picture function etc will be enabled.",
+    "No_SMTP_setting": "If you do not have SMTP settings, e-mails will be sent via SES. You need to verify from e-mail address and production settings.",
+    "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
+    "region": "region",
+    "packet name": "packet name",
+    "Plugin settings": "Plugin settings",
+    "Enable plugin loading": "Enable plugin loading",
+    "valid": "valid",
+    "invalid": "invalid"
+
+
   }
+
 }
+ 

+ 86 - 18
lib/locales/ja/translation.json

@@ -12,6 +12,7 @@
   "Create": "作成",
   "Admin": "管理",
   "New": "作成",
+  "Shortcuts": "ショートカット",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -68,16 +69,35 @@
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",
   "Create under": "<code>%s</code>以下に作成",
-
+  
+  
+ 
+  
   "Table of Contents": "目次",
-
+  "Management Wiki Home": "Wiki管理トップ",
+  "App settings": "アプリ設定",
+  "Security settings": "セキュリティ設定",
+  "Markdown settings": "Markdown設定",
+  "Customize": "カスタマイズ",
+  "Notification settings": "通知設定",
+  "User management": "ユーザー管理",
+  "Basic settings": "基本設定",
+  "Basic authentication": "Basic認証",
+  "Password": "パスワード",
+  "Guest users access": "ゲストユーザーのアクセス",
+  "Register limitation": "登録の制限",
+  "The contents entered here will be shown in the header etc": "ここに入力した内容は、ヘッダー等に表示されます。", 
   "Public": "公開",
   "Anyone with the link": "リンクを知っている人のみ",
   "Specified users": "特定ユーザーのみ",
   "Just me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "Shareable link": "このページの共有用URL",
+  "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
+  "Selecting authentication mechanism": "認証機構選択",
 
+  
+ 
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
   "edited this page": "さんがこのページを編集しました。",
@@ -125,9 +145,11 @@
   "Current password": "現在のパスワード",
   "New password": "新しいパスワード",
   "Re-enter new password": "(確認用)",
-  "Please set a password": "パスワードを設定してください",
+  "Password is not set": "パスワードが設定されていません",
   "You can sign in with email and password": "<code>%s</code> と設定されたパスワードの組み合わせでログイン可能になります。",
 
+  "Security settings": "セキュリティ設定",
+
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
@@ -148,6 +170,9 @@
       }
   },
 
+
+  
+
   "modal_rename": {
     "label": {
       "Rename page": "ページを移動する",
@@ -188,19 +213,62 @@
     }
   },
 
-  "modal_help": {
-      "basic": {
-          "title": "基本的な機能",
-          "body1": "表示される画面には、「一覧ページ」と「ページ」の2種類があります",
-          "body2": "スラッシュ <code>/</code> で終わるページは、その階層の一覧ページとなります。",
-          "body3": "ページでの変更はすべて記録されています。History からそのページの過去の状態を見ることができます。"
-      },
-      "tips": {
-          "title": "編集のコツ",
-          "body1": "文章の <strong>構造</strong> を意識しましょう。本を書くように、内容と文脈を整理してセクション・サブセクション...と構造的に書くと、わかりやすく他人に伝わりやすいページがになります。"
-      },
-      "markdown": {
-          "title": "記法"
-      }
-  }
+  "modal_shortcuts": {
+    "global": {
+        "Open/Close shortcut help": "ショートカットヘルプの表示/非表示",
+        "Edit Page": "ページ編集",
+        "Create Page": "ページ作成"
+    },
+    "editor": {
+        "Indent": "インデント",
+        "Outdent": "左インデント",
+        "Save Page": "保存",
+        "Delete Line": "行削除"
+    }
+  },
+   
+  "admin_top": {
+    "Management Wiki": "Wiki管理",
+    "System Information": "システム情報",
+    "wiki_administrator": "この画面はWiki管理者のみがアクセスできる画面です。",
+    "assign_administrator": "「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。",
+    "List of installed plugins": "インストールされているプラグイン一覧",
+    "Package name": "パッケージ名",
+    "Specified version": "指定バージョン",
+    "Installed version": "インストールされているバージョン"
+  
+  
+  
+  },
+
+  "app_setting": { 
+    "Wiki name": "Wikiの名前",
+    "wiki_change": "ヘッダーやHTMLタイトルに使用されるWikiの名前を変更できます。",
+    "header_content": "ここに入力した内容は、ヘッダー等に表示されます。",
+    "Confidential name": "コンフィデンシャル表示",
+    "コンフィデンシャル表示": "Confidential name",  
+    "enable_files_except_image": "画像以外のファイルアップロードを許可",
+    "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
+    "Reload": "更新",
+    "Mail settings": "メールの設定",
+    "SMTP_used": "SMTPの設定がされている場合、それが利用されます。",
+    "SMTP_but_AWS": "SMTP設定がなく、AWSの設定がある場合、SESでの送信を試みます。",
+    "neihter_of": "どちらの設定もない場合、メールは送信されません。",
+    "From e-mail address": "Fromアドレス",
+    "SMTP settings": "SMTP設定"   ,
+    "Host": "ホスト",
+    "Port": "ポート",
+    "User": "ユーザー",
+    "AWS settings": "AWS設定",
+    "AWS_access": "AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
+    "No_SMTP_setting": "また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。",
+    "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
+    "region": "リージョン",
+    "packet name": "パケット名",
+    "Plugin settings": "プラグイン設定",
+    "Enable plugin loading": "プラグインの読み込みを有効にします。",
+    "valid": "有効",
+    "invalid": "無効"
+
+   }
 }

+ 32 - 1
lib/models/config.js

@@ -28,6 +28,7 @@ module.exports = function(crowi) {
 
     // overwrite
     config['app:title'] = 'crowi-plus';
+    config['app:fileUpload'] = true;
     config['security:isEnabledPassport'] = true;
     config['customize:behavior'] = 'crowi-plus';
     config['customize:layout'] = 'crowi-plus';
@@ -54,6 +55,16 @@ module.exports = function(crowi) {
       'security:registrationWhiteList' : [],
 
       'security:isEnabledPassport' : false,
+      'security:passport-ldap:isEnabled' : false,
+      'security:passport-ldap:serverUrl' : undefined,
+      'security:passport-ldap:isUserBind' : undefined,
+      'security:passport-ldap:bindDN' : undefined,
+      'security:passport-ldap:bindDNPassword' : undefined,
+      'security:passport-ldap:searchFilter' : undefined,
+      'security:passport-ldap:attrMapUsername' : undefined,
+      'security:passport-ldap:groupSearchBase' : undefined,
+      'security:passport-ldap:groupSearchFilter' : undefined,
+      'security:passport-ldap:groupDnProperty' : undefined,
 
       'aws:bucket'          : 'crowi',
       'aws:region'          : 'ap-northeast-1',
@@ -73,6 +84,7 @@ module.exports = function(crowi) {
 
       'customize:css' : '',
       'customize:script' : '',
+      'customize:header' : '',
       'customize:behavior' : 'crowi',
       'customize:layout' : 'crowi',
       'customize:isEnabledTimeline' : true,
@@ -241,10 +253,21 @@ module.exports = function(crowi) {
 
   configSchema.statics.isEnabledPassport = function(config)
   {
+    // always true if crowi-plus installed cleanly
+    if (Object.keys(config.crowi).length == 0) {
+      return true;
+    }
+
     const key = 'security:isEnabledPassport';
     return getValueForCrowiNS(config, key);
   };
 
+  configSchema.statics.isEnabledPassportLdap = function(config)
+  {
+    const key = 'security:passport-ldap:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isUploadable = function(config)
   {
     var method = crowi.env.FILE_UPLOAD || 'aws';
@@ -329,6 +352,12 @@ module.exports = function(crowi) {
     return this._customScript;
   }
 
+  configSchema.statics.customHeader = function(config)
+  {
+    const key = 'customize:header';
+    return getValueForCrowiNS(config, key);
+  }
+
   configSchema.statics.behaviorType = function(config)
   {
     const key = 'customize:behavior';
@@ -361,7 +390,8 @@ module.exports = function(crowi) {
       return false;
     }
 
-    return config.crowi['app:fileUpload'] || false;
+    // convert to boolean
+    return !!config.crowi['app:fileUpload'];
   };
 
   configSchema.statics.hasSlackConfig = function(config)
@@ -430,6 +460,7 @@ module.exports = function(crowi) {
       },
       behaviorType: Config.behaviorType(config),
       layoutType: Config.layoutType(config),
+      isEnabledLineBreaks: Config.isEnabledLinebreaks(config),
       isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,

+ 148 - 0
lib/models/external-account.js

@@ -0,0 +1,148 @@
+const debug = require('debug')('crowi:models:external-account');
+const mongoose = require('mongoose');
+const mongoosePaginate = require('mongoose-paginate');
+const uniqueValidator = require('mongoose-unique-validator');
+const ObjectId = mongoose.Schema.Types.ObjectId;
+
+/*
+ * define schema
+ */
+const schema = new mongoose.Schema({
+  providerType: { type: String, required: true },
+  accountId: { type: String, required: true },
+  user: { type: ObjectId, ref: 'User', required: true },
+  createdAt: { type: Date, default: Date.now, required: true },
+});
+// compound index
+schema.index({ providerType: 1, accountId: 1}, { unique: true });
+// apply plugins
+schema.plugin(mongoosePaginate);
+schema.plugin(uniqueValidator);
+
+/**
+ * ExternalAccount Class
+ *
+ * @class ExternalAccount
+ */
+class ExternalAccount {
+
+  /**
+   * limit items num for pagination
+   *
+   * @readonly
+   * @static
+   * @memberof ExternalAccount
+   */
+  static get DEFAULT_LIMIT() {
+    return 50;
+  }
+
+  static set crowi(crowi) {
+    this._crowi = crowi;
+  }
+
+  static get crowi() {
+    return this._crowi;
+  }
+
+  /**
+   * get the populated user entity
+   *
+   * @returns Promise<User>
+   * @memberof ExternalAccount
+   */
+  getPopulatedUser() {
+    return this.populate('user').execPopulate()
+      .then((account) => {
+        return account.user;
+      })
+  }
+
+  /**
+   * find an account or register if not found
+   *
+   * @static
+   * @param {string} providerType
+   * @param {string} accountId
+   * @param {object} usernameToBeRegistered the username of User entity that will be created when accountId is not found
+   * @returns {Promise<ExternalAccount>}
+   * @memberof ExternalAccount
+   */
+  static findOrRegister(providerType, accountId, usernameToBeRegistered) {
+
+    return this.findOne({ providerType, accountId })
+      .then((account) => {
+        // found
+        if (account != null) {
+          debug(`ExternalAccount '${accountId}' is found `, account);
+          return account;
+        }
+        // not found
+        else {
+          debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
+
+          const User = ExternalAccount.crowi.model('User');
+
+          return User.count({username: usernameToBeRegistered})
+            .then((count) => {
+              // throw Exception when count is not zero
+              if (count > 0) {
+                throw new DuplicatedUsernameException(`username '${usernameToBeRegistered}' has already been existed`);
+              }
+
+              // create user with STATUS_ACTIVE
+              return User.createUser('', usernameToBeRegistered, undefined, undefined, undefined, User.STATUS_ACTIVE);
+            })
+            .then((user) => {
+              return this.create({ providerType: 'ldap', accountId, user: user._id });
+            });
+        }
+      });
+
+  }
+
+  /**
+   * find all entities with pagination
+   *
+   * @see https://github.com/edwardhotchkiss/mongoose-paginate
+   *
+   * @static
+   * @param {any} opts mongoose-paginate options object
+   * @returns {Promise<any>} mongoose-paginate result object
+   * @memberof ExternalAccount
+   */
+  static findAllWithPagination(opts) {
+    const query = {};
+    const options = Object.assign({ populate: 'user' }, opts);
+    if (options.sort == null) {
+      options.sort = {accountId: 1, createdAt: 1};
+    }
+    if (options.limit == null) {
+      options.limit = ExternalAccount.DEFAULT_LIMIT;
+    }
+
+    return this.paginate(query, options)
+      .catch((err) => {
+        debug('Error on pagination:', err);
+      });
+  }
+
+}
+
+/**
+ * The Exception class thrown when User.username is duplicated when creating user
+ *
+ * @class DuplicatedUsernameException
+ */
+class DuplicatedUsernameException {
+  constructor(message) {
+    this.name = this.constructor.name;
+    this.message = message;
+  }
+}
+
+module.exports = function(crowi) {
+  ExternalAccount.crowi = crowi;
+  schema.loadClass(ExternalAccount);
+  return mongoose.model('ExternalAccount', schema);
+}

+ 1 - 0
lib/models/index.js

@@ -4,6 +4,7 @@ module.exports = {
   Page: require('./page'),
   PageGroupRelation: require('./page-group-relation'),
   User: require('./user'),
+  ExternalAccount: require('./external-account'),
   UserGroup: require('./user-group'),
   UserGroupRelation: require('./user-group-relation'),
   Revision: require('./revision'),

+ 13 - 0
lib/models/revision.js

@@ -1,6 +1,7 @@
 module.exports = function(crowi) {
   var debug = require('debug')('crowi:models:revision')
     , mongoose = require('mongoose')
+    , Xss = require('../util/xss')
     , ObjectId = mongoose.Schema.Types.ObjectId
     , revisionSchema;
 
@@ -12,6 +13,18 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now }
   });
 
+  /*
+   * preparation for https://github.com/weseek/crowi-plus/issues/216
+   */
+  // // create a XSS Filter instance
+  // // TODO read options
+  // this.xss = new Xss(true);
+  // // prevent XSS when pre save
+  // revisionSchema.pre('save', function(next) {
+  //   this.body = xss.process(this.body);
+  //   next();
+  // });
+
   revisionSchema.statics.findLatestRevision = function(path, cb) {
     this.find({path: path})
       .sort({createdAt: -1})

+ 69 - 31
lib/models/user.js

@@ -31,8 +31,13 @@ module.exports = function(crowi) {
     isGravatarEnabled: { type: Boolean, default: false },
     googleId: String,
     name: { type: String },
-    username: { type: String, index: true },
-    email: { type: String, required: true, unique: true },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+    //// The official settings
+    // username: { type: String, index: true },
+    // email: { type: String, required: true, index: true },
+    //// crowi-plus (>= 2.1.0, <2.3.0) settings
+    // email: { type: String, required: true, unique: true },
     introduction: { type: String },
     password: String,
     apiToken: String,
@@ -71,6 +76,11 @@ module.exports = function(crowi) {
     }
   }
 
+  function generateRandomEmail() {
+    const randomstr = generateRandomTempPassword();
+    return `change-it-${randomstr}@example.com`
+  }
+
   function generateRandomTempPassword () {
     var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!=-_';
     var password = '';
@@ -426,34 +436,24 @@ module.exports = function(crowi) {
 
 
   userSchema.statics.findUserByUsername = function(username) {
-    var User = this;
-    return new Promise(function(resolve, reject) {
-      User.findOne({username: username}, function (err, userData) {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(userData);
-      });
-    });
+    if (username == null) {
+      return Promise.resolve(null);
+    }
+    return this.findOne({username});
   };
 
   userSchema.statics.findUserByApiToken = function(apiToken) {
-    var self = this;
-
-    return new Promise(function(resolve, reject) {
-      self.findOne({apiToken: apiToken}, function (err, userData) {
-        if (err) {
-          return reject(err);
-        } else {
-          return resolve(userData);
-        }
-      });
-    });
+    if (apiToken == null) {
+      return Promise.resolve(null);
+    }
+    return this.findOne({apiToken});
   };
 
   userSchema.statics.findUserByGoogleId = function(googleId, callback) {
-    this.findOne({googleId: googleId}, function (err, userData) {
+    if (googleId == null) {
+      callback(null, null);
+    }
+    this.findOne({googleId}, function (err, userData) {
       callback(err, userData);
     });
   };
@@ -577,7 +577,7 @@ module.exports = function(crowi) {
       emailList,
       function(email, next) {
         var newUser = new User()
-          ,password;
+          ,tmpUsername, password;
 
         email = email.trim();
 
@@ -595,8 +595,10 @@ module.exports = function(crowi) {
             return next();
           }
 
+          tmpUsername = 'temp_' + Math.random().toString(36).slice(-16);
           password = Math.random().toString(36).slice(-16);
 
+          newUser.username = tmpUsername;
           newUser.email = email;
           newUser.setPassword(password);
           newUser.createdAt = Date.now();
@@ -609,7 +611,7 @@ module.exports = function(crowi) {
                 password: null,
                 user: null,
               });
-              debug('save failed!! ', email);
+              debug('save failed!! ', err);
             } else {
               createdUserList.push({
                 email: email,
@@ -666,26 +668,62 @@ module.exports = function(crowi) {
     );
   };
 
-  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, lang, callback) {
+  userSchema.statics.createUserByEmailAndPasswordAndStatus = function(name, username, email, password, lang, status, callback) {
     var User = this
       , newUser = new User();
 
     newUser.name = name;
     newUser.username = username;
-    newUser.email = email;
-    newUser.setPassword(password);
-    newUser.lang = lang;
+    newUser.email = email || generateRandomEmail();   // don't set undefined for backward compatibility -- 2017.12.27 Yuki Takei
+    if (password != null) {
+      newUser.setPassword(password);
+    }
+    if (lang != null) {
+      newUser.lang = lang;
+    }
     newUser.createdAt = Date.now();
-    newUser.status = decideUserStatusOnRegistration();
+    newUser.status = status || decideUserStatusOnRegistration();
 
     newUser.save(function(err, userData) {
+      if (err) {
+        debug('createUserByEmailAndPassword failed: ', err);
+        return callback(err);
+      }
+
       if (userData.status == STATUS_ACTIVE) {
         userEvent.emit('activated', userData);
       }
       return callback(err, userData);
     });
+  }
+
+  /**
+   * A wrapper function of createUserByEmailAndPasswordAndStatus
+   *
+   * @return {Promise<User>}
+   */
+  userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, lang, callback) {
+    this.createUserByEmailAndPasswordAndStatus(name, username, email, password, lang, undefined, callback);
   };
 
+  /**
+   * A wrapper function of createUserByEmailAndPasswordAndStatus
+   *
+   * @return {Promise<User>}
+   */
+  userSchema.statics.createUser = function(name, username, email, password, lang, status) {
+    const User = this;
+
+    return new Promise((resolve, reject) => {
+      User.createUserByEmailAndPasswordAndStatus(name, username, email, password, lang, status, (err, userData) => {
+        if (err) {
+          return reject(err);
+        }
+        return resolve(userData);
+      });
+    });
+  }
+
   userSchema.statics.createUserPictureFilePath = function(user, name) {
     var ext = '.' + name.match(/(.*)(?:\.([^.]+$))/)[2];
 

+ 93 - 0
lib/routes/admin.js

@@ -7,6 +7,7 @@ module.exports = function(crowi, app) {
     , Page = models.Page
     , PageGroupRelation = models.PageGroupRelation
     , User = models.User
+    , ExternalAccount = models.ExternalAccount
     , UserGroup = models.UserGroup
     , UserGroupRelation = models.UserGroupRelation
     , Config = models.Config
@@ -430,6 +431,16 @@ module.exports = function(crowi, app) {
         });
       });
     })
+    .then((userData) => {
+      // remove all External Accounts
+      ExternalAccount.remove({user: userData})
+      .then((err) => {
+        if (err) {
+          throw new Error(err.message);
+        }
+        return userData;
+      })
+    })
     .then((userData) => {
       return Page.removePageByPath(`/user/${username}`)
         .then(() => userData);
@@ -475,6 +486,37 @@ module.exports = function(crowi, app) {
     });
   }
 
+  actions.externalAccount = {};
+  actions.externalAccount.index = function(req, res) {
+    const page = parseInt(req.query.page) || 1;
+
+    ExternalAccount.findAllWithPagination({page})
+      .then((result) => {
+        const pager = createPager(result.total, result.limit, result.page, result.pages, MAX_PAGE_LIST);
+
+        return res.render('admin/external-accounts', {
+          accounts: result.docs,
+          pager: pager
+        })
+      });
+  };
+
+  actions.externalAccount.remove = function(req, res) {
+    const accountId = req.params.id;
+
+    ExternalAccount.findOneAndRemove({accountId})
+      .then((result) => {
+        if (result == null) {
+          req.flash('errorMessage', '削除に失敗しました。');
+          return res.redirect('/admin/users/external-accounts');
+        }
+        else {
+          req.flash('successMessage', `外部アカウント '${accountId}' を削除しました`);
+          return res.redirect('/admin/users/external-accounts');
+        }
+      });
+  };
+
   actions.userGroup = {};
   actions.userGroup.index = function (req, res) {
     var page = parseInt(req.query.page) || 1;
@@ -829,6 +871,31 @@ module.exports = function(crowi, app) {
     }
   };
 
+  actions.api.securityPassportLdapSetting = function(req, res) {
+    var form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+    return saveSettingAsync(form)
+      .then(() => {
+        const config = crowi.getConfig();
+
+        // reset strategy
+        crowi.passportService.resetLdapStrategy();
+        // setup strategy
+        if (Config.isEnabledPassportLdap(config)) {
+          crowi.passportService.setupLdapStrategy(true);
+        }
+        return;
+      })
+      .then(() => {
+        res.json({status: true});
+      });
+  };
+
   actions.api.customizeSetting = function(req, res) {
     var form = req.form.settingForm;
 
@@ -892,6 +959,13 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * save settings, update config cache, and response json
+   *
+   * @param {any} req
+   * @param {any} res
+   * @param {any} form
+   */
   function saveSetting(req, res, form)
   {
     Config.updateNamespaceByArray('crowi', form, function(err, config) {
@@ -900,6 +974,25 @@ module.exports = function(crowi, app) {
     });
   }
 
+  /**
+   * save settings, update config cache ONLY. (this method don't response json)
+   *
+   * @param {any} form
+   * @returns
+   */
+  function saveSettingAsync(form) {
+    return new Promise((resolve, reject) => {
+      Config.updateNamespaceByArray('crowi', form, (err, config) => {
+        if (err) {
+          return reject(err)
+        };
+
+        Config.updateConfigCache('crowi', config);
+        return resolve();
+      });
+    });
+  }
+
   function validateMailSetting(req, form, callback)
   {
     var mailer = crowi.mailer;

+ 0 - 5
lib/routes/attachment.js

@@ -145,11 +145,6 @@ module.exports = function(crowi, app) {
           var fileUrl = data.fileUrl;
           var config = crowi.getConfig();
 
-          // isLocalUrl??
-          if (!fileUrl.match(/^https?/)) {
-            fileUrl = (config.crowi['app:url'] || '') + fileUrl;
-          }
-
           var result = {
             page: page.toObject(),
             attachment: data.toObject(),

+ 13 - 1
lib/routes/index.js

@@ -37,7 +37,8 @@ module.exports = function(crowi, app) {
 
   // switch POST /login route
   if (Config.isEnabledPassport(config)) {
-    app.post('/login'                , form.login                           , csrf, loginPassport.loginWithLdap, loginPassport.loginWithLocal, loginPassport.loginFailure);
+    app.post('/login'                , form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+    app.post('/_api/login/testLdap'  , loginRequired(crowi, app) , form.login , loginPassport.testLdapCredentials);
   }
   else {
     app.post('/login'                , form.login                           , csrf, login.login);
@@ -62,6 +63,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/security/general'       , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.securityGeneral, admin.api.securitySetting);
   app.post('/_api/admin/security/google'        , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityGoogle, admin.api.securitySetting);
   app.post('/_api/admin/security/mechanism'     , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityMechanism, admin.api.securitySetting);
+  app.post('/_api/admin/security/passport-ldap' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
 
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
@@ -71,6 +73,7 @@ module.exports = function(crowi, app) {
   app.get('/admin/customize'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.customize.index);
   app.post('/_api/admin/customize/css'      , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.customcss, admin.api.customizeSetting);
   app.post('/_api/admin/customize/script'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.customscript, admin.api.customizeSetting);
+  app.post('/_api/admin/customize/header'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.customheader, admin.api.customizeSetting);
   app.post('/_api/admin/customize/behavior' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.custombehavior, admin.api.customizeSetting);
   app.post('/_api/admin/customize/layout'   , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.customlayout, admin.api.customizeSetting);
   app.post('/_api/admin/customize/features' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.customfeatures, admin.api.customizeSetting);
@@ -100,6 +103,9 @@ module.exports = function(crowi, app) {
   // new route patterns from here:
   app.post('/_api/admin/users.resetPassword'  , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.resetPassword);
 
+  app.get('/admin/users/external-accounts'               , loginRequired(crowi, app) , middleware.adminRequired() , admin.externalAccount.index);
+  app.post('/admin/users/external-accounts/:id/remove'   , loginRequired(crowi, app) , middleware.adminRequired() , admin.externalAccount.remove);
+
   // user-groups admin
   app.get('/admin/user-groups'             , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.index);
   app.get('/admin/user-group-detail/:name'          , loginRequired(crowi, app), middleware.adminRequired(), admin.userGroup.detail);
@@ -117,6 +123,12 @@ module.exports = function(crowi, app) {
   app.get('/me/password'              , loginRequired(crowi, app) , me.password);
   app.get('/me/apiToken'              , loginRequired(crowi, app) , me.apiToken);
   app.post('/me'                      , form.me.user              , loginRequired(crowi, app) , me.index);
+  // external-accounts
+  if (Config.isEnabledPassport(config)) {
+    app.get('/me/external-accounts'                         , loginRequired(crowi, app) , me.externalAccounts.list);
+    app.post('/me/external-accounts/disassociate'           , loginRequired(crowi, app) , me.externalAccounts.disassociate);
+    app.post('/me/external-accounts/associateLdap'          , loginRequired(crowi, app) , form.login , me.externalAccounts.associateLdap);
+  }
   app.post('/me/password'             , form.me.password          , loginRequired(crowi, app) , me.password);
   app.post('/me/imagetype'            , form.me.imagetype         , loginRequired(crowi, app) , me.imagetype);
   app.post('/me/apiToken'             , form.me.apiToken          , loginRequired(crowi, app) , me.apiToken);

+ 160 - 15
lib/routes/login-passport.js

@@ -4,7 +4,10 @@ module.exports = function(crowi, app) {
   var debug = require('debug')('crowi:routes:login-passport')
     , passport = require('passport')
     , config = crowi.getConfig()
-    , Config = crowi.model('Config');
+    , Config = crowi.model('Config')
+    , ExternalAccount = crowi.model('ExternalAccount')
+    , passportService = crowi.passportService
+    ;
 
   /**
    * success handler
@@ -34,19 +37,159 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  const loginFailure = (req, res) => {
-    req.flash('warningMessage', 'Sign in failure.');
+  const loginFailure = (req, res, next) => {
+    req.flash('errorMessage', 'Sign in failure.');
     return res.redirect('/login');
   };
 
-
+  /**
+   * return true(valid) or false(invalid)
+   *
+   *  true ... group filter is not defined or the user has one or more groups
+   *  false ... group filter is defined and the user has any group
+   *
+   */
+  function isValidLdapUserByGroupFilter(user) {
+    let bool = true;
+    if (user._groups != null) {
+      if (user._groups.length == 0) {
+        bool = false;
+      }
+    }
+    return bool;
+  }
+  /**
+   * middleware that login with LdapStrategy
+   * @param {*} req
+   * @param {*} res
+   * @param {*} next
+   */
   const loginWithLdap = (req, res, next) => {
-    // TODO impl with vesse/passport-ldapauth
-    return next();
+    if (!passportService.isLdapStrategySetup) {
+      debug('LdapStrategy has not been set up');
+      return next();
+    }
+
+    const loginForm = req.body.loginForm;
+
+    if (!req.form.isValid) {
+      debug("invalid form");
+      return res.render('login', {
+      });
+    }
+
+    passport.authenticate('ldapauth', (err, ldapAccountInfo, info) => {
+      if (res.headersSent) {  // dirty hack -- 2017.09.25
+        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+      }
+
+      debug('--- authenticate with LdapStrategy ---');
+      debug('ldapAccountInfo', ldapAccountInfo);
+      debug('info', info);
+
+      if (err) {  // DB Error
+        console.log('LDAP Server Error: ', err);
+        req.flash('warningMessage', 'LDAP Server Error occured.');
+        return next(); // pass and the flash message is displayed when all of authentications are failed.
+      }
+
+      // authentication failure
+      if (!ldapAccountInfo) { return next(); }
+      // check groups
+      if (!isValidLdapUserByGroupFilter(ldapAccountInfo)) {
+        return loginFailure(req, res, next);
+      }
+
+      /*
+       * authentication success
+       */
+      // it is guaranteed that username that is input from form can be acquired
+      // because this processes after authentication
+      const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+
+      const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
+      const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
+
+      // find or register(create) user
+      ExternalAccount.findOrRegister('ldap', ldapAccountId, usernameToBeRegistered)
+        .then((externalAccount) => {
+          return externalAccount.getPopulatedUser();
+        })
+        .then((user) => {
+          // login
+          req.logIn(user, (err) => {
+            if (err) { return next(); }
+            else {
+              return loginSuccess(req, res, user);
+            }
+          });
+        })
+        .catch((err) => {
+          if (err.name != null && err.name === 'DuplicatedUsernameException') {
+            req.flash('isDuplicatedUsernameExceptionOccured', true);
+            return next();
+          }
+          else {
+            return next(err);
+          }
+        });
+
+    })(req, res, next);
   }
 
   /**
-   * login with LocalStrategy action
+   * middleware that test credentials with LdapStrategy
+   *
+   * @param {*} req
+   * @param {*} res
+   */
+  const testLdapCredentials = (req, res) => {
+    if (!passportService.isLdapStrategySetup) {
+      debug('LdapStrategy has not been set up');
+      return res.json({
+        status: 'warning',
+        message: 'LdapStrategy has not been set up',
+      });
+    }
+
+    const loginForm = req.body.loginForm;
+
+    passport.authenticate('ldapauth', (err, user, info) => {
+      if (res.headersSent) {  // dirty hack -- 2017.09.25
+        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+      }
+
+      if (err) {  // DB Error
+        console.log('LDAP Server Error: ', err);
+        return res.json({
+          status: 'warning',
+          message: 'LDAP Server Error occured.',
+        });
+      }
+      if (info && info.message) {
+        return res.json({
+          status: 'warning',
+          message: info.message,
+        });
+      }
+      if (user) {
+        // check groups
+        if (!isValidLdapUserByGroupFilter(user)) {
+          return res.json({
+            status: 'warning',
+            message: 'The user is found, but that has no groups.',
+          });
+        }
+        return res.json({
+          status: 'success',
+          message: 'Successfully authenticated.',
+        });
+      }
+    })(req, res, () => {});
+  }
+
+  /**
+   * middleware that login with LocalStrategy
    * @param {*} req
    * @param {*} res
    * @param {*} next
@@ -60,27 +203,29 @@ module.exports = function(crowi, app) {
     }
 
     passport.authenticate('local', (err, user, info) => {
-      debug('---authentication with LocalStrategy start---');
+      debug('--- authenticate with LocalStrategy ---');
       debug('user', user);
       debug('info', info);
 
-      if (err) { return next(err); }
+      if (err) {  // DB Error
+        console.log('Database Server Error: ', err);
+        req.flash('warningMessage', 'Database Server Error occured.');
+        return next(); // pass and the flash message is displayed when all of authentications are failed.
+      }
       if (!user) { return next(); }
       req.logIn(user, (err) => {
-        if (err != null) {
-          debug(err);
-          return next();
+        if (err) { return next(); }
+        else {
+          return loginSuccess(req, res, user);
         }
-        return loginSuccess(req, res, user);
       });
-
-      debug('---authentication with LocalStrategy end---');
     })(req, res, next);
   }
 
   return {
     loginFailure,
     loginWithLdap,
+    testLdapCredentials,
     loginWithLocal,
   };
 };

+ 1 - 0
lib/routes/login.js

@@ -130,6 +130,7 @@ module.exports = function(crowi, app) {
         User.findUserByGoogleId(googleId, function(err, userData) {
           debug('findUserByGoogleId', err, userData);
           if (!userData) {
+            clearGoogleSession(req);
             return loginFailure(req, res);
           }
           return loginSuccess(req, res, userData);

+ 138 - 0
lib/routes/me.js

@@ -7,6 +7,7 @@ module.exports = function(crowi, app) {
     , config = crowi.getConfig()
     , Page = models.Page
     , User = models.User
+    , ExternalAccount = models.ExternalAccount
     , Revision = models.Revision
     //, pluginService = require('../service/plugin')
     , actions = {}
@@ -84,10 +85,14 @@ module.exports = function(crowi, app) {
       var email = userForm.email;
       var lang= userForm.lang;
 
+      /*
+       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
+       *
       if (!User.isEmailValid(email)) {
         req.form.errors.push('You can\'t update to that email address');
         return res.render('me/index', {});
       }
+      */
 
       User.findOneAndUpdate(
         { email: userData.email },                  // query
@@ -107,10 +112,14 @@ module.exports = function(crowi, app) {
         });
 
     } else { // method GET
+      /*
+       * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
+       *
       /// そのうちこのコードはいらなくなるはず
       if (!userData.isEmailSet()) {
         req.flash('warningMessage', 'メールアドレスが設定されている必要があります');
       }
+      */
 
       return res.render('me/index', {
       });
@@ -147,15 +156,144 @@ module.exports = function(crowi, app) {
     });
   }
 
+  actions.externalAccounts = {};
+  actions.externalAccounts.list = function(req, res) {
+    const userData = req.user;
+
+    let renderVars = {};
+    ExternalAccount.find({user: userData})
+      .then((externalAccounts) => {
+        renderVars.externalAccounts = externalAccounts;
+        return;
+      })
+      .then(() => {
+        if (req.method == 'POST' && req.form.isValid) {
+          // TODO impl
+          return res.render('me/external-accounts', renderVars);
+        }
+        else { // method GET
+          return res.render('me/external-accounts', renderVars);
+        }
+      });
+  }
+
+  actions.externalAccounts.disassociate = function(req, res) {
+    const userData = req.user;
+
+    const redirectWithFlash = (type, msg) => {
+      req.flash(type, msg);
+      return res.redirect('/me/external-accounts');
+    }
+
+    if (req.body == null) {
+      redirectWithFlash('errorMessage', 'Invalid form.');
+    }
+
+    // make sure password set or this user has two or more ExternalAccounts
+    new Promise((resolve, reject) => {
+      if (userData.password != null) {
+        resolve(true);
+      }
+      else {
+        ExternalAccount.count({user: userData})
+          .then((count) => {
+            resolve(count > 1)
+          });
+      }
+    })
+    .then((isDisassociatable) => {
+      if (!isDisassociatable) {
+        let e = new Error();
+        e.name = 'couldntDisassociateError';
+        throw e;
+      }
+
+      const providerType = req.body.providerType;
+      const accountId = req.body.accountId;
+
+      return ExternalAccount.findOneAndRemove({providerType, accountId, user: userData});
+    })
+    .then((account) => {
+      if (account == null) {
+        return redirectWithFlash('errorMessage', 'ExternalAccount not found.');
+      }
+      else {
+        return redirectWithFlash('successMessage', 'Successfully disassociated.');
+      }
+    })
+    .catch((err) => {
+      if (err) {
+        if (err.name == 'couldntDisassociateError') {
+          return redirectWithFlash('couldntDisassociateError', true);
+        }
+        else {
+          return redirectWithFlash('errorMessage', err.message);
+        }
+      }
+    });
+
+  }
+
+  actions.externalAccounts.associateLdap = function(req, res) {
+    const passport = require('passport');
+    const passportService = crowi.passportService;
+
+    const redirectWithFlash = (type, msg) => {
+      req.flash(type, msg);
+      return res.redirect('/me/external-accounts');
+    }
+
+    if (!passportService.isLdapStrategySetup) {
+      debug('LdapStrategy has not been set up');
+      return redirectWithFlash('warning', 'LdapStrategy has not been set up');
+    }
+
+    const loginForm = req.body.loginForm;
+
+    passport.authenticate('ldapauth', (err, user, info) => {
+      if (res.headersSent) {  // dirty hack -- 2017.09.25
+        return;               // cz: somehow passport.authenticate called twice when ECONNREFUSED error occurred
+      }
+
+      if (err) {  // DB Error
+        console.log('LDAP Server Error: ', err);
+        return redirectWithFlash('warningMessage', 'LDAP Server Error occured.');
+      }
+      if (info && info.message) {
+        return redirectWithFlash('warningMessage', info.message);
+      }
+      if (user) {
+        // create ExternalAccount
+        const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
+        const user = req.user;
+
+        ExternalAccount.create({ providerType: 'ldap', accountId: ldapAccountId, user: user._id })
+          .then(() => {
+            return redirectWithFlash('successMessage', 'Successfully added.');
+          })
+          .catch((err) => {
+            return redirectWithFlash('errorMessage', err.message);
+          });
+
+      }
+    })(req, res, () => {});
+
+
+  }
+
   actions.password = function(req, res) {
     var passwordForm = req.body.mePassword;
     var userData = req.user;
 
+    /*
+      * disabled because the system no longer allows undefined email -- 2017.10.06 Yuki Takei
+      *
     // パスワードを設定する前に、emailが設定されている必要がある (schemaを途中で変更したため、最初の方の人は登録されていないかもしれないため)
     // そのうちこのコードはいらなくなるはず
     if (!userData.isEmailSet()) {
       return res.redirect('/me');
     }
+    */
 
     if (req.method == 'POST' && req.form.isValid) {
       var newPassword = passwordForm.newPassword;

+ 1 - 0
lib/routes/page.js

@@ -168,6 +168,7 @@ module.exports = function(crowi, app) {
     Page.hasPortalPage(path, req.user, req.query.revision)
     .then(function(portalPage) {
       renderVars.page = portalPage;
+      renderVars.revision = portalPage.revision;
 
       if (portalPage) {
         return Revision.findRevisionList(portalPage.path, {});

+ 183 - 2
lib/service/passport.js

@@ -1,6 +1,7 @@
 const debug = require('debug')('crowi:service:PassportService');
 const passport = require('passport');
 const LocalStrategy = require('passport-local').Strategy;
+const LdapStrategy = require('passport-ldapauth');
 
 /**
  * the service class of Passport
@@ -13,6 +14,32 @@ class PassportService {
 
   constructor(crowi) {
     this.crowi = crowi;
+
+    /**
+     * the flag whether LocalStrategy is set up successfully
+     */
+    this.isLocalStrategySetup = false;
+
+    /**
+     * the flag whether LdapStrategy is set up successfully
+     */
+    this.isLdapStrategySetup = false;
+
+    /**
+     * the flag whether serializer/deserializer are set up successfully
+     */
+    this.isSerializerSetup = false;
+  }
+
+  /**
+   * reset LocalStrategy
+   *
+   * @memberof PassportService
+   */
+  resetLocalStrategy() {
+    debug('LocalStrategy: reset');
+    passport.unuse('local');
+    this.isLocalStrategySetup = false;
   }
 
   /**
@@ -21,7 +48,12 @@ class PassportService {
    * @memberof PassportService
    */
   setupLocalStrategy() {
-    debug('setup LocalStrategy');
+    // check whether the strategy has already been set up
+    if (this.isLocalStrategySetup) {
+      throw new Error('LocalStrategy has already been set up');
+    }
+
+    debug('LocalStrategy: setting up..');
 
     const User = this.crowi.model('User');
 
@@ -42,6 +74,147 @@ class PassportService {
         });
       }
     ));
+
+    this.isLocalStrategySetup = true;
+    debug('LocalStrategy: setup is done');
+  }
+
+  /**
+   * reset LdapStrategy
+   *
+   * @memberof PassportService
+   */
+  resetLdapStrategy() {
+    debug('LdapStrategy: reset');
+    passport.unuse('ldapauth');
+    this.isLdapStrategySetup = false;
+  }
+
+  /**
+   * Asynchronous configuration retrieval
+   *
+   * @memberof PassportService
+   */
+  setupLdapStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isLdapStrategySetup) {
+      throw new Error('LdapStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    const isLdapEnabled = Config.isEnabledPassportLdap(config);
+
+    // when disabled
+    if (!isLdapEnabled) {
+      return;
+    }
+
+    debug('LdapStrategy: setting up..');
+
+    passport.use(new LdapStrategy(this.getLdapConfigurationFunc(config, {passReqToCallback: true}),
+      (req, ldapAccountInfo, done) => {
+        debug("LDAP authentication has succeeded", ldapAccountInfo);
+        done(null, ldapAccountInfo);
+      }
+    ));
+
+    this.isLdapStrategySetup = true;
+    debug('LdapStrategy: setup is done');
+  }
+
+  /**
+   * return attribute name for mapping to username of Crowi DB
+   *
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapAttrNameMappedToUsername() {
+    const config = this.crowi.config;
+    return config.crowi['security:passport-ldap:attrMapUsername'] || 'uid';
+  }
+
+  /**
+   * CAUTION: this method is capable to use only when `req.body.loginForm` is not null
+   *
+   * @param {any} req
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapAccountIdFromReq(req) {
+    return req.body.loginForm.username;
+  }
+
+  /**
+   * Asynchronous configuration retrieval
+   * @see https://github.com/vesse/passport-ldapauth#asynchronous-configuration-retrieval
+   *
+   * @param {object} config
+   * @param {object} opts
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapConfigurationFunc(config, opts) {
+    // get configurations
+    const isUserBind          = config.crowi['security:passport-ldap:isUserBind'];
+    const serverUrl           = config.crowi['security:passport-ldap:serverUrl'];
+    const bindDN              = config.crowi['security:passport-ldap:bindDN'];
+    const bindCredentials     = config.crowi['security:passport-ldap:bindDNPassword'];
+    const searchFilter        = config.crowi['security:passport-ldap:searchFilter'] || '(uid={{username}})';
+    const groupSearchBase     = config.crowi['security:passport-ldap:groupSearchBase'];
+    const groupSearchFilter   = config.crowi['security:passport-ldap:groupSearchFilter'];
+    const groupDnProperty     = config.crowi['security:passport-ldap:groupDnProperty'] || 'uid';
+
+    // parse serverUrl
+    // see: https://regex101.com/r/0tuYBB/1
+    const match = serverUrl.match(/(ldaps?:\/\/[^\/]+)\/(.*)?/);
+    if (match == null || match.length < 1) {
+      debug('LdapStrategy: serverUrl is invalid');
+      return;
+    }
+    const url = match[1];
+    const searchBase = match[2] || '';
+
+    debug(`LdapStrategy: url=${url}`);
+    debug(`LdapStrategy: searchBase=${searchBase}`);
+    debug(`LdapStrategy: isUserBind=${isUserBind}`);
+    if (!isUserBind) {
+      debug(`LdapStrategy: bindDN=${bindDN}`);
+      debug(`LdapStrategy: bindCredentials=${bindCredentials}`);
+    }
+    debug(`LdapStrategy: searchFilter=${searchFilter}`);
+    debug(`LdapStrategy: groupSearchBase=${groupSearchBase}`);
+    debug(`LdapStrategy: groupSearchFilter=${groupSearchFilter}`);
+    debug(`LdapStrategy: groupDnProperty=${groupDnProperty}`);
+
+    return (req, callback) => {
+      // get credentials from form data
+      const loginForm = req.body.loginForm;
+      if (!req.form.isValid) {
+        return callback({ message: 'Incorrect credentials.' });
+      }
+
+      // user bind
+      const fixedBindDN = (isUserBind) ?
+          bindDN.replace(/{{username}}/, loginForm.username):
+          bindDN;
+      const fixedBindCredentials = (isUserBind) ? loginForm.password : bindCredentials;
+      let serverOpt = { url, bindDN: fixedBindDN, bindCredentials: fixedBindCredentials, searchBase, searchFilter };
+
+      if (groupSearchBase && groupSearchFilter) {
+        serverOpt = Object.assign(serverOpt, { groupSearchBase, groupSearchFilter, groupDnProperty });
+      }
+
+      process.nextTick(() => {
+        const mergedOpts = Object.assign({
+          usernameField: PassportService.USERNAME_FIELD,
+          passwordField: PassportService.PASSWORD_FIELD,
+          server: serverOpt,
+        }, opts);
+        debug('ldap configuration: ', mergedOpts);
+        callback(null, mergedOpts);
+      });
+    };
   }
 
   /**
@@ -50,7 +223,12 @@ class PassportService {
    * @memberof PassportService
    */
   setupSerializer() {
-    debug('setup serializer and deserializer');
+    // check whether the serializer/deserializer have already been set up
+    if (this.isSerializerSetup) {
+      throw new Error('serializer/deserializer have already been set up');
+    }
+
+    debug('setting up serializer and deserializer');
 
     const User = this.crowi.model('User');
 
@@ -62,7 +240,10 @@ class PassportService {
         done(err, user);
       });
     });
+
+    this.isSerializerSetup = true;
   }
+
 }
 
 module.exports = PassportService;

+ 6 - 6
lib/util/googleAuth.js

@@ -5,7 +5,8 @@
 module.exports = function(config) {
   'use strict';
 
-  var google = require('googleapis')
+  const { GoogleApis } = require('googleapis');
+  var google = new GoogleApis()
     , debug = require('debug')('crowi:lib:googleAuth')
     , lib = {}
     ;
@@ -49,9 +50,7 @@ module.exports = function(config) {
         return callback(new Error('[googleAuth.handleCallback] Error to get token.'), null);
       }
 
-      oauth2Client.setCredentials({
-        access_token: tokens.access_token,
-      });
+      oauth2Client.credentials = tokens;
 
       var oauth2 = google.oauth2('v2');
       oauth2.userinfo.get({}, function(err, response) {
@@ -60,8 +59,9 @@ module.exports = function(config) {
           return callback(new Error('[googleAuth.handleCallback] Error while proceccing userinfo.get.'), null);
         }
 
-        response.user_id = response.id; // This is for B.C. (tokeninfo をつかっている前提のコードに対してのもの)
-        return callback(null, response);
+        let data = response.data;
+        data.user_id = data.id;           // This is for B.C. (tokeninfo をつかっている前提のコードに対してのもの)
+        return callback(null, data);
       });
     });
   };

+ 6 - 8
lib/util/i18nUserSettingDetector.js

@@ -2,15 +2,13 @@ module.exports = {
   name: 'userSettingDetector',
 
   lookup: function(req, res, options) {
-    var lang = null;
-
-    if (req.user) {
-      if ('lang' in req.user) {
-        lang = req.user.lang || null;
-      }
+    // return null if
+    //  1. user doesn't logged in
+    //  2. req.user is username/email string to login which is set by basic-auth-connect
+    if (req.user == null || !(req.user instanceof Object)) {
+      return null;
     }
-
-    return lang;
+    return req.user.lang || null;
   },
 
   cacheUserlanguage: function(req, res, lng, options) {

+ 19 - 3
lib/util/middlewares.js

@@ -38,6 +38,9 @@ exports.loginChecker = function(crowi, app) {
 
 exports.loginCheckerForPassport = function(crowi, app) {
   return function(req, res, next) {
+    if (req.user == null) {
+      req.user = false;
+    }
     res.locals.user = req.user;
     next();
   };
@@ -75,7 +78,8 @@ exports.swigFilters = function(app, swig) {
 
   // define a function for Gravatar
   const generateGravatarSrc = function(user) {
-    const hash = md5(user.email.trim().toLowerCase());
+    const email = user.email || '';
+    const hash = md5(email.trim().toLowerCase());
     return `https://gravatar.com/avatar/${hash}`;
   };
 
@@ -178,7 +182,9 @@ exports.swigFilters = function(app, swig) {
 
 exports.adminRequired = function() {
   return function(req, res, next) {
-    if (req.user && '_id' in req.user) {
+    // check the user logged in
+    //  make sure that req.user isn't username/email string to login which is set by basic-auth-connect
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.admin) {
         next();
         return;
@@ -211,7 +217,9 @@ exports.loginRequired = function(crowi, app, isStrictly = true) {
       }
     }
 
-    if (req.user && '_id' in req.user) {
+    // check the user logged in
+    //  make sure that req.user isn't username/email string to login which is set by basic-auth-connect
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.status === User.STATUS_ACTIVE) {
         // Active の人だけ先に進める
         return next();
@@ -285,6 +293,14 @@ exports.checkSearchIndicesGenerated = function(crowi, app) {
             debug('Index created.');
           }
           return searcher.addAllPages();
+        })
+        .catch((error) => {
+          if (error.message != null && error.message.match(/index_already_exists_exception/)) {
+            debug('Creating index is failed: ', error.message);
+          }
+          else {
+            console.log(`Error while building index of Elasticsearch': ${error.message}`);
+          }
         });
     }
 

+ 27 - 6
lib/util/search.js

@@ -207,34 +207,55 @@ SearchClient.prototype.deletePages = function(pages)
 SearchClient.prototype.addAllPages = function()
 {
   var self = this;
-  var offset = 0;
   var Page = this.crowi.model('Page');
   var cursor = Page.getStreamOfFindAll();
   var body = [];
-
-  var counter = 0;
+  var sent = 0;
+  var skipped = 0;
 
   return new Promise(function(resolve, reject) {
     cursor.on('data', function (doc) {
       if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
-        debug('Skipped', doc.path);
+        //debug('Skipped', doc.path);
+        skipped++;
         return ;
       }
 
       self.prepareBodyForCreate(body, doc);
+      //debug(body.length);
+      if (body.length > 2000) {
+        sent++;
+        debug('Sending request (seq, skipped)', sent, skipped);
+        self.client.bulk({
+          body: body,
+          requestTimeout: Infinity,
+        }).then(res => {
+          debug('addAllPages add anyway (items, errors, took): ', (res.items || []).length, res.errors, res.took)
+        }).catch(err => {
+          debug('addAllPages error on add anyway: ', err)
+        });
+
+        body = [];
+      }
     }).on('error', function (err) {
       // TODO: handle err
       debug('Error cursor:', err);
     }).on('close', function () {
       // all done
 
-      // 最後に送信
+      // return if body is empty
+      // see: https://github.com/weseek/crowi-plus/issues/228
+      if (body.length == 0) {
+        return resolve();
+      }
+
+      // 最後にすべてを送信
       self.client.bulk({
         body: body,
         requestTimeout: Infinity,
       })
       .then(function(res) {
-        debug('Reponse from es:', res);
+        debug('Reponse from es (item length, errros, took):', (res.items || []).length, res.errors, res.took);
         return resolve(res);
       }).catch(function(err) {
         debug('Err from es:', err);

+ 41 - 3
lib/util/swigFunctions.js

@@ -3,16 +3,17 @@ module.exports = function(crowi, app, req, locals) {
     , Page = crowi.model('Page')
     , Config = crowi.model('Config')
     , User = crowi.model('User')
+    , passportService = crowi.passportService
   ;
 
   locals.nodeVersion = function() {
-    return crowi.runtimeVersions.node ? crowi.runtimeVersions.node.version : '-';
+    return crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version : '-';
   }
   locals.npmVersion = function() {
-    return crowi.runtimeVersions.npm ? crowi.runtimeVersions.npm.version : '-';
+    return crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version : '-';
   }
   locals.yarnVersion = function() {
-    return crowi.runtimeVersions.yarn ? crowi.runtimeVersions.yarn.version : '-';
+    return crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version : '-';
   }
 
   locals.crowiVersion = function() {
@@ -24,6 +25,38 @@ module.exports = function(crowi, app, req, locals) {
     return req.csrfToken;
   };
 
+  /**
+   * return true if enabled
+   */
+  locals.isEnabledPassport = function() {
+    var config = crowi.getConfig()
+    return Config.isEnabledPassport(config);
+  }
+
+  /**
+   * return true if local strategy has been setup successfully
+   *  used whether restarting the server needed
+   */
+  locals.isPassportLocalStrategySetup = function() {
+    return passportService != null && passportService.isLocalStrategySetup;
+  }
+
+  /**
+   * return true if enabled and strategy has been setup successfully
+   */
+  locals.isLdapSetup = function() {
+    var config = crowi.getConfig()
+    return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && passportService.isLdapStrategySetup;
+  }
+
+  /**
+   * return true if enabled but strategy has some problem
+   */
+  locals.isLdapSetupFailed = function() {
+    var config = crowi.getConfig()
+    return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && !passportService.isLdapStrategySetup;
+  }
+
   locals.googleLoginEnabled = function() {
     var config = crowi.getConfig()
     return config.crowi['google:clientId'] && config.crowi['google:clientSecret'];
@@ -59,6 +92,11 @@ module.exports = function(crowi, app, req, locals) {
     return Config.customScript();
   }
 
+  locals.customHeader = function() {
+    var config = crowi.getConfig()
+    return Config.customHeader(config);
+  }
+
   locals.behaviorType = function() {
     var config = crowi.getConfig()
     return Config.behaviorType(config);

+ 28 - 0
lib/util/xss.js

@@ -0,0 +1,28 @@
+class Xss {
+
+  constructor(isAllowAllAttrs) {
+    const xss = require('xss');
+
+    // create the option object
+    let option = {
+      stripIgnoreTag: true,
+      css: false,
+      escapeHtml: (html) => html,
+    };
+    if (isAllowAllAttrs) {
+      // allow all attributes
+      option.onTagAttr = function(tag, name, value, isWhiteAttr) {
+        return `${name}="${value}"`;
+      }
+    }
+    // create the XSS Filter instance
+    this.myxss = new xss.FilterXSS(option);
+  }
+
+  process(markdown) {
+    return this.myxss.process(markdown);
+  }
+
+}
+
+module.exports = Xss;

+ 54 - 53
lib/views/_form.html

@@ -13,63 +13,64 @@
   </ul>
 </div>
 {% endif %}
-<div id="form-box" class="row">
-  <form action="/_/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %} page-form">
-    <textarea name="pageForm[body]" class="form-control" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not page.revision.body %}# {{ path|path2name }}{% else %}{{ page.revision.body }}{% endif %}</textarea>
 
-    <input type="hidden" name="pageForm[path]" value="{{ path }}">
-    <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
-    <div class="form-submit-group form-group form-inline">
-      {#<button class="btn btn-default">
-        <i class="fa fa-file-text"></i>
-        ファイルを追加 ...
-      </button>#}
+<form action="/_/edit" id="page-form" method="post" class="{% if isUploadable() %}uploadable{% endif %} page-form">
 
-      <div class="pull-right form-inline page-form-setting" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">
-        {% if slackConfigured() %}
-        <span class="input-group extended-setting">
-          <span class="input-group-addon">
-            <label>
-              <i class="fa fa-slack"></i>
-              <input class="" type="checkbox" name="pageForm[notify][slack][on]" value="1">
-            </label>
-          </span>
-          <input class="form-control" type="text" name="pageForm[notify][slack][channel]" value="{{ page.extended.slack|default('') }}" placeholder="slack-channel-name"
-            id="page-form-slack-channel"
-            data-toggle="popover"
-            title="Slack通知"
-            data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
-            data-trigger="focus"
-            data-placement="top"
-          >
-        </span>
-        {% endif %}
+  <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
+
+  <input type="hidden" id="form-body" name="pageForm[body]" value="{% if pageForm.body %}{{ pageForm.body }}{% endif %}">
+  <input type="hidden" name="pageForm[path]" value="{{ path }}">
+  <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
+  <div class="form-submit-group form-group form-inline">
+    {#<button class="btn btn-default">
+      <i class="fa fa-file-text"></i>
+      ファイルを追加 ...
+    </button>#}
 
-        {% if forceGrant %}
-        <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
-        {% else %}
-        <select name="pageForm[grant]" class="form-control">
-          {% for grantId, grantLabel in consts.pageGrants %}
-          <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %} {% if grantId == 5 && userRelatedGroups.length == 0 %}disabled{% endif %}>{{ t(grantLabel) }}</option>
-          {% endfor %}
-        </select>
-        {% endif %}
-        {% if userRelatedGroups.length != 0 %}
-        <select name="pageForm[grantUserGroupId]" class="form-control">
-          {% for userGroup in userRelatedGroups %}
-          <option value="{{ userGroup.id }}">{{ userGroup.name }}</option>
-          {% endfor %}
-        </select>
-        {% endif %}
-        <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">
-        <input type="submit" class="btn btn-primary" id="edit-form-submit" value="{{ t('Update Page') }}" />
-      </div>
+    <div class="pull-left">
+      <div id="page-editor-options-selector"></div>
     </div>
-  </form>
-  <div class="col-md-6 hidden-sm hidden-xs">
-    <div id="preview-body" class="wiki preview-body">
+
+    <div class="pull-right form-inline page-form-setting" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">
+      {% if slackConfigured() %}
+      <span class="input-group extended-setting">
+        <span class="input-group-addon">
+          <label>
+            <i class="fa fa-slack"></i>
+            <input class="" type="checkbox" name="pageForm[notify][slack][on]" value="1">
+          </label>
+        </span>
+        <input class="form-control" type="text" name="pageForm[notify][slack][channel]" value="{{ page.extended.slack|default('') }}" placeholder="slack-channel-name"
+          id="page-form-slack-channel"
+          data-toggle="popover"
+          title="Slack通知"
+          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
+          data-trigger="focus"
+          data-placement="top"
+        >
+      </span>
+      {% endif %}
+
+      {% if forceGrant %}
+      <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
+      {% else %}
+      <select name="pageForm[grant]" class="form-control">
+        {% for grantId, grantLabel in consts.pageGrants %}
+        <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %} {% if grantId == 5 && userRelatedGroups.length == 0 %}disabled{% endif %}>{{ t(grantLabel) }}</option>
+        {% endfor %}
+      </select>
+      {% endif %}
+      {% if userRelatedGroups.length != 0 %}
+      <select name="pageForm[grantUserGroupId]" class="form-control">
+        {% for userGroup in userRelatedGroups %}
+        <option value="{{ userGroup.id }}">{{ userGroup.name }}</option>
+        {% endfor %}
+      </select>
+      {% endif %}
+      <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">
+      <input type="submit" class="btn btn-primary" id="edit-form-submit" value="{{ t('Update Page') }}" />
     </div>
   </div>
-  <div class="file-module hidden">
-  </div>
+</form>
+<div class="file-module hidden">
 </div>

+ 22 - 22
lib/views/admin/app.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}アプリ設定 · {% endblock %}
+{% block html_title %}{{ t('App settings') }} · {% endblock %}
 
 {% block content_head %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 class="title" id="">アプリ設定</h1>
+    <h1 class="title" id="">{{ t('App settings') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -34,21 +34,21 @@
 
       <form action="/_api/admin/settings/app" method="post" class="form-horizontal" id="appSettingForm" role="form">
       <fieldset>
-        <legend>アプリ設定</legend>
+        <legend>{{ t('App settings') }}</legend>
         <div class="form-group">
-          <label for="settingForm[app:title]" class="col-xs-3 control-label">Wikiの名前</label>
+          <label for="settingForm[app:title]" class="col-xs-3 control-label">{{ t('app_setting.Wiki name') }}</label>
           <div class="col-xs-6">
             <input class="form-control" type="text" name="settingForm[app:title]" value="{{ settingForm['app:title'] }}">
 
-            <p class="help-block">ヘッダーやHTMLタイトルに使用されるWikiの名前を変更できます。</p>
+            <p class="help-block">{{ t("app_setting.wiki_change") }}</p>
           </div>
         </div>
 
         <div class="form-group">
-          <label for="settingForm[app:confidential]" class="col-xs-3 control-label">コンフィデンシャル表示</label>
+          <label for="settingForm[app:confidential]" class="col-xs-3 control-label">{{ t('app_setting.Confidential name') }}</label>
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[app:confidential]" value="{{ settingForm['app:confidential'] }}" placeholder="例: 社外秘">
-            <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
+            <input class="form-control" type="text" name="settingForm[app:confidential]" value="{{ settingForm['app:confidential'] }}" placeholder="{{ t('app_setting. ex): internal use only') }}">
+            <p class="help-block">{{ t("app_setting.header_content") }}</p>
           </div>
         </div>
 
@@ -63,11 +63,11 @@
               {% else %}
               {% endif %}
               >
-              <label for="settingForm[app:fileUpload]" class="">画像以外のファイルアップロードを許可</label>
+              <label for="settingForm[app:fileUpload]" class="">{{ t("app_setting.enable_files_except_image") }}</label>
 
               <p class="help-block">
-                ファイルアップロードの設定を有効にしている場合にのみ、選択可能です。<br>
-                許可をしている場合、画像以外のファイルをページに添付可能になります。
+                {{ t("app_setting.enable_files_except_image") }}<br>
+                {{ t("app_setting.attach_enable") }}
               </p>
           </div>
         </div>
@@ -75,7 +75,7 @@
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">更新</button>
+            <button type="submit" class="btn btn-primary">{{ t('app_setting.Reload') }}</button>
           </div>
         </div>
       </fieldset>
@@ -83,35 +83,35 @@
 
       <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>
+      <legend>{{ t('app_setting.Mail settings') }}</legend>
+      <p class="well">{{ t("app_setting.SMTP_used") }} {{ t("app_setting.SMTP_but_AWS") }}<br>{{ t("app_setting.neihter_of") }}</p>
 
         <div class="form-group">
-          <label for="settingForm[mail.from]" class="col-xs-3 control-label">Fromアドレス</label>
+          <label for="settingForm[mail.from]" class="col-xs-3 control-label">{{ t('app_setting.From e-mail address') }}</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>
+          <label class="col-xs-3 control-label">{{ t('app_setting.SMTP settings') }}</label>
           <div class="col-xs-4">
-            <label for="">ホスト</label>
+            <label for="">{{ t('app_setting.Host') }}</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>
+            <label for="">{{ t('app_setting.Port') }}</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>
+            <label for="">{{ t('app_setting.User') }}</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>
+            <label for="">{{ t('Password') }}</label>
             <input class="form-control" type="password" name="settingForm[mail:smtpPassword]" value="{{ settingForm['mail:smtpPassword']|default('') }}">
           </div>
         </div>
@@ -119,7 +119,7 @@
         <div class="form-group">
           <div class="col-xs-offset-3 col-xs-6">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">更新</button>
+            <button type="submit" class="btn btn-primary">{{ t('app_setting.Reload') }}</button>
           </div>
         </div>
 
@@ -128,7 +128,7 @@
 
       <form action="/_api/admin/settings/aws" method="post" class="form-horizontal" id="awsSettingForm" role="form">
       <fieldset>
-      <legend>AWS設定</legend>
+      <legend>{{ t('app_setting.AWS settings') }}</legend>
         <p class="well">AWS にアクセスするための設定を行います。AWS の設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。<br>
         また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。<br>
           <br>

+ 56 - 54
lib/views/admin/customize.html

@@ -204,19 +204,56 @@
       </fieldset>
       </form>
 
+      <form action="/_api/admin/customize/header" method="post" class="form-horizontal" id="cutomheaderSettingForm" role="form">
+      <fieldset>
+        <legend>カスタムヘッダーHTML</legend>
+
+        <p class="well">
+          システム全体に適用される HTML を記述できます。<code>&lt;header&gt;</code> タグ内の他の <code>&lt;script&gt;</code> タグ読み込み前に展開されます。<br>
+          変更の反映はページの更新が必要です。
+        </p>
+
+        <p class="help-block">
+          Examples:
+          <pre><code>&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js" defer&gt;&lt;script&gt;</script></code></pre>
+        </p>
+
+        <div class="form-group">
+          <div class="col-xs-12">
+            <div id="custom-header-editor"></div>
+            <input type="hidden" id="inputCustomHeader" name="settingForm[customize:header]" value="{{ settingForm['customize:header'] }}">
+          </div>
+          <div class="col-xs-12">
+            <p class="help-block text-right">
+              <i class="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
+              Ctrl+Space でコード補完
+            </p>
+          </div>
+        </div>
+
+        <div class="form-group">
+          <div class="col-xs-offset-5 col-xs-6">
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <button type="submit" class="btn btn-primary">更新</button>
+          </div>
+        </div>
+
+      </fieldset>
+      </form>
 
       <form action="/_api/admin/customize/css" method="post" class="form-horizontal" id="cutomcssSettingForm" role="form">
       <fieldset>
-        <legend>カスタムCSS</legend>
+        <legend>カスタムヘッダーCSS</legend>
 
         <p class="well">
-          システム全体に適用されるCSSを記述できます。<br>
+          システム全体に適用されるCSSを記述できます。<code>&lt;header&gt;</code> タグ内に展開されます。<br>
           変更の反映はページの更新が必要です。
         </p>
 
         <div class="form-group">
           <div class="col-xs-12">
-            <textarea id="taCustomCss" class="form-control" type="textarea" name="settingForm[customize:css]" rows="20">{{ settingForm['customize:css'] }}</textarea>
+            <div id="custom-css-editor"></div>
+            <input type="hidden" id="inputCustomCss" name="settingForm[customize:css]" value="{{ settingForm['customize:css'] }}">
           </div>
           <div class="col-xs-12">
             <p class="help-block text-right">
@@ -242,13 +279,16 @@
         <legend>カスタムスクリプト</legend>
 
         <p class="well">
-          システム全体に適用されるJavaScriptを記述できます。<br>
+          システム全体に適用されるJavaScriptを記述できます。<code>&lt;body&gt;</code> タグ内の最後に展開されます。<br>
           変更の反映はページの更新が必要です。
         </p>
 
         <p class="help-block">
-          Available Placeholders:
+          Placeholders:<br>
+          (Available after <code>load</code> event)
           <dl class="dl-horizontal">
+            <dt><code>$</code></dt>
+            <dd>jQuery instance</dd>
             <dt><code>crowi</code></dt>
             <dd>Crowi context instance</dd>
             <dt><code>Crowi</code></dt>
@@ -259,10 +299,19 @@
             <dd>crowi-plus plugin manager instance</dd>
           </dl>
         </p>
+        <p class="help-block">
+          Examples:
+<pre><code>console.log($('.main-container'));
+
+window.addEventListener('load', (event) => {
+  console.log('config: ', crowi.config);
+});</code></pre>
+        </p>
 
         <div class="form-group">
           <div class="col-xs-12">
-            <textarea id="taCustomScript" class="form-control" type="textarea" name="settingForm[customize:script]" rows="20">{{ settingForm['customize:script'] }}</textarea>
+            <div id="custom-script-editor"></div>
+            <input type="hidden" id="inputCustomScript" name="settingForm[customize:script]" value="{{ settingForm['customize:script'] }}">
           </div>
           <div class="col-xs-12">
             <p class="help-block text-right">
@@ -286,7 +335,7 @@
   </div>
 
   <script>
-    $('#cutomcssSettingForm, #cutomscriptSettingForm, #cutomlayoutSettingForm, #cutombehaviorSettingForm, #customfeaturesSettingForm').each(function() {
+    $('#cutomcssSettingForm, #cutomscriptSettingForm, #cutomlayoutSettingForm, #cutombehaviorSettingForm, #customfeaturesSettingForm, #cutomheaderSettingForm').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {
@@ -336,53 +385,6 @@
 
   </script>
 
-  <!-- CodeMirror -->
-  <script src="https://cdn.jsdelivr.net/g/codemirror@4.5.0(codemirror.min.js+addon/lint/css-lint.js+addon/lint/javascript-lint.js+mode/css/css.js+mode/javascript/javascript.js+addon/hint/css-hint.js+addon/hint/javascript-hint.js+addon/hint/show-hint.js+addon/edit/matchbrackets.js+addon/edit/closebrackets.js),jquery.ui@1.11.4"></script>
-  <script>
-    // Configure for CSS editor
-    var editorCss = CodeMirror.fromTextArea(document.getElementById('taCustomCss'), {
-      mode: "css",
-      lineNumbers: true,
-      tabSize: 2,
-      indentUnit: 2,
-      theme: 'eclipse',
-      matchBrackets: true,
-      autoCloseBrackets: true,
-      extraKeys: {"Ctrl-Space": "autocomplete"},
-    });
-    editorCss.on('change', function(cm, change) {
-      cm.save();
-    });
-    // resizable with jquery.ui
-    $(editorCss.getWrapperElement()).resizable({
-      resize: function() {
-        editorCss.setSize($(this).width(), $(this).height());
-      }
-    });
-
-    // Configure for JavaScript editor
-    var editorScript = CodeMirror.fromTextArea(document.getElementById('taCustomScript'), {
-      mode: "javascript",
-      lineNumbers: true,
-      tabSize: 2,
-      indentUnit: 2,
-      theme: 'eclipse',
-      matchBrackets: true,
-      autoCloseBrackets: true,
-      extraKeys: {"Ctrl-Space": "autocomplete"},
-    });
-    editorScript.on('change', function(cm, change) {
-      cm.save();
-    });
-    // resizable with jquery.ui
-    $(editorScript.getWrapperElement()).resizable({
-      resize: function() {
-        editorScript.setSize($(this).width(), $(this).height());
-      }
-    });
-
-  </script>
-
 </div>
 {% endblock content_main %}
 

+ 131 - 0
lib/views/admin/external-accounts.html

@@ -0,0 +1,131 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}外部アカウント管理 · {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">ユーザー管理/外部アカウント管理</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  {% set wmessage = req.flash('warningMessage') %}
+  {% if wmessage.length %}
+  <div class="alert alert-warning">
+    {{ wmessage }}
+  </div>
+  {% endif %}
+
+  {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set emessage = req.flash('errorMessage') %}
+  {% if emessage.length %}
+  <div class="alert alert-danger">
+    {{ emessage }}
+  </div>
+  {% endif %}
+
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'external-account'} %}
+    </div>
+
+    <div class="col-md-9">
+      <p>
+        <a class="btn btn-default" href="/admin/users">
+          <i class="fa fa-arrow-left" aria-hidden="true"></i>
+          ユーザー管理に戻る
+        </a>
+      </p>
+
+      <h2>外部アカウント一覧</h2>
+
+      <table class="table table-hover table-striped table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="120px">Authentication Provider</th>
+            <th><code>accountId</code></th>
+            <th>関連付けられているユーザーの <code>username</code></th>
+            <th>
+              パスワード設定
+              <a class="text-muted"
+                  data-toggle="popover" data-placement="top"
+                  data-trigger="hover focus" tabindex="0" role="button" {# dismiss settings #}
+                  data-animation="false" data-html="true"
+                  data-content="<small>関連付けられているユーザーがパスワードを設定しているかどうかを表示します</small>">
+                <small>
+                  <i class="fa fa-info-circle" aria-hidden="true"></i>
+                </small>
+              </a>
+            </th>
+            <th width="100px">作成日</th>
+            <th width="90px">操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for account in accounts %}
+          <tr>
+            <td>{{ account.providerType }}</td>
+            <td>
+              <strong>{{ account.accountId }}</strong>
+            </td>
+            <td>
+              <strong>{{ account.user.username }}</strong>
+            </td>
+            <td>
+              {% if account.user.password != null %}
+              <span class="label label-info">
+                設定済み
+              </span>
+              {% else %}
+              <span class="label label-warning">
+                未設定
+              </span>
+              {% endif %}
+            </td>
+            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td>
+              <div class="btn-group admin-user-menu">
+
+                <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+                  編集
+                  <span class="caret"></span>
+                </button>
+                <ul class="dropdown-menu" role="menu">
+                  <li class="dropdown-header">編集メニュー</li>
+                  <li class="dropdown-button">
+                    <form action="/admin/users/external-accounts/{{ account.accountId }}/remove" method="post">
+                      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                      <button type="submit" class="btn btn-block btn-danger">削除する</button>
+                    </form>
+                  </li>
+                </ul>{# end of .dropdown-menu #}
+
+
+
+              </div>{# end of .btn-group #}
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+
+      {% include '../widget/pager.html' with {path: "/admin/users/external-accounts", pager: pager} %}
+
+    </div>
+  </div>
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+

+ 9 - 10
lib/views/admin/index.html

@@ -1,11 +1,11 @@
 {% extends '../layout/admin.html' %}
 
-{% block html_title %}Wiki管理 · {{ path }}{% endblock %}
+{% block html_title %}Wiki管理· {{ path }}{% endblock %}
 
 {% block content_head %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 class="title" id="">Wiki管理</h1>
+    <h1 class="title" id=""> {{ t('admin_top.Management Wiki') }}</h1>
   </header>
 </div>
 {% endblock %}
@@ -25,12 +25,11 @@
       {% include './widget/menu.html' %}
     </div>
     <div class="col-md-9">
-      <p>
-      この画面はWiki管理者のみがアクセスできる画面です。<br>
-      「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。
+      <p> {{ t("admin_top.wiki_administrator") }}<br>
+      {{ t("admin_top.assign_administrator") }}
       </p>
 
-      <h3>システム情報</h3>
+      <h3>{{ t('admin_top.System Information') }}</h3>
       <table class="table table-bordered">
         <tr>
           <th class="col-sm-4">crowi-plus</th>
@@ -50,16 +49,16 @@
         </tr>
       </table>
 
-      <h3>インストールされているプラグイン一覧</h3>
+      <h3>{{ t('admin_top.List of installed plugins') }}</h3>
       <table class="table table-bordered">
         <th class="text-center">
-          パッケージ名
+          {{ t('admin_top.Package name') }}
         </th>
         <th class="text-center">
-          指定バージョン
+          {{ t('admin_top.Specified version') }}
         </th>
         <th class="text-center">
-          インストールされているバージョン
+          {{ t('admin_top.Installed version') }}
         </th>
         {% for pluginName in Object.keys(plugins) %}
         <tr>

+ 129 - 61
lib/views/admin/security.html

@@ -5,13 +5,13 @@
 {% block content_head %}
 <div class="header-wrap">
   <header id="page-header">
-    <h1 class="title" id="">カスタマイズ</h1>
+    <h1 class="title" id="">{{ t('Security settings') }}</h1>
   </header>
 </div>
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
+<div class="content-main admin-security">
   <div class="row">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'security'} %}
@@ -38,16 +38,16 @@
 
       <form action="/_api/admin/security/general" method="post" class="form-horizontal" id="generalSetting" role="form">
         <fieldset>
-        <legend>基本設定</legend>
+        <legend>{{ t('Security settings') }}</legend>
 
           <div class="form-group">
-            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">Basic認証</label>
+            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">{{ t('Basic authentication') }}</label>
             <div class="col-xs-3">
               <label for="">ID</label>
               <input class="form-control" type="text" name="settingForm[security:basicName]"   value="{{ settingForm['security:basicName']|default('') }}">
             </div>
             <div class="col-xs-3">
-              <label for="">パスワード</label>
+              <label for="">{{ t('Password') }}</label>
               <input class="form-control" type="text" name="settingForm[security:basicSecret]" value="{{ settingForm['security:basicSecret']|default('') }}">
             </div>
             <div class="col-xs-offset-3 col-xs-9">
@@ -59,7 +59,7 @@
           </div>
 
           <div class="form-group">
-            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">ゲストユーザーのアクセス</label>
+            <label for="settingForm[security:restrictGuestMode]" class="col-xs-3 control-label">{{ t('Guest users access') }}</label>
             <div class="col-xs-6">
               <select class="form-control" name="settingForm[security:restrictGuestMode]" value="{{ settingForm['security:restrictGuestMode'] }}">
                 {% for modeValue, modeLabel in consts.restrictGuestMode %}
@@ -70,19 +70,19 @@
           </div>
 
           <div class="form-group">
-            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">登録の制限</label>
+            <label for="settingForm[security:registrationMode]" class="col-xs-3 control-label">{{ t('Register limitation') }}</label>
             <div class="col-xs-6">
               <select class="form-control" name="settingForm[security:registrationMode]" value="{{ settingForm['security:registrationMode'] }}">
                 {% for modeValue, modeLabel in consts.registrationMode %}
                 <option value="{{ modeValue }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ modeLabel }}</option>
                 {% endfor %}
               </select>
-              <p class="help-block">ここに入力した内容は、ヘッダー等に表示されます。</p>
+              <p class="help-block">{{ t('The contents entered here will be shown in the header etc') }}</p>
             </div>
           </div>
 
           <div class="form-group">
-            <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">登録許可メールアドレスの<br>ホワイトリスト</label>
+            <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">{{ t('The whitelist of registration permission E-mail address') }}</label>
             <div class="col-xs-8">
               <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="例: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
               <p class="help-block">登録可能なメールアドレスを制限することができます。例えば、会社で使う場合、<code>@crowi.wiki</code> などと記載すると、その会社のメールアドレスを持っている人のみ登録可能になります。<br>
@@ -93,7 +93,7 @@
           <div class="form-group">
             <div class="col-xs-offset-3 col-xs-6">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">更新</button>
+              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
             </div>
           </div>
 
@@ -102,8 +102,8 @@
 
       <form action="/_api/admin/security/mechanism" method="post" class="form-horizontal" id="mechanismSetting" role="form">
         <fieldset>
-        <legend>認証機構設定</legend>
-          <p class="alert alert-info"><b>NOTE: </b>Reboot the server and apply the changes</p>
+          <legend>{{ t('Selecting authentication mechanism') }}</legend>
+          <p class="alert alert-info"><b>NOTE: </b>Restarting the server is needed if you switch the auth mechanism.</p>
           <div class="form-group">
             <div class="col-xs-6">
               <h4>
@@ -126,7 +126,7 @@
               </h4>
               <ul>
                 <li>Username, E-mail and Password authentication</li>
-                <li class="text-muted">(TBD) <del>LDAP authentication</del></li>
+                <li>LDAP authentication</li>
                 <li class="text-muted">(TBD) <del>Google OAuth2 authentication</del></li>
                 <li class="text-muted">(TBD) <del>Facebook OAuth2 authentication</del></li>
                 <li class="text-muted">(TBD) <del>Twitter OAuth authentication</del></li>
@@ -138,65 +138,133 @@
           <div class="form-group">
             <div class="col-xs-offset-5 col-xs-6">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">更新</button>
+              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
             </div>
           </div>
+        </fieldset>
       </form>
 
-      <form action="/_api/admin/security/google" method="post" class="form-horizontal officialCrowiMechanism" id="googleSetting" role="form"
-          {% if true === settingForm['security:isEnabledPassport'] %}style="display: none;"{% endif %}>
-        <fieldset>
-          <h3>Google 設定</h3>
-          <p class="well">
-            Google Cloud Platform の <a href="https://console.cloud.google.com/apis/credentials">API Manager</a>
-            から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。
+
+      <div class="auth-mechanism-configurations">
+
+        <legend>認証機構設定</legend>
+
+        {% set isOfficialConfigurationVisible = !isEnabledPassport() %}
+        <div class="official-crowi-auth-settings" {% if !isOfficialConfigurationVisible %}style="display: none;"{% endif %}>
+          {% set isRestartingServerNeeded = isPassportLocalStrategySetup() %}
+          <p class="alert alert-warning"
+              {% if !isRestartingServerNeeded %}style="display: none;"{% endif %}>
+            <b>
+              <i class="fa fa-exclamation-circle" aria-hidden="true"></i>
+              Restarting the server is needed.
+            </b>
+            The server is running with Passport authentication mechanism.
           </p>
 
-          <ol class="help-block">
-            <li><a href="https://console.cloud.google.com/apis/credentials">API Manager</a> へアクセス</li>
-            <li>プロジェクトを作成していない場合は作成してください</li>
-            <li>「認証情報を作成」-> OAuthクライアントID</li>
-            <ol>
-              <li>「ウェブアプリケーション」を選択</li>
-              <li>承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>
-              (<code>${crowi.host}</code>は環境に合わせて変更してください)</li>
-            </ol>
-          </ol>
+          <form action="/_api/admin/security/google" method="post" class="form-horizontal " id="googleSetting" role="form"
+              {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
 
-          <div class="form-group">
-            <label for="settingForm[google:clientId]" class="col-xs-3 control-label">Client ID</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="text" name="settingForm[google:clientId]" value="{{ settingForm['google:clientId'] }}">
+            <fieldset>
+              <h4>Google 設定</h4>
+              <p class="well">
+                Google Cloud Platform の <a href="https://console.cloud.google.com/apis/credentials">API Manager</a>
+                から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。
+              </p>
+
+              <ol class="help-block">
+                <li><a href="https://console.cloud.google.com/apis/credentials">API Manager</a> へアクセス</li>
+                <li>プロジェクトを作成していない場合は作成してください</li>
+                <li>「認証情報を作成」-> OAuthクライアントID</li>
+                <ol>
+                  <li>「ウェブアプリケーション」を選択</li>
+                  <li>承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>
+                  (<code>${crowi.host}</code>は環境に合わせて変更してください)</li>
+                </ol>
+              </ol>
+
+              <div class="form-group">
+                <label for="settingForm[google:clientId]" class="col-xs-3 control-label">Client ID</label>
+                <div class="col-xs-6">
+                  <input class="form-control" type="text" name="settingForm[google:clientId]" value="{{ settingForm['google:clientId'] }}">
+                </div>
+              </div>
+
+              <div class="form-group">
+                <label for="settingForm[google:clientSecret]" class="col-xs-3 control-label">Client Secret</label>
+                <div class="col-xs-6">
+                  <input class="form-control" type="text" name="settingForm[google:clientSecret]" value="{{ settingForm['google:clientSecret'] }}">
+                </div>
+              </div>
+
+              <div class="form-group">
+                <div class="col-xs-offset-3 col-xs-6">
+                  <input type="hidden" name="_csrf" value="{{ csrf() }}">
+                  <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+                </div>
+              </div>
+
+            </fieldset>
+          </form>
+        </div>
+
+        {#
+         # passport settings nav
+         #}
+        {% set isPassportConfigurationVisible = settingForm['security:isEnabledPassport'] %}
+        <div class="passport-settings" {% if !isPassportConfigurationVisible %}style="display: none;"{% endif %}>
+
+          {% set isRestartingServerNeeded = !isPassportLocalStrategySetup() %}
+          <p class="alert alert-warning"
+              {% if !isRestartingServerNeeded %}style="display: none;"{% endif %}>
+            <b>
+              <i class="fa fa-exclamation-circle" aria-hidden="true"></i>
+              Restarting the server is needed.
+            </b>
+            The server is running with Official Crowi authentication mechanism.
+          </p>
+          <ul class="nav nav-tabs" role="tablist" {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+            <li class="active">
+              <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
+            </li>
+            <li>
+              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google OAuth</a>
+            </li>
+            <li>
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> Facebook</a>
+            </li>
+            <li>
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
+            </li>
+            <li>
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> Github</a>
+            </li>
+          </ul>
+
+          <div class="tab-content" {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+            <div id="passport-ldap" class="tab-pane active" role="tabpanel" >
+              {% include './widget/passport/ldap.html' with { settingForm: settingForm } %}
             </div>
-          </div>
 
-          <div class="form-group">
-            <label for="settingForm[google:clientSecret]" class="col-xs-3 control-label">Client Secret</label>
-            <div class="col-xs-6">
-              <input class="form-control" type="text" name="settingForm[google:clientSecret]" value="{{ settingForm['google:clientSecret'] }}">
+            <div id="passport-google-oauth" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/google-oauth.html' %}
             </div>
-          </div>
 
-          <div class="form-group">
-            <div class="col-xs-offset-3 col-xs-6">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">更新</button>
+            <div id="passport-facebook" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/facebook.html' %}
             </div>
-          </div>
 
-        </fieldset>
-      </form>
+            <div id="passport-twitter" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/twitter.html' %}
+            </div>
 
-      <form action="/_api/admin/security/ldap" method="post" class="form-horizontal passportStrategy" id="ldapSetting" role="form"
-          {% if !settingForm['security:isEnabledPassport'] %}style="display: none;"{% endif %}>
-        <fieldset>
-          <h3>[Passport] LDAP Strategy</h3>
-          <p class="well">
-            (TBD)
-          </p>
+            <div id="passport-github" class="tab-pane" role="tabpanel">
+              {% include './widget/passport/github.html' %}
+            </div>
 
-        </fieldset>
-      </form>
+          </div><!-- /.tab-content -->
+        </div>
+
+      </div><!-- /.auth-mechanism-configurations -->
 
     </div>
   </div>
@@ -255,12 +323,12 @@
       const isEnabledPassport = ($(this).val() === "true");
 
       if (isEnabledPassport) {
-        $('form.officialCrowiMechanism').hide(400);
-        $('form.passportStrategy').show(400);
+        $('.official-crowi-auth-settings').hide(400);
+        $('.passport-settings').show(400);
       }
       else {
-        $('form.officialCrowiMechanism').show(400);
-        $('form.passportStrategy').hide(400);
+        $('.official-crowi-auth-settings').show(400);
+        $('.passport-settings').hide(400);
       }
     });
   </script>

+ 5 - 1
lib/views/admin/users.html

@@ -33,7 +33,11 @@
 
     <div class="col-md-9">
       <p>
-        <button  data-toggle="collapse" class="btn btn-default" href="#inviteUserForm">新規ユーザーの招待</button>
+        <button data-toggle="collapse" class="btn btn-default" href="#inviteUserForm">新規ユーザーの招待</button>
+        <a class="btn btn-default" href="/admin/users/external-accounts">
+          <i class="fa fa-user-plus" aria-hidden="true"></i>
+          外部アカウントの管理
+        </a>
       </p>
       <form role="form" action="/admin/user/invite" method="post">
         <div id="inviteUserForm" class="collapse">

+ 7 - 7
lib/views/admin/widget/menu.html

@@ -2,13 +2,13 @@
   {% set current = 'index' %}
 {% endif  %}
 <ul class="nav nav-pills nav-stacked">
-  <li class="{% if current == 'index'%}active{% endif %}"><a href="/admin"><i class="fa fa-cube"></i> Wiki管理トップ</a></li>
-  <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="fa fa-gears"></i> アプリ設定</a></li>
-  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="fa fa-shield"></i> セキュリティ設定</a></li>
-  <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="fa fa-pencil"></i> Markdown設定</a></li>
-  <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="fa fa-object-group"></i> カスタマイズ</a></li>
-  <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="fa fa-bell"></i> 通知設定</a></li>
-  <li class="{% if current == 'user'%}active{% endif %}"><a href="/admin/users"><i class="fa fa-user"></i> ユーザー管理</a></li>
+  <li class="{% if current == 'index'%}active{% endif %}"><a href="/admin"><i class="fa fa-cube"></i> {{ t('Management Wiki Home') }}</a></li>
+  <li class="{% if current == 'app'%}active{% endif %}"><a href="/admin/app"><i class="fa fa-gears"></i>{{ t('App settings') }}</a></li>
+  <li class="{% if current == 'security'%}active{% endif %}"><a href="/admin/security"><i class="fa fa-shield"></i> {{ t('Security settings') }}</a></li>
+  <li class="{% if current == 'markdown'%}active{% endif %}"><a href="/admin/markdown"><i class="fa fa-pencil"></i> {{ t('Markdown settings') }}</a></li>
+  <li class="{% if current == 'customize'%}active{% endif %}"><a href="/admin/customize"><i class="fa fa-object-group"></i> {{ t('Customize') }}</a></li>
+  <li class="{% if current == 'notification'%}active{% endif %}"><a href="/admin/notification"><i class="fa fa-bell"></i> {{ t('Notification settings') }}</a></li>
+  <li class="{% if current == 'user' || current == 'external-account' %}active{% endif %}"><a href="/admin/users"><i class="fa fa-users"></i> {{ t('User management') }}</a></li>
   <li class="{% if current == 'user-group'%}active{% endif %}"><a href="/admin/user-groups"><i class="fa fa-users"></i> グループ管理</a></li>
   {% if searchConfigured() %}
   <li class="{% if current == 'search'%}active{% endif %}"><a href="/admin/search"><i class="fa fa-search"></i> 検索管理</a></li>

+ 6 - 0
lib/views/admin/widget/passport/facebook.html

@@ -0,0 +1,6 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="facebookOauthSetting" role="form">
+  <fieldset>
+    <legend>Facebook OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>

+ 6 - 0
lib/views/admin/widget/passport/github.html

@@ -0,0 +1,6 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="githubOauthSetting" role="form">
+  <fieldset>
+    <legend>Github OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>

+ 82 - 0
lib/views/admin/widget/passport/google-oauth.html

@@ -0,0 +1,82 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="googleOauthSetting" role="form">
+  <fieldset>
+    <legend>Google OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>
+
+{% if false %}
+<hr>
+<h4>
+  <i class="fa fa-question-circle" aria-hidden="true"></i>
+  <a href="#collapseHelpForApp" data-toggle="collapse">How to configure Slack App?</a>
+</h4>
+
+<ol id="collapseHelpForApp" class="collapse">
+  <li>
+    Register Slack App
+    <ol>
+      <li>
+        Create App from <a href="https://api.slack.com/applications/new">this link</a>, and fill the form out as below:
+        <dl class="dl-horizontal">
+          <dt>App Name</dt> <dd><code>crowi-plus</code> </dd>
+          <dt>Development Slack Team</dt> <dd>Select the team you want to notify to.</dd>
+        </dl>
+      </li>
+      <li><strong>Save</strong> it.</li>
+    </ol>
+  </li>
+  <li>
+    Get App Credentials
+    <ol>
+      <li>Go To "Basic Information" page and make a note "Client ID" and "Client Secret".</li>
+    </ol>
+  </li>
+  <li>
+    Set Redirect URLs
+    <ol>
+      <li>Go to "OAuth &amp; Permissions" page.</li>
+      <li>Add <code><script>document.write(location.origin);</script>/admin/notification/slackAuth</code> .</li>
+      <li>Don't forget to <strong>save</strong>.</li>
+    </ol>
+  </li>
+  <li>
+    Set Permission Scopes to the App
+    <ol>
+      <li>Go to "OAuth &amp; Permissions" page.</li>
+      <li>Add "Send messages as crowi-plus"(<code>chat:write:bot</code>).</li>
+      <li>Don't forget to <strong>save</strong>.</li>
+    </ol>
+  </li>
+  <li>
+    Create a bot user
+    <ol>
+      <li>Go to "Bot Users" page and add.</li>
+    </ol>
+  </li>
+  <li>
+    Install the app
+    <ol>
+      <li>Go to "Install App to Your Team" page and install.</li>
+    </ol>
+  </li>
+  <li>
+    (At Team) Approve the app
+    <ol>
+      <li>Go to the management Apps page for the team you installed the app and approve crowi-plus.</li>
+    </ol>
+  </li>
+  <li>
+    (At Team) Invite the bot to your team
+    <ol>
+      <li>Invite the user you created in <code>4. Add a bot user</code> to the channel you notify to.</li>
+    </ol>
+  </li>
+  <li>
+    (At crowi-plus) Input "clientId" and "clientSecret" and submit on this page.
+  </li>
+  <li>
+    (At crowi-plus) Click "Connect to Slack" button to start OAuth process.
+  </li>
+</ol>
+{% endif %}

+ 311 - 0
lib/views/admin/widget/passport/ldap.html

@@ -0,0 +1,311 @@
+<form action="/_api/admin/security/passport-ldap" method="post" class="form-horizontal" id="ldapSetting" role="form">
+
+  <fieldset>
+    <legend>LDAP Configuration</legend>
+
+    {% set nameForIsLdapEnabled = "settingForm[security:passport-ldap:isEnabled]" %}
+    {% set isLdapEnabled = settingForm['security:passport-ldap:isEnabled'] %}
+    <div class="form-group">
+      <label for="{{nameForIsLdapEnabled}}" class="col-xs-3 control-label">Use LDAP</label>
+      <div class="col-xs-6">
+        <div class="btn-group btn-toggle" data-toggle="buttons">
+          <label class="btn btn-default {% if isLdapEnabled %}active{% endif %}" data-active-class="primary">
+            <input name="{{nameForIsLdapEnabled}}" value="true" type="radio"
+                {% if true === isLdapEnabled %}checked{% endif %}> Enable
+          </label>
+          <label class="btn btn-default {% if !isLdapEnabled %}active{% endif %}" data-active-class="primary">
+            <input name="{{nameForIsLdapEnabled}}" value="false" type="radio"
+                {% if !isLdapEnabled %}checked{% endif %}> Disable
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <div class="passport-ldap-hide-when-disabled" {%if !isLdapEnabled %}style="display: none;"{% endif %}>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:serverUrl]" class="col-xs-3 control-label">Server URL</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text"
+              name="settingForm[security:passport-ldap:serverUrl]" value="{{ settingForm['security:passport-ldap:serverUrl'] || '' }}">
+          <p class="help-block">
+            <small>
+              The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.<br>
+              Example: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
+            </small>
+          </p>
+        </div>
+      </div>
+
+      {% set nameForIsUserBind = "settingForm[security:passport-ldap:isUserBind]" %}
+      {% set isUserBind = settingForm['security:passport-ldap:isUserBind'] %}
+      <div class="form-group">
+        <label for="{{nameForIsUserBind}}" class="col-xs-3 control-label">Binding Mode</label>
+        <div class="col-xs-6">
+          <div class="btn-group btn-toggle" data-toggle="buttons">
+            <label class="btn btn-default {% if !isUserBind %}active{% endif %}" data-active-class="primary">
+              <input name="{{nameForIsUserBind}}" value="false" type="radio"
+                  {% if !isUserBind %}checked{% endif %}> Manager Bind
+            </label>
+            <label class="btn btn-default {% if isUserBind %}active{% endif %}" data-active-class="primary">
+              <input name="{{nameForIsUserBind}}" value="true" type="radio"
+                  {% if isUserBind %}checked{% endif %}> User Bind
+            </label>
+          </div>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:bindDN]" class="col-xs-3 control-label">Bind DN</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text"
+              name="settingForm[security:passport-ldap:bindDN]" value="{{ settingForm['security:passport-ldap:bindDN'] || '' }}">
+          <p class="help-block passport-ldap-managerbind" {% if isUserBind %}style="display: none;"{% endif %}>
+            <small>
+              The DN of the account that authenticates and queries the directory service
+            </small>
+          </p>
+          <p class="help-block passport-ldap-userbind" {% if !isUserBind %}style="display: none;"{% endif %}>
+            <small>
+              The query used to bind with the directory service.<br>
+              Use <code>{% raw %}{{username}}{% endraw %}</code> to reference the username entered in the login page.<br>
+              Example: <code>uid={% raw %}{{username}}{% endraw %},dc=domain,dc=com</code><br>
+            </small>
+          </p>
+          </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:bindDNPassword]" class="col-xs-3 control-label">Bind DN Password</label>
+        <div class="col-xs-6">
+          <input class="form-control passport-ldap-managerbind" type="text" {% if isUserBind %}style="display: none;"{% endif %}
+              name="settingForm[security:passport-ldap:bindDNPassword]" value="{{ settingForm['security:passport-ldap:bindDNPassword'] || '' }}">
+          <p class="help-block passport-ldap-managerbind">
+            <small>
+              The password for the Bind DN account.
+            </small>
+          </p>
+          <p class="help-block passport-ldap-userbind" {% if !isUserBind %}style="display: none;"{% endif %}>
+            <small>
+              The password that is entered in the login page will be used to bind.
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:searchFilter]" class="col-xs-3 control-label">Search Filter</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text" placeholder="Default: (uid={% raw %}{{username}}{% endraw %})"
+              name="settingForm[security:passport-ldap:searchFilter]" value="{{ settingForm['security:passport-ldap:searchFilter'] || '' }}">
+          <p class="help-block">
+            <small>
+              The query used to locate the authenticated user.<br>
+              Use <code>{% raw %}{{username}}{% endraw %}</code> to reference the username entered in the login page.<br>
+              If empty, the filter <code>(uid={% raw %}{{username}}{% endraw %})</code> is used.<br>
+              <br>
+              Example to match with 'uid' or 'mail': <code>(|(uid={% raw %}{{username}}{% endraw %})(mail={% raw %}{{username}}{% endraw %}))</code>
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <h4>Attribute Mapping (Optional)</h4>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:attrMapUsername]" class="col-xs-3 control-label">username</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text" placeholder="Default: uid"
+              name="settingForm[security:passport-ldap:attrMapUsername]" value="{{ settingForm['security:passport-ldap:attrMapUsername'] || '' }}">
+          <p class="help-block">
+            <small>
+              Specification of mappings when creating new users
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <h4>Group Search Filter (Optional)</h4>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:groupSearchBase]" class="col-xs-3 control-label">Group Search Base DN</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text"
+              name="settingForm[security:passport-ldap:groupSearchBase]" value="{{ settingForm['security:passport-ldap:groupSearchBase'] || '' }}">
+          <p class="help-block">
+            <small>
+              The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.<br>
+              Example: <code>ou=groups,dc=domain,dc=com</code><br>
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:groupSearchFilter]" class="col-xs-3 control-label">Group Search Filter</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text"
+              name="settingForm[security:passport-ldap:groupSearchFilter]" value="{{ settingForm['security:passport-ldap:groupSearchFilter'] || '' }}">
+          <p class="help-block">
+            <small>
+              The query used to filter for groups.<br>
+              Use <code>{% raw %}{{dn}}{% endraw %}</code> to have it replaced of the found user object.<br>
+              <br>
+              Example: <code>(&(cn=group1)(memberUid={% raw %}{{dn}}{% endraw %}))</code> hits the groups
+              which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>
+              (when <code>Group DN Property</code> is not changed from the default value.)
+            </small>
+          </p>
+        </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:groupSearchFilter]" class="col-xs-3 control-label">Group DN Property</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text" placeholder="Default: uid"
+              name="settingForm[security:passport-ldap:groupDnProperty]" value="{{ settingForm['security:passport-ldap:groupDnProperty'] || '' }}">
+          <p class="help-block">
+            <small>
+              The property of user object to use in <code>{% raw %}{{dn}}{% endraw %}</code> interpolation of <code>Group Search Filter</code>.
+            </small>
+          </p>
+        </div>
+      </div>
+
+    </div><!-- /.passport-ldap-configurations -->
+
+    <div class="form-group">
+      <div class="col-xs-offset-3 col-xs-6">
+        <button type="submit" class="btn btn-primary">{# the first element is the default button to submit #}
+          {{ t('Update') }}
+        </button>
+        <button type="button"
+            class="btn btn-default passport-ldap-hide-when-disabled"
+            data-target="#test-ldap-account" data-toggle="modal"
+            {%if !isLdapEnabled %}style="display: none;"{% endif %}>
+
+          Test Saved Configuration
+        </button>
+      </div>
+    </div>
+  </fieldset>
+  <input type="hidden" name="_csrf" value="{{ csrf() }}">
+
+  <script>
+    // switch display according to on / off of radio buttons
+    $('input[name="{{nameForIsLdapEnabled}}"]:radio').change(function() {
+      const isEnabled = ($(this).val() === "true");
+
+      if (isEnabled) {
+        $('.passport-ldap-hide-when-disabled').show(400);
+      }
+      else {
+        $('.passport-ldap-hide-when-disabled').hide(400);
+      }
+    });
+
+    // switch display according to on / off of radio buttons
+    $('input[name="{{nameForIsUserBind}}"]:radio').change(function() {
+      const isUserBind = ($(this).val() === "true");
+
+      if (isUserBind) {
+        $('input.passport-ldap-managerbind').hide();
+        $('.help-block.passport-ldap-managerbind').hide();
+        $('.help-block.passport-ldap-userbind').show();
+      }
+      else {
+        $('input.passport-ldap-managerbind').show();
+        $('.help-block.passport-ldap-managerbind').show();
+        $('.help-block.passport-ldap-userbind').hide();
+      }
+    });
+
+    // store which button is clicked when submit
+    var submittedButton;
+    $('button[type="submit"]').click(function() {
+      submittedButton = $(this);
+    });
+    $('#ldapSetting, #ldapTest').each(function() {
+      $(this).submit(function()
+      {
+        function showMessage(formId, msg, status) {
+          $('#' + formId + ' .alert').remove();
+
+          if (!status) {
+            status = 'success';
+          }
+          var $message = $('<p class="alert"></p>');
+          $message.addClass('alert-' + status);
+          $message.html(msg.replace('\n', '<br>'));
+          $message.insertAfter('#' + formId + ' legend');
+
+          if (status == 'success') {
+            setTimeout(function()
+            {
+              $message.fadeOut({
+                complete: function() {
+                  $message.remove();
+                }
+              });
+            }, 5000);
+          }
+        }
+
+        var $form = $(this);
+        var $id = $form.attr('id');
+        var $button = submittedButton;
+        var $action = $button.attr('formaction') || $form.attr('action');
+        $button.attr('disabled', 'disabled');
+        var jqxhr = $.post($action, $form.serialize(), function(data)
+        {
+          if (data.status) {
+            const message = data.message || '更新しました';
+            showMessage($id, message);
+          } else {
+            showMessage($id, data.message, 'danger');
+          }
+        })
+        .fail(function() {
+          showMessage($id, 'エラーが発生しました', 'danger');
+        })
+        .always(function() {
+          $button.prop('disabled', false);
+        });
+        return false;
+      });
+    });
+    </script>
+
+</form>
+
+<div class="modal test-ldap-account" id="test-ldap-account">
+  <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">{{ t('Test LDAP Account') }}</h4>
+      </div>
+
+      <div class="modal-body">
+
+        {% include '../../../widget/passport/ldap-association-tester.html' %}
+
+      </div><!-- /.modal-body -->
+
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+
+  <script>
+    /**
+     * associate (submit the form)
+     */
+    function associateLdap() {
+      var $form = $('#formLdapAssociationContainer > form');
+      var $action = '/me/external-accounts/associateLdap';
+      $form.attr('action', $action);
+      $form.submit();
+    }
+  </script>
+
+</div><!-- /.modal -->

+ 6 - 0
lib/views/admin/widget/passport/twitter.html

@@ -0,0 +1,6 @@
+<form action="" method="post" class="form-horizontal passportStrategy" id="twitterOauthSetting" role="form">
+  <fieldset>
+    <legend>Twitter OAuth Configuration</legend>
+    <p class="well">(TBD)</p>
+  </fieldset>
+</form>

+ 1 - 8
lib/views/crowi-plus/base/not_found_nosidebar.html

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

+ 1 - 8
lib/views/crowi-plus/base/page_list_nosidebar.html

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

+ 1 - 8
lib/views/crowi-plus/base/page_nosidebar.html

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

+ 4 - 9
lib/views/crowi-plus/base/user_page_nosidebar.html

@@ -46,7 +46,9 @@
 
         {# relocate #revision-toc #}
         <div class="col-lg-2 col-md-3 visible-lg visible-md">
-          <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="50"></div>
+          <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="54">
+            <div id="revision-toc-content" class="revision-toc-content"></div>
+          </div>
         </div> {# /.col- #}
 
       </div>
@@ -66,12 +68,5 @@
 
 {% block footer %}
   {% parent %}
-  <div class="system-version">
-    <span>
-      <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
-    </span>
-    <span>
-      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
-    </span>
-  </div>
+  {% include '../widget/system-version.html' %}
 {% endblock %}

+ 3 - 1
lib/views/crowi-plus/page.html

@@ -34,7 +34,9 @@
 
       {# relocate #revision-toc #}
       <div class="col-lg-2 col-md-3 visible-lg visible-md">
-        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100"></div>
+        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="128">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div> {# /.col- #}
 
     </div>

+ 6 - 1
lib/views/crowi-plus/page_list.html

@@ -29,13 +29,18 @@
         {# force remove #revision-toc from #content_main of parent #}
         <script>
           $('#revision-toc').remove();
+
+          // hide unportalize button
+          $('.portal > .nav > .dropdown').remove();
         </script>
 
       </div> {# /.col- #}
 
       {# relocate #revision-toc #}
       <div class="col-lg-2 col-md-3 visible-lg visible-md">
-        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100"></div>
+        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div> {# /.col- #}
 
     </div>

+ 1 - 1
lib/views/crowi-plus/widget/comments.html

@@ -27,7 +27,7 @@
         <div class="comment-form-main">
           <div class="comment-write" id="comment-write">
             <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"
-                rows="10" placeholder="Write comments here..." {% if not user %}disabled{% endif %}></textarea>
+                required placeholder="Write comments here..." {% if not user %}disabled{% endif %}></textarea>
           </div>
           <div class="comment-submit">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">

+ 1 - 1
lib/views/crowi-plus/widget/page_list_container.html

@@ -25,7 +25,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
         <h3 class="revision-path"><a href="{{ page.path }}">{{ page.path }}</a></h3>
         <div class="revision-body wiki"></div>
         <script type="text/template">{{ page.revision.body }}</script>

+ 24 - 0
lib/views/crowi-plus/widget/system-version.html

@@ -0,0 +1,24 @@
+<div class="system-version">
+  <span>
+    <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
+  </span>
+  <span>
+    <a href="" data-target="#shortcuts-modal" data-toggle="modal"><i class="fa fa-keyboard-o"></i>&nbsp;<span class="cmd-key"></span>-/</a>
+  </span>
+</div>
+<script>
+  /*
+  * add classes to cmd-key by OS
+  */
+  var platform = navigator.platform.toLowerCase();
+  var isMac = (platform.indexOf('mac') > -1);
+
+  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win');
+    }
+  });
+</script>

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

@@ -19,8 +19,8 @@
   <div id="footer-container" class="footer">
     <footer class="">
       <p>
-        <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
         <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
+        <a href="" class="pull-right" data-target="#shortcuts-modal" data-toggle="modal"><i class="fa fa-keyboard-o"></i>&nbsp;<span class="cmd-key"></span>-/</a>
       </p>
     </footer>
   </div>

+ 41 - 4
lib/views/layout/layout.html

@@ -22,6 +22,8 @@
   <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96">
   <link rel="icon" type="image/png" href="/android-chrome-192x192.png" sizes="192x192">
 
+  {{ customHeader() }}
+
   <!-- polyfills for IE11 -->
   <script>
     var userAgent = window.navigator.userAgent.toLowerCase();
@@ -33,8 +35,39 @@
     }
   </script>
 
-  <!-- jQuery -->
-  <script src="https://cdn.jsdelivr.net/jquery/3.2.1/jquery.min.js"></script>
+  <!-- jQuery, emojione -->
+  <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+  <!-- highlight.js -->
+  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
+  <script src="https://cdn.jsdelivr.net/combine/
+gh/highlightjs/cdn-release@9.12.0/build/languages/dockerfile.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/go.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/gradle.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/json.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/less.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/scss.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/typescript.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
+" defer></script>
+
+  {% if local_config.env.MATHJAX %}
+    <!-- Mathjax -->
+    <script type="text/x-mathjax-config" async>
+      MathJax.Hub.Config({
+        skipStartupTypeset: true,
+        extensions: ["tex2jax.js"],
+        jax: ["input/TeX", "output/SVG"],
+        tex2jax: {
+          processEscapes: true
+        },
+        showMathMenu: false,
+        showMathMenuMSIE: false,
+        showProcessingMessages: false,
+        messageStyle: "none"
+      });
+    </script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js" async></script>
+  {% endif %}
 
   {% if env === 'development' %}
     <script src="/dll/vendor.dll.js"></script>
@@ -54,7 +87,11 @@
   <!-- Google Fonts -->
   <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
   <!-- Font Awesome -->
-  <link href='https://cdn.jsdelivr.net/fontawesome/4.7.0/css/font-awesome.min.css' rel='stylesheet' type='text/css'>
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
+  <!-- emojione -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css">
+  <!-- highlight.js -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css">
 
   {% block html_additional_headers %}{% endblock %}
 
@@ -176,7 +213,7 @@
 {% block body_end %}
 {% endblock %}
 
-{% include '../modal/help.html' %}
+{% include '../modal/shortcuts.html' %}
 </body>
 {% endblock %}
 

+ 17 - 1
lib/views/layout/single.html

@@ -23,7 +23,23 @@
     <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
   </span>
   <span>
-    <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
+    <a href="" data-target="#shortcuts-modal" data-toggle="modal"><i class="fa fa-keyboard-o"></i>&nbsp;<span class="cmd-key"></span>-/</a>
   </span>
 </div>
+<script>
+  /*
+  * add classes to cmd-key by OS
+  */
+  var platform = navigator.platform.toLowerCase();
+  var isMac = (platform.indexOf('mac') > -1);
+
+  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win', 'key-longer');
+    }
+  })
+</script>
 {% endblock %}

+ 44 - 3
lib/views/login.html

@@ -21,6 +21,29 @@
     <h2>{{ t('Sign in') }}</h2>
 
     <div id="login-form-errors">
+      {% if isLdapSetupFailed() %}
+      <div class="alert alert-warning">
+        LDAP is enabled but the configuration has something wrong.<br>
+        <small>(set the environment variables <code>DEBUG=crowi:service:PassportService</code> and get the logs)</small>
+      </div>
+      {% endif %}
+
+      {#
+       # The case that there already exists a user whose username matches ID of the newly created LDAP user
+       # https://github.com/weseek/crowi-plus/issues/193
+       #}
+      {% set isDuplicatedUsernameExceptionOccured = req.flash('isDuplicatedUsernameExceptionOccured') %}
+      {% if isDuplicatedUsernameExceptionOccured != null %}
+      <div class="alert alert-warning">
+        <i class="fa fa-fw fa-info-circle"></i>
+        <strong>DuplicatedUsernameException occured</strong>
+        <p>
+          Your LDAP authentication was succeess, but a new user could not be created.
+          See the issue <a href="https://github.com/weseek/crowi-plus/issues/193">#193</a>.
+        </p>
+      </div>
+      {% endif %}
+
       {% set success = req.flash('successMessage') %}
       {% if success.length %}
       <div class="alert alert-success">
@@ -30,9 +53,20 @@
 
       {% set warn = req.flash('warningMessage') %}
       {% if warn.length %}
+      {% for w in warn %}
+      <div class="alert alert-warning">
+        {{ w }}
+      </div>
+      {% endfor %}
+      {% endif %}
+
+      {% set error = req.flash('errorMessage') %}
+      {% if error.length %}
+      {% for e in error %}
       <div class="alert alert-danger">
-        {{ warn }}
+        {{ e }}
       </div>
+      {% endfor %}
       {% endif %}
 
       {% if req.form.errors.length > 0 %}
@@ -47,12 +81,19 @@
     </div>
     <form role="form" action="/login" method="post">
       <div class="input-group">
-        <span class="input-group-addon"><i class="fa fa-user"></i></span>
+        <span class="input-group-addon"><i class="fa fa-fw fa-user"></i></span>
         <input type="text" class="form-control" placeholder="Username or E-mail" name="loginForm[username]">
+        {% if isLdapSetup() %}
+        <span class="input-group-addon">
+          <small class="text-primary">
+            <i class="fa fa-fw fa-check-circle"></i> LDAP
+          </small>
+        </span>
+        {% endif %}
       </div>
 
       <div class="input-group">
-        <span class="input-group-addon"><i class="fa fa-key"></i></span>
+        <span class="input-group-addon"><i class="fa fa-fw fa-key"></i></span>
         <input type="password" class="form-control" placeholder="Password" name="loginForm[password]">
       </div>
 

+ 1 - 0
lib/views/me/api_token.html

@@ -16,6 +16,7 @@
 
   <ul class="nav nav-tabs">
     <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
     <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
     <li class="active"><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>

+ 251 - 0
lib/views/me/external-accounts.html

@@ -0,0 +1,251 @@
+{% extends '../layout/2column.html' %}
+
+{% block html_title %}{{ t('Password Settings') }} · {{ path }}{% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">{{ t('User Settings') }}</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+
+  <ul class="nav nav-tabs">
+    <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li class="active"><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
+    <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
+    <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
+  </ul>
+
+  <div class="tab-content">
+
+  {% set couldntDisassociateError = req.flash('couldntDisassociateError') %}
+  {% if couldntDisassociateError != null %}
+  <div class="alert alert-danger">
+    <b>Couldn't disassociate External Account</b><br>
+    You have not set a password and have only one External Account.
+  </div>
+  {% endif %}
+
+  {% set error = req.flash('errorMessage') %}
+  {% if error.length %}
+  {% for e in error %}
+  <div class="alert alert-danger">
+    <b>Server Error occured:</b><br>
+    {{ e }}
+  </div>
+  {% endfor %}
+  {% endif %}
+
+  {% set warn = req.flash('warningMessage') %}
+  {% if warn.length %}
+  {% for w in warn %}
+  <div class="alert alert-warning">
+    {{ w }}
+  </div>
+  {% endfor %}
+  {% endif %}
+
+  {% set message = req.flash('successMessage') %}
+  {% if message.length %}
+  <div class="alert alert-success">
+    <b>{{ message }}</b>
+  </div>
+  {% endif %}
+
+
+
+  <legend style="line-height: 1.7em;">
+    <button class="btn btn-default btn-sm pull-right" data-target="#create-external-account" data-toggle="modal">
+      <i class="fa fa-plus-circle" aria-hidden="true"></i>
+      Add
+    </button>
+    {{ t('External Accounts') }}
+  </legend>
+
+  <div class="row">
+    <div class="col-md-12">
+      <table class="table table-hover table-striped table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th width="120px">Authentication Provider</th>
+            <th>
+              <code>accountId</code>
+            </th>
+            <th width="200px">{{ t('Created') }}</th>
+            <th width="150px">{{ t('Admin') }}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for account in externalAccounts %}
+          <tr>
+            <td>{{ account.providerType }}</td>
+            <td>
+              <strong>{{ account.accountId }}</strong>
+            </td>
+            <td>{{ account.createdAt|date('Y-m-d', account.createdAt.getTimezoneOffset()) }}</td>
+            <td class="text-center">
+              <button class="btn btn-default btn-sm btn-danger"
+                  data-toggle="modal" data-target="#diassociate-external-account" data-provider-type="{{ account.providerType }}" data-account-id="{{ account.accountId }}">
+                <i class="fa fa-unlink"></i>
+                {{ t('Diassociate') }}
+              </button>
+            </td>
+          </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+  </div>
+
+  {# modal #}
+  <style>
+    .modal.create-external-account .modal-dialog {
+      width: 750px;
+    }
+  </style>
+  <div class="modal create-external-account" id="create-external-account">
+    <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">{{ t('Create External Account') }}</h4>
+        </div>
+
+        <div class="modal-body">
+
+          <ul class="nav nav-tabs passport-settings" role="tablist">
+            <li class="active">
+              <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
+            </li>
+            <li>
+              <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google OAuth</a>
+            </li>
+            <li>
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> Facebook</a>
+            </li>
+            <li>
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
+            </li>
+            <li>
+              <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> Github</a>
+            </li>
+          </ul>
+
+          <div class="tab-content passport-settings">
+            <div id="passport-ldap" class="tab-pane active" role="tabpanel" >
+              <div id="formLdapAssociationContainer">
+                {% include '../widget/passport/ldap-association-tester.html' %}
+                <div class="clearfix">
+                  <button type="button" class="btn btn-primary pull-right" onclick="associateLdap()">
+                    <i class="fa fa-plus-circle" aria-hidden="true"></i>
+                    {{ t('Add') }}
+                  </button>
+                </div>
+              </div>
+            </div>
+
+            <div id="passport-google-oauth" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+            <div id="passport-facebook" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+            <div id="passport-twitter" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+            <div id="passport-github" class="tab-pane" role="tabpanel">
+              (TBD)
+            </div>
+
+          </div><!-- /.tab-content -->
+
+        </div><!-- /.modal-body -->
+
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+
+    <script>
+      /**
+       * associate (submit the form)
+       */
+      function associateLdap() {
+        var $form = $('#formLdapAssociationContainer > form');
+        var $action = '/me/external-accounts/associateLdap';
+        $form.attr('action', $action);
+        $form.submit();
+      }
+    </script>
+
+  </div><!-- /.modal -->
+
+  <div class="modal diassociate-external-account" id="diassociate-external-account">
+    <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">{{ t('Diassociate External Account') }}</h4>
+        </div>
+
+        <div class="modal-body">
+          <div class="row">
+            <div class="col-md-12">
+              <p><b>
+                Are you sure to diassociate the
+                <span class="diassociate-provider-type"></span> account
+                <code class="diassociate-account-id"></code>?
+              </b></p>
+            </div>
+          </div>
+        </div>
+
+        <div class="modal-footer">
+          <form action="/me/external-accounts/disassociate" method="post">
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <input type="hidden" name="providerType">
+            <input type="hidden" name="accountId">
+            <button type="button" class="btn btn-sm btn-default" data-dismiss="modal">
+              {{ t('Cancel') }}
+            </button>
+            <button type="submit" class="btn btn-sm btn-danger">
+              <i class="fa fa-unlink"></i>
+              {{ t('Diassociate') }}
+            </button>
+          </form>
+        </div>
+      </div><!-- /.modal-content -->
+    </div><!-- /.modal-dialog -->
+
+    <script>
+      $('#diassociate-external-account').on('show.bs.modal', function (event) {
+        var modal = $(this);
+        var button = $(event.relatedTarget); // Button that triggered the modal
+        // get data-*
+        var providerType = button.data('provider-type');
+        var accountId = button.data('account-id');
+        // set labels
+        modal.find('.diassociate-provider-type').text(providerType);
+        modal.find('.diassociate-account-id').text(accountId);
+        // set hidden inputs
+        modal.find('input:hidden[name="providerType"]').val(providerType);
+        modal.find('input:hidden[name="accountId"]').val(accountId);
+      })
+    </script>
+  </div><!-- /.modal -->
+
+</div>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock %}
+
+{% block footer %}
+{% endblock %}

+ 3 - 2
lib/views/me/index.html

@@ -15,6 +15,7 @@
 
   <ul class="nav nav-tabs">
     <li class="active"><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
     <li><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
     <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>
@@ -56,10 +57,10 @@
           <input class="form-control" type="text" name="userForm[name]" value="{{ user.name }}" required>
         </div>
       </div>
-      <div class="form-group {% if not user.email %}has-error{% endif %}">
+      <div class="form-group">
         <label for="userForm[email]" class="col-sm-2 control-label">{{ t('Email') }}</label>
         <div class="col-sm-4">
-          <input class="form-control" type="email" name="userForm[email]" value="{{ user.email }}" required>
+          <input class="form-control" type="email" name="userForm[email]" value="{{ user.email }}">
         </div>
         <div class="col-sm-offset-2 col-sm-10">
           {% if config.crowi['security:registrationWhiteList'] && config.crowi['security:registrationWhiteList'].length %}

+ 3 - 2
lib/views/me/password.html

@@ -15,6 +15,7 @@
 
   <ul class="nav nav-tabs">
     <li><a href="/me"><i class="fa fa-gears"></i> {{ t('User Information') }}</a></li>
+    <li><a href="/me/external-accounts"><i class="fa fa-user-plus"></i> {{ t('External Accounts') }}</a></li>
     <li class="active"><a href="/me/password"><i class="fa fa-key"></i> {{ t('Password Settings') }}</a></li>
     <li><a href="/me/apiToken"><i class="fa fa-rocket"></i> {{ t('API Settings') }}</a></li>
   </ul>
@@ -22,8 +23,8 @@
   <div class="tab-content">
 
   {% if not user.password %}
-  <div class="alert alert-danger">
-    {{ t('Please set a password') }}
+  <div class="alert alert-warning">
+    {{ t('Password is not set') }}
   </div>
   {% endif %}
 

+ 7 - 3
lib/views/modal/create_page.html

@@ -16,7 +16,7 @@
             </div>
             <div class="col-xs-10">
               <span class="page-today-prefix">{{ userPageRoot(user) }}/</span>
-              <input type="text" data-prefix="{{ userPageRoot(user) }}/" class="page-today-input1 form-control" value="{{ t('Memo') }}" id="" name="">
+              <input type="text" data-prefix="{{ userPageRoot(user) }}/" class="page-today-input1 form-control text-center" value="{{ t('Memo') }}" id="" name="">
               <span class="page-today-suffix">/{{ now|datetz('Y/m/d') }}/</span>
               <input type="text" data-prefix="/{{ now|datetz('Y/m/d') }}/" class="page-today-input2 form-control" id="page-today-input2" name="" placeholder="{{ t('Input page name (optional)') }}">
             </div>
@@ -29,11 +29,15 @@
 
         <form class="form-horizontal" id="create-page-under-tree" role="form">
           <fieldset>
-            <div class="col-xs-12">
+            <div class="col-xs-12 create-page-under-tree-label">
               <h4>{{ t('Create under', parentPath(path)) }}</h4>
             </div>
             <div class="col-xs-10">
-              <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required>
+              {% if searchConfigured() %}
+              <div class="clearfix" id="page-name-inputter"></div>
+              {% else %}
+              <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required />
+              {% endif %}
             </div>
             <div class="col-xs-2">
               <button type="submit" class="btn btn-primary">{{ t('Create') }}</button>

+ 0 - 113
lib/views/modal/help.html

@@ -1,113 +0,0 @@
-<div class="modal" id="help-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">{{ t('Help') }}</h4>
-      </div>
-      <div class="modal-body">
-        <h4>{{ t('modal_help.basic.title') }}</h4>
-        <br>
-        <ul>
-          <li>{{ t('modal_help.basic.body1') }}</li>
-          <li>{{ t('modal_help.basic.body2') }}</li>
-          <li>{{ t('modal_help.basic.body3') }}</li>
-        </ul>
-        <br>
-
-        <h4>{{ t('modal_help.tips.title') }}</h4>
-        <br>
-        <p>
-        {{ t('modal_help.tips.body1') }}
-        </p>
-        <br>
-
-        <h4>{{ t('modal_help.markdown.title') }}</h4>
-        <br>
-        <div class="wiki">
-        <pre># Section</pre>
-        <h1>Section</h1>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>## Sub section</pre>
-        <h2>Sub Section</h2>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>### Sub sub section</pre>
-        <h3>Sub Sub Section</h3>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>- このようにハイフンと半角スペースを先頭に書くと、
-- 箇条書きのリストにになります
-    - タブキーを押すと半角スペース4つが挿入され、インデントされます
-    - インデントはリストにも反映されます</pre>
-          <ul>
-            <li>リスト記法はこのように</li>
-            <li>箇条書きになります
-            <ul>
-              <li>タブキーを押すと半角スペース4つが挿入され、インデントされます</li>
-              <li>インデントはリストにも反映されます</li>
-            </ul>
-            </li>
-          </ul>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>1. 番号付きリストも作れます
-2. "数字" "ドット" "半角スペース" の後に項目を記載しましょう
-2. "1." "2." "2." などと数字がズレても、正しい数字で整形されます</pre>
-          <ol>
-            <li>番号付きリストも作れます</li>
-            <li>"数字" "ドット" "半角スペース" の後に項目を記載しましょう</li>
-            <li>"1." "2." "2." などと数字がズレても、正しい数字で整形されます</li>
-          </ol>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>**アスタリスク2つで囲った箇所は** 太字になります</pre>
-        <p><strong>アスタリスク2つで囲った箇所は</strong> 太字になります</p>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>Wiki内リンクは &lt;/とある/ページへの/リンク&gt; のように、 &lt; と &gt; で囲います</pre>
-        <p>Wiki内リンクは <a href="/とある/ページへの/リンク">/とある/ページへの/リンク</a> のように、 &lt; と &gt; で囲います</p>
-        </div>
-        <hr>
-
-        <h4>プレゼンモード</h4>
-        <br>
-        <p>
-        文章をきちんと構造的に作成していれば、自然とプレゼンモードが適用できます。ページの <code><i class="fa"></i></code> アイコンをクリックし、プレゼンモードを選択してください。
-        </p>
-        <p>
-        プレゼンモードでは、<strong>改行2つ</strong> をページ区切りとして利用します。プレゼンモードでページを区切りたい場合、2つの改行を挿入してください。
-        </p>
-
-        <p>例:</p>
-        <div class="wiki">
-          <pre># 改行したいプレゼンの説明
-
-何かしらの説明
-
-
-## 次の章
-
-ここでは、「次の章」の前に2つの改行があるため、「次の章」以降が1つのプレゼンページになります。 </pre>
-        </div>
-        <br>
-
-      </div>
-
-    </div><!-- /.modal-content -->
-  </div><!-- /.modal-dialog -->
-</div><!-- /.modal -->

+ 79 - 0
lib/views/modal/shortcuts.html

@@ -0,0 +1,79 @@
+<div class="modal" id="shortcuts-modal" tabindex="-1">
+  <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">{{ t('Shortcuts') }}</h4>
+      </div>
+
+      <div class="modal-body">
+
+        <div class="row">
+          <div class="col-sm-6">
+            <h3><strong>{{ t('modal_shortcuts.global.title') }}</strong></h3>
+
+            <table class="table">
+              <tr>
+                <th>{{ t('modal_shortcuts.global.Open/Close shortcut help') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key">/</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.global.Create Page') }}:</th>
+                <td><span class="key">C</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.global.Edit Page') }}:</th>
+                <td><span class="key">E</span></td>
+              </tr>
+            </table>
+          </div><!-- /.col-sm-6 -->
+
+          <div class="col-sm-6">
+            <h3><strong>{{ t('modal_shortcuts.editor.title') }}</strong></h3>
+
+            <table class="table">
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Indent') }}:</th>
+                <td><span class="key key-longer">Tab</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Outdent') }}:</th>
+                <td><span class="key key-long">Shift</span> + <span class="key key-longer">Tab</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Save Page') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key">S</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Delete Line') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key">D</span></td>
+              </tr>
+            </table>
+          </div><!-- /.col-sm-6 -->
+
+        </div>
+
+      </div>
+
+
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+
+  <script>
+    /*
+     * add classes to cmd-key by OS
+     */
+    var platform = navigator.platform.toLowerCase();
+    var isMac = (platform.indexOf('mac') > -1);
+
+    document.querySelectorAll('#shortcuts-modal .cmd-key').forEach((element) => {
+      if (isMac) {
+        element.classList.add('mac');
+      }
+      else {
+        element.classList.add('win', 'key-longer');
+      }
+    });
+  </script>
+</div><!-- /.modal -->

+ 0 - 5
lib/views/not_found.html

@@ -36,12 +36,7 @@
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path }}"
   data-path-shortname="{{ path|path2name }}"
-  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
-  data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
-  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
   >
 
   <ul class="nav nav-tabs hidden-print">

+ 3 - 4
lib/views/page.html

@@ -57,7 +57,6 @@
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
   >
 
   {% if not page %}
@@ -164,7 +163,7 @@
   </div>
   {% endif %}
   {% if req.query.unlinked %}
-  <div class="alert alert-info">
+  <div class="alert alert-info alert-unlinked">
     <strong>{{ t('Unlinked') }}: </strong> {{ t('page_page.notice.unlinked') }}
   </div>
   {% endif %}
@@ -190,9 +189,9 @@
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
       <div class="revision-toc" id="revision-toc">
         <a data-toggle="collapse" data-parent="#revision-toc" href="#revision-toc-content" class="revision-toc-head">{{ t('Table of Contents') }}</a>
-
+        <div id="revision-toc-content" class="revision-toc-content collapse in"></div>
       </div>
-      <div class="wiki" id="revision-body-content"></div>
+      <div id="page"></div>
     </div>
 
     {# edit form #}

+ 2 - 5
lib/views/page_list.html

@@ -80,7 +80,6 @@
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
   >
 
 <div class="portal {% if not page or req.query.offset > 0 %}hide{% endif %}">
@@ -129,9 +128,7 @@
   </div>
   {% endif %}
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
-      <div class="wiki" id="revision-body-content">
-        <i class="fa fa-spinner fa-pulse fa-fw"></i>
-      </div>
+      <div id="page"></div>
     </div>
 
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
@@ -215,7 +212,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
         <h3 class="revision-path"><a href="{{ page.path }}">{{ page.path }}</a></h3>
         <div class="revision-body wiki"></div>
         <script type="text/template">{{ page.revision.body }}</script>

+ 18 - 2
lib/views/page_presentation.html

@@ -6,6 +6,8 @@
     <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
 
+    {{ customHeader() }}
+
     <!-- polyfills for IE11 -->
     <script>
       var userAgent = window.navigator.userAgent.toLowerCase();
@@ -17,8 +19,20 @@
       }
     </script>
 
-    <!-- jQuery -->
-    <script src="//cdn.jsdelivr.net/jquery/3.2.1/jquery.min.js"></script>
+    <!-- jQuery, emojione (expect to hit the cache) -->
+    <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+    <!-- highlight.js -->
+    <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/combine/
+gh/highlightjs/cdn-release@9.12.0/build/languages/dockerfile.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/go.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/gradle.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/json.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/less.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/scss.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/typescript.min.js,
+gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
+" defer></script>
 
     {% if env === 'development' %}
       <script src="/dll/vendor.dll.js"></script>
@@ -34,6 +48,8 @@
 
     <!-- Google Fonts -->
     <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
+    <!-- highlight.js -->
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css">
 
     <style>
       {{ customCss() }}

+ 72 - 0
lib/views/widget/passport/ldap-association-tester.html

@@ -0,0 +1,72 @@
+<form id="formTestLdapCredentials" method="post" class="form-horizontal" role="form">
+  <div class="alert-container"></div>
+  <fieldset>
+    <div class="form-group">
+      <label for="username" class="col-xs-3 control-label">{{ t('Username') }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" name="loginForm[username]">
+      </div>
+    </div>
+    <div class="form-group">
+      <label for="password" class="col-xs-3 control-label">{{ t('Password') }}</label>
+      <div class="col-xs-6">
+        <input class="form-control col-xs-4" type="password" name="loginForm[password]">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <button type="button" class="btn btn-default col-xs-offset-5 col-xs-2" onclick="testLdapCredentials()">{{ t('Test') }}</button>
+    </div>
+
+  </fieldset>
+
+  <script>
+    /**
+     * test association (ajax)
+     */
+    function testLdapCredentials() {
+      function showMessage(formId, msg, status) {
+        $('#' + formId + ' .alert-container .alert').remove();
+
+        var $message = $('<p class="alert"></p>');
+        $message.addClass('alert-' + status);
+        $message.html(msg.replace('\n', '<br>'));
+        $message.appendTo('#' + formId + '> .alert-container');
+
+        if (status == 'success') {
+          setTimeout(function()
+          {
+            $message.fadeOut({
+              complete: function() {
+                $message.remove();
+              }
+            });
+          }, 5000);
+        }
+      }
+
+      var $form = $('#formTestLdapCredentials');
+      var $action = '/_api/login/testLdap';
+      var $id = $form.attr('id');
+      var $button = $('button', this);
+      $button.attr('disabled', 'disabled');
+
+      var jqxhr = $.post($action, $form.serialize(), function(data)
+        {
+          if (!data.status) {
+            showMessage($id, 'data.status not found', 'danger');
+          }
+          else {
+            showMessage($id, data.message, data.status);
+          }
+        })
+        .fail(function() {
+          showMessage($id, 'エラーが発生しました', 'danger');
+        })
+        .always(function() {
+          $button.prop('disabled', false);
+        });
+        return false;
+    }
+  </script>
+</form>

+ 41 - 23
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "2.2.2-RC",
+  "version": "2.4.2-RC",
   "description": "Enhanced Crowi",
   "tags": [
     "wiki",
@@ -39,28 +39,29 @@
     "server:dev:watch": "env-cmd config/env.dev.js node-dev --respawn app.js --livereload",
     "server:dev": "env-cmd config/env.dev.js node app.js",
     "server:prod:container": "npm run server:prod",
-    "server:prod": "node app.js --production | pino-clf common",
+    "server:prod": "env-cmd config/env.prod.js node app.js --production | pino-clf common",
     "server": "npm run server:dev:watch",
     "start": "npm run server:prod",
-    "test": "mocha -r test/bootstrap.js test/**/*.js",
+    "test": "mocha --timeout 10000 -r test/bootstrap.js test/**/*.js",
     "version": "node -p \"require('./package.json').version\"",
     "webpack": "webpack"
   },
   "dependencies": {
-    "8fold-marked": "^0.3.7",
     "@slack/client": "^3.14.0",
     "assets-webpack-plugin": "~3.5.1",
     "async": "^2.3.0",
     "aws-sdk": "^2.88.0",
-    "axios": "^0.17.0",
+    "axios": "^0.18.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
+    "babel-plugin-lodash": "^3.3.2",
     "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
     "basic-auth-connect": "~1.0.0",
     "body-parser": "^1.18.2",
     "bootstrap-sass": "~3.3.6",
-    "check-node-version": "^2.0.1",
+    "check-node-version": "^3.1.1",
+    "codemirror": "^5.33.0",
     "connect-flash": "~0.1.1",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
@@ -68,35 +69,44 @@
     "crowi-pluginkit": "^1.1.0",
     "csrf": "~3.0.3",
     "css-loader": "^0.28.0",
+    "csv-to-markdown-table": "^0.4.0",
+    "date-fns": "^1.29.0",
     "debug": "^3.1.0",
     "diff": "^3.3.0",
-    "diff2html": "^2.3.0",
-    "elasticsearch": "^13.2.0",
-    "emojify.js": "^1.1.0",
+    "diff2html": "^2.3.3",
+    "elasticsearch": "^14.0.0",
+    "entities": "^1.1.1",
     "env-cmd": "^7.0.0",
     "escape-string-regexp": "^1.0.5",
     "express": "^4.16.1",
     "express-form": "~0.12.0",
-    "express-pino-logger": "^2.0.0",
+    "express-pino-logger": "^3.0.1",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "file-loader": "^1.1.0",
-    "googleapis": "^22.0.0",
+    "googleapis": "^26.0.0",
     "graceful-fs": "^4.1.11",
-    "highlight.js": "^9.10.0",
     "i18next": "^10.0.1",
     "i18next-express-middleware": "^1.0.5",
     "i18next-node-fs-backend": "^1.0.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
-    "inline-attachment": "~2.0.3",
+    "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
+    "lodash-webpack-plugin": "^0.11.4",
+    "markdown-it": "^8.4.0",
+    "markdown-it-emoji": "^1.4.0",
+    "markdown-it-footnote": "^3.0.1",
+    "markdown-it-mathjax": "^2.0.0",
+    "markdown-it-named-headers": "^0.0.4",
+    "markdown-it-plantuml": "^0.3.1",
+    "markdown-it-task-lists": "^2.1.0",
+    "markdown-it-toc-and-anchor-with-slugid": "^1.1.2",
     "md5": "^2.2.1",
     "method-override": "^2.3.10",
     "mkdirp": "~0.5.1",
-    "moment": "2.18.1",
-    "mongoose": "4.11.14",
-    "mongoose-paginate": "5.0.x",
-    "mongoose-unique-validator": "^1.0.6",
+    "mongoose": "^5.0.0",
+    "mongoose-paginate": "^5.0.0",
+    "mongoose-unique-validator": "^2.0.0",
     "multer": "~1.3.0",
     "node-sass": "^4.5.0",
     "nodemailer": "^4.0.1",
@@ -104,27 +114,34 @@
     "normalize-path": "^2.1.1",
     "optimize-js-plugin": "0.0.4",
     "passport": "^0.4.0",
+    "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "pino-clf": "^1.0.2",
-    "plantuml-encoder": "^1.2.4",
+    "plantuml-encoder": "^1.2.5",
     "react": "^16.0.0",
-    "react-bootstrap": "^0.31.3",
-    "react-bootstrap-typeahead": "^1.4.2",
+    "react-bootstrap": "^0.32.0",
+    "react-bootstrap-typeahead": "^2.0.2",
     "react-clipboard.js": "^1.1.2",
+    "react-codemirror2": "^4.0.0",
     "react-dom": "^16.0.0",
+    "react-dropzone": "^4.2.7",
     "redis": "^2.7.1",
     "reveal.js": "^3.5.0",
     "rimraf": "^2.6.1",
     "sass-loader": "^6.0.3",
     "socket.io": "^2.0.3",
     "socket.io-client": "^2.0.3",
-    "style-loader": "^0.19.0",
+    "style-loader": "^0.20.1",
     "swig-templates": "^2.0.2",
+    "throttle-debounce": "^1.0.1",
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.27",
+    "url-join": "^4.0.0",
+    "uslug": "^1.0.4",
     "webpack": "^3.1.0",
     "webpack-bundle-analyzer": "^2.9.0",
-    "webpack-merge": "~4.1.0"
+    "webpack-merge": "~4.1.0",
+    "xss": "^0.3.5"
   },
   "devDependencies": {
     "chai": "^4.1.0",
@@ -132,9 +149,10 @@
     "colors": "^1.1.2",
     "commander": "^2.11.0",
     "easy-livereload": "^1.2.0",
-    "mocha": "^4.0.0",
+    "mocha": "^5.0.0",
     "morgan": "^1.8.2",
     "node-dev": "^3.1.3",
+    "on-headers": "^1.0.1",
     "sinon": "^4.0.0",
     "sinon-chai": "^2.13.0",
     "webpack-dll-bundles-plugin": "^1.0.0-beta.5"

+ 32 - 26
resource/css/_admin.scss

@@ -1,7 +1,7 @@
 // import crowi variable
 @import 'utilities';
 
-.crowi { // {{{
+.crowi {
 
   .admin-user-menu {
     .dropdown-menu {
@@ -17,42 +17,48 @@
     }
   }
 
-  .content-main.admin-customize {
+  .admin-customize {
     .ss-container img {
       padding: .5em;
       background-color: #ddd;
     }
-  }
 
-  // Toggle Twitter Bootstrap button class when active
-  // https://jsfiddle.net/ms040m01/3/
-  .btn-group.btn-toggle {
-    .btn.active[data-active-class="primary"] {
-      color: $btn-primary-color;
-      background-color: darken($btn-primary-bg, 10%);
-      border-color: $btn-primary-border;
+    // Toggle Twitter Bootstrap button class when active
+    // https://jsfiddle.net/ms040m01/3/
+    .btn-group.btn-toggle {
+      .btn.active[data-active-class="primary"] {
+        color: $btn-primary-color;
+        background-color: darken($btn-primary-bg, 10%);
+        border-color: $btn-primary-border;
 
-      &:hover {
-        background-color: darken($btn-primary-bg, 15%);
+        &:hover {
+          background-color: darken($btn-primary-bg, 15%);
+        }
       }
     }
-  }
 
-  .table-user-list {
-    .label-admin {
-      margin-left: 1em;
+    .table-user-list {
+      .label-admin {
+        margin-left: 1em;
+      }
     }
-  }
 
-  // override CodeMirror styles
-  .CodeMirror pre {
-    font-family: $font-family-monospace;
+    // override CodeMirror styles
+    .CodeMirror pre {
+      font-family: $font-family-monospace;
+    }
   }
 
-  .passport-logo {
-    background: url("/images/admin/security/passport_logo.svg") center left no-repeat;
-    padding: 4px;
-    height: 32px;
-    background-color: black;
+  .admin-security {
+    .passport-logo {
+      padding: 4px;
+      height: 32px;
+      background-color: black;
+    }
+
+    .auth-mechanism-configurations {
+      min-height: 800px;
+    }
   }
-} // }}}
+  
+}

+ 10 - 3
resource/css/_comment_crowi-plus.scss

@@ -90,19 +90,26 @@
   .comment-form {
     position: relative;
     margin-top: 2em;
-    // ユーザーアイコン
+    // user icon
     .picture.picture-rounded {
       @extend %picture-rounded;
     }
 
-    // コメントフォームセクション
+    // seciton
     .comment-form-main {
       @extend %comment-section;
     }
 
-    // コメント入力フォーム
+    // textarea
     .comment-write {
       margin-bottom: 0.5em;
     }
+    .comment-form-comment {
+      height: 80px;
+      &:focus, &:not(:invalid) {
+        transition: height 0.2s ease-out;
+        height: 180px;
+      }
+    }
   }
 }

+ 192 - 66
resource/css/_form.scss

@@ -24,82 +24,179 @@
   .tab-content {
     top: 48px;
     bottom: 58px;
-    padding: 0 12px;
     position: absolute;
     z-index: 1051;
     left: 0;
     right: 0;
     margin-top: 4px;
 
-    .alert-info {
+    .alert-info.alert-moved,
+    .alert-info.alert-unlinked {
       display: none;
     }
 
-    .edit-form { // {{{
+    // layout (height: 100%)
+    .edit-form {
       height: 100%;
-      > .row {
+      > form {
         height: 100%;
-        .col-md-6 {
+        > #page-editor {
           height: 100%;
-        }
-        form {
-          padding: 0;
-          border-right: solid 1px #ccc;
-          &::after {
-            position: absolute;
-            top: 0;
-            right: 15px;
-            font-size: 10px;
-            font-weight: 700;
-            color: #959595;
-            text-transform: uppercase;
-            letter-spacing: 1px;
-            content: "Input Content ...";
+
+          .row, .col-md-6, .col-sm-12,
+          .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
+          .page-editor-preview-body
+          {
+            height: 100%;
           }
         }
-        textarea {
-          height: 100%;
-          padding-top: 18px;
-          border: none;
-          box-shadow: none;
+      }
+    }
+  }
 
-          &.dragover {
-            border: dashed 6px #ccc;
-            padding: 12px 6px 0px;
-          }
+  .page-form-setting {
+    .extended-setting {
+      label {
+        margin-bottom: 0;
+      }
+    }
+  }
+
+  .page-editor-editor-container {
+    padding-right: 0;
+    border-right: 1px solid #ccc;
+
+    // override CodeMirror styles
+    .CodeMirror {
+      pre {
+        font-family: $font-family-monospace-not-strictly;
+      }
+      .cm-matchhighlight {
+        background-color: cyan;
+      }
+      .CodeMirror-selection-highlight-scrollbar {
+        background-color: darkcyan;
+      }
+    }
+
+    // for Dropzone
+    .dropzone {
+      @mixin insertFontAwesome($code) {
+        &:before {
+          margin-right: 0.2em;
+          font-family: FontAwesome;
+          content: $code;
         }
-        .preview-body {
-          height: 100%;
-          padding-top: 18px;
-          overflow: scroll;
-
-          &::after {
-            position: absolute;
-            top: 0;
-            right: 15px;
-            font-size: 10px;
-            font-weight: 700;
-            color: #959595;
-            text-transform: uppercase;
-            letter-spacing: 1px;
-            content: "Preview";
-          }
+      }
+
+      // default layout and style
+      .dropzone-overlay {
+        // layout
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        // style
+        margin: 0 15px;
+      }
+      .dropzone-overlay-content {
+        font-size: 2em;
+        padding: 0.2em;
+        border-radius: 5px;
+      }
+
+      // unuploadable or rejected
+      &.dropzone-unuploadable, &.dropzone-rejected {
+        .dropzone-overlay {
+          background: rgba(200,200,200,0.8);
+        }
+        .dropzone-overlay-content {
+          color: #444;
+        }
+      }
+      // uploading
+      &.dropzone-uploading {
+        .dropzone-overlay {
+          background: rgba(255,255,255,0.5);
         }
+        .dropzone-overlay-content {
+          padding: 0.3em;
+          background: rgba(200,200,200,0.5);
+          color: #444;
+        }
+      }
 
-        .page-form-setting {
-          .extended-setting {
-            label {
-              margin-bottom: 0;
+      // unuploadable
+      &.dropzone-unuploadable {
+        .dropzone-overlay-content {
+          // insert content
+          @include insertFontAwesome("\f06a ");  // fa-exclamation-circle
+          &:after {
+            content: "File uploading is disabled";
+          }
+        }
+      }
+      // uploadable
+      &.dropzone-uploadable {
+        // accepted
+        &.dropzone-accepted:not(.dropzone-rejected) {
+          .dropzone-overlay {
+            border: 4px dashed #ccc;
+          }
+          .dropzone-overlay-content {
+            // insert content
+            @include insertFontAwesome("\f093");  // fa-upload
+            &:after {
+              content: "Drop here to upload";
             }
+            // style
+            color: #666;
+            background: rgba(200,200,200,0.8);
+          }
+        }
+        // file type mismatch
+        &.dropzone-rejected:not(.dropzone-uploadablefile) .dropzone-overlay-content {
+          // insert content
+          @include insertFontAwesome("\f03e");  // fa-picture-o
+          &:after {
+            content: "Only an image file is allowed";
+          }
+        }
+        // multiple files
+        &.dropzone-accepted.dropzone-rejected .dropzone-overlay-content {
+          // insert content
+          @include insertFontAwesome("\f071");  // fa-fa-exclamation-triangle
+          &:after {
+            content: "Only 1 file is allowed";
           }
         }
       }
+    } // end of.dropzone
+
+    .btn-open-dropzone {
+      font-size: small;
+      text-align: right;
+      padding-top: 3px;
+      padding-bottom: 0;
+      border: none;
+      border-radius: 0;
+      border-top: 1px dotted #ccc;
+      background-color: transparent;
+
+      &:active {
+        box-shadow: none;
+      }
     }
+  }
+  .page-editor-preview-container {
+  }
 
-  } // }}}
+  .page-editor-preview-body {
+    padding-top: 18px;
+    padding-right: 15px;
+    overflow-y: scroll;
+  }
 
   .form-group.form-submit-group {
-
     position: fixed;
     z-index: 1054;
     bottom: 0;
@@ -111,6 +208,24 @@
     border-top: solid 1px #ccc;
     margin-bottom: 0;
   }
+
+  #page-editor-options-selector {
+    label {
+      margin-right: 0.5em;
+    }
+
+    .btn-style-active-line, .btn-render-mathjax-in-realtime {
+      &:hover:not(.active), &:focus:not(.active) {
+        background-color: inherit;
+      }
+    }
+  }
+
+  @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+    #page-editor-options-selector {
+      display: none;
+    }
+  }
 } // }}}
 
 .crowi.main-container .main .page-list.content-main { // {{{ Edit Form of Page List
@@ -127,10 +242,6 @@
   .page-list-container {
     display: none;
   }
-  .portal-form-header,
-  .portal-form {
-    display: block;
-  }
 
   .portal-form-header {
     height: 16px;
@@ -139,18 +250,6 @@
   }
 } // }}}
 
-textarea {
-  font-family: $font-family-monospace-not-strictly;
-  line-height: 1.1em;
-}
-
-input::-webkit-input-placeholder {
-  color: #ccc;
-}
-input:-moz-placeholder {
-  color: #ccc;
-}
-
 @media (max-width: $screen-sm-max) { // {{{ less than tablet size
 
   .content-main.on-edit {
@@ -169,3 +268,30 @@ input:-moz-placeholder {
     float: right;
   }
 } // }}}
+
+// overwrite .CodeMirror-hints
+.CodeMirror-hints {
+  // FIXME: required because .content-main.on-edit has 'z-index:1050'
+  z-index: 1060 !important;
+
+  max-height: 30em !important;
+
+  .CodeMirror-hint.crowi-emoji-autocomplete {
+    font-family: $font-family-monospace-not-strictly;
+    line-height: 1.6em;
+
+    .img-container {
+      display: inline-block;
+      width: 30px;
+    }
+  }
+  // active line
+  .CodeMirror-hint-active.crowi-emoji-autocomplete {
+    .img-container {
+      font-size: 15px;  // adjust to .wiki
+      padding-top: 0.3em;
+      padding-bottom: 0.3em;
+    }
+  }
+
+}

+ 11 - 11
resource/css/_layout_crowi-plus.scss

@@ -3,20 +3,20 @@
     padding: 0;
   }
 
-  .system-version {
-    position: fixed;
-    right: 0;
-    bottom: 0;
-    opacity: .6;
-
-    > span {
-      margin-left: .5em;
-    }
-  }
-
   @media (max-width: $screen-sm-max) {
     .system-version {
       display: none;
     }
   }
 }
+
+.system-version {
+  position: fixed;
+  right: 0.5em;
+  bottom: 0;
+  opacity: .6;
+
+  > span {
+    margin-left: .5em;
+  }
+}

+ 2 - 2
resource/css/_page.scss

@@ -125,7 +125,7 @@
 
       // alert component settings for trash page and moved page
       // see: https://jsfiddle.net/me420sky/2/
-      .alert-trash, .alert-moved {
+      .alert-trash, .alert-moved, .alert-unlinked {
         padding: 10px 15px;
 
         span {
@@ -267,7 +267,7 @@
 
   .footer { // {{{
     position: fixed;
-    width: 100%;
+    width: calc(25% - 18px);
     bottom: 0px;
     height: 26px;
     padding: 4px;

+ 13 - 5
resource/css/_search.scss

@@ -1,3 +1,6 @@
+// import react-bootstrap-typeahead
+@import "~react-bootstrap-typeahead/css/Typeahead";
+
 @import "./page_list";
 
 .search-listpage-icon {
@@ -41,7 +44,7 @@
 
   // using react-bootstrap-typeahead
   // see: https://github.com/ericgio/react-bootstrap-typeahead
-  .bootstrap-typeahead-menu {
+  .rbt-menu {
     li a span {
       .picture {
         width: 14px;
@@ -64,6 +67,10 @@
       }
     }
   }
+  // hide loading icon
+  .rbt-aux {
+    display: none;
+  }
 }
 
 
@@ -152,16 +159,16 @@
   @media (min-width: $screen-sm-max) {
     // using react-bootstrap-typeahead
     // see: https://github.com/ericgio/react-bootstrap-typeahead
-    .bootstrap-typeahead-input input {
-      width: 500px;
+    .rbt-input input {
+      width: 500px !important;
     }
   }
   @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
     background-color: #0f0;
     // using react-bootstrap-typeahead
     // see: https://github.com/ericgio/react-bootstrap-typeahead
-    .bootstrap-typeahead-input input {
-      width: 270px;
+    .rbt-input input {
+      width: 270px !important;
     }
   }
   @media (min-width: $screen-xs-min) and (max-width: $screen-xs-max) {
@@ -175,3 +182,4 @@
     min-width: 120px;
   }
 }
+

+ 66 - 0
resource/css/_shortcuts.scss

@@ -0,0 +1,66 @@
+// import crowi variable
+@import 'utilities';
+
+#shortcuts-modal {
+  @media (min-width: $screen-sm-min) { // {{{ more than tablet size
+    .modal-dialog {
+      width: 750px;
+    }
+  }
+  @media (max-width: $screen-xs-max) { // {{{ less than tablet size
+    table {
+      table-layout: fixed;
+    }
+  }
+
+  h3 {
+    margin-bottom: 1em;
+  }
+
+  table {
+    th {
+      vertical-align: middle;
+    }
+    td {
+      min-width: 170px;
+    }
+  }
+
+  // see http://coliss.com/articles/build-websites/operation/css/css-apple-keyboard-style-by-nrjmadan.html
+  .key {
+    /*Box Properties*/
+    display:inline-block;
+    width:36px;
+    height:36px;
+    margin: 0px 4px;
+    background: #fff;
+    border-radius: 4px;
+    box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.5);
+    /*Text Properties*/
+    font: 18px/36px Helvetica, serif ;
+    text-transform: uppercase;
+    text-align: center;
+    color: #666;
+
+    &.key-longer {
+      width: 64px;
+    }
+    &.key-long {
+      width: 72px;
+    }
+  }
+
+  .dl-horizontal {
+    dt {
+      // width: 180px;
+      height: 41px;
+      display: flex;
+      justify-content: flex-end;
+      align-items: center;
+    }
+    // dd {
+    //   margin-left: 190px;
+    // }
+  }
+
+}

+ 2 - 2
resource/css/_user_crowi-plus.scss

@@ -7,12 +7,12 @@
 
     .revision-toc {
       .revision-toc-content {
-        margin-top: 25px;
+        margin-top: 0;
         background: none;
       }
 
       &.affix {
-        top: 10px;
+        top: 0;
       }
     }
 

+ 26 - 75
resource/css/_wiki.scss

@@ -34,7 +34,7 @@ div.body {
     background: #fcfcfc;
     padding: 10px;
 
-    > ul {
+    > ul > li { // first level of li
       margin: 4px 4px 4px 15px;
       padding: 5px;
     }
@@ -135,22 +135,24 @@ div.body {
 
   img {
     margin: 5px 0;
+/* ensure to disable in crowi-plus system
     box-shadow: 0 0 5px 0px rgba(0,0,0,.2);
     border: solid 1px #ccc;
+*/
     max-width: 100%;
   }
+/* ensure to disable in crowi-plus system
   .noborder img, .img.noborder {
     box-shadow: none;
     border: none;
   }
+*/
 
-  img.emoji {
-    width: 1.1em;
-    margin: 1px;
+  img.emojione {
+    margin-top: -0.3em !important;
+    margin-bottom: 0 !important;
     border: none;
     box-shadow: none;
-    vertical-align: middle;
-    display: inline;
   }
 
   ul, ol {
@@ -167,8 +169,21 @@ div.body {
     }
   }
 
-/* ensure to disable in crowi-plus
-  .wiki-code {
+  // borrowed from https://www.npmjs.com/package/github-markdown-css
+  .contains-task-list {
+    .task-list-item {
+      list-style-type: none;
+    }
+    .task-list-item+.task-list-item {
+      margin-top: 3px;
+    }
+    .task-list-item input {
+      margin: 0 0.2em 0.25em -1.6em;
+      vertical-align: middle;
+    }
+  }
+
+  pre.hljs {
     position: relative;
 
     cite {
@@ -178,11 +193,11 @@ div.body {
       padding: 0 4px;
       background: #ccc;
       color: #333;
-      font-size: .8em;
-
+      font-style: normal;
+      font-weight: bold;
+      opacity: 0.6;
     }
   };
-*/
 
   p code {  // only inline code blocks
     font-family: $font-family-monospace-not-strictly;
@@ -210,70 +225,6 @@ div.body {
     border-radius: 3px;
   }
 
-  // {{{ table (copied from bootstrap .table
-  table {
-    width: 100%;
-    margin-bottom: $line-height-computed;
-    // Cells
-    > thead,
-    > tbody,
-    > tfoot {
-      > tr {
-        > th,
-        > td {
-          padding: $table-cell-padding;
-          line-height: $line-height-base;
-          vertical-align: top;
-          border-top: 1px solid $table-border-color;
-        }
-      }
-    }
-    // Bottom align for column headings
-    > thead > tr > th {
-      vertical-align: bottom;
-      border-bottom: 2px solid $table-border-color;
-    }
-    // Remove top border from thead by default
-    > caption + thead,
-    > colgroup + thead,
-    > thead:first-child {
-      > tr:first-child {
-        > th,
-        > td {
-          border-top: 0;
-        }
-      }
-    }
-    // Account for multiple tbody instances
-    > tbody + tbody {
-      border-top: 2px solid $table-border-color;
-    }
-
-    // Nesting
-    table {
-      background-color: $body-bg;
-    }
-
-    // .table-bordered
-    border: 1px solid $table-border-color;
-    > thead,
-    > tbody,
-    > tfoot {
-      > tr {
-        > th,
-        > td {
-          border: 1px solid $table-border-color;
-        }
-      }
-    }
-    > thead > tr {
-      > th,
-      > td {
-        border-bottom-width: 2px;
-      }
-    }
-  }
-  // }}}
 }
 
 

+ 1 - 1
resource/css/_wiki_crowi-plus.scss

@@ -15,7 +15,7 @@
 
     // affix
     &.affix {
-      top: 30px;
+      top: 0;
     }
   }
 }

+ 1 - 1
resource/css/crowi-reveal.scss

@@ -126,4 +126,4 @@
     // }}}
 
   }
-}
+}

+ 20 - 22
resource/css/crowi.scss

@@ -5,12 +5,6 @@
 $bootstrap-sass-asset-helper: true;
 @import "~bootstrap-sass/assets/stylesheets/bootstrap";
 
-// import react-bootstrap-typeahead styles
-@import '~react-bootstrap-typeahead/css/ClearButton';
-@import '~react-bootstrap-typeahead/css/Loader';
-@import '~react-bootstrap-typeahead/css/Token';
-@import '~react-bootstrap-typeahead/css/Typeahead';
-
 // import toastr styles
 @import '~toastr/build/toastr';
 
@@ -28,6 +22,7 @@ $bootstrap-sass-asset-helper: true;
 @import 'page_crowi-plus';
 @import 'portal';
 @import 'search';
+@import 'shortcuts';
 @import 'user';
 @import 'user_crowi-plus';
 @import 'wiki';
@@ -122,7 +117,7 @@ footer {
 
 .modal.create-page {
 
-  @media (min-width: 768px) {
+  @media (min-width: $screen-sm-min) { // {{{ more than tablet size
     .modal-dialog {
       width: 750px;
     }
@@ -135,21 +130,6 @@ 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;
@@ -171,6 +151,13 @@ footer {
         // width: 100%;
         display: inline-block;
       }
+      #page-name-inputter input {
+        min-width: 300px; // Workaround to display placeholder.
+                          //   cf https://github.com/ericgio/react-bootstrap-typeahead/issues/256
+      }
+      .create-page-under-tree-label code {
+        font-family: $font-family-monospace-not-strictly;
+      }
     }
   }
 }
@@ -468,3 +455,14 @@ input.searching {
     border: 0;
   }
 }
+
+.cmd-key.mac {
+  &:after {
+    content: '⌘';
+  }
+}
+.cmd-key.win {
+  &:after {
+    content: 'Ctrl';
+  }
+}

+ 124 - 11
resource/js/app.js

@@ -2,10 +2,15 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 
 import Crowi from './util/Crowi';
-import CrowiRenderer from './util/CrowiRenderer';
+// import CrowiRenderer from './util/CrowiRenderer';
+import GrowiRenderer from './util/GrowiRenderer';
 
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
+import PageEditor       from './components/PageEditor';
+import OptionsSelector  from './components/PageEditor/OptionsSelector';
+import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
+import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
@@ -15,6 +20,15 @@ import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
+import NewPageNameInputter from './components/NewPageNameInputter';
+import SearchTypeahead  from './components/SearchTypeahead';
+
+import CustomCssEditor  from './components/Admin/CustomCssEditor';
+import CustomScriptEditor from './components/Admin/CustomScriptEditor';
+import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
+
+import * as entities from 'entities';
+
 
 if (!window) {
   window = {};
@@ -25,17 +39,20 @@ let pageId = null;
 let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
 let pagePath;
-let pageContent = null;
+let pageContent = '';
+let markdown = '';
 if (mainContent !== null) {
-  pageId = mainContent.attributes['data-page-id'].value;
-  pageRevisionId = mainContent.attributes['data-page-revision-id'].value;
-  pageRevisionCreatedAt = +mainContent.attributes['data-page-revision-created'].value;
+  pageId = mainContent.getAttribute('data-page-id');
+  pageRevisionId = mainContent.getAttribute('data-page-revision-id');
+  pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
   pagePath = mainContent.attributes['data-path'].value;
   const rawText = document.getElementById('raw-text-original');
   if (rawText) {
     pageContent = rawText.innerHTML;
   }
+  markdown = entities.decodeHTML(pageContent);
 }
+const isLoggedin = document.querySelector('.main-container.nologin') == null;
 
 // FIXME
 const crowi = new Crowi({
@@ -44,9 +61,15 @@ const crowi = new Crowi({
 }, window);
 window.crowi = crowi;
 crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));
-crowi.fetchUsers();
+if (isLoggedin) {
+  crowi.fetchUsers();
+}
 
-const crowiRenderer = new CrowiRenderer(crowi);
+const crowiRenderer = new GrowiRenderer(crowi, null, {
+  mode: 'page',
+  isAutoSetup: false,                                     // manually setup because plugins may configure it
+  renderToc: crowi.getCrowiForJquery().renderTocContent,  // function for rendering Table Of Contents
+});
 window.crowiRenderer = crowiRenderer;
 
 // FIXME
@@ -56,6 +79,9 @@ if (isEnabledPlugins) {
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 
+// configure renderer
+crowiRenderer.setup(crowi.config);
+
 /**
  * define components
  *  key: id of element
@@ -63,22 +89,29 @@ if (isEnabledPlugins) {
  */
 const componentMappings = {
   'search-top': <HeaderSearchBox crowi={crowi} />,
-  'search-page': <SearchPage crowi={crowi} />,
+  'search-page': <SearchPage crowi={crowi} crowiRenderer={crowiRenderer} />,
   'page-list-search': <PageListSearch crowi={crowi} />,
-  'page-comments-list': <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt= {pageRevisionCreatedAt} crowi={crowi} />,
-  'page-attachment': <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />,
 
   //'revision-history': <PageHistory pageId={pageId} />,
   'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
+
+  'page-name-inputter': <NewPageNameInputter crowi={crowi} parentPageName={pagePath} />,
+
 };
-// additional definitions if pagePath exists
+// additional definitions if data exists
+if (pageId) {
+  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} />;
+  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />;
+}
 if (pagePath) {
+  componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} />;
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
   componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
 }
 
 let componentInstances = {};
+
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   if (elem) {
@@ -92,6 +125,86 @@ if (elem) {
   ReactDOM.render(<PageCommentFormBehavior crowi={crowi} pageComments={componentInstances['page-comments-list']} />, elem);
 }
 
+/*
+ * PageEditor
+ */
+let pageEditor = null;
+const editorOptions = new EditorOptions(crowi.editorOptions);
+const previewOptions = new PreviewOptions(crowi.previewOptions);
+// render PageEditor
+const pageEditorElem = document.getElementById('page-editor');
+if (pageEditorElem) {
+  // create onSave event handler
+  const onSaveSuccess = function(page) {
+    // modify the revision id value to pass checking id when updating
+    crowi.getCrowiForJquery().updateCurrentRevision(page.revision._id);
+    // re-render Page component if exists
+    if (componentInstances.page != null) {
+      componentInstances.page.setMarkdown(page.revision.body);
+    }
+  }
+
+  pageEditor = ReactDOM.render(
+    <PageEditor crowi={crowi} crowiRenderer={crowiRenderer}
+        pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
+        markdown={markdown}
+        editorOptions={editorOptions} previewOptions={previewOptions}
+        onSaveSuccess={onSaveSuccess} />,
+    pageEditorElem
+  );
+  // set refs for pageEditor
+  crowi.setPageEditor(pageEditor);
+}
+// render OptionsSelector
+const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
+if (pageEditorOptionsSelectorElem) {
+  ReactDOM.render(
+    <OptionsSelector crowi={crowi}
+        editorOptions={editorOptions} previewOptions={previewOptions}
+        onChange={(newEditorOptions, newPreviewOptions) => { // set onChange event handler
+          // set options
+          pageEditor.setEditorOptions(newEditorOptions);
+          pageEditor.setPreviewOptions(newPreviewOptions);
+          // save
+          crowi.saveEditorOptions(newEditorOptions);
+          crowi.savePreviewOptions(newPreviewOptions);
+        }} />,
+    pageEditorOptionsSelectorElem
+  );
+}
+
+// render for admin
+const customCssEditorElem = document.getElementById('custom-css-editor');
+if (customCssEditorElem != null) {
+  // get input[type=hidden] element
+  const customCssInputElem = document.getElementById('inputCustomCss');
+
+  ReactDOM.render(
+    <CustomCssEditor inputElem={customCssInputElem} />,
+    customCssEditorElem
+  )
+}
+const customScriptEditorElem = document.getElementById('custom-script-editor');
+if (customScriptEditorElem != null) {
+  // get input[type=hidden] element
+  const customScriptInputElem = document.getElementById('inputCustomScript');
+
+  ReactDOM.render(
+    <CustomScriptEditor inputElem={customScriptInputElem} />,
+    customScriptEditorElem
+  )
+}
+const customHeaderEditorElem = document.getElementById('custom-header-editor');
+if (customHeaderEditorElem != null) {
+  // get input[type=hidden] element
+  const customHeaderInputElem = document.getElementById('inputCustomHeader');
+
+  ReactDOM.render(
+    <CustomHeaderEditor inputElem={customHeaderInputElem} />,
+    customHeaderEditorElem
+  )
+}
+
 // うわーもうー
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));

+ 61 - 0
resource/js/components/Admin/CustomCssEditor.js

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { UnControlled as CodeMirror } from 'react-codemirror2';
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/display/autorefresh');
+require('codemirror/addon/lint/css-lint');
+require('codemirror/addon/hint/css-hint');
+require('codemirror/addon/hint/show-hint');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/edit/closebrackets');
+require('codemirror/mode/css/css');
+require('codemirror/theme/eclipse.css');
+
+require('jquery-ui/ui/widgets/resizable');
+
+export default class CustomCssEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    // get initial value from inputElem
+    const value = this.props.inputElem.value;
+
+    return (
+      <CodeMirror
+        value={value}
+        autoFocus={true}
+        options={{
+          mode: 'css',
+          lineNumbers: true,
+          tabSize: 2,
+          indentUnit: 2,
+          theme: 'eclipse',
+          autoRefresh: true,
+          matchBrackets: true,
+          autoCloseBrackets: true,
+          extraKeys: {"Ctrl-Space": "autocomplete"},
+        }}
+        editorDidMount={(editor, next) => {
+          // resizable with jquery.ui
+          $(editor.getWrapperElement()).resizable({
+            resize: function() {
+              editor.setSize($(this).width(), $(this).height());
+            }
+          });
+        }}
+        onChange={(editor, data, value) => {
+          this.props.inputElem.value = value;
+        }}
+      />
+    )
+  }
+
+}
+
+CustomCssEditor.propTypes = {
+  inputElem: PropTypes.object.isRequired,
+};

+ 59 - 0
resource/js/components/Admin/CustomHeaderEditor.js

@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { UnControlled as CodeMirror } from 'react-codemirror2';
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/display/autorefresh');
+require('codemirror/addon/hint/show-hint');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/edit/closebrackets');
+require('codemirror/mode/htmlmixed/htmlmixed');
+require('codemirror/theme/eclipse.css');
+
+require('jquery-ui/ui/widgets/resizable');
+
+export default class CustomHeaderEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    // get initial value from inputElem
+    const value = this.props.inputElem.value;
+
+    return (
+      <CodeMirror
+        value={value}
+        autoFocus={true}
+        options={{
+          mode: 'htmlmixed',
+          lineNumbers: true,
+          tabSize: 2,
+          indentUnit: 2,
+          theme: 'eclipse',
+          autoRefresh: true,
+          matchBrackets: true,
+          autoCloseBrackets: true,
+          extraKeys: {"Ctrl-Space": "autocomplete"},
+        }}
+        editorDidMount={(editor, next) => {
+          // resizable with jquery.ui
+          $(editor.getWrapperElement()).resizable({
+            resize: function() {
+              editor.setSize($(this).width(), $(this).height());
+            }
+          });
+        }}
+        onChange={(editor, data, value) => {
+          this.props.inputElem.value = value;
+        }}
+      />
+    )
+  }
+
+}
+
+CustomHeaderEditor.propTypes = {
+  inputElem: PropTypes.object.isRequired,
+};

+ 61 - 0
resource/js/components/Admin/CustomScriptEditor.js

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { UnControlled as CodeMirror } from 'react-codemirror2';
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/display/autorefresh');
+require('codemirror/addon/lint/javascript-lint');
+require('codemirror/addon/hint/javascript-hint');
+require('codemirror/addon/hint/show-hint');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/edit/closebrackets');
+require('codemirror/mode/javascript/javascript');
+require('codemirror/theme/eclipse.css');
+
+require('jquery-ui/ui/widgets/resizable');
+
+export default class CustomScriptEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    // get initial value from inputElem
+    const value = this.props.inputElem.value;
+
+    return (
+      <CodeMirror
+        value={value}
+        autoFocus={true}
+        options={{
+          mode: 'javascript',
+          lineNumbers: true,
+          tabSize: 2,
+          indentUnit: 2,
+          theme: 'eclipse',
+          autoRefresh: true,
+          matchBrackets: true,
+          autoCloseBrackets: true,
+          extraKeys: {"Ctrl-Space": "autocomplete"},
+        }}
+        editorDidMount={(editor, next) => {
+          // resizable with jquery.ui
+          $(editor.getWrapperElement()).resizable({
+            resize: function() {
+              editor.setSize($(this).width(), $(this).height());
+            }
+          });
+        }}
+        onChange={(editor, data, value) => {
+          this.props.inputElem.value = value;
+        }}
+      />
+    )
+  }
+
+}
+
+CustomScriptEditor.propTypes = {
+  inputElem: PropTypes.object.isRequired,
+};

+ 2 - 2
resource/js/components/Common/UserDate.js

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import moment from 'moment';
+import dateFnsFormat from 'date-fns/format';
 
 /**
  * UserDate
@@ -11,7 +11,7 @@ import moment from 'moment';
 export default class UserDate extends React.Component {
 
   render() {
-    const dt = moment(this.props.dateTime).format(this.props.format);
+    const dt = dateFnsFormat(this.props.dateTime, this.props.format);
 
     return (
       <span className={this.props.className}>

+ 18 - 79
resource/js/components/HeaderSearchBox/SearchForm.js

@@ -1,7 +1,9 @@
 import React from 'react';
-import { FormGroup, Button, InputGroup } from 'react-bootstrap';
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import Button from 'react-bootstrap/es/Button';
+import InputGroup from 'react-bootstrap/es/InputGroup';
 
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+import SearchTypeahead from '../SearchTypeahead';
 
 import UserPicture from '../User/UserPicture';
 import PageListMeta from '../PageList/PageListMeta';
@@ -17,18 +19,10 @@ export default class SearchForm extends React.Component {
     this.crowi = window.crowi; // FIXME
 
     this.state = {
-      input: '',
-      keyword: '',
-      searchedKeyword: '',
-      pages: [],
       searchError: null,
     };
 
-    this.search = this.search.bind(this);
-    this.clearForm = this.clearForm.bind(this);
-    this.getFormClearComponent = this.getFormClearComponent.bind(this);
-    this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
-    this.onInputChange = this.onInputChange.bind(this);
+    this.onSearchError = this.onSearchError.bind(this);
     this.onChange = this.onChange.bind(this);
   }
 
@@ -38,70 +32,25 @@ export default class SearchForm extends React.Component {
   componentWillUnmount() {
   }
 
-  search(keyword) {
-
-    if (keyword === '') {
-      this.setState({
-        keyword: '',
-        searchedKeyword: '',
-      });
-      return;
-    }
-
-    this.crowi.apiGet('/search', {q: keyword})
-      .then(res => {
-        this.setState({
-          keyword: '',
-          pages: res.data,
-        });
-      }).catch(err => {
-        this.setState({
-          searchError: err
-        });
-      });
-  }
-
-  getFormClearComponent() {
-    let isHidden = (this.state.input.length === 0);
-
-    return isHidden ? <span></span> : (
-      <a className="btn btn-link search-top-clear" onClick={this.clearForm} hidden={isHidden}>
-        <i className="fa fa-times-circle" />
-      </a>
-    );
+  onSearchError(err) {
+    this.setState({
+      searchError: err,
+    });
   }
 
-  clearForm() {
-    this._typeahead.getInstance().clear();
-    this.setState({keyword: ''});
-  }
-
-  onInputChange(text) {
-    this.setState({input: text});
-  }
+  onChange(selected) {
+    const page = selected[0];  // should be single page selected
 
-  onChange(options) {
-    const page = options[0];  // should be single page selected
     // navigate to page
-    window.location = page.path;
-  }
-
-  renderMenuItemChildren(option, props, index) {
-    const page = option;
-    return (
-      <span>
-        <UserPicture user={page.revision.author} />
-        <PagePath page={page} />
-        <PageListMeta page={page} />
-      </span>
-    );
+    if (page != null) {
+      window.location = page.path;
+    }
   }
 
   render() {
     const emptyLabel = (this.state.searchError !== null)
         ? 'Error on searching.'
         : 'No matches found on title... Hit [Enter] key so that search on contents.';
-    const formClear = this.getFormClearComponent();
 
     return (
       <form
@@ -110,22 +59,12 @@ export default class SearchForm extends React.Component {
       >
         <FormGroup>
           <InputGroup>
-            <AsyncTypeahead
-              ref={ref => this._typeahead = ref}
-              name="q"
-              labelKey="path"
-              minLength={2}
-              options={this.state.pages}
-              placeholder="Search ..."
-              emptyLabel={emptyLabel}
-              align='left'
-              submitFormOnEnter={true}
-              onSearch={this.search}
-              onInputChange={this.onInputChange}
+            <SearchTypeahead
+              crowi={this.crowi}
               onChange={this.onChange}
-              renderMenuItemChildren={this.renderMenuItemChildren}
+              emptyLabel={emptyLabel}
+              placeholder="Search ..."
             />
-            {formClear}
             <InputGroup.Button>
               <Button type="submit">
                 <i className="search-top-icon fa fa-search"></i>

+ 75 - 0
resource/js/components/NewPageNameInputter.js

@@ -0,0 +1,75 @@
+import React from 'react';
+import { FormGroup } from 'react-bootstrap/es/FormGroup';
+import { Button } from 'react-bootstrap/es/Button';
+import { InputGroup } from 'react-bootstrap/es/InputGroup';
+
+import UserPicture from './User/UserPicture';
+import PageListMeta from './PageList/PageListMeta';
+import PagePath from './PageList/PagePath';
+import PropTypes from 'prop-types';
+import SearchTypeahead from './SearchTypeahead';
+
+export default class NewPageNameInputter extends React.Component {
+
+  constructor(props) {
+
+    super(props);
+
+    this.state = {
+      searchError: null,
+    };
+    this.crowi = this.props.crowi;
+
+    this.onSearchError = this.onSearchError.bind(this);
+    this.getParentPageName = this.getParentPageName.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  componentWillUnmount() {
+  }
+
+  onSearchError(err) {
+    this.setState({
+      searchError: err,
+    });
+  }
+
+  getParentPageName(path) {
+    if (path == '/') {
+      return path;
+    }
+
+    if (path.match(/.+\/$/)) {
+      return path;
+    }
+
+    return path + '/';
+  }
+
+  render() {
+    const emptyLabel = (this.state.searchError !== null)
+      ? 'Error on searching.'
+      : 'No matches found on title...';
+
+    return (
+      <SearchTypeahead
+        crowi={this.crowi}
+        onSearchError={this.onSearchError}
+        emptyLabel={emptyLabel}
+        placeholder="Input page name"
+        keywordOnInit={this.getParentPageName(this.props.parentPageName)}
+      />
+    );
+  }
+}
+
+NewPageNameInputter.propTypes = {
+  crowi:          PropTypes.object.isRequired,
+  parentPageName: PropTypes.string,
+};
+
+NewPageNameInputter.defaultProps = {
+  parentPageName: '',
+};

+ 131 - 0
resource/js/components/Page.js

@@ -0,0 +1,131 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import RevisionBody from './Page/RevisionBody';
+
+export default class Page extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+    };
+
+    this.appendEditSectionButtons = this.appendEditSectionButtons.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+    this.getHighlightedBody = this.getHighlightedBody.bind(this);
+  }
+
+  componentWillMount() {
+    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
+  }
+
+  componentDidUpdate() {
+    this.appendEditSectionButtons();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.renderHtml(nextProps.markdown, nextProps.highlightKeywords);
+  }
+
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+    this.renderHtml(markdown, this.props.highlightKeywords);
+  }
+
+  /**
+   * Add edit section buttons to headers
+   * This invoke `appendEditSectionButtons` method of `legacy/crowi.js`
+   *
+   * TODO: transplant `appendEditSectionButtons` to this class in the future
+   */
+  appendEditSectionButtons(parentElement) {
+    if (this.props.showHeadEditButton) {
+      const crowiForJquery = this.props.crowi.getCrowiForJquery();
+      crowiForJquery.appendEditSectionButtons(this.revisionBodyElement);
+    }
+  }
+
+  /**
+   * transplanted from legacy code -- Yuki Takei
+   * @param {string} body html strings
+   * @param {string} keywords
+   */
+  getHighlightedBody(body, keywords) {
+    let returnBody = body;
+
+    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
+      if (keyword === '') {
+        return;
+      }
+      const k = keyword
+            .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
+      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
+      returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
+    });
+
+    return returnBody;
+  }
+
+  renderHtml(markdown, highlightKeywords) {
+    var context = {
+      markdown,
+      dom: this.revisionBodyElement,
+      currentPagePath: this.props.pagePath,
+    };
+
+    const crowiRenderer = this.props.crowiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRender', context)
+      .then(() => interceptorManager.process('prePreProcess', context))
+      .then(() => {
+        context.markdown = crowiRenderer.preProcess(context.markdown);
+      })
+      .then(() => interceptorManager.process('postPreProcess', context))
+      .then(() => {
+        var parsedHTML = crowiRenderer.process(context.markdown);
+        context['parsedHTML'] = parsedHTML;
+      })
+      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => {
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+
+        // highlight
+        if (highlightKeywords != null) {
+          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
+        }
+      })
+      .then(() => interceptorManager.process('postPostProcess', context))
+      .then(() => interceptorManager.process('preRenderHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => interceptorManager.process('postRenderHtml', context));
+
+  }
+
+  render() {
+    const config = this.props.crowi.getConfig();
+    const isMathJaxEnabled = !!config.env.MATHJAX;
+
+    return (
+      <RevisionBody html={this.state.html}
+          inputRef={el => this.revisionBodyElement = el}
+          isMathJaxEnabled={isMathJaxEnabled}
+          renderMathJaxOnInit={true}
+      />
+    )
+  }
+}
+
+Page.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  showHeadEditButton: PropTypes.bool,
+  highlightKeywords: PropTypes.string,
+};

+ 0 - 70
resource/js/components/Page/PageBody.js

@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class PageBody extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.crowiRenderer = window.crowiRenderer; // FIXME
-    this.getMarkupHTML = this.getMarkupHTML.bind(this);
-    this.getHighlightBody = this.getHighlightBody.bind(this);
-  }
-
-  getHighlightBody(body, keywords) {
-    let returnBody = body;
-
-    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
-      if (keyword === '') {
-        return;
-      }
-      const k = keyword
-            .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
-            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
-      returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
-    });
-
-    return returnBody;
-  }
-
-  getMarkupHTML() {
-    let body = this.props.pageBody;
-    if (body === '') {
-      body = this.props.page.revision.body;
-    }
-
-    body = this.crowiRenderer.render(body, undefined, this.props.rendererOptions);
-
-    if (this.props.highlightKeywords) {
-      body = this.getHighlightBody(body, this.props.highlightKeywords);
-    }
-
-    return { __html: body };
-  }
-
-  render() {
-    let parsedBody = this.getMarkupHTML();
-
-    return (
-      <div
-        className="content"
-        dangerouslySetInnerHTML={parsedBody}
-        />
-    );
-  }
-}
-
-PageBody.propTypes = {
-  page: PropTypes.object.isRequired,
-  highlightKeywords: PropTypes.string,
-  pageBody: PropTypes.string,
-  rendererOptions: PropTypes.object,
-};
-
-PageBody.defaultProps = {
-  page: {},
-  pageBody: '',
-  rendererOptions: {},
-};
-

+ 66 - 0
resource/js/components/Page/RevisionBody.js

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { debounce } from 'throttle-debounce';
+
+export default class RevisionBody extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    // create debounced method for rendering MathJax
+    this.renderMathJaxWithDebounce = debounce(200, this.renderMathJax);
+  }
+
+  componentDidMount() {
+    const MathJax = window.MathJax;
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
+      this.renderMathJaxWithDebounce();
+    }
+  }
+
+  componentDidUpdate() {
+    const MathJax = window.MathJax;
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxInRealtime) {
+      this.renderMathJaxWithDebounce();
+    }
+  }
+
+  componentWillReceiveProps(nextProps) {
+    const MathJax = window.MathJax;
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
+      this.renderMathJaxWithDebounce();
+    }
+  }
+
+  renderMathJax() {
+    const MathJax = window.MathJax;
+    MathJax.Hub.Queue(["Typeset", MathJax.Hub, this.element]);
+  }
+
+  generateInnerHtml(html) {
+    return {__html: html};
+  }
+
+  render() {
+    return (
+      <div
+        ref={(elm) => {
+          this.element = elm;
+          if (this.props.inputRef != null) {
+            this.props.inputRef(elm);
+          }
+        }}
+        className="wiki" dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+      </div>
+    )
+  }
+}
+
+RevisionBody.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func,  // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  renderMathJaxInRealtime: PropTypes.bool,
+};

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

@@ -17,7 +17,7 @@ export default class RevisionUrl extends React.Component {
       fontSize: "1em"
     }
 
-    const url = (this.props.pageId === '')
+    const url = (this.props.pageId == null)
         ? decodeURIComponent(location.href)
         : `${location.origin}/${this.props.pageId}`;
     const copiedText = this.props.pagePath + '\n' + url;
@@ -33,6 +33,6 @@ export default class RevisionUrl extends React.Component {
 }
 
 RevisionUrl.propTypes = {
-  pageId: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
   pagePath: PropTypes.string.isRequired,
 };

+ 6 - 5
resource/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -1,5 +1,6 @@
 import React from 'react';
-import { Button, Modal } from 'react-bootstrap';
+import Button from 'react-bootstrap/es/Button';
+import Modal from 'react-bootstrap/es/Modal';
 
 import Icon from '../Common/Icon';
 import User from '../User/User';
@@ -18,12 +19,12 @@ export default class DeleteAttachmentModal extends React.Component {
   renderByFileFormat(attachment) {
     if (attachment.fileFormat.match(/image\/.+/i)) {
       return (
-        <p className="attachment-delete-image">
-          <span>
+        <div className="attachment-delete-image">
+          <p>
             {attachment.originalName} uploaded by <User user={attachment.creator} username />
-          </span>
+          </p>
           <img src={attachment.url} />
-        </p>
+        </div>
       );
     }
 

+ 2 - 2
resource/js/components/PageComment/Comment.js

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import moment from 'moment';
+import dateFnsFormat from 'date-fns/format';
 
 import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
@@ -53,7 +53,7 @@ export default class Comment extends React.Component {
     const creator = comment.creator;
 
     const rootClassName = this.getRootClassName();
-    const commentDate = moment(comment.createdAt).format('YYYY/MM/DD HH:mm');
+    const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
     const commentBody = ReactUtils.nl2br(comment.comment);
     const creatorsPage = `/user/${creator.username}`;
     const revHref = `?revision=${comment.revision}`;

Некоторые файлы не были показаны из-за большого количества измененных файлов