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

Merge branch 'support/apply-bootstrap4' into bst4-login

sooouh 6 лет назад
Родитель
Сommit
ceb3902f64
79 измененных файлов с 1655 добавлено и 795 удалено
  1. 22 1
      CHANGES.md
  2. 1 0
      config/logger/config.dev.js
  3. 6 6
      package.json
  4. 3 1
      resource/locales/en-US/admin/admin.json
  5. 9 3
      resource/locales/en-US/translation.json
  6. 3 1
      resource/locales/ja/admin/admin.json
  7. 9 3
      resource/locales/ja/translation.json
  8. 2 0
      src/client/js/bootstrap.jsx
  9. 24 21
      src/client/js/components/Admin/App/AppSetting.jsx
  10. 5 5
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  11. 17 17
      src/client/js/components/Admin/App/AwsSetting.jsx
  12. 11 11
      src/client/js/components/Admin/App/MailSetting.jsx
  13. 6 5
      src/client/js/components/Admin/App/PluginSetting.jsx
  14. 40 42
      src/client/js/components/Admin/App/SiteUrlSetting.jsx
  15. 15 0
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  16. 31 21
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  17. 4 6
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  18. 2 2
      src/client/js/components/Admin/Users/RemoveAdminButton.jsx
  19. 2 2
      src/client/js/components/Admin/Users/StatusSuspendedButton.jsx
  20. 2 2
      src/client/js/components/BookmarkButton.jsx
  21. 2 2
      src/client/js/components/HeaderSearchBox.jsx
  22. 2 2
      src/client/js/components/LikeButton.jsx
  23. 60 0
      src/client/js/components/Navbar/PersonalDropdown.jsx
  24. 22 11
      src/client/js/components/Page/RevisionPath.jsx
  25. 1 1
      src/client/js/components/PageAttachment.jsx
  26. 52 149
      src/client/js/components/PageComment/Comment.jsx
  27. 24 0
      src/client/js/components/PageComment/CommentControl.jsx
  28. 1 3
      src/client/js/components/PageComment/CommentEditor.jsx
  29. 2 3
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  30. 123 0
      src/client/js/components/PageComment/ReplayComments.jsx
  31. 8 2
      src/client/js/components/PageComments.jsx
  32. 6 2
      src/client/js/components/PageHistory/RevisionDiff.jsx
  33. 1 1
      src/client/js/components/RecentCreated/RecentCreated.jsx
  34. 7 0
      src/client/js/legacy/crowi.js
  35. 11 0
      src/client/js/services/AdminCustomizeContainer.js
  36. 20 10
      src/client/js/services/AppContainer.js
  37. 4 0
      src/client/styles/scss/_admin.scss
  38. 20 7
      src/client/styles/scss/_comment.scss
  39. 0 5
      src/client/styles/scss/_comment_growi.scss
  40. 0 5
      src/client/styles/scss/_comment_kibela.scss
  41. 48 0
      src/client/styles/scss/_layout.scss
  42. 0 5
      src/client/styles/scss/_layout_crowi_sidebar.scss
  43. 5 0
      src/client/styles/scss/_navbar.scss
  44. 5 0
      src/client/styles/scss/_override-bootstrap-variables.scss
  45. 4 0
      src/client/styles/scss/_override-bootstrap.scss
  46. 1 70
      src/client/styles/scss/_page.scss
  47. 1 1
      src/client/styles/scss/style-app.scss
  48. 33 10
      src/client/styles/scss/theme/_apply-colors.scss
  49. 5 3
      src/linter-checker/test.js
  50. 5 3
      src/linter-checker/test.scss
  51. 4 0
      src/server/crowi/express-init.js
  52. 1 0
      src/server/form/admin/securityPassportSaml.js
  53. 3 1
      src/server/middleware/access-token-parser.js
  54. 1 1
      src/server/middleware/login-required.js
  55. 65 0
      src/server/middleware/safe-redirect.js
  56. 2 0
      src/server/models/config.js
  57. 23 2
      src/server/models/page.js
  58. 10 2
      src/server/routes/admin.js
  59. 6 0
      src/server/routes/apiv3/customize-setting.js
  60. 2 2
      src/server/routes/index.js
  61. 20 31
      src/server/routes/login-passport.js
  62. 27 46
      src/server/routes/login.js
  63. 4 1
      src/server/routes/logout.js
  64. 7 1
      src/server/service/config-loader.js
  65. 2 1
      src/server/service/config-manager.js
  66. 114 0
      src/server/service/passport.js
  67. 2 2
      src/server/views/admin/app.html
  68. 1 1
      src/server/views/admin/export.html
  69. 1 1
      src/server/views/admin/importer.html
  70. 50 0
      src/server/views/admin/widget/passport/saml.html
  71. 1 1
      src/server/views/installer.html
  72. 1 1
      src/server/views/layout-growi/widget/header.html
  73. 11 14
      src/server/views/layout/layout.html
  74. 4 1
      src/server/views/widget/not_found_tabs.html
  75. 29 5
      src/server/views/widget/page_tabs.html
  76. 29 5
      src/server/views/widget/page_tabs_kibela.html
  77. 4 4
      src/test/middleware/login-required.test.js
  78. 108 0
      src/test/middleware/safe-redirect.test.js
  79. 436 228
      yarn.lock

+ 22 - 1
CHANGES.md

@@ -1,8 +1,29 @@
 # CHANGES
 
-## v3.6.8-RC
+## v3.6.10
 
+*
+
+## v3.6.9
+
+* Improvement: Redirection when login/logout
+* Improvement: Add home icon before '/'
+* Fix: Client crashed when the first login
+    * Introduced by 3.6.8
+
+## v3.6.8
+
+* Improvement: Show page history side-by-side
 * Improvement: Optimize markdown rendering
+* Improvement: Reactify admin pages (Navigation)
+* Fix: Reply comments collapsed are broken
+    * Introduced by 3.6.7
+* Support: Update libs
+    * cross-env
+    * mkdirp
+    * diff2html
+    * jest
+    * stylelint
 
 ## v3.6.7
 

+ 1 - 0
config/logger/config.dev.js

@@ -14,6 +14,7 @@ module.exports = {
   'growi:models:external-account': 'debug',
   // 'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
+  'growi:middleware:safe-redirect': 'debug',
   'growi:service:PassportService': 'debug',
   // 'growi:service:ConfigManager': 'debug',
   'growi:lib:search': 'debug',

+ 6 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.6.8-RC",
+  "version": "3.6.9-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -85,7 +85,7 @@
     "connect-mongo": "^3.0.0",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
-    "cross-env": "^6.0.3",
+    "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "diff": "^4.0.1",
@@ -112,7 +112,7 @@
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "migrate-mongo": "^7.0.1",
-    "mkdirp": "~0.5.1",
+    "mkdirp": "^1.0.3",
     "module-alias": "^2.0.6",
     "mongoose": "5.4.4",
     "mongoose-gridfs": "^1.2.2",
@@ -172,7 +172,7 @@
     "core-js": "=2.6.9",
     "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
-    "diff2html": "^2.3.3",
+    "diff2html": "^3.1.2",
     "eazy-logger": "^3.0.2",
     "eslint": "^6.0.1",
     "eslint-config-weseek": "^1.0.3",
@@ -184,7 +184,7 @@
     "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
-    "jest": "^24.8.0",
+    "jest": "^25.1.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -230,7 +230,7 @@
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^2.0.3",
     "style-loader": "^1.0.0",
-    "stylelint": "^12.0.1",
+    "stylelint": "^13.2.0",
     "stylelint-config-recess-order": "^2.0.1",
     "swagger-jsdoc": "^3.4.0",
     "swagger2openapi": "^5.3.1",

+ 3 - 1
resource/locales/en-US/admin/admin.json

@@ -126,7 +126,9 @@
       "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
       "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
       "stale_notification": "Display Notification on Stale Pages",
-      "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update."
+      "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
+      "show_all_reply_comments": "Show all reply comments",
+      "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted."
     },
     "code_highlight": "Code Highlight",
     "nocdn_desc": "This function is disabled when the environment variable <code>NO_CDN=true</code>.<br>Github style has been forcibly applied.",

+ 9 - 3
resource/locales/en-US/translation.json

@@ -46,6 +46,7 @@
   "Timeline View": "Timeline",
   "History": "History",
   "Presentation Mode": "Presentation",
+  "Not available for guest": "Not available for guest",
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
@@ -123,7 +124,8 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "form_validation": {
-    "required": "<code>%s</code> is required"
+    "required": "<code>%s</code> is required",
+    "invalid_syntax": "The syntax of <code>%s</code> is invalid."
   },
   "installer": {
     "setup": "Setup",
@@ -455,7 +457,10 @@
       "mapping_detail": "Specification of mappings for %s when creating new users",
       "cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
       "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>%s</code> is used.",
-      "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> ."
+      "note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>%s</code> .",
+      "attr_based_login_control_detail": "Limit who can sign up by using <code>&lt;saml: Attribute&gt;</code> element included in <code>&lt;saml: AttributeStatement&gt;</code> element and its child element <code>&lt;saml: AttributeValue&gt;</code>.",
+      "attr_based_login_control_rule_detail": "A rule is written in the form of concatenating <code>attribute name = value</code> with <code>|</code> and <code>&</code>. The or operator (|) has a lower precedence than the and operator (&). If operator precedence is equal, left to right associativity is used.",
+      "attr_based_login_control_rule_example": "For example, if a rule is <code>Department = A | Department = B & Position = Leader</code>, users with <code>Department</code> as <code>A</code> or users with <code>Department</code> as <code>B</code> and <code>Position</code> as <code>Leader</code> <strong>can</strong> sign in."
     },
     "Basic": {
       "name": "Basic Authentication",
@@ -515,7 +520,8 @@
       "security:passport-saml:attrMapUsername": "Username",
       "security:passport-saml:attrMapMail": "Mail Address",
       "security:passport-saml:attrMapFirstName": "First Name",
-      "security:passport-saml:attrMapLastName": "Last Name"
+      "security:passport-saml:attrMapLastName": "Last Name",
+      "security:passport-saml:ABLCRule": "Rule"
     }
   },
   "notification_setting": {

+ 3 - 1
resource/locales/ja/admin/admin.json

@@ -126,7 +126,9 @@
       "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
       "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
       "stale_notification": "古いページに通知を表示する",
-      "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。"
+      "stale_notification_desc": "最後の更新から1年を超えるページへの通知を表示します。",
+      "show_all_reply_comments": "返信コメントを全て表示する",
+      "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。"
     },
     "code_highlight": "コードハイライト",
     "nocdn_desc": "この機能は、環境変数 <code>NO_CDN=true</code> の時は無効化されます。<br>GitHub スタイルが適用されています。",

+ 9 - 3
resource/locales/ja/translation.json

@@ -46,6 +46,7 @@
   "Timeline View": "タイムライン表示",
   "History": "更新履歴",
   "Presentation Mode": "プレゼンテーション",
+  "Not available for guest": "ゲストユーザーは利用できません",
   "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
@@ -122,7 +123,8 @@
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "form_validation": {
-    "required": "<code>%s</code> に値を入力してください"
+    "required": "<code>%s</code> に値を入力してください",
+    "invalid_syntax": "<code>%s</code> の構文が不正です"
   },
   "installer": {
     "setup": "セットアップ",
@@ -449,7 +451,10 @@
       "mapping_detail": "新規ユーザーの%sに関連付ける属性",
       "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>%s</code> の値を利用します",
-      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください"
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>%s</code> の値をfalseに変更もしくは削除してください",
+      "attr_based_login_control_detail": "SAMLの <code>&lt;saml:AttributeStatement&gt;</code> 要素に含まれる <code>&lt;saml:Attribute&gt;</code> 要素と、その子要素 <code>&lt;saml:AttributeValue&gt;</code> を利用してログインの可否を制御します。",
+      "attr_based_login_control_rule_detail": "<code>属性名 = 値</code> を <code>|</code>(論理和)、 <code>&</code>(論理積) で連結した形式で記述してください。演算子の優先順位は論理積が論理和より高く、各演算子の結合規則は左から右です。",
+      "attr_based_login_control_rule_example": "例えば <code>Department=A | Department=B & Position=Leader</code> だと <code>Department</code> が <code>A</code> の場合, もしくは<code>Department</code> が <code>B</code> かつ <code>Position</code> が <code>Leader</code> の場合にログインを<strong>許可</strong>します。"
     },
     "Basic": {
       "name": "Basic 認証",
@@ -498,7 +503,8 @@
       "security:passport-saml:attrMapUsername": "ユーザー名",
       "security:passport-saml:attrMapMail": "メールアドレス",
       "security:passport-saml:attrMapFirstName": "姓",
-      "security:passport-saml:attrMapLastName": "名"
+      "security:passport-saml:attrMapLastName": "名",
+      "security:passport-saml:ABLCRule": "ルール"
     }
   },
   "notification_setting": {

+ 2 - 0
src/client/js/bootstrap.jsx

@@ -4,6 +4,7 @@ import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
 
 import HeaderSearchBox from './components/HeaderSearchBox';
+import PersonalDropdown from './components/Navbar/PersonalDropdown';
 import StaffCredit from './components/StaffCredit/StaffCredit';
 
 import AppContainer from './services/AppContainer';
@@ -37,6 +38,7 @@ appContainer.injectToWindow();
 const componentMappings = {
   'search-top': <HeaderSearchBox />,
   'search-sidebar': <HeaderSearchBox crowi={appContainer} />,
+  'personal-dropdown': <PersonalDropdown />,
 
   'staff-credit': <StaffCredit />,
 };

+ 24 - 21
src/client/js/components/Admin/App/AppSetting.jsx

@@ -38,9 +38,9 @@ class AppSetting extends React.Component {
 
     return (
       <React.Fragment>
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">{t('admin:app_setting.site_name')}</label>
-          <div className="col-xs-6">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">{t('admin:app_setting.site_name')}</label>
+          <div className="col-6">
             <input
               className="form-control"
               type="text"
@@ -48,13 +48,13 @@ class AppSetting extends React.Component {
               onChange={(e) => { adminAppContainer.changeTitle(e.target.value) }}
               placeholder="GROWI"
             />
-            <p className="help-block">{t('admin:app_setting.sitename_change')}</p>
+            <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">{t('admin:app_setting.confidential_name')}</label>
-          <div className="col-xs-6">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">{t('admin:app_setting.confidential_name')}</label>
+          <div className="col-6">
             <input
               className="form-control"
               type="text"
@@ -62,53 +62,56 @@ class AppSetting extends React.Component {
               onChange={(e) => { adminAppContainer.changeConfidential(e.target.value) }}
               placeholder={t('admin:app_setting.confidential_example')}
             />
-            <p className="help-block">{t('admin:app_setting.header_content')}</p>
+            <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">{t('admin:app_setting.default_language')}</label>
-          <div className="col-xs-6">
-            <div className="radio radio-primary radio-inline">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">{t('admin:app_setting.default_language')}</label>
+          <div className="col-6">
+            <div className="custom-control custom-radio d-inline">
               <input
                 type="radio"
                 id="radioLangEn"
+                className="custom-control-input"
                 name="globalLang"
                 value="en-US"
                 checked={adminAppContainer.state.globalLang === 'en-US'}
                 onChange={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
               />
-              <label htmlFor="radioLangEn">{t('English')}</label>
+              <label className="custom-control-label" htmlFor="radioLangEn">{t('English')}</label>
             </div>
-            <div className="radio radio-primary radio-inline">
+            <div className="custom-control custom-radio d-inline">
               <input
                 type="radio"
                 id="radioLangJa"
+                className="custom-control-input"
                 name="globalLang"
                 value="ja"
                 checked={adminAppContainer.state.globalLang === 'ja'}
                 onChange={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
               />
-              <label htmlFor="radioLangJa">{t('Japanese')}</label>
+              <label className="custom-control-label" htmlFor="radioLangJa">{t('Japanese')}</label>
             </div>
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">{t('admin:app_setting.file_uploading')}</label>
-          <div className="col-xs-6">
-            <div className="checkbox checkbox-info">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">{t('admin:app_setting.file_uploading')}</label>
+          <div className="col-6">
+            <div className="custom-control custom-checkbox custom-checkbox-info">
               <input
                 type="checkbox"
                 id="cbFileUpload"
+                className="custom-control-input"
                 name="fileUpload"
                 checked={adminAppContainer.state.fileUpload}
                 onChange={(e) => { adminAppContainer.changeFileUpload(e.target.checked) }}
               />
-              <label htmlFor="cbFileUpload">{t('admin:app_setting.enable_files_except_image')}</label>
+              <label className="custom-control-label" htmlFor="cbFileUpload">{t('admin:app_setting.enable_files_except_image')}</label>
             </div>
 
-            <p className="help-block">
+            <p className="form-text text-muted">
               {t('admin:app_setting.enable_files_except_image')}
               <br />
               {t('admin:app_setting.attach_enable')}

+ 5 - 5
src/client/js/components/Admin/App/AppSettingsPage.jsx

@@ -38,35 +38,35 @@ class AppSettingsPage extends React.Component {
     return (
       <Fragment>
         <div className="row">
-          <div className="col-md-12">
+          <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('App Settings')}</h2>
             <AppSetting />
           </div>
         </div>
 
         <div className="row mt-5">
-          <div className="col-md-12">
+          <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
             <SiteUrlSetting />
           </div>
         </div>
 
         <div className="row mt-5">
-          <div className="col-md-12">
+          <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
             <MailSetting />
           </div>
         </div>
 
         <div className="row mt-5">
-          <div className="col-md-12">
+          <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('admin:app_setting.aws_settings')}</h2>
             <AwsSetting />
           </div>
         </div>
 
         <div className="row mt-5">
-          <div className="col-md-12">
+          <div className="col-lg-12">
             <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
             <PluginSetting />
           </div>

+ 17 - 17
src/client/js/components/Admin/App/AwsSetting.jsx

@@ -38,7 +38,7 @@ class AwsSetting extends React.Component {
 
     return (
       <React.Fragment>
-        <p className="well">
+        <p className="card well">
           {t('admin:app_setting.aws_access')}
           <br />
           {t('admin:app_setting.no_smtp_setting')}
@@ -50,11 +50,11 @@ class AwsSetting extends React.Component {
           </span>
         </p>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">
             {t('admin:app_setting.region')}
           </label>
-          <div className="col-xs-6">
+          <div className="col-6">
             <input
               className="form-control"
               placeholder={`${t('eg')} ap-northeast-1`}
@@ -66,11 +66,11 @@ class AwsSetting extends React.Component {
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">
             {t('admin:app_setting.custom_endpoint')}
           </label>
-          <div className="col-xs-6">
+          <div className="col-6">
             <input
               className="form-control"
               type="text"
@@ -80,15 +80,15 @@ class AwsSetting extends React.Component {
                 adminAppContainer.changeCustomEndpoint(e.target.value);
               }}
             />
-            <p className="help-block">{t('admin:app_setting.custom_endpoint_change')}</p>
+            <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">
             {t('admin:app_setting.bucket_name')}
           </label>
-          <div className="col-xs-6">
+          <div className="col-6">
             <input
               className="form-control"
               type="text"
@@ -101,11 +101,11 @@ class AwsSetting extends React.Component {
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">
             Access Key ID
           </label>
-          <div className="col-xs-6">
+          <div className="col-6">
             <input
               className="form-control"
               type="text"
@@ -117,11 +117,11 @@ class AwsSetting extends React.Component {
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">
             Secret Access Key
           </label>
-          <div className="col-xs-6">
+          <div className="col-6">
             <input
               className="form-control"
               type="text"

+ 11 - 11
src/client/js/components/Admin/App/MailSetting.jsx

@@ -38,10 +38,10 @@ class MailSetting extends React.Component {
 
     return (
       <React.Fragment>
-        <p className="well">{t('admin:app_setting.smtp_used')} {t('admin:app_setting.smtp_but_aws')}<br />{t('admin:app_setting.neihter_of')}</p>
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">{t('admin:app_setting.from_e-mail_address')}</label>
-          <div className="col-xs-6">
+        <p className="card well">{t('admin:app_setting.smtp_used')} {t('admin:app_setting.smtp_but_aws')}<br />{t('admin:app_setting.neihter_of')}</p>
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">{t('admin:app_setting.from_e-mail_address')}</label>
+          <div className="col-6">
             <input
               className="form-control"
               type="text"
@@ -52,9 +52,9 @@ class MailSetting extends React.Component {
           </div>
         </div>
 
-        <div className="row mb-5">
-          <label className="col-xs-3 control-label">{t('admin:app_setting.smtp_settings')}</label>
-          <div className="col-xs-4">
+        <div className="row form-group mb-5">
+          <label className="col-3 col-form-label">{t('admin:app_setting.smtp_settings')}</label>
+          <div className="col-4">
             <label>{t('admin:app_setting.host')}</label>
             <input
               className="form-control"
@@ -63,7 +63,7 @@ class MailSetting extends React.Component {
               onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
             />
           </div>
-          <div className="col-xs-2">
+          <div className="col-2">
             <label>{t('admin:app_setting.port')}</label>
             <input
               className="form-control"
@@ -73,8 +73,8 @@ class MailSetting extends React.Component {
           </div>
         </div>
 
-        <div className="row mb-5">
-          <div className="col-xs-3 col-xs-offset-3">
+        <div className="row form-group mb-5">
+          <div className="col-3 offset-3">
             <label>{t('admin:app_setting.user')}</label>
             <input
               className="form-control"
@@ -83,7 +83,7 @@ class MailSetting extends React.Component {
               onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
             />
           </div>
-          <div className="col-xs-3">
+          <div className="col-3">
             <label>{t('Password')}</label>
             <input
               className="form-control"

+ 6 - 5
src/client/js/components/Admin/App/PluginSetting.jsx

@@ -39,20 +39,21 @@ class PluginSetting extends React.Component {
 
     return (
       <React.Fragment>
-        <p className="well">{t('admin:app_setting.enable_plugin_loading')}</p>
+        <p className="card well">{t('admin:app_setting.enable_plugin_loading')}</p>
 
-        <div className="row mb-5">
-          <div className="col-xs-offset-3 col-xs-6 text-left">
-            <div className="checkbox checkbox-success">
+        <div className="row form-group mb-5">
+          <div className="offset-3 col-6 text-left">
+            <div className="custom-control custom-switch checkbox-success">
               <input
                 id="isEnabledPlugins"
+                className="custom-control-input"
                 type="checkbox"
                 checked={adminAppContainer.state.isEnabledPlugins}
                 onChange={(e) => {
                   adminAppContainer.changeIsEnabledPlugins(e.target.checked);
                 }}
               />
-              <label htmlFor="isEnabledPlugins">{t('admin:app_setting.load_plugins')}</label>
+              <label className="custom-control-label" htmlFor="isEnabledPlugins">{t('admin:app_setting.load_plugins')}</label>
             </div>
           </div>
         </div>

+ 40 - 42
src/client/js/components/Admin/App/SiteUrlSetting.jsx

@@ -38,51 +38,49 @@ class SiteUrlSetting extends React.Component {
 
     return (
       <React.Fragment>
-        <p className="well">{t('admin:app_setting.site_url_desc')}</p>
+        <p className="card well">{t('admin:app_setting.site_url_desc')}</p>
         {!adminAppContainer.state.isSetSiteUrl
           && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('admin:app_setting.site_url_warn')}</p>)}
 
-        <div className="row">
-          <div className="col-md-12">
-            <div className="col-xs-offset-3">
-              <table className="table settings-table">
-                <colgroup>
-                  <col className="from-db" />
-                  <col className="from-env-vars" />
-                </colgroup>
-                <thead>
-                  <tr>
-                    <th>Database</th>
-                    <th>Environment variables</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  <tr>
-                    <td>
-                      <input
-                        className="form-control"
-                        type="text"
-                        name="settingForm[app:siteUrl]"
-                        defaultValue={adminAppContainer.state.siteUrl || ''}
-                        onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
-                        placeholder="e.g. https://my.growi.org"
-                      />
-                      <p className="help-block">
-                        {/* eslint-disable-next-line react/no-danger */}
-                        <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.siteurl_help') }} />
-                      </p>
-                    </td>
-                    <td>
-                      <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl || ''} readOnly />
-                      <p className="help-block">
-                        {/* eslint-disable-next-line react/no-danger */}
-                        <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'APP_SITE_URL' }) }} />
-                      </p>
-                    </td>
-                  </tr>
-                </tbody>
-              </table>
-            </div>
+        <div className="row form-group">
+          <div className="col-9 offset-3">
+            <table className="table settings-table">
+              <colgroup>
+                <col className="from-db" />
+                <col className="from-env-vars" />
+              </colgroup>
+              <thead>
+                <tr>
+                  <th>Database</th>
+                  <th>Environment variables</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      name="settingForm[app:siteUrl]"
+                      defaultValue={adminAppContainer.state.siteUrl || ''}
+                      onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
+                      placeholder="e.g. https://my.growi.org"
+                    />
+                    <p className="form-text text-muted">
+                      {/* eslint-disable-next-line react/no-danger */}
+                      <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.siteurl_help') }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl || ''} readOnly />
+                    <p className="form-text text-muted">
+                      {/* eslint-disable-next-line react/no-danger */}
+                      <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'APP_SITE_URL' }) }} />
+                    </p>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
           </div>
         </div>
 

+ 15 - 0
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -150,6 +150,21 @@ class CustomizeBehaviorSetting extends React.Component {
               </div>
             </div>
 
+            <div className="form-group row">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <CustomizeFunctionOption
+                  optionId="isAllReplyShown"
+                  label={t('admin:customize_setting.function_options.show_all_reply_comments')}
+                  isChecked={adminCustomizeContainer.state.isAllReplyShown || false}
+                  onChecked={() => { adminCustomizeContainer.switchIsAllReplyShown() }}
+                >
+                  <p className="help-block">
+                    {t('admin:customize_setting.function_options.show_all_reply_comments_desc')}
+                  </p>
+                </CustomizeFunctionOption>
+              </div>
+            </div>
+
             <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
           </div>
         </div>

+ 31 - 21
src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx

@@ -67,82 +67,91 @@ class ImportCollectionConfigurationModal extends React.Component {
     /* eslint-disable react/no-unescaped-entities */
     return (
       <>
-        <div className="checkbox checkbox-warning">
+        <div className="custom-control custom-checkbox custom-checkbox-warning">
           <input
             id="cbOpt4"
             type="checkbox"
+            className="custom-control-input"
             checked={option.isOverwriteAuthorWithCurrentUser || false} // add ' || false' to avoid uncontrolled input warning
             onChange={() => this.changeHandler({ isOverwriteAuthorWithCurrentUser: !option.isOverwriteAuthorWithCurrentUser })}
           />
-          <label htmlFor="cbOpt4">
+          <label htmlFor="cbOpt4" className="custom-control-label">
             {t(`${translationBase}.overwrite_author.label`)}
-            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
+            <p className="form-text text-muted mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
           </label>
         </div>
-        <div className="checkbox checkbox-warning">
+        <div className="custom-control custom-checkbox custom-checkbox-warning">
           <input
             id="cbOpt1"
             type="checkbox"
+            className="custom-control-input"
             checked={option.makePublicForGrant2 || false} // add ' || false' to avoid uncontrolled input warning
             onChange={() => this.changeHandler({ makePublicForGrant2: !option.makePublicForGrant2 })}
           />
-          <label htmlFor="cbOpt1">
+          <label htmlFor="cbOpt1" className="custom-control-label">
             {t(`${translationBase}.set_public_to_page.label`, { from: t('Anyone with the link') })}
             <p
-              className="help-block mt-0"
+              className="form-text text-muted mt-0"
               dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Anyone with the link') }) }}
             />
           </label>
         </div>
-        <div className="checkbox checkbox-warning">
+        <div className="custom-control custom-checkbox custom-checkbox-warning">
           <input
             id="cbOpt2"
             type="checkbox"
+            className="custom-control-input"
             checked={option.makePublicForGrant4 || false} // add ' || false' to avoid uncontrolled input warning
             onChange={() => this.changeHandler({ makePublicForGrant4: !option.makePublicForGrant4 })}
           />
-          <label htmlFor="cbOpt2">
+          <label htmlFor="cbOpt2" className="custom-control-label">
             {t(`${translationBase}.set_public_to_page.label`, { from: t('Just me') })}
-            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Just me') }) }} />
+            <p
+              className="form-text text-muted mt-0"
+              dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Just me') }) }}
+            />
           </label>
         </div>
-        <div className="checkbox checkbox-warning">
+        <div className="custom-control custom-checkbox custom-checkbox-warning">
           <input
             id="cbOpt3"
             type="checkbox"
+            className="custom-control-input"
             checked={option.makePublicForGrant5 || false} // add ' || false' to avoid uncontrolled input warning
             onChange={() => this.changeHandler({ makePublicForGrant5: !option.makePublicForGrant5 })}
           />
-          <label htmlFor="cbOpt3">
+          <label htmlFor="cbOpt3" className="custom-control-label">
             {t(`${translationBase}.set_public_to_page.label`, { from: t('Only inside the group') })}
             <p
-              className="help-block mt-0"
+              className="form-text text-muted mt-0"
               dangerouslySetInnerHTML={{ __html: t(`${translationBase}.set_public_to_page.desc`, { from: t('Only inside the group') }) }}
             />
           </label>
         </div>
-        <div className="checkbox checkbox-default">
+        <div className="custom-control custom-checkbox custom-checkbox-warning">
           <input
             id="cbOpt5"
             type="checkbox"
+            className="custom-control-input"
             checked={option.initPageMetadatas || false} // add ' || false' to avoid uncontrolled input warning
             onChange={() => this.changeHandler({ initPageMetadatas: !option.initPageMetadatas })}
           />
-          <label htmlFor="cbOpt5">
+          <label htmlFor="cbOpt5" className="custom-control-label">
             {t(`${translationBase}.initialize_meta_datas.label`)}
-            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.initialize_meta_datas.desc`) }} />
+            <p className="form-text text-muted mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.initialize_meta_datas.desc`) }} />
           </label>
         </div>
-        <div className="checkbox checkbox-default">
+        <div className="custom-control custom-checkbox custom-checkbox-warning">
           <input
             id="cbOpt6"
             type="checkbox"
+            className="custom-control-input"
             checked={option.initHackmdDatas || false} // add ' || false' to avoid uncontrolled input warning
             onChange={() => this.changeHandler({ initHackmdDatas: !option.initHackmdDatas })}
           />
-          <label htmlFor="cbOpt6">
+          <label htmlFor="cbOpt6" className="custom-control-label">
             {t(`${translationBase}.initialize_hackmd_related_datas.label`)}
-            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.initialize_hackmd_related_datas.desc`) }} />
+            <p className="form-text text-muted mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.initialize_hackmd_related_datas.desc`) }} />
           </label>
         </div>
       </>
@@ -159,16 +168,17 @@ class ImportCollectionConfigurationModal extends React.Component {
     /* eslint-disable react/no-unescaped-entities */
     return (
       <>
-        <div className="checkbox checkbox-warning">
+        <div className="custom-control custom-checkbox custom-checkbox-warning">
           <input
             id="cbOpt1"
             type="checkbox"
+            className="custom-control-input"
             checked={option.isOverwriteAuthorWithCurrentUser || false} // add ' || false' to avoid uncontrolled input warning
             onChange={() => this.changeHandler({ isOverwriteAuthorWithCurrentUser: !option.isOverwriteAuthorWithCurrentUser })}
           />
-          <label htmlFor="cbOpt1">
+          <label htmlFor="cbOpt1" className="custom-control-label">
             {t(`${translationBase}.overwrite_author.label`)}
-            <p className="help-block mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
+            <p className="form-text text-muted mt-0" dangerouslySetInnerHTML={{ __html: t(`${translationBase}.overwrite_author.desc`) }} />
           </label>
         </div>
       </>

+ 4 - 6
src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -83,18 +83,18 @@ export default class ImportCollectionItem extends React.Component {
     } = this.props;
 
     return (
-      <div className="checkbox checkbox-info my-0">
+      <div className="custom-control custom-checkbox custom-checkbox-info my-0">
         <input
           type="checkbox"
           id={collectionName}
           name={collectionName}
-          className="form-check-input"
+          className="custom-control-input"
           value={collectionName}
           checked={isSelected}
           disabled={isImporting}
           onChange={this.changeHandler}
         />
-        <label className="text-capitalize form-check-label" htmlFor={collectionName}>
+        <label className="text-capitalize custom-control-label" htmlFor={collectionName}>
           {collectionName}
         </label>
       </div>
@@ -205,9 +205,7 @@ export default class ImportCollectionItem extends React.Component {
         <div className="card-header bg-light">
           <div className="d-flex justify-content-between align-items-center">
             {/* left */}
-            <div className="pl-4">
-              {this.renderCheckbox()}
-            </div>
+            {this.renderCheckbox()}
             {/* right */}
             <span className="d-flex align-items-center">
               {this.renderModeSelector()}

+ 2 - 2
src/client/js/components/Admin/Users/RemoveAdminButton.jsx

@@ -51,11 +51,11 @@ class RemoveAdminButton extends React.Component {
 
   render() {
     const { user } = this.props;
-    const me = this.props.appContainer.me;
+    const { currentUsername } = this.props.appContainer;
 
     return (
       <Fragment>
-        {user.username !== me ? this.renderRemoveAdminBtn()
+        {user.username !== currentUsername ? this.renderRemoveAdminBtn()
           : this.renderRemoveAdminAlert()}
       </Fragment>
     );

+ 2 - 2
src/client/js/components/Admin/Users/StatusSuspendedButton.jsx

@@ -50,11 +50,11 @@ class StatusSuspendedButton extends React.Component {
 
   render() {
     const { user } = this.props;
-    const me = this.props.appContainer.me;
+    const { currentUsername } = this.props.appContainer;
 
     return (
       <Fragment>
-        {user.username !== me ? this.renderSuspendedBtn()
+        {user.username !== currentUsername ? this.renderSuspendedBtn()
           : this.renderSuspendedAlert()}
       </Fragment>
     );

+ 2 - 2
src/client/js/components/BookmarkButton.jsx

@@ -56,7 +56,7 @@ export default class BookmarkButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me != null;
+    return this.props.crowi.currentUserId != null;
   }
 
   render() {
@@ -78,7 +78,7 @@ export default class BookmarkButton extends React.Component {
         href="#"
         title="Bookmark"
         onClick={this.handleClick}
-        className={`btn-bookmark btn btn-circle btn-outline-secondary ${addedClassName}`}
+        className={`btn btn-circle btn-outline-warning border-0 ${addedClassName}`}
       >
         <i className="icon-star"></i>
       </button>

+ 2 - 2
src/client/js/components/HeaderSearchBox.jsx

@@ -66,10 +66,10 @@ class HeaderSearchBox extends React.Component {
     const isReachable = config.isSearchServiceReachable;
 
     return (
-      <div className={`form-group ${isReachable ? '' : 'has-error'}`}>
+      <div className={`form-group mb-0 ${isReachable ? '' : 'has-error'}`}>
         <div className="input-group flex-nowrap">
           <div className="input-group-prepend">
-            <button className="btn btn-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true">
+            <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
               {scopeLabel}
             </button>
             <div className="dropdown-menu">

+ 2 - 2
src/client/js/components/LikeButton.jsx

@@ -37,7 +37,7 @@ class LikeButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me != null;
+    return this.props.appContainer.currentUserId != null;
   }
 
   render() {
@@ -59,7 +59,7 @@ class LikeButton extends React.Component {
         href="#"
         title="Like"
         onClick={this.handleClick}
-        className={`btn-like btn btn-circle btn-outline-secondary ${addedClassName}`}
+        className={`btn btn-circle btn-outline-info border-0 ${addedClassName}`}
       >
         <i className="icon-like"></i>
       </button>

+ 60 - 0
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
+import UserPicture from '../User/UserPicture';
+
+const PersonalDropdown = (props) => {
+
+  const { t, appContainer } = props;
+  const user = appContainer.currentUser || {};
+
+  const logoutHandler = () => {
+    const { interceptorManager } = appContainer;
+
+    const context = {
+      user,
+      currentPagePath: decodeURIComponent(window.location.pathname),
+    };
+    interceptorManager.process('logout', context);
+
+    window.location.href = '/logout';
+  };
+
+  return (
+    <>
+      <a className="dropdown-toggle waves-effect waves-light" data-toggle="dropdown">
+        <UserPicture user={user} withoutLink />&nbsp;{user.name}
+      </a>
+      <ul className="dropdown-menu dropdown-menu-right">
+        <li><a href={`/user/${user.username}`}><i className="icon-fw icon-home"></i>{ t('Home') }</a></li>
+        <li><a href="/me"><i className="icon-fw icon-wrench"></i>{ t('User Settings') }</a></li>
+        <li role="separator" className="divider"></li>
+        <li><a href={`/user/${user.username}#user-draft-list`}><i className="icon-fw icon-docs"></i>{ t('List Drafts') }</a></li>
+        <li><a href="/trash"><i className="icon-fw icon-trash"></i>{ t('Deleted Pages') }</a></li>
+        <li role="separator" className="divider"></li>
+        <li><a role="button" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{ t('Sign out') }</a></li>
+      </ul>
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PersonalDropdownWrapper = (props) => {
+  return createSubscribedElement(PersonalDropdown, props, [AppContainer]);
+};
+
+
+PersonalDropdown.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(PersonalDropdownWrapper);

+ 22 - 11
src/client/js/components/Page/RevisionPath.jsx

@@ -100,9 +100,6 @@ class RevisionPath extends React.Component {
 
   render() {
     // define styles
-    const rootStyle = {
-      marginRight: '0.2em',
-    };
     const separatorStyle = {
       marginLeft: '0.2em',
       marginRight: '0.2em',
@@ -115,6 +112,26 @@ class RevisionPath extends React.Component {
     const { isInTrash } = this.state;
     const pageLength = this.state.pages.length;
 
+    const rootElement = isInTrash
+      ? (
+        <>
+          <span className="path-segment">
+            <a href="/trash"><i className="icon-trash"></i></a>
+          </span>
+          <span className="separator" style={separatorStyle}><a href="/">/</a></span>
+        </>
+      )
+      : (
+        <>
+          <span className="path-segment">
+            <a href="/">
+              <i className="icon-home"></i>
+              <span className="separator" style={separatorStyle}>/</span>
+            </a>
+          </span>
+        </>
+      );
+
     const afterElements = [];
     this.state.pages.forEach((page, index) => {
       const isLastElement = (index === pageLength - 1);
@@ -136,14 +153,8 @@ class RevisionPath extends React.Component {
 
     return (
       <span className="d-flex align-items-center">
-        { isInTrash && (
-          <span className="path-segment">
-            <a href="/trash"><i className="icon-trash"></i></a>
-          </span>
-        ) }
-        <span className="separator" style={isInTrash ? separatorStyle : rootStyle}>
-          <a href="/">/</a>
-        </span>
+
+        {rootElement}
         {afterElements}
 
         <CopyDropdown t={this.props.t} pagePath={this.props.pagePath} pageId={this.props.pageId} buttonStyle={buttonStyle}></CopyDropdown>

+ 1 - 1
src/client/js/components/PageAttachment.jsx

@@ -89,7 +89,7 @@ class PageAttachment extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me != null;
+    return this.props.appContainer.currentUser != null;
   }
 
   render() {

+ 52 - 149
src/client/js/components/PageComment/Comment.jsx

@@ -3,11 +3,7 @@ import PropTypes from 'prop-types';
 
 import { format, formatDistanceStrict } from 'date-fns';
 
-import {
-  Button,
-  Collapse,
-  UncontrolledTooltip,
-} from 'reactstrap';
+import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
@@ -17,6 +13,7 @@ import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
+import CommentControl from './CommentControl';
 
 /**
  *
@@ -26,15 +23,14 @@ import CommentEditor from './CommentEditor';
  * @class Comment
  * @extends {React.Component}
  */
-class Comment extends React.Component {
+class Comment extends React.PureComponent {
 
   constructor(props) {
     super(props);
 
     this.state = {
       html: '',
-      isOlderRepliesShown: false,
-      showReEditorIds: new Set(),
+      isReEdit: false,
     };
 
     this.growiRenderer = this.props.appContainer.getRenderer('comment');
@@ -43,23 +39,39 @@ class Comment extends React.Component {
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
+    this.editBtnClickedHandler = this.editBtnClickedHandler.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
     this.renderText = this.renderText.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.commentButtonClickedHandler = this.commentButtonClickedHandler.bind(this);
   }
 
-  componentWillMount() {
-    this.renderHtml(this.props.comment.comment);
+
+  initCurrentRenderingContext() {
+    this.currentRenderingContext = {
+      markdown: this.props.comment.comment,
+    };
   }
 
-  componentWillReceiveProps(nextProps) {
-    this.renderHtml(nextProps.comment.comment);
+  componentDidMount() {
+    this.initCurrentRenderingContext();
+    this.renderHtml();
   }
 
-  // not used
-  setMarkdown(markdown) {
-    this.renderHtml(markdown);
+  componentDidUpdate(prevProps) {
+    const { comment: prevComment } = prevProps;
+    const { comment } = this.props;
+
+    // render only when props.markdown is updated
+    if (comment !== prevComment) {
+      this.initCurrentRenderingContext();
+      this.renderHtml();
+      return;
+    }
+
+    const { interceptorManager } = this.props.appContainer;
+
+    interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
   }
 
   checkPermissionToControlComment() {
@@ -67,7 +79,7 @@ class Comment extends React.Component {
   }
 
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.appContainer.me;
+    return this.props.comment.creator.username === this.props.appContainer.currentUsername;
   }
 
   isCurrentRevision() {
@@ -100,18 +112,12 @@ class Comment extends React.Component {
       this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
   }
 
-  editBtnClickedHandler(commentId) {
-    const ids = this.state.showReEditorIds.add(commentId);
-    this.setState({ showReEditorIds: ids });
+  editBtnClickedHandler() {
+    this.setState({ isReEdit: !this.state.isReEdit });
   }
 
-  commentButtonClickedHandler(commentId) {
-    this.setState((prevState) => {
-      prevState.showReEditorIds.delete(commentId);
-      return {
-        showReEditorIds: prevState.showReEditorIds,
-      };
-    });
+  commentButtonClickedHandler() {
+    this.editBtnClickedHandler();
   }
 
   deleteBtnClickedHandler() {
@@ -135,120 +141,23 @@ class Comment extends React.Component {
     );
   }
 
-  toggleOlderReplies() {
-    this.setState((prevState) => {
-      return {
-        showOlderReplies: !prevState.showOlderReplies,
-      };
-    });
-  }
-
-  renderHtml(markdown) {
-    const context = {
-      markdown,
-    };
-
-    const growiRenderer = this.props.growiRenderer;
-    const interceptorManager = this.props.appContainer.interceptorManager;
-    interceptorManager.process('preRenderComment', context)
-      .then(() => { return interceptorManager.process('prePreProcess', context) })
-      .then(() => {
-        context.markdown = growiRenderer.preProcess(context.markdown);
-      })
-      .then(() => { return interceptorManager.process('postPreProcess', context) })
-      .then(() => {
-        const parsedHTML = growiRenderer.process(context.markdown);
-        context.parsedHTML = parsedHTML;
-      })
-      .then(() => { return interceptorManager.process('prePostProcess', context) })
-      .then(() => {
-        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
-      })
-      .then(() => { return interceptorManager.process('postPostProcess', context) })
-      .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
-      .then(() => {
-        this.setState({ html: context.parsedHTML });
-      })
-      // process interceptors for post rendering
-      .then(() => { return interceptorManager.process('postRenderCommentHtml', context) });
+  async renderHtml() {
 
-  }
+    const { growiRenderer, appContainer } = this.props;
+    const { interceptorManager } = appContainer;
+    const context = this.currentRenderingContext;
 
-  renderReply(reply) {
-    return (
-      <div key={reply._id} className="page-comment-reply">
-        <CommentWrapper
-          comment={reply}
-          deleteBtnClicked={this.props.deleteBtnClicked}
-          growiRenderer={this.props.growiRenderer}
-        />
-      </div>
-    );
-  }
-
-  renderReplies() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
-
-    let replyList = this.props.replyList;
-    if (!isBaloonStyle) {
-      replyList = replyList.slice().reverse();
-    }
-
-    const areThereHiddenReplies = replyList.length > 2;
-
-    const { isOlderRepliesShown } = this.state;
-    const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
-    const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
-    const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
-    const toggleButton = (
-      <Button
-        color="link"
-        className="page-comments-list-toggle-older"
-        onClick={() => { this.setState({ isOlderRepliesShown: !isOlderRepliesShown }) }}
-      >
-        {toggleButtonIcon} {toggleButtonLabel}
-      </Button>
-    );
-
-    const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
-    const hiddenReplies = replyList.slice(0, replyList.length - 2);
-
-    const hiddenElements = hiddenReplies.map((reply) => {
-      return this.renderReply(reply);
-    });
-
-    const shownElements = shownReplies.map((reply) => {
-      return this.renderReply(reply);
-    });
-
-    return (
-      <React.Fragment>
-        { areThereHiddenReplies && (
-          <div className="page-comments-hidden-replies">
-            <Collapse isOpen={this.state.isOlderRepliesShown}>
-              <div>{hiddenElements}</div>
-            </Collapse>
-            <div className="text-center">{toggleButton}</div>
-          </div>
-        ) }
-
-        {shownElements}
-      </React.Fragment>
-    );
-  }
-
-  renderCommentControl(comment) {
-    return (
-      <div className="page-comment-control">
-        <button type="button" className="btn btn-link p-2" onClick={() => { this.editBtnClickedHandler(comment._id) }}>
-          <i className="ti-pencil"></i>
-        </button>
-        <button type="button" className="btn btn-link p-2 mr-2" onClick={this.deleteBtnClickedHandler}>
-          <i className="ti-close"></i>
-        </button>
-      </div>
-    );
+    await interceptorManager.process('preRenderComment', context);
+    await interceptorManager.process('prePreProcess', context);
+    context.markdown = await growiRenderer.preProcess(context.markdown);
+    await interceptorManager.process('postPreProcess', context);
+    context.parsedHTML = await growiRenderer.process(context.markdown);
+    await interceptorManager.process('prePostProcess', context);
+    context.parsedHTML = await growiRenderer.postProcess(context.parsedHTML);
+    await interceptorManager.process('postPostProcess', context);
+    await interceptorManager.process('preRenderCommentHtml', context);
+    this.setState({ html: context.parsedHTML });
+    await interceptorManager.process('postRenderCommentHtml', context);
   }
 
   render() {
@@ -260,8 +169,6 @@ class Comment extends React.Component {
     const updatedAt = new Date(comment.updatedAt);
     const isEdited = createdAt < updatedAt;
 
-    const showReEditor = this.state.showReEditorIds.has(commentId);
-
     const rootClassName = this.getRootClassName(comment);
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
@@ -279,7 +186,7 @@ class Comment extends React.Component {
     return (
       <React.Fragment>
 
-        {showReEditor ? (
+        {this.state.isReEdit ? (
           <CommentEditor
             growiRenderer={this.growiRenderer}
             currentCommentId={commentId}
@@ -307,12 +214,12 @@ class Comment extends React.Component {
                 ) }
                 <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
               </div>
-              { this.checkPermissionToControlComment() && this.renderCommentControl(comment) }
+              {this.checkPermissionToControlComment()
+                  && <CommentControl onClickDeleteBtn={this.deleteBtnClickedHandler} onClickEditBtn={this.editBtnClickedHandler} />}
             </div>
           </div>
-        )
-      }
-        {this.renderReplies()}
+          )
+        }
 
       </React.Fragment>
     );
@@ -334,10 +241,6 @@ Comment.propTypes = {
   comment: PropTypes.object.isRequired,
   growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
-  replyList: PropTypes.array,
-};
-Comment.defaultProps = {
-  replyList: [],
 };
 
 export default CommentWrapper;

+ 24 - 0
src/client/js/components/PageComment/CommentControl.jsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+const CommentControl = (props) => {
+  return (
+    <div className="page-comment-control">
+      <button type="button" className="btn btn-link p-2" onClick={props.onClickEditBtn}>
+        <i className="ti-pencil"></i>
+      </button>
+      <button type="button" className="btn btn-link p-2 mr-2" onClick={props.onClickDeleteBtn}>
+        <i className="ti-close"></i>
+      </button>
+    </div>
+  );
+};
+
+CommentControl.propTypes = {
+
+  onClickEditBtn: PropTypes.func.isRequired,
+  onClickDeleteBtn: PropTypes.func.isRequired,
+};
+
+export default CommentControl;

+ 1 - 3
src/client/js/components/PageComment/CommentEditor.jsx

@@ -216,8 +216,6 @@ class CommentEditor extends React.Component {
     const { appContainer, commentContainer } = this.props;
     const { activeTab } = this.state;
 
-    const username = appContainer.me;
-    const user = appContainer.findUser(username);
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const emojiStrategy = appContainer.getEmojiStrategy();
 
@@ -246,7 +244,7 @@ class CommentEditor extends React.Component {
         <div className="comment-form">
           { isBaloonStyle && (
             <div className="comment-form-user">
-              <UserPicture user={user} />
+              <UserPicture user={appContainer.currentUser} />
             </div>
           ) }
           <div className="comment-form-main">

+ 2 - 3
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -27,9 +27,8 @@ class CommentEditorLazyRenderer extends React.Component {
 
   render() {
     const { appContainer } = this.props;
-    const username = appContainer.me;
-    const isLoggedIn = username != null;
-    const user = appContainer.findUser(username);
+    const user = appContainer.currentUser;
+    const isLoggedIn = user != null;
 
     const layoutType = this.props.appContainer.getConfig().layoutType;
     const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);

+ 123 - 0
src/client/js/components/PageComment/ReplayComments.jsx

@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Button, Collapse } from 'reactstrap';
+
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
+import Comment from './Comment';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+
+class ReplayComments extends React.PureComponent {
+
+  constructor() {
+    super();
+
+    this.state = {
+      isOlderRepliesShown: false,
+    };
+
+    this.toggleIsOlderRepliesShown = this.toggleIsOlderRepliesShown.bind(this);
+  }
+
+  toggleIsOlderRepliesShown() {
+    this.setState({ isOlderRepliesShown: !this.state.isOlderRepliesShown });
+  }
+
+  renderReply(reply) {
+    return (
+      <div key={reply._id} className="page-comment-reply">
+        <Comment
+          comment={reply}
+          deleteBtnClicked={this.props.deleteBtnClicked}
+          growiRenderer={this.props.growiRenderer}
+        />
+      </div>
+    );
+  }
+
+  render() {
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
+
+    const isAllReplyShown = this.props.appContainer.getConfig().isAllReplyShown || false;
+
+    let replyList = this.props.replyList;
+    if (!isBaloonStyle) {
+      replyList = replyList.slice().reverse();
+    }
+
+    if (isAllReplyShown) {
+      return (
+        <React.Fragment>
+          {replyList.map((reply) => {
+            return this.renderReply(reply);
+          })}
+        </React.Fragment>
+      );
+    }
+
+    const areThereHiddenReplies = (replyList.length > 2);
+
+    const { isOlderRepliesShown } = this.state;
+    const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
+    const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
+    const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
+
+    const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
+    const hiddenReplies = replyList.slice(0, replyList.length - 2);
+
+    const hiddenElements = hiddenReplies.map((reply) => {
+      return this.renderReply(reply);
+    });
+
+    const shownElements = shownReplies.map((reply) => {
+      return this.renderReply(reply);
+    });
+
+    return (
+      <React.Fragment>
+        {areThereHiddenReplies && (
+          <div className="page-comments-hidden-replies">
+            <Collapse in={this.state.isOlderRepliesShown}>
+              <div>{hiddenElements}</div>
+            </Collapse>
+            <div className="text-center">
+              <Button
+                bsStyle="link"
+                className="page-comments-list-toggle-older"
+                onClick={this.toggleIsOlderRepliesShown}
+              >
+                {toggleButtonIcon} {toggleButtonLabel}
+              </Button>
+            </div>
+          </div>
+        )}
+        {shownElements}
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ReplayCommentsWrapper = (props) => {
+  return createSubscribedElement(ReplayComments, props, [AppContainer, PageContainer]);
+};
+
+ReplayComments.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  growiRenderer: PropTypes.object.isRequired,
+  deleteBtnClicked: PropTypes.func.isRequired,
+  replyList: PropTypes.array,
+};
+
+export default ReplayCommentsWrapper;

+ 8 - 2
src/client/js/components/PageComments.jsx

@@ -16,6 +16,7 @@ import { createSubscribedElement } from './UnstatedUtils';
 import CommentEditor from './PageComment/CommentEditor';
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
+import ReplayComments from './PageComment/ReplayComments';
 
 
 /**
@@ -129,7 +130,7 @@ class PageComments extends React.Component {
   renderThread(comment, replies) {
     const commentId = comment._id;
     const showEditor = this.state.showEditorIds.has(commentId);
-    const isLoggedIn = this.props.appContainer.me != null;
+    const isLoggedIn = this.props.appContainer.currentUser != null;
 
     let rootClassNames = 'page-comment-thread';
     if (replies.length === 0) {
@@ -140,11 +141,16 @@ class PageComments extends React.Component {
       <div key={commentId} className={`mb-5 ${rootClassNames}`}>
         <Comment
           comment={comment}
-          editBtnClicked={this.confirmToEditComment}
           deleteBtnClicked={this.confirmToDeleteComment}
           growiRenderer={this.growiRenderer}
+        />
+        {replies.length !== 0 && (
+        <ReplayComments
           replyList={replies}
+          deleteBtnClicked={this.confirmToDeleteComment}
+          growiRenderer={this.growiRenderer}
         />
+        )}
         { !showEditor && isLoggedIn && (
           <div className="text-right">
             <Button

+ 6 - 2
src/client/js/components/PageHistory/RevisionDiff.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { createPatch } from 'diff';
-import { Diff2Html } from 'diff2html';
+import { html } from 'diff2html';
 
 export default class RevisionDiff extends React.Component {
 
@@ -29,8 +29,12 @@ export default class RevisionDiff extends React.Component {
         previousText,
         currentRevision.body,
       );
+      const option = {
+        drawFileList: false,
+        outputFormat: 'side-by-side',
+      };
 
-      diffViewHTML = Diff2Html.getPrettyHtml(patch);
+      diffViewHTML = html(patch, option);
     }
 
     const diffView = { __html: diffViewHTML };

+ 1 - 1
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -37,7 +37,7 @@ class RecentCreated extends React.Component {
     const { appContainer, pageContainer } = this.props;
     const { pageId } = pageContainer.state;
 
-    const userId = appContainer.me;
+    const userId = appContainer.currentUserId;
     const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
 

+ 7 - 0
src/client/js/legacy/crowi.js

@@ -595,6 +595,11 @@ $(() => {
 window.addEventListener('load', (e) => {
   const { appContainer } = window;
 
+  // do nothing if user is guest
+  if (appContainer.currentUser == null) {
+    return;
+  }
+
   // hash on page
   if (window.location.hash) {
     if ((window.location.hash === '#edit' || window.location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
@@ -618,7 +623,9 @@ window.addEventListener('load', (e) => {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
+});
 
+window.addEventListener('load', (e) => {
   const crowi = window.crowi;
   if (crowi && crowi.users && crowi.users.length !== 0) {
     const totalUsers = crowi.users.length;

+ 11 - 0
src/client/js/services/AdminCustomizeContainer.js

@@ -28,6 +28,7 @@ export default class AdminCustomizeContainer extends Container {
       isEnabledAttachTitleHeader: false,
       currentRecentCreatedLimit: 10,
       isEnabledStaleNotification: false,
+      isAllReplyShown: false,
       currentHighlightJsStyleId: '',
       isHighlightJsStyleBorderEnabled: false,
       currentCustomizeTitle: '',
@@ -76,6 +77,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
         currentRecentCreatedLimit: customizeParams.recentCreatedLimit,
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
+        isAllReplyShown: customizeParams.isAllReplyShown,
         currentHighlightJsStyleId: customizeParams.styleName,
         isHighlightJsStyleBorderEnabled: customizeParams.styleBorder,
         currentCustomizeTitle: customizeParams.customizeTitle,
@@ -159,6 +161,13 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ isEnabledStaleNotification:  !this.state.isEnabledStaleNotification });
   }
 
+  /**
+   * Switch isAllReplyShown
+   */
+  switchIsAllReplyShown() {
+    this.setState({ isAllReplyShown: !this.state.isAllReplyShown });
+  }
+
   /**
    * Switch highlightJsStyle
    */
@@ -289,6 +298,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
         recentCreatedLimit: this.state.currentRecentCreatedLimit,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
+        isAllReplyShown: this.state.isAllReplyShown,
       });
       const { customizedParams } = response.data;
       this.setState({
@@ -297,6 +307,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledAttachTitleHeader: customizedParams.isEnabledAttachTitleHeader,
         recentCreatedLimit: customizedParams.currentRecentCreatedLimit,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
+        isAllReplyShown: customizedParams.isAllReplyShown,
       });
     }
     catch (err) {

+ 20 - 10
src/client/js/services/AppContainer.js

@@ -31,13 +31,17 @@ export default class AppContainer extends Container {
 
     const body = document.querySelector('body');
 
-    this.me = body.dataset.currentUsername || null; // will be initialized with null when data is empty string
     this.isAdmin = body.dataset.isAdmin === 'true';
     this.csrfToken = body.dataset.csrftoken;
     this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
     this.isLoggedin = document.querySelector('body.nologin') == null;
 
-    this.config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+    this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
+
+    const currentUserElem = document.getElementById('growi-current-user');
+    if (currentUserElem != null) {
+      this.currentUser = JSON.parse(currentUserElem.textContent);
+    }
 
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
@@ -107,6 +111,20 @@ export default class AppContainer extends Container {
     window.crowiPlugin = window.growiPlugin;
   }
 
+  get currentUserId() {
+    if (this.currentUser == null) {
+      return null;
+    }
+    return this.currentUser._id;
+  }
+
+  get currentUsername() {
+    if (this.currentUser == null) {
+      return null;
+    }
+    return this.currentUser.username;
+  }
+
   /**
    * @return {Object} window.Crowi (js/legacy/crowi.js)
    */
@@ -271,14 +289,6 @@ export default class AppContainer extends Container {
     return users;
   }
 
-  findUser(username) {
-    if (this.userByName && this.userByName[username]) {
-      return this.userByName[username];
-    }
-
-    return null;
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {

+ 4 - 0
src/client/styles/scss/_admin.scss

@@ -156,6 +156,10 @@
       background-color: rgba($info, 0.1);
     }
   }
+
+  label.custom-control-label {
+    font-weight: normal;
+  }
 }
 
 .admin-navigation > a + a {

+ 20 - 7
src/client/styles/scss/_comment.scss

@@ -1,10 +1,18 @@
-.page-comment-main {
-  // delete button
-  .page-comment-control {
-    position: absolute;
-    top: 0;
-    right: 0;
-    display: none; // default hidden
+.main-container {
+  .page-comment-main {
+    pointer-events: auto;
+
+    // delete button
+    .page-comment-control {
+      position: absolute;
+      top: 0;
+      right: 0;
+      visibility: hidden;
+    }
+
+    &:hover > .page-comment-control {
+      visibility: visible;
+    }
   }
 }
 
@@ -24,6 +32,11 @@
     display: inline-block;
     font-size: 0.9em;
   }
+  .page-comment {
+    padding-top: 50px;
+    margin-top: -50px;
+    pointer-events: none;
+  }
 
   .page-comment {
     // older comments

+ 0 - 5
src/client/styles/scss/_comment_growi.scss

@@ -101,11 +101,6 @@
     border-left: none;
   }
 
-  // show when hover
-  .page-comment-main:hover > .page-comment-control {
-    display: block;
-  }
-
   // display cheatsheet for comment form only
   .comment-form {
     .editor-cheatsheet {

+ 0 - 5
src/client/styles/scss/_comment_kibela.scss

@@ -106,11 +106,6 @@
     border-left: none;
   }
 
-  // show when hover
-  .page-comment-main:hover > .page-comment-control {
-    display: block;
-  }
-
   // display cheatsheet for comment form only
   .comment-form {
     .editor-cheatsheet {

+ 48 - 0
src/client/styles/scss/_layout.scss

@@ -50,6 +50,42 @@
 
   header {
     line-height: 1em;
+    // the container of h1
+    div.title-container {
+      padding-right: 5px;
+      padding-left: 5px;
+      margin-right: auto;
+    }
+
+    .btn-copy,
+    .btn-copy-link,
+    .btn-edit {
+      display: block;
+      color: $text-muted;
+      border: none;
+      opacity: 0.3;
+
+      &:not(:hover) {
+        background-color: transparent;
+      }
+      // change button opacity
+      &:hover {
+        opacity: unset;
+      }
+    }
+
+    .btn-edit-tags {
+      color: $text-muted;
+      opacity: 0.5;
+
+      &.no-tags {
+        opacity: 0.7;
+      }
+      // change button opacity
+      &:hover {
+        opacity: unset;
+      }
+    }
 
     h1 {
       @include variable-font-size(28px);
@@ -70,6 +106,18 @@
       }
     }
   }
+
+  #like-button,
+  #bookmark-button {
+    & button {
+      font-size: 1.2em;
+      line-height: 0.8em;
+
+      &:not(:hover):not(.active) {
+        background-color: transparent;
+      }
+    }
+  }
 }
 
 .main {

+ 0 - 5
src/client/styles/scss/_layout_crowi_sidebar.scss

@@ -144,11 +144,6 @@
               display: none; // default hidden
             }
           }
-
-          // show controls when hover
-          .page-comment-main:hover > .page-comment-control {
-            display: block;
-          }
         }
       }
     }

+ 5 - 0
src/client/styles/scss/_navbar.scss

@@ -7,6 +7,11 @@
     padding: 0 1rem;
   }
 
+  .personal-dropdown > .dropdown-toggle::after {
+    // hide caret
+    content: none;
+  }
+
   .nav-link {
     &:hover {
       background: rgba(0, 0, 0, 0.1);

+ 5 - 0
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -103,3 +103,8 @@ $progress-height: 4px;
 $progress-border-radius: 0;
 $progress-bg: #f0f0f0;
 $progress-box-shadow: none;
+
+//== Custom Checkbox
+$custom-checkbox-indicator-border-radius: 0px;
+$custom-control-indicator-focus-box-shadow: none;
+$custom-control-indicator-size: 1.2rem;

+ 4 - 0
src/client/styles/scss/_override-bootstrap.scss

@@ -74,6 +74,10 @@ h5 {
   .dropdown-toggle.btn.disabled {
     cursor: not-allowed;
   }
+  button {
+    background: transparent;
+    border: none;
+  }
 }
 
 // agile-admin style

+ 1 - 70
src/client/styles/scss/_page.scss

@@ -1,77 +1,9 @@
 // import diff2html styles
-@import '~diff2html/dist/diff2html.css';
+@import '~diff2html/bundles/css/diff2html.min.css';
 
 .main-container {
   // padding controll of .header-wrap and .content-main are moved to _layout and _form
 
-  /*
-   * header
-   */
-  header {
-
-    // the container of h1
-    div.title-container {
-      padding-right: 5px;
-      padding-left: 5px;
-      margin-right: auto;
-    }
-
-    .btn-copy,
-    .btn-copy-link,
-    .btn-edit {
-      color: $text-muted;
-      border: none;
-      opacity: 0.3;
-
-      &:not(:hover) {
-        background-color: transparent;
-      }
-    }
-
-    .btn-edit-tags {
-      color: $text-muted;
-      opacity: 0.5;
-
-      &.no-tags {
-        opacity: 0.7;
-      }
-    }
-
-    // change button opacity
-    &:hover {
-
-      .btn.btn-copy,
-      .btn-copy-link,
-      .btn.btn-edit,
-      .btn.btn-edit-tags {
-        opacity: unset;
-      }
-    }
-  }
-
-  .btn-like,
-  .btn-bookmark {
-    font-size: 1.2em;
-    line-height: 0.8em;
-    border: none;
-
-    &:not(:hover):not(.active) {
-      background-color: transparent;
-    }
-  }
-
-  .btn-like {
-    &.active {
-      @extend .btn-info;
-    }
-  }
-
-  .btn-bookmark {
-    &.active {
-      @extend .btn-warning;
-    }
-  }
-
   .url-line {
     font-size: 1rem;
     color: #999;
@@ -116,7 +48,6 @@
 .main .content-main .revision-history {
   .revision-history-list {
     .revision-history-outer {
-
       // add border-top except of first element
       &:not(:first-of-type) {
         border-top: 1px solid $border;

+ 1 - 1
src/client/styles/scss/style-app.scss

@@ -58,7 +58,7 @@
 /*
  * for Guest User Mode
  */
-.dropdown-disabled {
+.dropdown-toggle.dropdown-toggle-disabled {
   cursor: not-allowed;
 }
 

+ 33 - 10
src/client/styles/scss/theme/_apply-colors.scss

@@ -3,27 +3,49 @@
 //
 @import '~bootstrap/scss/bootstrap-reboot';
 
-@each $color,
-  $value in $theme-colors {
+@each $color, $value in $theme-colors {
   @include bg-variant('.bg-#{$color}', $value);
 }
 
-@each $color,
-  $value in $theme-colors {
+@each $color, $value in $theme-colors {
   .btn-#{$color} {
     @include button-variant($value, $value);
   }
 }
 
-@each $color,
-  $value in $theme-colors {
+@each $color, $value in $theme-colors {
   .btn-outline-#{$color} {
     @include button-outline-variant($value);
   }
 }
 
-@each $theme-color,
-  $color in $theme-colors {
+@each $theme-color, $color in $theme-colors {
+  .custom-checkbox-#{$theme-color} {
+    .custom-control-label::before {
+      border-color: #d7d7d7;
+      transition: 0.3s ease-in-out;
+    }
+    .custom-control-input:checked + .custom-control-label::before {
+      background-color: $color;
+      border-color: $color;
+    }
+    .custom-control-input:checked + .custom-control-label::after {
+      color: white;
+    }
+    .custom-control-input:not(:disabled):active ~ .custom-control-label::before {
+      color: white;
+      background-color: $color;
+      border-color: $color;
+    }
+    .custom-control-input:focus:not(:checked) ~ .custom-control-label::before {
+      color: white;
+      background-color: white;
+      border-color: #d7d7d7;
+    }
+  }
+}
+
+@each $theme-color, $color in $theme-colors {
   .alert.alert-#{$theme-color} {
     color: white;
     background: $color;
@@ -56,7 +78,6 @@
 
 // Dropdown
 .dropdown-item {
-
   &.active,
   &:active {
     @include gradient-bg($dropdown-link-active-bg);
@@ -89,7 +110,6 @@ body {
 }
 
 .logo {
-
   // set transition for fill
   svg * {
     transition: fill 0.8s ease-out;
@@ -116,6 +136,9 @@ body {
 
 .grw-navbar {
   background: $bgcolor-navbar;
+  .nav-item > .nav-link {
+    color: white;
+  }
 }
 
 .grw-title-bar {

+ 5 - 3
src/linter-checker/test.js

@@ -1,13 +1,15 @@
 /*
  * VSCode の Eslint 設定チェック方法
  *
- * 1. VSCode で以下のエラーが表示されていることを確認
+ * 1. .eslilntignore ファイル中の `/src/linter-checker/**` 行を消す
+ * 
+ * 2. VSCode で以下のエラーが表示されていることを確認
  *   - constructor で eslint(space-before-blocks)
  *   - ファイル末尾の ";" で eslint(eol-last)
  *
- * 2. VSCode で上書き保存
+ * 3. VSCode で上書き保存
  *
- * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ * 4. 以下のように整形され、全てのエラーが消えていることを確認
  *   - "constructor() {" のように間にスペースが入る
  *   - ファイル末尾に空行が入る
  *

+ 5 - 3
src/linter-checker/test.scss

@@ -1,13 +1,15 @@
 /*
  * VSCode の Stylelint 設定チェック方法
  *
- * 1. VSCode で以下のエラーが表示されていることを確認
+ * 1. .stylelintrc.json ファイル中の `src/linter-checker/test.scss` 行を削除
+ * 
+ * 2. VSCode で以下のエラーが表示されていることを確認
  *   - color で stylelint(order/properties-order)
  *   - ul で stylelint(selector-combinator-space-after)
  *
- * 2. VSCode で上書き保存
+ * 3. VSCode で上書き保存
  *
- * 3. 以下のように整形され、全てのエラーが消えていることを確認
+ * 4. 以下のように整形され、全てのエラーが消えていることを確認
  *   - color が background の上の行にくる
  *   - ul と li の間にスペースが入る
  *

+ 4 - 0
src/server/crowi/express-init.js

@@ -19,6 +19,8 @@ module.exports = function(crowi, app) {
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nMiddleware = require('i18next-express-middleware');
 
+  const safeRedirect = require('../middleware/safe-redirect')();
+
   const avoidSessionRoutes = require('../routes/avoid-session-routes');
   const i18nUserSettingDetector = require('../util/i18nUserSettingDetector');
 
@@ -113,6 +115,8 @@ module.exports = function(crowi, app) {
 
   app.use(flash());
 
+  app.use(safeRedirect);
+
   const middlewares = require('../util/middlewares')(crowi, app);
 
   app.use(middlewares.swigFilters(swig));

+ 1 - 0
src/server/form/admin/securityPassportSaml.js

@@ -14,4 +14,5 @@ module.exports = form(
   field('settingForm[security:passport-saml:cert]').trim(),
   field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-saml:isSameEmailTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+  field('settingForm[security:passport-saml:ABLCRule]').trim(),
 );

+ 3 - 1
src/server/middleware/access-token-parser.js

@@ -16,7 +16,9 @@ module.exports = (crowi) => {
     logger.debug('accessToken is', accessToken);
 
     const user = await User.findUserByApiToken(accessToken);
-    req.user = user;
+    // transforming attributes
+    // see User model
+    req.user = user.toObject();
     req.skipCsrfVerify = true;
 
     logger.debug('Access token parsed: skipCsrfVerify');

+ 1 - 1
src/server/middleware/login-required.js

@@ -42,7 +42,7 @@ module.exports = (crowi, isGuestAllowed = false) => {
       return res.sendStatus(403);
     }
 
-    req.session.jumpTo = req.originalUrl;
+    req.session.redirectTo = req.originalUrl;
     return res.redirect('/login');
   };
 

+ 65 - 0
src/server/middleware/safe-redirect.js

@@ -0,0 +1,65 @@
+/**
+ * Redirect with prevention from Open Redirect
+ *
+ * Usage: app.use(require('middleware/safe-redirect')(['example.com', 'some.example.com:8080']))
+ */
+
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:middleware:safe-redirect');
+
+/**
+ * Check whether the redirect url host is in specified whitelist
+ * @param {Array<string>} whitelistOfHosts
+ * @param {string} redirectToFqdn
+ */
+function isInWhitelist(whitelistOfHosts, redirectToFqdn) {
+  if (whitelistOfHosts == null || whitelistOfHosts.length === 0) {
+    return false;
+  }
+
+  const redirectUrl = new URL(redirectToFqdn);
+  return whitelistOfHosts.includes(redirectUrl.hostname) || whitelistOfHosts.includes(redirectUrl.host);
+}
+
+
+module.exports = (whitelistOfHosts) => {
+
+  return function(req, res, next) {
+
+    // extend res object
+    res.safeRedirect = function(redirectTo) {
+      if (redirectTo == null) {
+        return res.redirect('/');
+      }
+
+      try {
+        // check inner redirect
+        const redirectUrl = new URL(redirectTo, `${req.protocol}://${req.get('host')}`);
+        if (redirectUrl.hostname === req.hostname) {
+          logger.debug(`Requested redirect URL (${redirectTo}) is local.`);
+          return res.redirect(redirectUrl.href);
+        }
+        logger.debug(`Requested redirect URL (${redirectTo}) is NOT local.`);
+
+        // check whitelisted redirect
+        const isWhitelisted = isInWhitelist(whitelistOfHosts, redirectTo);
+        if (isWhitelisted) {
+          logger.debug(`Requested redirect URL (${redirectTo}) is in whitelist.`, `whitelist=${whitelistOfHosts}`);
+          return res.redirect(redirectTo);
+        }
+        logger.debug(`Requested redirect URL (${redirectTo}) is NOT in whitelist.`, `whitelist=${whitelistOfHosts}`);
+      }
+      catch (err) {
+        logger.warn(`Requested redirect URL (${redirectTo}) is invalid.`, err);
+      }
+
+      logger.warn(`Requested redirect URL (${redirectTo}) is UNSAFE, redirecting to root page.`);
+      return res.redirect('/');
+    };
+
+    next();
+
+  };
+
+};

+ 2 - 0
src/server/models/config.js

@@ -109,6 +109,7 @@ module.exports = function(crowi) {
       'customize:isEnabledAttachTitleHeader' : false,
       'customize:showRecentCreatedNumber' : 10,
       'customize:isEnabledStaleNotification': false,
+      'customize:isAllReplyShown': false,
 
       'importer:esa:team_name': undefined,
       'importer:esa:access_token': undefined,
@@ -188,6 +189,7 @@ module.exports = function(crowi) {
       pageBreakCustomSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator'),
       isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
       isEnabledTimeline: crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
+      isAllReplyShown: crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
       tagWhiteList: crowi.xssService.getTagWhiteList(),
       attrWhiteList: crowi.xssService.getAttrWhiteList(),

+ 23 - 2
src/server/models/page.js

@@ -214,7 +214,7 @@ class PageQueryBuilder {
     return this;
   }
 
-  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup) {
+  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
     const grantConditions = [
       { grant: null },
       { grant: GRANT_PUBLIC },
@@ -831,6 +831,27 @@ module.exports = function(crowi) {
     return builder.addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink, !hidePagesRestrictedByOwner, !hidePagesRestrictedByGroup);
   }
 
+  /**
+   * Add condition that filter pages by viewer
+   *  by considering Config
+   *
+   * @param {PageQueryBuilder} builder
+   * @param {User} user
+   * @param {boolean} showAnyoneKnowsLink
+   */
+  async function addConditionToFilteringByViewerToEdit(builder, user) {
+    validateCrowi();
+
+    // determine UserGroup condition
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = crowi.model('UserGroupRelation');
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+
+    return builder.addConditionToFilteringByViewer(user, userGroups, false, false, false);
+  }
+
   /**
    * export addConditionToFilteringByViewerForList as static method
    */
@@ -1038,7 +1059,7 @@ module.exports = function(crowi) {
     builder.addConditionToExcludeRedirect();
 
     // add grant conditions
-    await addConditionToFilteringByViewerForList(builder, user);
+    await addConditionToFilteringByViewerToEdit(builder, user);
 
     // get all pages that the specified user can update
     const pages = await builder.query.exec();

+ 10 - 2
src/server/routes/admin.js

@@ -781,8 +781,9 @@ module.exports = function(crowi, app) {
   /**
    * validate setting form values for SAML
    *
-   * This validation checks, for the value of each mandatory items,
-   * whether it from the environment variables is empty and form value to update it is empty.
+   * - For the value of each mandatory items,
+   *     check whether it from the environment variables is empty and form value to update it is empty
+   * - validate the syntax of a attribute-based login control rule
    */
   function validateSamlSettingForm(form, t) {
     for (const key of crowi.passportService.mandatoryConfigKeysForSaml) {
@@ -792,6 +793,13 @@ module.exports = function(crowi, app) {
         form.errors.push(t('form_validation.required', formItemName));
       }
     }
+
+    const rule = form.settingForm['security:passport-saml:ABLCRule'];
+    // Empty string disables attribute-based login control.
+    // So, when rule is empty string, validation is passed.
+    if (rule !== '' && (rule == null || crowi.passportService.parseABLCRule(rule) == null)) {
+      form.errors.push(t('form_validation.invalid_syntax', t('security_setting.form_item_name.security:passport-saml:ABLCRule')));
+    }
   }
 
   return actions;

+ 6 - 0
src/server/routes/apiv3/customize-setting.js

@@ -49,6 +49,8 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
  *            type: number
  *          isEnabledStaleNotification:
  *            type: boolean
+ *          isAllReplyShown:
+ *            type: boolean
  *      CustomizeHighlight:
  *        description: CustomizeHighlight
  *        type: object
@@ -112,6 +114,7 @@ module.exports = (crowi) => {
       body('isEnabledAttachTitleHeader').isBoolean(),
       body('recentCreatedLimit').isInt().isInt({ min: 1, max: 1000 }),
       body('isEnabledStaleNotification').isBoolean(),
+      body('isAllReplyShown').isBoolean(),
     ],
     customizeTitle: [
       body('customizeTitle').isString(),
@@ -164,6 +167,7 @@ module.exports = (crowi) => {
       isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
       recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
       isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
+      isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
@@ -329,6 +333,7 @@ module.exports = (crowi) => {
       'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
       'customize:showRecentCreatedNumber': req.body.recentCreatedLimit,
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
+      'customize:isAllReplyShown': req.body.isAllReplyShown,
     };
 
     try {
@@ -339,6 +344,7 @@ module.exports = (crowi) => {
         isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
         recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
         isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
+        isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       };
       return res.apiv3({ customizedParams });
     }

+ 2 - 2
src/server/routes/index.js

@@ -47,14 +47,14 @@ module.exports = function(crowi, app) {
   }
 
   app.get('/login/error/:reason'     , login.error);
-  app.get('/login'                   , middlewares.applicationInstalled    , login.login);
+  app.get('/login'                   , middlewares.applicationInstalled     , login.preLogin, login.login);
   app.get('/login/invited'           , login.invited);
   app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
   app.post('/login'                  , form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
   app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
 
   app.post('/register'               , form.register                        , csrf, login.register);
-  app.get('/register'                , middlewares.applicationInstalled    , login.register);
+  app.get('/register'                , middlewares.applicationInstalled     , login.preLogin, login.register);
   app.get('/logout'                  , logout.logout);
 
   app.get('/admin'                          , loginRequiredStrictly , adminRequired , admin.index);

+ 20 - 31
src/server/routes/login-passport.js

@@ -4,7 +4,6 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login-passport');
   const logger = require('@alias/logger')('growi:routes:login-passport');
   const passport = require('passport');
-  const { URL } = require('url');
   const ExternalAccount = crowi.model('ExternalAccount');
   const passportService = crowi.passportService;
 
@@ -22,25 +21,10 @@ module.exports = function(crowi, app) {
       }
     });
 
-    const jumpTo = req.session.jumpTo;
-    if (jumpTo) {
-      req.session.jumpTo = null;
-
-      // prevention from open redirect
-      try {
-        const redirectUrl = new URL(jumpTo, `${req.protocol}://${req.get('host')}`);
-        if (redirectUrl.hostname === req.hostname) {
-          return res.redirect(redirectUrl);
-        }
-        logger.warn('Requested redirect URL is invalid, redirect to root page');
-      }
-      catch (err) {
-        logger.warn('Requested redirect URL is invalid, redirect to root page', err);
-        return res.redirect('/');
-      }
-    }
-
-    return res.redirect('/');
+    const { redirectTo } = req.session;
+    // remove session.redirectTo
+    delete req.session.redirectTo;
+    return res.safeRedirect(redirectTo);
   };
 
   /**
@@ -48,8 +32,8 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} res
    */
-  const loginFailure = (req, res, next) => {
-    req.flash('errorMessage', 'Sign in failure.');
+  const loginFailure = (req, res, message) => {
+    req.flash('errorMessage', message || 'Sign in failure.');
     return res.redirect('/login');
   };
 
@@ -250,7 +234,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -270,7 +254,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -301,7 +285,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -312,7 +296,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -343,7 +327,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -354,7 +338,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -390,7 +374,7 @@ module.exports = function(crowi, app) {
     }
     catch (err) {
       debug(err);
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const userInfo = {
@@ -403,7 +387,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     // login
@@ -461,6 +445,11 @@ module.exports = function(crowi, app) {
 
     const user = await externalAccount.getPopulatedUser();
 
+    // Attribute-based Login Control
+    if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
+      return loginFailure(req, res, 'Sign in failure due to insufficient privileges.');
+    }
+
     // login
     req.logIn(user, (err) => {
       if (err != null) {
@@ -503,7 +492,7 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailure(req, res, next);
+      return loginFailure(req, res);
     }
 
     const user = await externalAccount.getPopulatedUser();

+ 27 - 46
src/server/routes/login.js

@@ -15,7 +15,9 @@ module.exports = function(crowi, app) {
   const actions = {};
 
   const loginSuccess = function(req, res, userData) {
-    req.user = req.session.user = userData;
+    // transforming attributes
+    // see User model
+    req.user = req.session.user = userData.toObject();
 
     // update lastLoginAt
     userData.updateLastLoginAt(new Date(), (err, uData) => {
@@ -28,30 +30,10 @@ module.exports = function(crowi, app) {
       return res.redirect('/me/password');
     }
 
-    const jumpTo = req.session.jumpTo;
-    if (jumpTo) {
-      req.session.jumpTo = null;
-
-      // prevention from open redirect
-      try {
-        const redirectUrl = new URL(jumpTo, `${req.protocol}://${req.get('host')}`);
-        if (redirectUrl.hostname === req.hostname) {
-          return res.redirect(redirectUrl);
-        }
-        logger.warn('Requested redirect URL is invalid, redirect to root page');
-      }
-      catch (err) {
-        logger.warn('Requested redirect URL is invalid, redirect to root page', err);
-        return res.redirect('/');
-      }
-    }
-
-    return res.redirect('/');
-  };
-
-  const loginFailure = function(req, res) {
-    req.flash('warningMessage', 'Sign in failure.');
-    return res.redirect('/login');
+    const { redirectTo } = req.session;
+    // remove session.redirectTo
+    delete req.session.redirectTo;
+    return res.safeRedirect(redirectTo);
   };
 
   actions.error = function(req, res) {
@@ -72,30 +54,29 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.login = function(req, res) {
-    const loginForm = req.body.loginForm;
+  actions.preLogin = function(req, res, next) {
+    // user has already logged in
+    if (req.user != null) {
+      const { redirectTo } = req.session;
+      // remove session.redirectTo
+      delete req.session.redirectTo;
+      return res.safeRedirect(redirectTo);
+    }
 
-    if (req.method == 'POST' && req.form.isValid) {
-      const username = loginForm.username;
-      const password = loginForm.password;
-
-      // find user
-      User.findUserByUsernameOrEmail(username, password, (err, user) => {
-        if (err) { return loginFailure(req, res) }
-        // check existence and password
-        if (!user || !user.isPasswordValid(password)) {
-          return loginFailure(req, res);
-        }
-        return loginSuccess(req, res, user);
-      });
+    // set referer to 'redirectTo'
+    if (req.session.redirectTo == null && req.headers.referer != null) {
+      req.session.redirectTo = req.headers.referer;
     }
-    else { // method GET
-      if (req.form) {
-        debug(req.form.errors);
-      }
-      return res.render('login', {
-      });
+
+    next();
+  }
+
+  actions.login = function(req, res) {
+    if (req.form) {
+      debug(req.form.errors);
     }
+
+    return res.render('login', {});
   };
 
   actions.register = function(req, res) {

+ 4 - 1
src/server/routes/logout.js

@@ -2,7 +2,10 @@ module.exports = function(crowi, app) {
   return {
     logout(req, res) {
       req.session.destroy();
-      return res.redirect('/');
+
+      // redirect
+      const redirectTo = req.headers.referer || '/';
+      return res.safeRedirect(redirectTo);
     },
   };
 };

+ 7 - 1
src/server/service/config-loader.js

@@ -247,6 +247,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     default: null,
   },
+  SAML_ABLC_RULE: {
+    ns:      'crowi',
+    key:     'security:passport-saml:ABLCRule',
+    type:    TYPES.STRING,
+    default: null,
+  },
   GCS_API_KEY_JSON_PATH: {
     ns:      'crowi',
     key:     'gcs:apiKeyJsonPath',
@@ -288,7 +294,7 @@ class ConfigLoader {
     };
 
     // In getConfig API, only null is used as a value to indicate that a config is not set.
-    // So, if a value loaded from the database is emtpy,
+    // So, if a value loaded from the database is empty,
     // it is converted to null because an empty string is used as the same meaning in the config model.
     // By this processing, whether a value is loaded from the database or from the environment variable,
     // only null indicates a config is not set.

+ 2 - 1
src/server/service/config-manager.js

@@ -15,6 +15,7 @@ const KEYS_FOR_SAML_USE_ONLY_ENV_OPTION = [
   'security:passport-saml:attrMapFirstName',
   'security:passport-saml:attrMapLastName',
   'security:passport-saml:cert',
+  'security:passport-saml:ABLCRule',
 ];
 
 class ConfigManager {
@@ -66,7 +67,7 @@ class ConfigManager {
   }
 
   /**
-   * get a config specified by namespace and regular expresssion
+   * get a config specified by namespace and regular expression
    */
   getConfigByRegExp(namespace, regexp) {
     const result = {};

+ 114 - 0
src/server/service/passport.js

@@ -598,6 +598,120 @@ class PassportService {
     return missingRequireds;
   }
 
+  /**
+   * Verify that a SAML response meets the attribute-base login control rule
+   */
+  verifySAMLResponseByABLCRule(response) {
+    const rule = this.crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule');
+    if (rule == null) {
+      return true;
+    }
+
+    const expr = this.parseABLCRule(rule);
+    if (expr == null) {
+      return false;
+    }
+    debug({ 'Parsed Rule': JSON.stringify(expr, null, 2) });
+
+    const attributes = this.extractAttributesFromSAMLResponse(response);
+    debug({ 'Extracted Attributes': JSON.stringify(attributes, null, 2) });
+
+    let evaluatedExpr = false;
+    for (const orOp of expr) {
+      let evaluatedOrOp = true;
+      for (const andOp of orOp) {
+        if (attributes[andOp[0]] == null) {
+          evaluatedOrOp = false;
+          break;
+        }
+        evaluatedOrOp = evaluatedOrOp && attributes[andOp[0]].includes(andOp[1]);
+      }
+      evaluatedExpr = evaluatedExpr || evaluatedOrOp;
+    }
+
+    return evaluatedExpr;
+  }
+
+  /**
+   * Parse a rule string for the attribute-based login control
+   *
+   * The syntax rules are as follows.
+   * <attr> and <value> are any characters except "|", "&", "=".
+   *
+   * ## Syntax
+   *    <expr>   ::= <or_op> | <or_op> "|" <expr>
+   *    <or_op>  ::= <and_op> | <and_op> "&" <or_op>
+   *    <and_op> ::= <attr> "=" <value>
+   *
+   * ## Example
+   *  In:  "Department = A | Department = B & Position = Leader"
+   *  Out:
+   *    [
+   *      [
+   *        ["Department", "A"]
+   *      ],
+   *      [
+   *        ["Department","B"],
+   *        ["Position","Leader"]
+   *      ]
+   *    ]
+   *
+   *   In:  Invalid syntax string like a "This is a & bad & rule string."
+   *   Out: null
+   */
+  parseABLCRule(rule) {
+    let expr = rule.split('|');
+    expr = expr.map(orOp => orOp.trim().split('&'));
+    expr = expr.map(orOp => orOp.map(andOp => andOp.trim().split('=')));
+    expr = expr.map(orOp => orOp.map(andOp => andOp.map(v => v.trim())));
+    for (const orOp of expr) {
+      for (const andOp of orOp) {
+        if (andOp.length !== 2) {
+          return null;
+        }
+      }
+    }
+    return expr;
+  }
+
+
+  /**
+   * Extract attributes from a SAML response
+   *
+   * The format of extracted attributes is the following.
+   *
+   * {
+   *    "attribute_name1": ["value1", "value2", ...],
+   *    "attribute_name2": ["value1", "value2", ...],
+   *    ...
+   * }
+   */
+  extractAttributesFromSAMLResponse(response) {
+    const attributeStatement = response.getAssertion().Assertion.AttributeStatement;
+    if (attributeStatement == null || attributeStatement[0] == null) {
+      return {};
+    }
+
+    const attributes = attributeStatement[0].Attribute;
+    if (attributes == null) {
+      return {};
+    }
+
+    const result = {};
+    for (const attribute of attributes) {
+      const name = attribute.$.Name;
+      const attributeValues = attribute.AttributeValue.map(v => v._);
+      if (result[name] == null) {
+        result[name] = attributeValues;
+      }
+      else {
+        result[name] = result[name].concat(attributeValues);
+      }
+    }
+
+    return result;
+  }
+
   /**
    * reset BasicStrategy
    *

+ 2 - 2
src/server/views/admin/app.html

@@ -14,9 +14,9 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main row">
+<div class="content-main admin-app row">
   {% parent %}
-  <div class="col-md-9" id="admin-app"></div>
+  <div class="col-lg-9" id="admin-app"></div>
 </div>
 {% endblock content_main %}
 

+ 1 - 1
src/server/views/admin/export.html

@@ -13,7 +13,7 @@
 {% block content_main %}
 <div class="content-main admin-export row">
   {% parent %}
-  <div id="admin-export-page" class="col-md-9"></div>
+  <div id="admin-export-page" class="col-lg-9"></div>
 </div>
 
 {% endblock content_main %}

+ 1 - 1
src/server/views/admin/importer.html

@@ -13,7 +13,7 @@
 {% block content_main %}
 <div class="content-main admin-importer row">
   {% parent %}
-  <div class="col-md-9" id="admin-importer"></div>
+  <div class="col-lg-9" id="admin-importer"></div>
 </div>
 
 {% endblock content_main %}

+ 50 - 0
src/server/views/admin/widget/passport/saml.html

@@ -349,6 +349,56 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
       </div>
     </div>
 
+    <h4>Attribute-based Login Control</h4>
+
+    <p class="help-block">
+      <small>
+        {{ t("security_setting.SAML.attr_based_login_control_detail") }}
+      </small>
+    </p>
+
+    <table class="table settings-table {% if useOnlyEnvVars %}use-only-env-vars{% endif %}">
+      <colgroup>
+        <col class="item-name">
+        <col class="from-db">
+        <col class="from-env-vars">
+      </colgroup>
+      <thead>
+        <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+      </thead>
+      <tbody>
+      <tr>
+        <th>
+          {{ t("security_setting.form_item_name.security:passport-saml:ABLCRule") }}
+        </th>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 name="settingForm[security:passport-saml:ABLCRule]"
+                 value="{{ getConfigFromDB('crowi', 'security:passport-saml:ABLCRule') || '' }}"
+                 {% if useOnlyEnvVars %}readonly{% endif %}>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.attr_based_login_control_rule_detail") }}<br>
+              {{ t("security_setting.SAML.attr_based_login_control_rule_example") }}
+            </small>
+          </p>
+        </td>
+        <td>
+          <input class="form-control"
+                 type="text"
+                 value="{{ getConfigFromEnvVars('crowi', 'security:passport-saml:ABLCRule') || '' }}"
+                 readonly>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.SAML.Use env var if empty", "SAML_ABLC_RULE") }}
+            </small>
+          </p>
+        </td>
+      </tr>
+      </tbody>
+    </table>
+
   </fieldset>
 
   <div class="form-group" id="btn-update">

+ 1 - 1
src/server/views/installer.html

@@ -86,7 +86,7 @@
 </body>
 {% endblock %}
 
-<script type="application/json" id="crowi-context-hydrate">
+<script type="application/json" id="growi-context-hydrate">
 {{ local_config|json|safe|preventXss }}
 </script>
 

+ 1 - 1
src/server/views/layout-growi/widget/header.html

@@ -8,7 +8,7 @@
         </a>
       </div><!-- /.title-logo-container -->
 
-      <div class="title-container">
+      <div class="title-container mr-auto">
         <h1 class="title" id="revision-path"></h1>
         {% if not forbidden and not isTrashPage() %}
           <div id="tag-label"></div>

+ 11 - 14
src/server/views/layout/layout.html

@@ -63,7 +63,6 @@
 <body
   class="{% block html_base_css %}{% endblock %}
       {% if !getConfig('crowi', 'customize:layout') || 'crowi' === getConfig('crowi', 'customize:layout') %}crowi{% elseif !getConfig('crowi', 'customize:layout') || 'kibela' === getConfig('crowi', 'customize:layout') %}kibela{% else %}growi{% endif %}"
-  data-me="{{ user._id.toString() }}"
   data-is-admin="{{ user.admin }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}
@@ -127,16 +126,6 @@
           {% endif %}
         </li>
       </ul>
-
-      <ul class="nav navbar-top-links navbar-right pull-right">
-        {% if user and user.admin %}
-        <li class="nav-item-admin">
-          <a href="/admin">
-            <i class="icon-settings"></i><span>{{ t('Admin') }}</span>
-          </a>
-        </li>
-        {% endif %}
-      </form>
     </ul>
 
     <!-- 5 user action -->
@@ -159,10 +148,10 @@
       </li>
       <li class="nav-item">
         <a class="nav-link" href="https://docs.growi.org/" target="_blank">
-          <i class="icon-question"></i><span class="d-none d-md-inline-block">{{ t('Help') }}</span><span class="text-muted small"><i class="icon-share-alt"></i></span>
+          <i class="icon-question mr-2"></i><span class="d-none d-md-inline-block mr-2">{{ t('Help') }}</span><span class="text-muted small"><i class="icon-share-alt"></i></span>
         </a>
       </li>
-      <li class="nav-item dropdown">
+      <li class="nav-item dropdown personal-dropdown">
         <a class="btn nav-link dropdown-toggle waves-effect waves-light" data-toggle="dropdown">
           <img src="{{ user|picture }}" class="picture rounded-circle" width="25" />
           <span class="user-name text-wrap ml-2">{{ user.name }}</span>
@@ -196,6 +185,7 @@
 
   {% block sidebar %}
   <!-- Left navbar-header -->
+  {#
   <div class="navbar-default sidebar hidden-print" role="navigation">
     <div class="sidebar-nav navbar-collapse slimscrollsidebar">
       <ul class="nav" id="side-menu">
@@ -209,6 +199,7 @@
       </ul>
     </div>
   </div>
+  #}
   <!-- Left navbar-header end -->
   {% endblock %}
 
@@ -230,10 +221,16 @@
 </body>
 {% endblock %}
 
-<script type="application/json" id="crowi-context-hydrate">
+<script type="application/json" id="growi-context-hydrate">
 {{ local_config|json|safe|preventXss }}
 </script>
 
+{% if user != null %}
+  <script type="application/json" id="growi-current-user">
+  {{ user|json|safe|preventXss }}
+  </script>
+{% endif %}
+
 {% block custom_script %}
 <script>
   {{ customizeService.getCustomScript() }}

+ 4 - 1
src/server/views/widget/not_found_tabs.html

@@ -7,7 +7,10 @@
 
   {% if !isTrashPage() and !page.isDeleted() %}
   <li class="nav-item grw-nav-main-left-tab">
-    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="nav-link edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#edit" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %} class="edit-button edit-button-disabled" data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}" {% endif %}
+    >
       <i class="icon-note"></i> {{ t('Create') }}
     </a>
   </li>

+ 29 - 5
src/server/views/widget/page_tabs.html

@@ -12,13 +12,25 @@
 
   {% if !isTrashPage() %}
   <li class="nav-item grw-main-nav-item-left grw-nav-item-edit">
-    <a {% if user %}href="#edit" role="tab" data-toggle="tab" {% endif %} class="nav-link edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#edit" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="icon-note"></i> {{ t('Edit') }}
     </a>
   </li>
   {% if isHackmdSetup() %}
-  <li class="nav-item grw-main-nav-item-left grw-nav-item-hackmd">
-    <a {% if user %}href="#hackmd" role="tab" data-toggle="tab" {% endif %} class="nav-link {% if not user %}edit-button-disabled{% endif %}">
+  <li class="nav-item grw-main-nav-item-left grw-nav-tab-hackmd">
+    <a
+      {% if user %} href="#hackmd" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="fa fa-file-text-o"></i> {{ t('HackMD') }}
     </a>
   </li>
@@ -38,7 +50,13 @@
   {% if !isTrashPage() %}
     {% if page.isPortal() %}
     <li class="nav-item">
-      <a class="btn nav-link dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" {% endif %}>
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <div class="dropdown-menu dropdown-menu-right">
@@ -54,7 +72,13 @@
     </li>
     {% else %}
     <li class="nav-item dropdown">
-      <a class="btn nav-link dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown"{% endif %}>
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <div class="dropdown-menu dropdown-menu-right">

+ 29 - 5
src/server/views/widget/page_tabs_kibela.html

@@ -12,13 +12,25 @@
 
   {% if !isTrashPage() %}
   <li class="grw-nav-main-left-tab nav-tab-edit">
-    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="nav-link edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#edit" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="icon-note"></i> {{ t('Edit') }}
     </a>
   </li>
   {% if isHackmdSetup() %}
   <li class="grw-nav-main-left-tab nav-tab-hackmd">
-    <a {% if user %}href="#hackmd" data-toggle="tab"{% endif %} class="nav-link {% if not user %}edit-button-disabled{% endif %}">
+    <a
+      {% if user %} href="#hackmd" data-toggle="tab" class="edit-button" {% endif %}
+      {% if not user %}
+        class="edit-button edit-button-disabled"
+        data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+      {% endif %}
+    >
       <i class="fa fa-file-text-o"></i> {{ t('HackMD') }}
     </a>
   </li>
@@ -30,8 +42,14 @@
   #}
   {% if !isTrashPage() %}
     {% if page.isPortal() %}
-    <li class="dropdown float-right">
-      <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
+    <li class="float-right dropdown">
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
@@ -44,7 +62,13 @@
     </li>
     {% else %}
     <li class="dropdown float-right">
-      <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
+      <a
+        {% if user %} role="button" class="dropdown-toggle" data-toggle="dropdown" {% endif %}
+        {% if not user %}
+          class="dropdown-toggle dropdown-toggle-disabled"
+          data-toggle="tooltip" data-placement="top" data-container="body" title="{{ t('Not available for guest') }}"
+        {% endif %}
+      >
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">

+ 4 - 4
src/test/middleware/login-required.test.js

@@ -101,7 +101,7 @@ describe('loginRequired', () => {
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
       expect(result).toBe('redirect');
-      expect(req.session.jumpTo).toBe('original url 1');
+      expect(req.session.redirectTo).toBe('original url 1');
     });
 
     test('pass user who logged in', () => {
@@ -119,7 +119,7 @@ describe('loginRequired', () => {
       expect(res.redirect).not.toHaveBeenCalled();
       expect(next).toHaveBeenCalledTimes(1);
       expect(result).toBe('next');
-      expect(req.session.jumpTo).toBe(undefined);
+      expect(req.session.redirectTo).toBe(undefined);
     });
 
     /* eslint-disable indent */
@@ -142,7 +142,7 @@ describe('loginRequired', () => {
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith(expectedPath);
       expect(result).toBe('redirect');
-      expect(req.session.jumpTo).toBe(undefined);
+      expect(req.session.redirectTo).toBe(undefined);
     });
     /* eslint-disable indent */
 
@@ -163,7 +163,7 @@ describe('loginRequired', () => {
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/login');
       expect(result).toBe('redirect');
-      expect(req.session.jumpTo).toBe('original url 1');
+      expect(req.session.redirectTo).toBe('original url 1');
     });
 
   });

+ 108 - 0
src/test/middleware/safe-redirect.test.js

@@ -0,0 +1,108 @@
+/* eslint-disable arrow-body-style */
+
+describe('safeRedirect', () => {
+  let safeRedirect;
+
+  const whitelistOfHosts = [
+    'white1.example.com:8080',
+    'white2.example.com',
+  ];
+
+  beforeEach(async(done) => {
+    safeRedirect = require('@server/middleware/safe-redirect')(whitelistOfHosts);
+    done();
+  });
+
+  describe('res.safeRedirect', () => {
+    // setup req/res/next
+    const req = {
+      protocol: 'http',
+      hostname: 'example.com',
+      get: jest.fn().mockReturnValue('example.com'),
+    };
+    const res = {
+      redirect: jest.fn().mockReturnValue('redirect'),
+    };
+    const next = jest.fn();
+
+    test('redirects to \'/\' because specified url causes open redirect vulnerability', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('//evil.example.com');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('/');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to \'/\' because specified host without port is not in whitelist', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://white1.example.com/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('/');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified local url', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified local url (fqdn)', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://example.com/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified whitelisted url (white1.example.com:8080)', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://white1.example.com:8080/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://white1.example.com:8080/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+    test('redirects to the specified whitelisted url (white2.example.com:8080)', () => {
+      safeRedirect(req, res, next);
+
+      const result = res.safeRedirect('http://white2.example.com:8080/path/to/page');
+
+      expect(next).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledTimes(1);
+      expect(req.get).toHaveBeenCalledWith('host');
+      expect(res.redirect).toHaveBeenCalledTimes(1);
+      expect(res.redirect).toHaveBeenCalledWith('http://white2.example.com:8080/path/to/page');
+      expect(result).toBe('redirect');
+    });
+
+  });
+
+});

Разница между файлами не показана из-за своего большого размера
+ 436 - 228
yarn.lock


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