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

Merge remote-tracking branch 'origin/master' into imprv/brushup-group-select-ui

Tatsuya Ise 8 лет назад
Родитель
Сommit
ed02493953
35 измененных файлов с 591 добавлено и 252 удалено
  1. 14 2
      .eslintrc.js
  2. 1 0
      .gitignore
  3. 14 1
      CHANGES.md
  4. 2 0
      lib/crowi/express-init.js
  5. 94 17
      lib/locales/en-US/translation.json
  6. 80 4
      lib/locales/ja/translation.json
  7. 5 5
      lib/models/config.js
  8. 1 2
      lib/views/admin/app.html
  9. 30 32
      lib/views/admin/security.html
  10. 1 1
      lib/views/admin/widget/passport/facebook.html
  11. 1 1
      lib/views/admin/widget/passport/github.html
  12. 1 1
      lib/views/admin/widget/passport/google-oauth.html
  13. 39 41
      lib/views/admin/widget/passport/ldap.html
  14. 1 1
      lib/views/admin/widget/passport/twitter.html
  15. 2 2
      lib/views/layout-crowi/page_list.html
  16. 2 2
      lib/views/layout-growi/page.html
  17. 2 2
      lib/views/layout-growi/page_list.html
  18. 2 2
      lib/views/layout-growi/user_page.html
  19. 3 3
      lib/views/widget/page_alerts.html
  20. 6 3
      package.json
  21. 0 2
      resource/js/components/Admin/CustomCssEditor.js
  22. 0 2
      resource/js/components/Admin/CustomHeaderEditor.js
  23. 0 2
      resource/js/components/Admin/CustomScriptEditor.js
  24. 25 25
      resource/js/components/PageAttachment.js
  25. 12 11
      resource/js/components/PageAttachment/Attachment.js
  26. 26 20
      resource/js/components/PageAttachment/DeleteAttachmentModal.js
  27. 26 28
      resource/js/components/PageEditor.js
  28. 100 17
      resource/js/components/PageEditor/Editor.js
  29. 44 2
      resource/js/components/PageEditor/OptionsSelector.js
  30. 0 2
      resource/js/legacy/crowi.js
  31. 1 1
      resource/styles/agile-admin/inverse/colors/_apply-colors.scss
  32. 5 1
      resource/styles/scss/_login.scss
  33. 1 1
      resource/styles/scss/_search.scss
  34. 5 0
      resource/styles/scss/_vendor.scss
  35. 45 16
      yarn.lock

+ 14 - 2
.eslintrc.js

@@ -5,7 +5,10 @@ module.exports = {
     "es6": true,
     "node": true
   },
-  "extends": "eslint:recommended",
+  "extends": [
+    "eslint:recommended",
+    "plugin:react/recommended"
+  ],
   "parserOptions": {
     "ecmaFeatures": {
       "experimentalObjectRestSpread": true,
@@ -32,7 +35,11 @@ module.exports = {
     "indent": [
       "error",
       2,
-      { "SwitchCase": 1 }
+      {
+        "SwitchCase": 1,
+        "FunctionExpression": {"parameters": 2},
+        "CallExpression": {"parameters": 2}
+      }
     ],
     "key-spacing": [
       "error", { "beforeColon": false, "afterColon": true }
@@ -48,6 +55,11 @@ module.exports = {
       "error",
       "single"
     ],
+    "react/jsx-indent": [
+      "error",
+      4,
+      { "ignoredNodes": ["JSXElement *"] }
+    ],
     "semi": [
       "error",
       "always"

+ 1 - 0
.gitignore

@@ -14,6 +14,7 @@ Thumbs.db
 /bower_components/
 npm-debug.log
 /npm-debug.log.*
+package-lock.json
 
 # Dist #
 /report/

+ 14 - 1
CHANGES.md

@@ -1,11 +1,24 @@
 CHANGES
 ========
 
-## 3.0.11-RC
+## 3.0.12-RC
 
+* Feature: Support Vim/Emacs/Sublime-Text keybindings
+* Improvement: Add some CodeMirror themes (Eclipse, Dracula)
+* Improvement: Dynamic loading for CodeMirror theme files from CDN
+* Improvement: Prevent XSS when move/redirect/duplicate
+
+
+## 3.0.11
+
+* Fix: login.html is broken in iOS
+* Fix: Removing attachment is crashed
+* Fix: File-attaching error after new page creation
 * Support: Optimize development build
 * Support: Upgrade libs
     * env-cmd
+    * googleapis
+    * sinon
 
 ## 3.0.10
 

+ 2 - 0
lib/crowi/express-init.js

@@ -9,6 +9,7 @@ module.exports = function(crowi, app) {
     , methodOverride = require('method-override')
     , passport       = require('passport')
     , session        = require('express-session')
+    , sanitizer      = require('express-sanitizer')
     , basicAuth      = require('basic-auth-connect')
     , flash          = require('connect-flash')
     , swig           = require('swig-templates')
@@ -94,6 +95,7 @@ module.exports = function(crowi, app) {
   app.use(methodOverride());
   app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
   app.use(bodyParser.json({limit: '50mb'}));
+  app.use(sanitizer());
   app.use(cookieParser());
   app.use(session(crowi.sessionConfig));
 

+ 94 - 17
lib/locales/en-US/translation.json

@@ -253,7 +253,7 @@
     "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",
+    "bucket name": "Bucket name",
     "Plugin settings": "Plugin settings",
     "Enable plugin loading": "Enable plugin loading",
     "Load plugins": "Load plugins",
@@ -262,23 +262,100 @@
 
   },
   "security_setting": {
-    "Basic authentication": "Basic authentication",
-    "Security settings": "Security settings",
-    "Guest users access": "Guest users access",
-    "Register limitation": "Register limitation",
-    "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-    "Selecting authentication mechanism": "Selecting authentication mechanism",
-    "common_authentication": "If you set the basic authentication, common authentication is applied on the whole page." ,
-    "without_encryption": "Please be noted that your ID and Password will be sent wihtout encryption.",
-    "users_without_account": "Users without account is not accessible",
+		"Basic authentication": "Basic authentication",
+		"Security settings": "Security settings",
+		"Guest users access": "Guest users access",
+		"Register limitation": "Register limitation",
+		"The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
+		"Selecting authentication mechanism": "Selecting authentication mechanism",
+		"common_authentication": "If you set the basic authentication, common authentication is applied on the whole page.",
+		"without_encryption": "Please be noted that your ID and Password will be sent wihtout encryption.",
+		"users_without_account": "Users without account is not accessible",
+    "example": "Example",
     "restrict_emails": "You can restrict registerable e-mail address.",
-    "for_instance":" For instance, if you use growi within a company, you can write ",
-    "only_those":" Only those whose e-mail address including the company address can register.",
-    "insert_single":"Please insert single e-mail address per line.",
-    "Authentication mechanism settings":"Authentication mechanism settings",
-    "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
-    "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>."
-  },
+		"for_instance": " For instance, if you use growi within a company, you can write ",
+		"only_those": " Only those whose e-mail address including the company address can register.",
+		"insert_single": "Please insert single e-mail address per line.",
+		"Authentication mechanism settings": "Authentication mechanism settings",
+    "note": "Note",
+    "require_server_restart_change_auth": "Restarting the server is required if you switch the auth mechanism.",
+    "passport": "Passport",
+    "auth_mechanism": "authentication mechanism",
+    "recommended": "Recommended",
+    "username_email_password": "Username, Email and Password authentication",
+    "ldap_auth": "LDAP authentication",
+    "google_auth2": "Google OAuth2 authentication",
+    "facebook_auth2": "Facebook OAuth2 authentication",
+    "twitter_auth2": "Twitter OAuth authentication",
+    "github_auth2": "Github OAuth2 authentication",
+    "crowi_auth": "Crowi classic authentication mechanism",
+		"require_server_restart": "Restarting the server is required.",
+		"server_on_passport_auth": "The server is running with Passport authentication mechanism.",
+		"server_on_crowi_auth": "The server is running with official Crowi authentication mechanism.",
+		"google_setting": "Google Setting",
+    "connect_api_manager": "You can use your Google account to sign up and login after creating OAuth2 ClientId at <a href=\"https://console.cloud.google.com/apis/credentials\">API Manager on Google Cloud Platform</a>",
+		"access_api_manager": "Access <a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a>",
+		"create_project": "Create Project if no projects have been created.",
+		"create_auth_to_oauth": "\"Create credentials\" -> \"OAuth clientID\"",
+		"select_webapp": "Select \"Web Application\"",
+    "change_redirect_url": "Enter <code>https://${crowi.host}/google/callback</code> <br>(where < code > $ {crowi.host} < /code> is your host name) for \"Authorized redirect URIs\".",
+    "clientID": "Client ID",
+    "client_secret": "クライアントシークレット",
+    "guest_mode": {
+      "deny": "Deny Unregistered Users",
+      "readonly": "View Only"
+    },
+    "registration_mode": {
+      "open": "Anyone",
+      "restricted": "Reuqire Admin permission",
+      "closed": "Invitation Only"
+    },
+    "configuration": "Configuration",
+    "default": "Default",
+    "optional": "Optional",
+    "ldap": {
+      "use_ldap": "Use LDAP",
+      "server_url": "Server URL",
+      "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
+      "bind_mode": "Binding Mode",
+      "bind_manager": "Manager Bind",
+      "bind_user": "User Bind",
+      "bind_DN": "Bind DN",
+      "bind_DN_manager_detail": "The DN of the account that authenticates and queries the directory service",
+      "bind_DN_user_detail1": "The query used to bind with the directory service.",
+      "bind_DN_user_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
+      "bind_DN_password": "Bind DN Password",
+      "bind_DN_password_manager_detail": "The password for the Bind DN account.",
+      "bind_DN_password_user_detail": "The password that is entered in the login page will be used to bind.",
+      "search_filter": "Search Filter",
+      "search_filter_detail1": "The query used to locate the authenticated user.",
+      "search_filter_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
+      "search_filter_detail3": "If empty, the filter <code>(uid=&#123;&#123;username&#125;&#125;)</code> is used.",
+      "search_filter_example": "Example to match with 'uid' or 'mail'",
+      "attribute_mapping": "Attribute Mapping",
+      "username": "Username",
+      "username_detail": "Specification of mappings when creating new users",
+      "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
+  		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_DN_detail": "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.",
+      "group_search_filter": "Group Search Filter",
+      "group_search_filter_detail1": "The query used to filter for groups.",
+      "group_search_filter_detail2": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
+      "group_search_filter_detail3": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</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.)",
+      "group_DN_property": "Group DN Property",
+      "group_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
+      "test_config": "Test Saved Configuration"
+    },
+    "Google OAuth": {
+    },
+    "Facebook": {
+    },
+    "Twitter": {
+    },
+    "Github": {
+    }
+	},
 
   "markdown_setting": {
     "markdown_rendering": "You can change Markdown rendering settings.",

+ 80 - 4
lib/locales/ja/translation.json

@@ -272,7 +272,7 @@
     "No_SMTP_setting": "また、SMTP の設定が無い場合、SES を利用したメール送信が行われます。FromメールアドレスのVerify、プロダクション利用設定をする必要があります。",
     "change_setting": "この設定を途中で変更すると、これまでにアップロードしたファイル等へのアクセスができなくなりますのでご注意下さい。",
     "region": "リージョン",
-    "Packet name": "パケット名",
+    "bucket name": "バケット名",
     "Plugin settings": "プラグイン設定",
     "Enable plugin loading": "プラグインの読み込みを有効にします。",
     "Load plugins": "プラグインを読み込む",
@@ -291,13 +291,90 @@
     "common_authentication": "Basic認証を設定すると、ページ全体に共通の認証がかかります。",
     "without_encryption": "IDとパスワードは暗号化されずに送信されるのでご注意下さい。",
     "users_without_account": "アカウントを持たないユーザーはアクセス不可",
+    "example": "例",
     "restrict_emails": "登録可能なメールアドレスを制限することができます。",
     "for_instance":"例えば、会社で使う場合 などと記載すると、",
     "only_those":"その会社のメールアドレスを持っている人のみ登録可能になります。",
     "insert_single":"1行に1メールアドレス入力してください。",
     "Authentication mechanism settings":"認証機構設定",
-    "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-    "Treat username matching as identical_warn": "WARNING: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください"
+    "note": "メモ",
+    "require_server_restart_change_auth": "認証機構の変更後はサーバーを再起動してください。",
+    "passport": "パスポート",
+    "auth_mechanism": "認証機構",
+    "recommended": "推奨",
+    "username_email_password": "ユーサー名、Eメール、パスワードでの認証",
+    "ldap_auth": "LDAP認証",
+    "google_auth2": "Google OAuth2認証",
+    "facebook_auth2": "Facebook OAuth2認証",
+    "twitter_auth2": "Twitter OAuth認証",
+    "github_auth2": "Github OAuth2認証",
+    "crowi_auth": "Crowiクラシック認証機構",
+    "require_server_restart": "サーバーを再起動してください。",
+    "server_on_passport_auth": "パスポート認証でサーバーが稼働しています。",
+    "server_on_crowi_auth": "公式crowi認証でサーバーが稼働しています。",
+    "google_setting": "Google 設定",
+    "connect_api_manager": "Google Cloud Platform の <a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a>から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。",
+    "access_api_manager": "<a href=\"https://console.cloud.google.com/apis/credentials\">API Manager</a> へアクセス",
+    "create_project": "プロジェクトを作成していない場合は作成してください",
+    "create_auth_to_oauth": "「認証情報を作成」-> OAuthクライアントID",
+    "select_webapp": "「ウェブアプリケーション」を選択",
+    "change_redirect_url": "承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>(<code>${crowi.host}</code>は環境に合わせて変更してください)",
+    "clientID": "クライアントID",
+    "client_secret": "クライアントシークレット",
+    "guest_mode": {
+      "deny": "アカウントを持たないユーザーはアクセス不可",
+      "readonly": "閲覧のみ許可"
+    },
+    "registration_mode": {
+      "open": "公開 (だれでも登録可能)",
+      "restricted": "制限 (登録完了には管理者の承認が必要)",
+      "closed": "非公開 (登録には管理者による招待が必要)"
+    },
+    "configuration": "コンフィギュレーション",
+    "default": "デフォルト",
+    "optional": "任意",
+    "ldap": {
+      "use_ldap": "LDAPを使う",
+      "server_url": "サーバーURL",
+      "server_url_detail": "ディレクトリーサービスのLDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code>の形式で入力してください。",
+      "bind_mode": "モード",
+      "bind_manager": "管理者 Bind",
+      "bind_user": "ユーザー Bind",
+      "bind_DN": "DN Bind",
+      "bind_DN_manager_detail": "ディレクトリーサービスを認証、クエリするアカウントのDN",
+      "bind_DN_user_detail1": "ディレクトリーサービスをBindするのに用いるクエリ",
+      "bind_DN_user_detail2": "ログイン時のユーザー名を使用するには <code>&#123;&#123;username&#125;&#125;</code> の形式を使用してください。",
+      "bind_DN_password": "Bind DN パスワード",
+      "bind_DN_password_manager_detail": "Bind DN アカウントのパスワード",
+      "bind_DN_password_user_detail": "ログイン時のパスワードがBindに使用されます。",
+      "search_filter": "検索フィルター",
+      "search_filter_detail1": "認証されたユーザーを特定するのに用いるクエリ",
+      "search_filter_detail2": "ログイン時のユーザー名を使用するには<code>&#123;&#123;username&#125;&#125;</code> の形式を使用してください。",
+      "search_filter_detail3": "空欄の場合 <code>(uid=&#123;&#123;username&#125;&#125;)</code> が使用されます。",
+      "search_filter_example": "例えば 'uid' または 'mail' が一致するユーザーを探す場合",
+      "attribute_mapping": "要素の関連付け",
+      "username": "ユーザー名",
+      "username_detail": "新規ユーザーの関連付けを設定",
+      "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+      "Treat username matching as identical_warn": "WARNING: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+      "group_search_base_DN": "グループ検索ベースDN",
+      "group_search_base_DN_detail": "グループ検索を実行するベースDN。利用するのであれば <code>Group Search Filter</code> を定義する必要があります。",
+      "group_search_filter": "グループ検索フィルター",
+      "group_search_filter_detail1": "グループフィルターに用いるクエリ",
+      "group_search_filter_detail2": "ユーザーオブジェクトを使用する場合は <code>&#123;&#123;dn&#125;&#125;</code> を用いてください。",
+      "group_search_filter_detail3": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> を持ち <code>memberUid</code> が <code>uid</code> を含むグループが検索されます(<code>Group DN Property</code> がデフォルトから変更されていない場合)",
+      "group_DN_property": "グループDNプロパティー",
+      "group_DN_property_detail": "<code>Group Search Filter</code> の <code>&#123;&#123;dn&#125;&#125;</code> インターポレーションで使用するユーザーオブジェクトのプロパティー",
+      "test_config": "新しい設定を試す"
+    },
+    "Google OAuth": {
+    },
+    "Facebook": {
+    },
+    "Twitter": {
+    },
+    "Github": {
+    }
   },
   "markdown_setting": {
     "markdown_rendering": "Markdownレンダリングの設定を変更できます。",
@@ -351,4 +428,3 @@
   }
 
 }
-

+ 5 - 5
lib/models/config.js

@@ -115,8 +115,8 @@ module.exports = function(crowi) {
   configSchema.statics.getRestrictGuestModeLabels = function()
   {
     var labels = {};
-    labels[SECURITY_RESTRICT_GUEST_MODE_DENY]     = 'アカウントを持たないユーザーはアクセス不可';
-    labels[SECURITY_RESTRICT_GUEST_MODE_READONLY] = '閲覧のみ許可';
+    labels[SECURITY_RESTRICT_GUEST_MODE_DENY]     = 'security_setting.guest_mode.deny';
+    labels[SECURITY_RESTRICT_GUEST_MODE_READONLY] = 'security_setting.guest_mode.readonly';
 
     return labels;
   };
@@ -124,9 +124,9 @@ module.exports = function(crowi) {
   configSchema.statics.getRegistrationModeLabels = function()
   {
     var labels = {};
-    labels[SECURITY_REGISTRATION_MODE_OPEN]       = '公開 (だれでも登録可能)';
-    labels[SECURITY_REGISTRATION_MODE_RESTRICTED] = '制限 (登録完了には管理者の承認が必要)';
-    labels[SECURITY_REGISTRATION_MODE_CLOSED]     = '非公開 (登録には管理者による招待が必要)';
+    labels[SECURITY_REGISTRATION_MODE_OPEN]       = 'security_setting.registration_mode.open';
+    labels[SECURITY_REGISTRATION_MODE_RESTRICTED] = 'security_setting.registration_mode.restricted';
+    labels[SECURITY_REGISTRATION_MODE_CLOSED]     = 'security_setting.registration_mode.closed';
 
     return labels;
   };

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

@@ -147,7 +147,7 @@
         </div>
 
         <div class="form-group">
-          <label for="settingForm[aws:bucket]" class="col-xs-3 control-label">{{ t('app_setting.Packet name') }}</label>
+          <label for="settingForm[aws:bucket]" class="col-xs-3 control-label">{{ t('app_setting.bucket name') }}</label>
           <div class="col-xs-6">
             <input class="form-control" type="text" name="settingForm[aws:bucket]" placeholder="例: crowi"  value="{{ settingForm['aws:bucket'] }}">
           </div>
@@ -269,4 +269,3 @@
 
 {% block content_footer %}
 {% endblock content_footer %}
-

+ 30 - 32
lib/views/admin/security.html

@@ -63,7 +63,7 @@
             <div class="col-xs-6">
               <select class="form-control selectpicker" name="settingForm[security:restrictGuestMode]" value="{{ settingForm['security:restrictGuestMode'] }}">
                 {% for modeValue, modeLabel in consts.restrictGuestMode %}
-                <option value="{{ modeValue }}" {% if modeValue == settingForm['security:restrictGuestMode'] %}selected{% endif %} >{{ modeLabel }}</option>
+                <option value="{{ t(modeValue) }}" {% if modeValue == settingForm['security:restrictGuestMode'] %}selected{% endif %} >{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
             </div>
@@ -74,7 +74,7 @@
             <div class="col-xs-6">
               <select class="form-control selectpicker" 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>
+                <option value="{{ t(modeValue) }}" {% if modeValue == settingForm['security:registrationMode'] %}selected{% endif %} >{{ t(modeLabel) }}</option>
                 {% endfor %}
               </select>
               <p class="help-block">{{ t('The contents entered here will be shown in the header etc') }}</p>
@@ -84,7 +84,7 @@
           <div class="form-group">
             <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>
+              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
               <p class="help-block">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@crowi.wiki</code>{{ t("security_setting.only_those") }}<br>
               {{ t("security_setting.insert_single") }}</p>
             </div>
@@ -103,7 +103,7 @@
       <form action="/_api/admin/security/mechanism" method="post" class="form-horizontal m-t-30" id="mechanismSetting" role="form">
         <fieldset>
           <legend class="alert-anchor">{{ 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>
+          <p class="alert alert-info"><b>{{ t("security_setting.note") }}: </b>{{ t("security_setting.require_server_restart_change_auth") }}</p>
           <div class="form-group">
             <div class="col-xs-6">
               <h4>
@@ -112,18 +112,18 @@
                       {% if true === settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
                   <label for="radioPassportAuthMech">
                     <a href="http://passportjs.org/">
-                      <img src="/images/admin/security/passport-logo.svg" class="passport-logo"> Passport
-                    </a> authentication mechanism <small class="text-success">(Recommended)</small>
+                      <img src="/images/admin/security/passport-logo.svg" class="passport-logo"> {{ t("security_setting.passport") }}
+                    </a> {{ t("security_setting.auth_mechanism") }} <small class="text-success">({{ t("security_setting.recommended") }})</small>
                   </label>
                 </div>
               </h4>
               <ul>
-                <li>Username, E-mail and Password authentication</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>
-                <li class="text-muted">(TBD) <del>Github OAuth2 authentication</del></li>
+                <li>{{ t("security_setting.username_email_password") }}</li>
+                <li>{{ t("security_setting.ldap_auth") }}</li>
+                <li class="text-muted">(TBD) <del>{{ t("security_setting.google_auth2") }}</del></li>
+                <li class="text-muted">(TBD) <del>{{ t("security_setting.facebook_auth2") }}</del></li>
+                <li class="text-muted">(TBD) <del>{{ t("security_setting.twitter_auth2") }}</del></li>
+                <li class="text-muted">(TBD) <del>{{ t("security_setting.github_auth2") }}</del></li>
               </ul>
             </div>
             <div class="col-xs-6">
@@ -132,13 +132,13 @@
                   <input type="radio" id="radioCrowiAuthMech" name="settingForm[security:isEnabledPassport]" value="false"
                       {% if !settingForm['security:isEnabledPassport'] %}checked="checked"{% endif %}>
                   <label for="radioCrowiAuthMech">
-                    Crowi classic authentication mechanism
+                    {{ t("security_setting.crowi_auth") }}
                   </label>
                 </div>
               </h4>
               <ul>
-                <li>Username, E-mail and Password authentication</li>
-                <li>Google OAuth2 authentication</li>
+                <li>{{ t("security_setting.username_email_password") }}</li>
+                <li>{{ t("security_setting.google_auth2") }}</li>
               </ul>
             </div>
           </div>
@@ -164,41 +164,39 @@
               {% if !isRestartingServerNeeded %}style="display: none;"{% endif %}>
             <b>
               <i class="icon-exclamation" aria-hidden="true"></i>
-              Restarting the server is needed.
+              {{ t("security_setting.require_server_restart") }}
             </b>
-            The server is running with Passport authentication mechanism.
+            {{ t("security_setting.server_on_passport_auth") }}
           </p>
 
           <form action="/_api/admin/security/google" method="post" class="form-horizontal" id="googleSetting" role="form"
               {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
 
             <fieldset>
-              <h4>Google 設定</h4>
+              <h4>{{ t("security_setting.google_setting") }}</h4>
               <p class="well alert-anchor">
-                Google Cloud Platform の <a href="https://console.cloud.google.com/apis/credentials">API Manager</a>
-                から OAuth2 Client ID を作成すると、Google アカウントにコネクトして登録やログインが可能になります。
+                {{ t("security_setting.connect_api_manager") }}
               </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>
+                <li>{{ t("security_setting.access_api_manager") }}</li>
+                <li>{{ t("security_setting.create_project") }}</li>
+                <li>{{ t("security_setting.create_auth_to_oauth") }}</li>
                 <ol>
-                  <li>「ウェブアプリケーション」を選択</li>
-                  <li>承認済みのリダイレクトURLに、 <code>https://${crowi.host}/google/callback</code> を入力<br>
-                  (<code>${crowi.host}</code>は環境に合わせて変更してください)</li>
+                  <li>{{ t("security_setting.select_webapp") }}</li>
+                  <li>{{ t("security_setting.change_redirect_url") }}</li>
                 </ol>
               </ol>
 
               <div class="form-group">
-                <label for="settingForm[google:clientId]" class="col-xs-3 control-label">Client ID</label>
+                <label for="settingForm[google:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</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>
+                <label for="settingForm[google:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
                 <div class="col-xs-6">
                   <input class="form-control" type="text" name="settingForm[google:clientSecret]" value="{{ settingForm['google:clientSecret'] }}">
                 </div>
@@ -226,9 +224,9 @@
               {% if !isRestartingServerNeeded %}style="display: none;"{% endif %}>
             <b>
               <i class="icon-exclamation" aria-hidden="true"></i>
-              Restarting the server is needed.
+              {{ t("security_setting.require_server_restart") }}
             </b>
-            The server is running with Official Crowi authentication mechanism.
+            {{ t("security_setting.server_on_crowi_auth") }}
           </p>
           <ul class="nav nav-tabs" role="tablist" {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
             <li class="active">
@@ -311,13 +309,13 @@
         var jqxhr = $.post($form.attr('action'), $form.serialize(), function(data)
           {
             if (data.status) {
-              showMessage($id, '更新しました');
+              showMessage($id, '更新しました Updated');
             } else {
               showMessage($id, data.message, 'danger');
             }
           })
           .fail(function() {
-            showMessage($id, 'エラーが発生しました', 'danger');
+            showMessage($id, 'エラーが発生しました Error', 'danger');
           })
           .always(function() {
             $button.prop('disabled', false);

+ 1 - 1
lib/views/admin/widget/passport/facebook.html

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

+ 1 - 1
lib/views/admin/widget/passport/github.html

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

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

@@ -1,6 +1,6 @@
 <form action="" method="post" class="form-horizontal passportStrategy" id="googleOauthSetting" role="form">
   <fieldset>
-    <legend>Google OAuth Configuration</legend>
+    <legend>Google OAuth {{ t("security_setting.configuration") }}</legend>
     <p class="well">(TBD)</p>
   </fieldset>
 </form>

+ 39 - 41
lib/views/admin/widget/passport/ldap.html

@@ -1,12 +1,12 @@
 <form action="/_api/admin/security/passport-ldap" method="post" class="form-horizontal" id="ldapSetting" role="form">
 
   <fieldset>
-    <legend>LDAP Configuration</legend>
+    <legend>LDAP {{ t("security_setting.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>
+      <label for="{{nameForIsLdapEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.ldap.use_ldap") }}</label>
       <div class="col-xs-6">
         <div class="btn-group btn-toggle" data-toggle="buttons">
           <label class="btn btn-default btn-rounded btn-outline {% if isLdapEnabled %}active{% endif %}" data-active-class="primary">
@@ -24,14 +24,14 @@
     <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>
+        <label for="settingForm[security:passport-ldap:serverUrl]" class="col-xs-3 control-label">{{ t("security_setting.ldap.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>
+              {{ t("security_setting.ldap.server_url_detail") }}<br>
+              {{ t("security_setting.example") }}: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
             </small>
           </p>
         </div>
@@ -40,86 +40,86 @@
       {% 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>
+        <label for="{{nameForIsUserBind}}" class="col-xs-3 control-label">{{ t("security_setting.ldap.bind_mode") }}</label>
         <div class="col-xs-6">
           <div class="btn-group btn-toggle" data-toggle="buttons">
             <label class="btn btn-default btn-rounded btn-outline {% if !isUserBind %}active{% endif %}" data-active-class="primary">
               <input name="{{nameForIsUserBind}}" value="false" type="radio"
-                  {% if !isUserBind %}checked{% endif %}> Manager Bind
+                  {% if !isUserBind %}checked{% endif %}> {{ t("security_setting.ldap.bind_manager") }}
             </label>
             <label class="btn btn-default btn-rounded btn-outline {% if isUserBind %}active{% endif %}" data-active-class="primary">
               <input name="{{nameForIsUserBind}}" value="true" type="radio"
-                  {% if isUserBind %}checked{% endif %}> User Bind
+                  {% if isUserBind %}checked{% endif %}> {{ t("security_setting.ldap.bind_user") }}
             </label>
           </div>
         </div>
       </div>
 
       <div class="form-group">
-        <label for="settingForm[security:passport-ldap:bindDN]" class="col-xs-3 control-label">Bind DN</label>
+        <label for="settingForm[security:passport-ldap:bindDN]" class="col-xs-3 control-label">{{ t("security_setting.ldap.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
+              {{ t("security_setting.ldap.bind_DN_manager_detail") }}
             </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>
+              {{ t("security_setting.ldap.bind_DN_user_detail1") }}<br>
+              {{ t("security_setting.ldap.bind_DN_user_detail2") }}<br>
+              {{ t("security_setting.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>
+        <label for="settingForm[security:passport-ldap:bindDNPassword]" class="col-xs-3 control-label">{{ t("security_setting.ldap.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.
+              {{ t("security_setting.ldap.bind_DN_password_manager_detail") }}
             </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.
+              {{ t("security_setting.ldap.bind_DN_password_user_detail") }}
             </small>
           </p>
         </div>
       </div>
 
       <div class="form-group">
-        <label for="settingForm[security:passport-ldap:searchFilter]" class="col-xs-3 control-label">Search Filter</label>
+        <label for="settingForm[security:passport-ldap:searchFilter]" class="col-xs-3 control-label">{{ t("security_setting.ldap.search_filter") }}</label>
         <div class="col-xs-6">
-          <input class="form-control" type="text" placeholder="Default: (uid={% raw %}{{username}}{% endraw %})"
+          <input class="form-control" type="text" placeholder="{{ t("security_setting.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>
+              {{ t("security_setting.ldap.search_filter_detail1") }}<br>
+              {{ t("security_setting.ldap.search_filter_detail2") }}<br>
+              {{ t("security_setting.ldap.search_filter_detail3") }}<br>
               <br>
-              Example to match with 'uid' or 'mail': <code>(|(uid={% raw %}{{username}}{% endraw %})(mail={% raw %}{{username}}{% endraw %}))</code>
+              {{ t("security_setting.ldap.search_filter_example") }}: <code>(|(uid={% raw %}{{username}}{% endraw %})(mail={% raw %}{{username}}{% endraw %}))</code>
             </small>
           </p>
         </div>
       </div>
 
-      <h4>Attribute Mapping (Optional)</h4>
+      <h4>{{ t("security_setting.ldap.attribute_mapping") }} ({{ t("security_setting.optional") }})</h4>
 
       <div class="form-group">
-        <label for="settingForm[security:passport-ldap:attrMapUsername]" class="col-xs-3 control-label">username</label>
+        <label for="settingForm[security:passport-ldap:attrMapUsername]" class="col-xs-3 control-label">{{ t("security_setting.ldap.username") }}</label>
         <div class="col-xs-6">
-          <input class="form-control" type="text" placeholder="Default: uid"
+          <input class="form-control" type="text" placeholder="{{ t("security_setting.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
+              {{ t("security_setting.ldap.username_detail") }}
             </small>
           </p>
 
@@ -127,55 +127,53 @@
             <input type="checkbox" id="cbSameUsernameTreatedAsIdenticalUser" name="settingForm[security:passport-ldap:isSameUsernameTreatedAsIdenticalUser]" value="1"
                 {% if settingForm['security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
             <label for="cbSameUsernameTreatedAsIdenticalUser">
-              {{ t("security_setting.Treat username matching as identical") }}
+              {{ t("security_setting.ldap.Treat username matching as identical") }}
             </label>
           </div>
         </div>
       </div>
 
 
-      <h4>Group Search Filter (Optional)</h4>
+      <h4>{{ t("security_setting.ldap.group_search_filter") }} ({{ t("security_setting.optional") }})</h4>
 
       <div class="form-group">
-        <label for="settingForm[security:passport-ldap:groupSearchBase]" class="col-xs-3 control-label">Group Search Base DN</label>
+        <label for="settingForm[security:passport-ldap:groupSearchBase]" class="col-xs-3 control-label">{{ t("security_setting.ldap.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>
+              {{ t("security_setting.ldap.group_search_base_DN_detail") }}<br>
+              {{ t("security_setting.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>
+        <label for="settingForm[security:passport-ldap:groupSearchFilter]" class="col-xs-3 control-label">{{ t("security_setting.ldap.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>
+              {{ t("security_setting.ldap.group_search_filter_detail1") }}<br>
+              {{ t("security_setting.ldap.group_search_filter_detail2") }}<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.)
+              {{ t("security_setting.example") }}: {{ t("security_setting.ldap.group_search_filter_detail3") }}
             </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>
+        <label for="settingForm[security:passport-ldap:groupSearchFilter]" class="col-xs-3 control-label">{{ t("security_setting.ldap.group_DN_property") }}</label>
         <div class="col-xs-6">
-          <input class="form-control" type="text" placeholder="Default: uid"
+          <input class="form-control" type="text" placeholder="{{ t("security_setting.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>.
+              {{ t("security_setting.ldap.group_DN_property_detail") }}
             </small>
           </p>
         </div>
@@ -193,7 +191,7 @@
             data-target="#test-ldap-account" data-toggle="modal"
             {%if !isLdapEnabled %}style="display: none;"{% endif %}>
 
-          Test Saved Configuration
+          {{ t("security_setting.ldap.test_config") }}
         </button>
       </div>
     </div>

+ 1 - 1
lib/views/admin/widget/passport/twitter.html

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

+ 2 - 2
lib/views/layout-crowi/page_list.html

@@ -59,11 +59,11 @@
   {% endif %}
   {% endif %}
 
-  <div class="{% if isPortal %}m-b-30{% endif %}">
+  <div>
     {% include '../widget/page_content.html' %}
   </div>
 
-  <div class="row page-list">
+  <div class="row page-list {% if isPortal %}m-t-30{% endif %}">
     <div class="col-md-12">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 2 - 2
lib/views/layout-growi/page.html

@@ -11,7 +11,7 @@
 
 
 {% block content_main %}
-  <div class="row m-b-30">
+  <div class="row">
 
     <div class="col-lg-10 col-md-9">
 
@@ -34,7 +34,7 @@
   </div>
 
   {% if 'growi' === behaviorType() || 'crowi-plus' === behaviorType() %}
-  <div class="row page-list">
+  <div class="row page-list m-t-30">
     <div class="col-md-10">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 2 - 2
lib/views/layout-growi/page_list.html

@@ -11,7 +11,7 @@
 
 
 {% block content_main %}
-  <div class="row m-b-30">
+  <div class="row">
 
     <div class="col-lg-10 col-md-9">
 
@@ -33,7 +33,7 @@
 
   </div>
 
-  <div class="row page-list">
+  <div class="row page-list {% if isPortal %}m-t-30{% endif %}">
     <div class="col-md-10">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 2 - 2
lib/views/layout-growi/user_page.html

@@ -15,7 +15,7 @@
 
 
 {% block content_main %}
-  <div class="row m-b-30">
+  <div class="row">
 
     <div class="col-lg-10 col-md-9">
 
@@ -52,7 +52,7 @@
   </div>
 
   {% if 'growi' === behaviorType() || 'crowi-plus' === behaviorType() %}
-  <div class="row page-list">
+  <div class="row page-list m-t-30">
     <div class="col-md-10">
       {% include '../widget/page_list_and_timeline.html' %}
     </div>

+ 3 - 3
lib/views/widget/page_alerts.html

@@ -29,7 +29,7 @@
     {% if req.query.renamed and not page.isDeleted() %}
     <div class="alert alert-info alert-moved">
       <span>
-        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.query.renamed) }}
+        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.renamed)) }}
       </span>
     </div>
     {% endif %}
@@ -37,7 +37,7 @@
     {% if req.query.redirectFrom and not page.isDeleted() %}
     <div class="alert alert-info alert-moved d-flex align-items-center justify-content-between">
       <span>
-        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.query.redirectFrom) }}
+        <strong>{{ t('Moved') }}: </strong> {{ t('page_page.notice.moved', req.sanitize(req.query.redirectFrom)) }}
       </span>
       {% if user %}
       <form role="form" id="unlink-page-form" onsubmit="return false;">
@@ -56,7 +56,7 @@
     {% if req.query.duplicated and not page.isDeleted() %}
     <div class="alert alert-success alert-moved">
       <span>
-        <strong>{{ t('Duplicated') }}: </strong> {{ t('page_page.notice.duplicated', req.query.duplicated) }}
+        <strong>{{ t('Duplicated') }}: </strong> {{ t('page_page.notice.duplicated', req.sanitize(req.query.duplicated)) }}
       </span>
     </div>
     {% endif %}

+ 6 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.0.11-RC",
+  "version": "3.0.13-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -83,11 +83,12 @@
     "express": "^4.16.1",
     "express-form": "~0.12.0",
     "express-pino-logger": "^3.0.1",
+    "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "extract-text-webpack-plugin": "^3.0.2",
     "file-loader": "^1.1.0",
-    "googleapis": "^28.1.0",
+    "googleapis": "^29.0.0",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "i18next": "^11.1.1",
@@ -97,6 +98,7 @@
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
+    "load-css-file": "^1.0.0",
     "markdown-it": "^8.4.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
@@ -136,6 +138,7 @@
     "reveal.js": "^3.5.0",
     "rimraf": "^2.6.1",
     "sass-loader": "^7.0.1",
+    "simple-load-script": "^1.0.2",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
     "socket.io-client": "^2.0.3",
@@ -164,7 +167,7 @@
     "morgan": "^1.8.2",
     "node-dev": "^3.1.3",
     "on-headers": "^1.0.1",
-    "sinon": "^4.0.0",
+    "sinon": "^5.0.2",
     "sinon-chai": "^3.0.0",
     "webpack-dll-bundles-plugin": "^1.0.0-beta.5"
   },

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

@@ -2,7 +2,6 @@ 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');
@@ -10,7 +9,6 @@ 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');
 

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

@@ -2,13 +2,11 @@ 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');
 

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

@@ -2,7 +2,6 @@ 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');
@@ -10,7 +9,6 @@ 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');
 

+ 25 - 25
resource/js/components/PageAttachment.js

@@ -27,19 +27,19 @@ export default class PageAttachment extends React.Component {
     }
 
     this.props.crowi.apiGet('/attachments.list', {page_id: pageId })
-    .then(res => {
-      const attachments = res.attachments;
-      let inUse = {};
-
-      for (const attachment of attachments) {
-        inUse[attachment._id] = this.checkIfFileInUse(attachment);
-      }
-
-      this.setState({
-        attachments: attachments,
-        inUse: inUse,
+      .then(res => {
+        const attachments = res.attachments;
+        let inUse = {};
+
+        for (const attachment of attachments) {
+          inUse[attachment._id] = this.checkIfFileInUse(attachment);
+        }
+
+        this.setState({
+          attachments: attachments,
+          inUse: inUse,
+        });
       });
-    });
   }
 
   checkIfFileInUse(attachment) {
@@ -62,20 +62,20 @@ export default class PageAttachment extends React.Component {
     });
 
     this.props.crowi.apiPost('/attachments.remove', {attachment_id: attachmentId})
-    .then(res => {
-      this.setState({
-        attachments: this.state.attachments.filter((at) => {
-          return at._id != attachmentId;
-        }),
-        attachmentToDelete: null,
-        deleting: false,
-      });
-    }).catch(err => {
-      this.setState({
-        deleteError: 'Something went wrong.',
-        deleting: false,
+      .then(res => {
+        this.setState({
+          attachments: this.state.attachments.filter((at) => {
+            return at._id != attachmentId;
+          }),
+          attachmentToDelete: null,
+          deleting: false,
+        });
+      }).catch(err => {
+        this.setState({
+          deleteError: 'Something went wrong.',
+          deleting: false,
+        });
       });
-    });
   }
 
   isUserLoggedIn() {

+ 12 - 11
resource/js/components/PageAttachment/Attachment.js

@@ -12,10 +12,10 @@ export default class Attachment extends React.Component {
 
   iconNameByFormat(format) {
     if (format.match(/image\/.+/i)) {
-      return 'fa fa-file-image-o';
+      return 'icon-picture';
     }
 
-    return 'fa fa-file-o';
+    return 'icon-doc';
   }
 
   _onAttachmentDeleteClicked(event) {
@@ -24,7 +24,6 @@ export default class Attachment extends React.Component {
 
   render() {
     const attachment = this.props.attachment;
-    const attachmentId = attachment._id
     const formatIcon = this.iconNameByFormat(attachment.fileFormat);
 
     let fileInUse = '';
@@ -35,16 +34,18 @@ export default class Attachment extends React.Component {
     const fileType = <span className="attachment-filetype label label-default">{attachment.fileFormat}</span>;
 
     const btnDownload = (this.props.isUserLoggedIn)
-        ? <a className="attachment-download" href={`/download/${attachment._id}`}>
-            <i className="icon-cloud-download"></i>
-          </a>
-        : '';
+      ? (
+        <a className="attachment-download" href={`/download/${attachment._id}`}>
+          <i className="icon-cloud-download"></i>
+        </a>)
+      : '';
 
     const btnTrash = (this.props.isUserLoggedIn)
-        ? <a className="text-danger attachment-delete" onClick={this._onAttachmentDeleteClicked}>
-            <i className="icon-trash"></i>
-          </a>
-        : '';
+      ? (
+        <a className="text-danger attachment-delete" onClick={this._onAttachmentDeleteClicked}>
+          <i className="icon-trash"></i>
+        </a>)
+      : '';
 
     return (
       <li>

+ 26 - 20
resource/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -15,22 +15,30 @@ export default class DeleteAttachmentModal extends React.Component {
     this.props.onAttachmentDeleteClickedConfirm(this.props.attachmentToDelete);
   }
 
-  renderByFileFormat(attachment) {
-    if (attachment.fileFormat.match(/image\/.+/i)) {
-      return (
-        <div className="attachment-delete-image">
-          <p>
-            {attachment.originalName} uploaded by <User user={attachment.creator} username />
-          </p>
-          <img src={attachment.url} />
-        </div>
-      );
+  iconNameByFormat(format) {
+    if (format.match(/image\/.+/i)) {
+      return 'icon-picture';
     }
 
+    return 'icon-doc';
+  }
+
+  renderByFileFormat(attachment) {
+    const content = (attachment.fileFormat.match(/image\/.+/i))
+      ? <img src={attachment.url} />
+      : '';
+
+
     return (
-        <p className="attachment-delete-file">
-          <i className="fa fa-file-o"></i>
+      <div className="attachment-delete-image">
+        <p>
+          <i className={this.iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+        </p>
+        <p>
+          uploaded by <User user={attachment.creator} username />
         </p>
+        {content}
+      </div>
     );
   }
 
@@ -40,9 +48,6 @@ export default class DeleteAttachmentModal extends React.Component {
       return null;
     }
 
-
-    const inUse = this.props.inUse;
-
     const props = Object.assign({}, this.props);
     delete props.onAttachmentDeleteClickedConfirm;
     delete props.attachmentToDelete;
@@ -52,10 +57,10 @@ export default class DeleteAttachmentModal extends React.Component {
 
     let deletingIndicator = '';
     if (this.props.deleting) {
-      deletingIndicator = <Icon name="spinner" spin />;
+      deletingIndicator = <div class="speeding-wheel-sm"></div>;
     }
     if (this.props.deleteError) {
-      deletingIndicator = <p>{this.props.deleteError}</p>;
+      deletingIndicator = <span>{this.props.deleteError}</span>;
     }
 
     let renderAttachment = this.renderByFileFormat(attachment);
@@ -69,10 +74,11 @@ export default class DeleteAttachmentModal extends React.Component {
           {renderAttachment}
         </Modal.Body>
         <Modal.Footer>
-          {deletingIndicator}
+          <div className="mr-3 d-inline-block">
+            {deletingIndicator}
+          </div>
           <Button onClick={this._onDeleteConfirm} bsStyle="danger"
-            disabled={this.props.deleting}
-            >Delete!</Button>
+            disabled={this.props.deleting}>Delete!</Button>
         </Modal.Footer>
       </Modal>
     );

+ 26 - 28
resource/js/components/PageEditor.js

@@ -100,7 +100,7 @@ export default class PageEditor extends React.Component {
    */
   onMarkdownChanged(value) {
     this.renderWithDebounce(value);
-    this.saveDraftWithDebounce()
+    this.saveDraftWithDebounce();
   }
 
   /**
@@ -135,15 +135,15 @@ export default class PageEditor extends React.Component {
           closeButton: true,
           progressBar: true,
           newestOnTop: false,
-          showDuration: "100",
-          hideDuration: "100",
-          timeOut: "1200",
-          extendedTimeOut: "150",
+          showDuration: '100',
+          hideDuration: '100',
+          timeOut: '1200',
+          extendedTimeOut: '150',
         });
 
         this.pageSavedHandler(res.page);
       })
-      .catch(this.apiErrorHandler)
+      .catch(this.apiErrorHandler);
   }
 
   /**
@@ -158,7 +158,7 @@ export default class PageEditor extends React.Component {
     formData.append('_csrf', this.props.crowi.csrfToken);
     formData.append('file', file);
     formData.append('path', this.props.pagePath);
-    formData.append('page_id', this.props.pageId || 0);
+    formData.append('page_id', this.state.pageId || 0);
 
     // post
     this.props.crowi.apiPost(endpoint, formData)
@@ -233,7 +233,7 @@ export default class PageEditor extends React.Component {
     // turn on the flag
     this.isOriginOfScrollSyncEditor = true;
     scrollSyncHelper.scrollPreview(this.previewElement, line);
-  };
+  }
 
   /**
    * scroll Preview element by cursor moving
@@ -253,7 +253,7 @@ export default class PageEditor extends React.Component {
     // turn on the flag
     this.isOriginOfScrollSyncEditor = true;
     scrollSyncHelper.scrollPreviewToRevealOverflowing(this.previewElement, line);
-  };
+  }
 
   /**
    * the scroll event handler from Preview component
@@ -316,15 +316,13 @@ export default class PageEditor extends React.Component {
       closeButton: true,
       progressBar: true,
       newestOnTop: false,
-      showDuration: "100",
-      hideDuration: "100",
-      timeOut: "3000",
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
     });
   }
 
   renderPreview(value) {
-    const config = this.props.crowi.config;
-
     this.setState({ markdown: value });
 
     // render html
@@ -367,27 +365,27 @@ export default class PageEditor extends React.Component {
       <div className="row">
         <div className="col-md-6 col-sm-12 page-editor-editor-container">
           <Editor ref="editor" value={this.state.markdown}
-              editorOptions={this.state.editorOptions}
-              isUploadable={this.state.isUploadable}
-              isUploadableFile={this.state.isUploadableFile}
-              onScroll={this.onEditorScroll}
-              onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
-              onChange={this.onMarkdownChanged}
-              onSave={this.onSave}
-              onUpload={this.onUpload}
+            editorOptions={this.state.editorOptions}
+            isUploadable={this.state.isUploadable}
+            isUploadableFile={this.state.isUploadableFile}
+            onScroll={this.onEditorScroll}
+            onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
+            onChange={this.onMarkdownChanged}
+            onSave={this.onSave}
+            onUpload={this.onUpload}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
           <Preview html={this.state.html}
-              inputRef={el => this.previewElement = el}
-              isMathJaxEnabled={this.state.isMathJaxEnabled}
-              renderMathJaxOnInit={false}
-              previewOptions={this.state.previewOptions}
-              onScroll={this.onPreviewScroll}
+            inputRef={el => this.previewElement = el}
+            isMathJaxEnabled={this.state.isMathJaxEnabled}
+            renderMathJaxOnInit={false}
+            previewOptions={this.state.previewOptions}
+            onScroll={this.onPreviewScroll}
           />
         </div>
       </div>
-    )
+    );
   }
 }
 

+ 100 - 17
resource/js/components/PageEditor/Editor.js

@@ -1,10 +1,13 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import urljoin from 'url-join';
+const loadScript = require('simple-load-script');
+const loadCssSync = require('load-css-file');
+
 import * as codemirror from 'codemirror';
 
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
-require('codemirror/lib/codemirror.css');
 require('codemirror/addon/display/autorefresh');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/matchtags');
@@ -23,13 +26,6 @@ require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/mode/gfm/gfm');
 
-require('codemirror/theme/elegant.css');
-require('codemirror/theme/neo.css');
-require('codemirror/theme/mdn-like.css');
-require('codemirror/theme/material.css');
-require('codemirror/theme/monokai.css');
-require('codemirror/theme/twilight.css');
-
 
 import Dropzone from 'react-dropzone';
 
@@ -46,8 +42,7 @@ export default class Editor extends React.Component {
   constructor(props) {
     super(props);
 
-    // https://regex101.com/r/7BN2fR/2
-    this.indentAndMarkPattern = /^([ \t]*)(?:>|\-|\+|\*|\d+\.) /;
+    this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.37.0';
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([
@@ -61,9 +56,15 @@ export default class Editor extends React.Component {
       isUploading: false,
     };
 
+    this.loadedThemeSet = new Set(['eclipse', 'elegant']);   // themes imported in _vendor.scss
+    this.loadedKeymapSet = new Set();
+
     this.getCodeMirror = this.getCodeMirror.bind(this);
     this.setCaretLine = this.setCaretLine.bind(this);
     this.setScrollTopByLine = this.setScrollTopByLine.bind(this);
+    this.loadTheme = this.loadTheme.bind(this);
+    this.loadKeymapMode = this.loadKeymapMode.bind(this);
+    this.setKeymapMode = this.setKeymapMode.bind(this);
     this.forceToFocus = this.forceToFocus.bind(this);
     this.dispatchSave = this.dispatchSave.bind(this);
     this.handleEnterKey = this.handleEnterKey.bind(this);
@@ -80,17 +81,39 @@ export default class Editor extends React.Component {
     this.renderOverlay = this.renderOverlay.bind(this);
   }
 
+
   componentDidMount() {
     // initialize caret line
     this.setCaretLine(0);
     // set save handler
     codemirror.commands.save = this.dispatchSave;
+
+    // set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
+    window.CodeMirror = require('codemirror');
+  }
+
+  componentWillReceiveProps(nextProps) {
+    // load theme
+    const theme = nextProps.editorOptions.theme;
+    this.loadTheme(theme);
+
+    // set keymap
+    const prevKeymapMode = this.props.editorOptions.keymapMode;
+    const keymapMode = nextProps.editorOptions.keymapMode;
+    this.setKeymapMode(keymapMode);
   }
 
   getCodeMirror() {
     return this.refs.cm.editor;
   }
 
+  loadCss(source) {
+    return new Promise((resolve) => {
+      loadCssSync(source);
+      resolve();
+    });
+  }
+
   forceToFocus() {
     const editor = this.getCodeMirror();
     // use setInterval with reluctance -- 2018.01.11 Yuki Takei
@@ -133,6 +156,66 @@ export default class Editor extends React.Component {
     editor.scrollTo(null, top);
   }
 
+  /**
+   * load Theme
+   * @see https://codemirror.net/doc/manual.html#config
+   *
+   * @param {string} theme
+   */
+  loadTheme(theme) {
+    // load theme
+    let cssList = [];
+    if (!this.loadedThemeSet.has(theme)) {
+      this.loadCss(urljoin(this.cmCdnRoot, `theme/${theme}.min.css`));
+
+      // update Set
+      this.loadedThemeSet.add(theme);
+    }
+  }
+
+  /**
+   * load assets for Key Maps
+   * @param {*} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
+   */
+  loadKeymapMode(keymapMode) {
+    const loadCss = this.loadCss;
+    let scriptList = [];
+    let cssList = [];
+
+    // add dependencies
+    if (this.loadedKeymapSet.size == 0) {
+      scriptList.push(loadScript(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js')));
+      cssList.push(loadCss(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.css')));
+    }
+    // load keymap
+    if (!this.loadedKeymapSet.has(keymapMode)) {
+      scriptList.push(loadScript(urljoin(this.cmCdnRoot, `keymap/${keymapMode}.min.js`)));
+      // update Set
+      this.loadedKeymapSet.add(keymapMode);
+    }
+
+    return Promise.all(scriptList.concat(cssList));
+  }
+
+  /**
+   * set Key Maps
+   * @see https://codemirror.net/doc/manual.html#keymaps
+   *
+   * @param {string} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
+   */
+  setKeymapMode(keymapMode) {
+    if (!keymapMode.match(/^(vim|emacs|sublime)$/)) {
+      // reset
+      this.getCodeMirror().setOption('keyMap', 'default');
+      return;
+    }
+
+    this.loadKeymapMode(keymapMode)
+    .then(() => {
+      this.getCodeMirror().setOption('keyMap', keymapMode);
+    });
+  }
+
   /**
    * remove overlay and set isUploading to false
    */
@@ -321,7 +404,7 @@ export default class Editor extends React.Component {
       height: '100%',
       display: 'flex',
       flexDirection: 'column',
-    }
+    };
 
     const theme = this.props.editorOptions.theme || 'elegant';
     const styleActiveLine = this.props.editorOptions.styleActiveLine || undefined;
@@ -363,17 +446,17 @@ export default class Editor extends React.Component {
               matchTags: {bothTags: true},
               // folding
               foldGutter: true,
-              gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
+              gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
               // match-highlighter, matchesonscrollbar, annotatescrollbar options
               highlightSelectionMatches: {annotateScrollbar: true},
               // markdown mode options
               highlightFormatting: true,
               // continuelist, indentlist
               extraKeys: {
-                "Enter": this.handleEnterKey,
-                "Tab": "indentMore",
-                "Shift-Tab": "indentLess",
-                "Ctrl-Q": (cm) => { cm.foldCode(cm.getCursor()) },
+                'Enter': this.handleEnterKey,
+                'Tab': 'indentMore',
+                'Shift-Tab': 'indentLess',
+                'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
               }
             }}
             onScroll={(editor, data) => {
@@ -405,7 +488,7 @@ export default class Editor extends React.Component {
           or pasting from the clipboard.
         </button>
       </div>
-    )
+    );
   }
 
 }

+ 44 - 2
resource/js/components/PageEditor/OptionsSelector.js

@@ -28,10 +28,17 @@ export default class OptionsSelector extends React.Component {
     }
 
     this.availableThemes = [
-      'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
-    ]
+      'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight'
+    ];
+    this.keymapModes = {
+      default: 'Default',
+      vim: 'Vim',
+      emacs: 'Emacs',
+      sublime: 'Sublime Text',
+    }
 
     this.onChangeTheme = this.onChangeTheme.bind(this);
+    this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
@@ -43,6 +50,7 @@ export default class OptionsSelector extends React.Component {
 
   init() {
     this.themeSelectorInputEl.value = this.state.editorOptions.theme;
+    this.keymapModeSelectorInputEl.value = this.state.editorOptions.keymapMode;
   }
 
   onChangeTheme() {
@@ -54,6 +62,15 @@ export default class OptionsSelector extends React.Component {
     this.dispatchOnChange();
   }
 
+  onChangeKeymapMode() {
+    const newValue = this.keymapModeSelectorInputEl.value;
+    const newOpts = Object.assign(this.state.editorOptions, {keymapMode: newValue});
+    this.setState({editorOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
   onClickStyleActiveLine(event) {
     // keep dropdown opened
     this._cddForceOpen = true;
@@ -121,6 +138,29 @@ export default class OptionsSelector extends React.Component {
     )
   }
 
+  renderKeymapModeSelector() {
+    const optionElems = [];
+    for (let mode in this.keymapModes) {
+      const label = this.keymapModes[mode];
+      optionElems.push(<option key={mode} value={mode}>{label}</option>);
+    }
+
+    const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
+
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <ControlLabel>Mode:</ControlLabel>
+        <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
+            onChange={this.onChangeKeymapMode}
+            inputRef={ el => this.keymapModeSelectorInputEl=el }>
+
+          {optionElems}
+
+        </FormControl>
+      </FormGroup>
+    )
+  }
+
   renderConfigurationDropdown() {
     return (
       <FormGroup controlId="formControlsSelect">
@@ -188,6 +228,7 @@ export default class OptionsSelector extends React.Component {
   render() {
     return <span>
       <span className="m-l-5">{this.renderThemeSelector()}</span>
+      <span className="m-l-5">{this.renderKeymapModeSelector()}</span>
       <span className="m-l-5">{this.renderConfigurationDropdown()}</span>
     </span>
   }
@@ -196,6 +237,7 @@ export default class OptionsSelector extends React.Component {
 export class EditorOptions {
   constructor(props) {
     this.theme = 'elegant';
+    this.keymapMode = 'default';
     this.styleActiveLine = false;
 
     Object.assign(this, props);

+ 0 - 2
resource/js/legacy/crowi.js

@@ -502,8 +502,6 @@ $(function() {
     if ($affixContent.length > 0) {
       var $affixContentContainer = $('.row.bg-title');
       var containerHeight = $affixContentContainer.outerHeight(true);
-      // fix height(固定)
-      $affixContentContainer.css({height: containerHeight + 'px'});
       $affixContent.affix({
         offset: {
           top: function() {

+ 1 - 1
resource/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -281,7 +281,7 @@ body.on-edit {
     background-color: darken($bodycolor, 2%);
 
     .page-editor-editor-container {
-      border-right-color: $border;
+      border-right-color: $navbar-border;
     }
     .page-editor-preview-container {
       background-color: $bodycolor;

+ 5 - 1
resource/styles/scss/_login.scss

@@ -202,9 +202,12 @@
     }
 
     .front, .back {
-      backface-visibility: hidden;
       transition: 0.4s;
+      backface-visibility: hidden;
       transform-style: preserve-3d;
+      // fix https://github.com/weseek/growi/issues/330
+      -webkit-backface-visibility: hidden;
+      -webkit-transform-style: preserve-3d;
     }
     .front {
       z-index: 2;
@@ -223,6 +226,7 @@
       // 'backface-visibility: hidden' and 'z-index: -1' breaks layout in iOS
       .fcbtn:after {
         z-index: 0;
+        opacity: 0.3;
       }
     }
     &.to-flip .back {

+ 1 - 1
resource/styles/scss/_search.scss

@@ -75,7 +75,7 @@
     border-radius: 40px;
     border-top-right-radius: 40px;
     border-bottom-right-radius: 40px;
-    padding-top: 4px;
+    padding-top: 6px;
     height: 30px;
 
     .rbt-input-wrapper {

+ 5 - 0
resource/styles/scss/_vendor.scss

@@ -13,3 +13,8 @@ $bootstrap-sass-asset-helper: true;
 
 // import bootstrap-select styles
 @import '~bootstrap-select/sass/bootstrap-select';
+
+// import CodeMirror styles
+@import '~codemirror/lib/codemirror.css';
+@import '~codemirror/theme/elegant.css';
+@import '~codemirror/theme/eclipse.css';

+ 45 - 16
yarn.lock

@@ -2,6 +2,12 @@
 # yarn lockfile v1
 
 
+"@sinonjs/formatio@^2.0.0":
+  version "2.0.0"
+  resolved "http://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2"
+  dependencies:
+    samsam "1.3.0"
+
 "@types/body-parser@*":
   version "1.16.8"
   resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.16.8.tgz#687ec34140624a3bec2b1a8ea9268478ae8f3be3"
@@ -2688,6 +2694,13 @@ express-pino-logger@^3.0.1:
   dependencies:
     pino-http "^3.0.1"
 
+express-sanitizer@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/express-sanitizer/-/express-sanitizer-1.0.4.tgz#5331a12de6577582901a6581e91e38a8b99a6ee2"
+  dependencies:
+    sanitizer "0.1.3"
+    underscore "1.8.3"
+
 express-session@~1.15.0:
   version "1.15.6"
   resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.15.6.tgz#47b4160c88f42ab70fe8a508e31cbff76757ab0a"
@@ -2980,7 +2993,7 @@ form-data@~2.3.1:
     combined-stream "^1.0.5"
     mime-types "^2.1.12"
 
-formatio@1.2.0, formatio@^1.2.0:
+formatio@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
   dependencies:
@@ -3191,14 +3204,14 @@ google-p12-pem@^1.0.0:
     node-forge "^0.7.1"
     pify "^3.0.0"
 
-googleapis@^28.1.0:
-  version "28.1.0"
-  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-28.1.0.tgz#f78ce5751581387274f8eb22eee947a13c7c4285"
+googleapis@^29.0.0:
+  version "29.0.0"
+  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-29.0.0.tgz#b1b2c080b7c5722621671f732a278a879758f4dd"
   dependencies:
     google-auth-library "^1.3.1"
     pify "^3.0.0"
     qs "^6.5.1"
-    string-template "1.0.0"
+    url-template "^2.0.8"
     uuid "^3.2.1"
 
 graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6:
@@ -4033,6 +4046,10 @@ linkify-it@^2.0.0:
   dependencies:
     uc.micro "^1.0.1"
 
+load-css-file@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/load-css-file/-/load-css-file-1.0.0.tgz#dac097ead6470f4c3f23d4bc5b9ff2c3decb212f"
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -6484,10 +6501,14 @@ safe-json-stringify@~1:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.0.4.tgz#81a098f447e4bbc3ff3312a243521bc060ef5911"
 
-samsam@1.x:
+samsam@1.3.0, samsam@1.x:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
 
+sanitizer@0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/sanitizer/-/sanitizer-0.1.3.tgz#d4f0af7475d9a7baf2a9e5a611718baa178a39e1"
+
 sass-graph@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49"
@@ -6687,20 +6708,24 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
 
+simple-load-script@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/simple-load-script/-/simple-load-script-1.0.2.tgz#d92951fe7b601ad90af8c9429bd4b2ee127ab8a3"
+
 sinon-chai@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.0.0.tgz#d5cbd70fa71031edd96b528e0eed4038fcc99f29"
 
-sinon@^4.0.0:
-  version "4.1.4"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-4.1.4.tgz#36bb237bae38ddf9cc92dcc1b16c51e7785bbc9c"
+sinon@^5.0.2:
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-5.0.2.tgz#1d54bd6fa3d736053333f02e6e11def4fe7a2a9c"
   dependencies:
+    "@sinonjs/formatio" "^2.0.0"
     diff "^3.1.0"
-    formatio "1.2.0"
     lodash.get "^4.4.2"
     lolex "^2.2.0"
     nise "^1.2.0"
-    supports-color "^4.4.0"
+    supports-color "^5.1.0"
     type-detect "^4.0.5"
 
 slack-node@^0.1.8:
@@ -6914,10 +6939,6 @@ strict-uri-encode@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
 
-string-template@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
-
 string-width@^1.0.1, string-width@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
@@ -7018,7 +7039,7 @@ supports-color@^3.2.3:
   dependencies:
     has-flag "^1.0.0"
 
-supports-color@^4.0.0, supports-color@^4.2.1, supports-color@^4.4.0:
+supports-color@^4.0.0, supports-color@^4.2.1:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b"
   dependencies:
@@ -7298,6 +7319,10 @@ uncontrollable@^4.1.0:
   dependencies:
     invariant "^2.1.0"
 
+underscore@1.8.3:
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
+
 uniq@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
@@ -7328,6 +7353,10 @@ url-join@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a"
 
+url-template@^2.0.8:
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
+
 url@0.10.3:
   version "0.10.3"
   resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"