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

Merge branch 'master' into imprv/kibela-branches-for-marges

# Conflicts:
#	config/webpack.common.js
#	src/server/views/admin/customize.html
yusuketk 7 лет назад
Родитель
Сommit
3532420b1b
47 измененных файлов с 878 добавлено и 84 удалено
  1. 3 3
      .vscode/launch.json
  2. 11 5
      CHANGES.md
  3. 4 0
      config/logger/config.dev.js
  4. 3 0
      config/webpack.common.js
  5. 3 2
      package.json
  6. BIN
      public/images/themes/halloween/halloween-navbar.jpg
  7. BIN
      public/images/themes/halloween/halloween.jpg
  8. BIN
      public/images/themes/island/island.png
  9. BIN
      public/images/themes/wood/wood-navbar.jpg
  10. BIN
      public/images/themes/wood/wood.jpg
  11. 0 0
      resource/locales/en-US/admin/userInvitation.txt
  12. 0 0
      resource/locales/en-US/admin/userWaitingActivation.txt
  13. 7 0
      resource/locales/en-US/translation.json
  14. 14 0
      resource/locales/ja/admin/userInvitation.txt
  15. 21 0
      resource/locales/ja/admin/userWaitingActivation.txt
  16. 7 0
      resource/locales/ja/translation.json
  17. 1 1
      src/client/styles/agile-admin/inverse/colors/_apply-colors-dark.scss
  18. 1 1
      src/client/styles/agile-admin/inverse/colors/_apply-colors-light.scss
  19. 13 0
      src/client/styles/agile-admin/inverse/colors/_apply-colors.scss
  20. 81 0
      src/client/styles/agile-admin/inverse/colors/halloween.scss
  21. 120 0
      src/client/styles/agile-admin/inverse/colors/island.scss
  22. 82 0
      src/client/styles/agile-admin/inverse/colors/wood.scss
  23. 18 11
      src/client/styles/scss/_admin.scss
  24. 8 0
      src/client/styles/scss/_login.scss
  25. 8 0
      src/client/styles/scss/theme/halloween.scss
  26. 8 0
      src/client/styles/scss/theme/island.scss
  27. 8 0
      src/client/styles/scss/theme/wood.scss
  28. 0 0
      src/server/.node-dev.json
  29. 1 1
      src/server/crowi/index.js
  30. 17 0
      src/server/form/admin/securityPassportSaml.js
  31. 1 0
      src/server/form/index.js
  32. 6 1
      src/server/models/config.js
  33. 4 4
      src/server/models/user.js
  34. 31 3
      src/server/routes/admin.js
  35. 6 3
      src/server/routes/index.js
  36. 39 0
      src/server/routes/login-passport.js
  37. 9 8
      src/server/routes/login.js
  38. 7 2
      src/server/service/global-notification.js
  39. 63 5
      src/server/service/passport.js
  40. 1 2
      src/server/util/mailer.js
  41. 6 1
      src/server/util/swigFunctions.js
  42. 5 2
      src/server/views/admin/customize.html
  43. 9 1
      src/server/views/admin/security.html
  44. 157 0
      src/server/views/admin/widget/passport/saml.html
  45. 13 12
      src/server/views/admin/widget/theme-colorbox.html
  46. 11 2
      src/server/views/login.html
  47. 71 14
      yarn.lock

+ 3 - 3
.vscode/launch.json

@@ -23,11 +23,11 @@
         "request": "launch",
         "name": "Debug: Chrome",
         "sourceMaps": true,
+        "webRoot": "${workspaceFolder}/public",
         "sourceMapPathOverrides": {
-          "webpack:///*": "${workspaceRoot}/*"
+          "webpack:///*": "${workspaceFolder}/*"
         },
-        "url": "http://localhost:3000",
-        "webRoot": "${workspaceRoot}/public"
+        "url": "http://localhost:3000"
       }
     ]
 }

+ 11 - 5
CHANGES.md

@@ -1,9 +1,15 @@
 CHANGES
 ========
 
-## 3.2.2-RC
+## 3.2.2
 
-* 
+* Feature: SAML Authentication (SSO)
+* Improvement: Add 'wood' theme
+* Improvement: Add 'halloween' theme
+* Improvement: Add 'island' theme
+* Fix: Sending email function doesn't work
+* Support Upgrade libs
+    * style-loader
 
 ## 3.2.1
 
@@ -23,7 +29,7 @@ CHANGES
 ## 3.2.0
 
 * Feature: HackMD integration so that user will be able to simultaneously edit with multiple people
-* Feature: Login with Twitter Account
+* Feature: Login with Twitter Account (OAuth)
 * Fix: The Initial scroll position is wrong when reloading the page
 
 ## 3.1.14
@@ -74,8 +80,8 @@ CHANGES
 
 ## 3.1.9
 
-* Feature: Login with Google Account
-* Feature: Login with GitHub Account
+* Feature: Login with Google Account (OAuth)
+* Feature: Login with GitHub Account (OAuth)
 * Feature: Attach files in Comment
 * Improvement: Write comment with CodeMirror Editor
 * Improvement: Post comment with `Ctrl-Enter`

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

@@ -11,11 +11,15 @@ module.exports = {
   // 'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:service:PassportService': 'debug',
+  // 'growi:service:GlobalNotification': 'debug',
   // 'growi:lib:importer': 'debug',
   // 'growi:routes:page': 'debug',
   // 'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
 
+  // email
+  // 'growi:lib:mailer': 'debug',
+
   //// configure level for client
   'growi:app': 'debug',
 };

+ 3 - 0
config/webpack.common.js

@@ -38,6 +38,9 @@ module.exports = (options) => {
       'styles/theme-future':          './src/client/styles/scss/theme/future.scss',
       'styles/theme-blue-night':      './src/client/styles/scss/theme/blue-night.scss',
       'styles/theme-kibela':          './src/client/styles/scss/theme/kibela.scss',
+      'styles/theme-halloween':       './src/client/styles/scss/theme/halloween.scss',
+      'styles/theme-wood':          './src/client/styles/scss/theme/wood.scss',
+      'styles/theme-island':      './src/client/styles/scss/theme/island.scss',
       // styles for external services
       'styles/style-hackmd':          './src/client/styles/hackmd/style.scss',
     }, options.entry || {}),  // Merge with env dependent settings

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.2.2-RC",
+  "version": "3.2.3-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -104,6 +104,7 @@
     "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
+    "passport-saml": "^0.35.0",
     "passport-twitter": "^1.0.4",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
@@ -188,7 +189,7 @@
     "sinon": "^6.0.0",
     "sinon-chai": "^3.2.0",
     "socket.io-client": "^2.0.3",
-    "style-loader": "^0.22.1",
+    "style-loader": "^0.23.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "uglifyjs-webpack-plugin": "^1.2.5",

BIN
public/images/themes/halloween/halloween-navbar.jpg


BIN
public/images/themes/halloween/halloween.jpg


BIN
public/images/themes/island/island.png


BIN
public/images/themes/wood/wood-navbar.jpg


BIN
public/images/themes/wood/wood.jpg


+ 0 - 0
src/server/views/mail/admin/userInvitation.txt → resource/locales/en-US/admin/userInvitation.txt


+ 0 - 0
src/server/views/mail/admin/userWaitingActivation.txt → resource/locales/en-US/admin/userWaitingActivation.txt


+ 7 - 0
resource/locales/en-US/translation.json

@@ -309,6 +309,7 @@
     "recommended": "Recommended",
     "username_email_password": "Username, Email and Password authentication",
     "ldap_auth": "LDAP authentication",
+    "saml_auth": "SAML authentication",
     "google_auth2": "Google OAuth authentication",
     "google_auth2_by_crowi_desc": "However, this feature does not create new users, butit only makes it possible to login to the existing user who set up the association.",
     "facebook_auth2": "Facebook OAuth authentication",
@@ -374,6 +375,12 @@
       "group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
       "test_config": "Test Saved Configuration"
     },
+    "SAML": {
+      "name": "SAML",
+      "entry_point": "Entry Point",
+      "issuer": "Issuer",
+      "mapping_detail": "Specification of mappings for %s when creating new users"
+    },
     "OAuth": {
       "register": "Register for %s",
       "change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",

+ 14 - 0
resource/locales/ja/admin/userInvitation.txt

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

+ 21 - 0
resource/locales/ja/admin/userWaitingActivation.txt

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

+ 7 - 0
resource/locales/ja/translation.json

@@ -328,6 +328,7 @@
     "recommended": "推奨",
     "username_email_password": "ユーザー名、Eメール、パスワードでの認証",
     "ldap_auth": "LDAP 認証",
+    "saml_auth": "SAML 認証",
     "google_auth2": "Google OAuth 認証",
     "google_auth2_by_crowi_desc": "ただし、この機能では新たなユーザーは作成されず、関連付け設定を行った既存ユーザーをログインできるようにするだけです。",
     "facebook_auth2": "Facebook OAuth 認証",
@@ -392,6 +393,12 @@
       "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
       "test_config": "ログインテスト"
     },
+    "SAML": {
+      "name": "SAML",
+      "entry_point": "エントリーポイント",
+      "issuer": "発行者",
+      "mapping_detail": "新規ユーザーの%sに関連付ける属性"
+    },
     "OAuth": {
       "register": "%sに登録",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力<br>(<code>%s</code>は環境に合わせて変更してください)",

+ 1 - 1
src/client/styles/agile-admin/inverse/colors/_apply-colors-dark.scss

@@ -156,7 +156,7 @@ legend {
  */
 .admin-page {
   #themeOptions {
-    a.active {
+    .theme-option-container.active a {
       background-color: darken($themecolor,15%);
       border-color: darken($themecolor,15%);
     }

+ 1 - 1
src/client/styles/agile-admin/inverse/colors/_apply-colors-light.scss

@@ -72,7 +72,7 @@
  */
 .admin-page {
   #themeOptions {
-    a.active {
+    .theme-option-container.active a {
       background-color: lighten($themecolor,20%);
       border-color: lighten($themecolor,20%);
     }

+ 13 - 0
src/client/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -372,3 +372,16 @@ body.on-edit {
   border-top-color: $border;
   background-color: darken($bodycolor, 2%);
 }
+
+/*
+ * GROWI admin page #themeOptions
+ */
+ .admin-page {
+  #themeOptions {
+    .theme-option-container.active {
+      .theme-option-name {
+        color: $bodytext;
+      }
+    }
+  }
+}

+ 81 - 0
src/client/styles/agile-admin/inverse/colors/halloween.scss

@@ -0,0 +1,81 @@
+@import '../variables';
+
+$basecolor:#0a010a;
+$themecolor: #aa4a04;
+$subthemecolor: #e9af2b;
+$linkcolor: #aa4a04;
+$topbar: $themecolor;
+$sidebar:#061f2f;
+$subthemecolor: #e9af2b;
+$bodycolor: #0f0101;
+$headingtext: #e9af2b;
+$bodytext: #e9af2b;
+$linktext: $linkcolor;
+$linktext-hover: rgba($linktext, 0.8);
+$sidebar-text: $themecolor;
+$dark-themecolor:#4f5467;
+$primary: $themecolor;
+$logo-mark-fill: lighten($subthemecolor, 20%);
+$wikilinktext: $linkcolor;
+$wikilinktext-hover: rgba($linktext, 0.8);
+$dark: darken($bodytext, 5%);
+$border: $themecolor;
+$navbar-border: lighten($basecolor, 25%);
+$active-navbar-border: darken($navbar-border, 3%);
+$btn-default-bgcolor: darken($basecolor, 10%);
+$inline-code-color: #a94f04;
+$inline-code-bg: #0a121b;
+
+@import 'apply-colors';
+@import 'apply-colors-dark';
+
+.wiki {
+  .highlighted {
+    background-color: lighten($themecolor, 20%);
+  }
+}
+
+.panel {
+
+  &,
+  &.panel-white,
+  &.panel-default {
+    border-color: $bodytext;
+    background-color: lighten($basecolor, 5%);
+
+    .panel-heading {
+      color: $basecolor;
+      background-color: 1px solid $bodytext;
+    }
+  }
+}
+
+.rbt-menu {
+  background: lighten($basecolor, 7%);
+}
+
+.nav-pills>li>a:hover {
+  background: #d3671a;
+  color: $white;
+}
+
+#wrapper > .navbar > .navbar-header {
+  background-image: url("/images/themes/halloween/halloween-navbar.jpg");
+}
+
+.main-container > #wrapper > #page-wrapper {
+  background-image: url("/images/themes/halloween/halloween.jpg");
+  background-attachment: fixed;
+}
+
+.bg-title {
+  background-color: rgba(0, 0, 0, 0.3);
+}
+
+/* Tabs */
+.nav.nav-tabs {
+  >li.active>a {
+    background: transparent;
+    border-bottom: 1px solid #1f1b1b;
+  }
+}

+ 120 - 0
src/client/styles/agile-admin/inverse/colors/island.scss

@@ -0,0 +1,120 @@
+@import '../variables';
+
+$themecolor: #0c2a44;
+$themelight: rgba(183, 226, 219, 1);
+
+$subthemecolor: rgba($linktext, 0.8);
+$linkcolor: #3c6d72;
+$themecolor: #97cbc3;
+$topbar: #0c2a44;
+$sidebar: $themelight;
+$bodycolor: lighten($themelight, 5%);
+$headingtext:#3c6d72;
+$bodytext: #3c6d72;
+$linktext: $linkcolor;
+$linktext-hover: rgba($linktext, 0.8);
+$sidebar-text: $themecolor;
+$primary: $themecolor;
+$logo-mark-fill: lighten($themelight, 10%);
+$wikilinktext: $linkcolor;
+$wikilinktext-hover: rgba($linktext, 0.8);
+$dark: darken($bodytext, 5%);
+$border: #76b1a8;
+$navbar-border: #76b1a8;
+$active-navbar-border: darken($navbar-border, 13%);
+$btn-default-bgcolor: darken($themecolor, 10%);
+$inline-code-color: #8f5313;
+$inline-code-bg: darken($themelight, 3%);
+
+@import 'apply-colors';
+@import 'apply-colors-light';
+
+.wiki {
+  .highlighted {
+    background-color: lighten($themecolor, 20%);
+  }
+}
+
+.panel {
+
+  &,
+  &.panel-white,
+  &.panel-default {
+    background-color: lighten($themecolor, 30%);
+    border-color: white;
+    color: $themecolor;
+
+    .panel-heading {
+      color: $themecolor;
+      background-color: white;
+    }
+
+    ul {
+      li {
+        a {
+          color: darken($themecolor, 15%);
+        }
+      }
+    }
+  }
+}
+
+/* GROWI page list */
+.page-list {
+  .page-list-ul {
+    >li {
+      >a strong {
+        color: $linkcolor;
+      }
+    }
+  }
+}
+
+.rbt-menu {
+  background: lighten($themelight, 5%);
+}
+
+.main-container > #wrapper > #page-wrapper {
+  background-image: url("/images/themes/island/island.png");
+  background-attachment: fixed;
+}
+
+/* Tabs */
+.nav.nav-tabs {
+  >li.active>a {
+    background: transparent;
+    border-bottom: 1px solid #d0ece7;
+  }
+}
+/* Table */
+ .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th,
+ .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td ,
+ .table > thead > tr > th, .table-bordered{
+     border-top: 1px solid $border;
+ }
+
+ .table-bordered > thead > tr > th,
+ .table-bordered > tbody > tr > th,
+ .table-bordered > tfoot > tr > th,
+ .table-bordered > thead > tr > td,
+ .table-bordered > tbody > tr > td,
+ .table-bordered > tfoot > tr > td {
+   border: 1px solid $border;
+ }
+ .table > thead > tr > th {
+     border-bottom: 1px solid $border;
+ }
+
+ .table-bordered {
+     border: 1px solid $border;
+ }
+
+
+ // login page
+.nologin {
+  &.login-page {
+    > #wrapper > #page-wrapper {
+      background-image: unset;
+    }
+  }
+}

+ 82 - 0
src/client/styles/agile-admin/inverse/colors/wood.scss

@@ -0,0 +1,82 @@
+@import '../variables';
+
+$themecolor: #aaa45f;
+$themelight: #f5f3ee;
+$subthemecolor: #dddebf;
+$topbar: $themecolor;
+$sidebar: $themelight;
+$bodycolor: $themelight;
+$headingtext: #577254;
+$bodytext: #7c7a70;
+$linktext: lighten(#6d8969, 5%);
+$linktext-hover: lighten($linktext, 12%);
+$sidebar-text: #859083;
+$primary: $themecolor;
+$info: lighten($themecolor, 10%);
+$logo-mark-fill: lighten(desaturate($topbar, 50%), 50%);
+$wikilinktext: lighten($themecolor, 5%);
+$wikilinktext-hover: lighten($wikilinktext, 15%);
+$inline-code-color: darken($themecolor, 20%);
+$inline-code-bg: lighten($subthemecolor, 70%);
+
+@import 'apply-colors';
+@import 'apply-colors-light';
+
+// change color of highlighted header in wiki (default: orange)
+.wiki {
+  .code-line.revision-head.highlighted {
+    background-color: lighten($themecolor, 20%);
+    color: $themelight;
+
+    .icon-note,
+    .icon-link {
+      color: $themelight;
+    }
+  }
+}
+
+.rbt-menu {
+  background: $themelight;
+}
+
+.main-container > #wrapper > #page-wrapper {
+  background-image: url("/images/themes/wood/wood.jpg");
+  background-attachment: fixed;
+}
+
+.bg-title {
+  background-color: rgba(226, 221, 192, 0.205);
+}
+
+/* Tabs */
+.nav.nav-tabs {
+  >li.active>a {
+    background: transparent;
+    border-bottom: 1px solid $bodycolor;
+  }
+}
+
+#wrapper > .navbar > .navbar-header {
+  background-image: url("/images/themes/wood/wood-navbar.jpg");
+}
+
+// login page
+.nologin {
+  .input-group {
+    .input-group-addon {
+      background-color: rgba(lighten(black, 10%), 0.6);
+    }
+    .form-control {
+      background-color: rgba(lighten(black, 10%), 0.6);
+    }
+  }
+
+  &.login-page {
+    .login-header, .login-dialog {
+      background-color: rgba(#ccc, 0.5);
+    }
+    .link-switch {
+      color: $bodytext;
+    }
+  }
+}

+ 18 - 11
src/client/styles/scss/_admin.scss

@@ -85,24 +85,31 @@
   // theme selector
   #themeOptions {
     // layout
-    a {
-      margin-right: 10px;
-      margin-bottom: 10px;
-      padding: 3px;
+    .theme-option-container {
+      min-width: 100px;
+      a {
+        margin-right: 10px;
+        margin-bottom: 10px;
+        padding: 3px;
 
-      svg {
-        display: block;
+        svg {
+          display: block;
+        }
       }
     }
 
     // style
-    a {
+    .theme-option-container a {
       border: 1px solid #ccc;
       background-color: #f5f5f5;
-
-      &.active {
-        border-color: $brand-success;
-        background-color: $brand-success;
+    }
+    .theme-option-name {
+      opacity: 0.3;
+    }
+    // style (active)
+    .theme-option-container.active {
+      .theme-option-name {
+        opacity: 1;
       }
     }
   }

+ 8 - 0
src/client/styles/scss/_login.scss

@@ -176,6 +176,14 @@
       background-color: #555;
     }
   }
+  .btn-login-oauth.fcbtn#saml {
+    .btn-label {
+      background-color: rgba(#55a79a, 0.4);
+    }
+    &:after {
+      background-color: #555;
+    }
+  }
   .btn-register.fcbtn {
     .btn-label {
       background-color: rgba($brand-success, 0.4);

+ 8 - 0
src/client/styles/scss/theme/halloween.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/halloween';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 8 - 0
src/client/styles/scss/theme/island.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/island';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 8 - 0
src/client/styles/scss/theme/wood.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/wood';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 0 - 0
.node-dev.json → src/server/.node-dev.json


+ 1 - 1
src/server/crowi/index.js

@@ -27,7 +27,6 @@ function Crowi(rootdir, env) {
   this.libDir      = path.join(this.rootDir, 'src/server') + sep;
   this.eventsDir   = path.join(this.libDir, 'events') + sep;
   this.viewsDir    = path.join(this.libDir, 'views') + sep;
-  this.mailDir     = path.join(this.viewsDir, 'mail') + sep;
   this.resourceDir = path.join(this.rootDir, 'resource') + sep;
   this.localeDir   = path.join(this.resourceDir, 'locales') + sep;
   this.tmpDir      = path.join(this.rootDir, 'tmp') + sep;
@@ -283,6 +282,7 @@ Crowi.prototype.setupPassport = function() {
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();
     this.passportService.setupTwitterStrategy();
+    this.passportService.setupSamlStrategy();
   }
   catch (err) {
     logger.error(err);

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

@@ -0,0 +1,17 @@
+'use strict';
+
+const form = require('express-form');
+const field = form.field;
+
+module.exports = form(
+  field('settingForm[security:passport-saml:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-saml:entryPoint]').trim(),
+  field('settingForm[security:passport-saml:callbackUrl]').trim(),
+  field('settingForm[security:passport-saml:issuer]').trim(),
+  field('settingForm[security:passport-saml:attrMapId]'),
+  field('settingForm[security:passport-saml:attrMapUsername]'),
+  field('settingForm[security:passport-saml:attrMapMail]'),
+  field('settingForm[security:passport-saml:attrMapFirstName]'),
+  field('settingForm[security:passport-saml:attrMapLastName]'),
+  field('settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+);

+ 1 - 0
src/server/form/index.js

@@ -21,6 +21,7 @@ module.exports = {
     securityGoogle: require('./admin/securityGoogle'),
     securityMechanism: require('./admin/securityMechanism'),
     securityPassportLdap: require('./admin/securityPassportLdap'),
+    securityPassportSaml: require('./admin/securityPassportSaml'),
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),

+ 6 - 1
src/server/models/config.js

@@ -282,6 +282,11 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
+  configSchema.statics.isEnabledPassportSaml = function(config) {
+    const key = 'security:passport-saml:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isEnabledPassportGoogle = function(config) {
     const key = 'security:passport-google:isEnabled';
     return getValueForCrowiNS(config, key);
@@ -292,7 +297,7 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
-   configSchema.statics.isEnabledPassportTwitter = function(config) {
+  configSchema.statics.isEnabledPassportTwitter = function(config) {
     const key = 'security:passport-twitter:isEnabled';
     return getValueForCrowiNS(config, key);
   };

+ 4 - 4
src/server/models/user.js

@@ -1,11 +1,11 @@
 module.exports = function(crowi) {
-  var debug = require('debug')('growi:models:user')
+  const debug = require('debug')('growi:models:user')
+    , path = require('path')
     , mongoose = require('mongoose')
     , mongoosePaginate = require('mongoose-paginate')
     , uniqueValidator = require('mongoose-unique-validator')
     , crypto = require('crypto')
     , async = require('async')
-    , ObjectId = mongoose.Schema.Types.ObjectId
 
     , STATUS_REGISTERED = 1
     , STATUS_ACTIVE     = 2
@@ -23,7 +23,7 @@ module.exports = function(crowi) {
 
     , userEvent = crowi.event('user')
 
-    , userSchema;
+  let userSchema;
 
   userSchema = new mongoose.Schema({
     userId: String,
@@ -654,7 +654,7 @@ module.exports = function(crowi) {
               mailer.send({
                 to: user.email,
                 subject: 'Invitation to ' + Config.appTitle(config),
-                template: 'admin/userInvitation.txt',
+                template: path.join(crowi.localeDir, 'en-US/admin/userInvitation.txt'),
                 vars: {
                   email: user.email,
                   password: user.password,

+ 31 - 3
src/server/routes/admin.js

@@ -1046,6 +1046,34 @@ module.exports = function(crowi, app) {
       });
   };
 
+  actions.api.securityPassportSamlSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+    await saveSettingAsync(form);
+    const config = await crowi.getConfig();
+
+    // reset strategy
+    await crowi.passportService.resetSamlStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportSaml(config)) {
+      try {
+        await crowi.passportService.setupSamlStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetSamlStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
+
   actions.api.securityPassportGoogleSetting = async(req, res) => {
     const form = req.form.settingForm;
 
@@ -1371,8 +1399,8 @@ module.exports = function(crowi, app) {
   }
 
   function validateMailSetting(req, form, callback) {
-    var mailer = crowi.mailer;
-    var option = {
+    const mailer = crowi.mailer;
+    const option = {
       host: form['mail:smtpHost'],
       port: form['mail:smtpPort'],
     };
@@ -1386,7 +1414,7 @@ module.exports = function(crowi, app) {
       option.secure = true;
     }
 
-    var smtpClient = mailer.createSMTPClient(option);
+    const smtpClient = mailer.createSMTPClient(option);
     debug('mailer setup for validate SMTP setting', smtpClient);
 
     smtpClient.sendMail({

+ 6 - 3
src/server/routes/index.js

@@ -67,22 +67,25 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/security/google'        , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityGoogle, admin.api.securitySetting);
   app.post('/_api/admin/security/mechanism'     , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityMechanism, admin.api.securitySetting);
   app.post('/_api/admin/security/passport-ldap' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportLdap, admin.api.securityPassportLdapSetting);
+  app.post('/_api/admin/security/passport-saml' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportSaml, admin.api.securityPassportSamlSetting);
 
   // OAuth
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
-  app.post('/_api/admin/security/passport-twitter' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
+  app.post('/_api/admin/security/passport-twitter', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
+  app.get('/passport/saml'                        , loginPassport.loginWithSaml);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
-  app.get('/passport/twitter/callback'             , loginPassport.loginPassportTwitterCallback);
+  app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback);
+  app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback);
 
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
   app.post('/admin/markdown/lineBreaksSetting', loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdown, admin.markdown.lineBreaksSetting); //change form name
-  app.post('/admin/markdown/xss-setting'       , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
+  app.post('/admin/markdown/xss-setting'      , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.markdownXss, admin.markdown.xssSetting);
 
   // markdown admin
   app.get('/admin/customize'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.customize.index);

+ 39 - 0
src/server/routes/login-passport.js

@@ -311,6 +311,43 @@ module.exports = function(crowi, app) {
     });
   };
 
+  const loginWithSaml = function(req, res, next) {
+    if (!passportService.isSamlStrategySetup) {
+      debug('SamlStrategy has not been set up');
+      req.flash('warningMessage', 'SamlStrategy has not been set up');
+      return next();
+    }
+
+    passport.authenticate('saml')(req, res);
+  };
+
+  const loginPassportSamlCallback = async(req, res, next) => {
+    const providerId = 'saml';
+    const strategyName = 'saml';
+    const attrMapId = config.crowi['security:passport-saml:attrMapId'] || 'id';
+    const attrMapUsername = config.crowi['security:passport-saml:attrMapUsername'] || 'userName';
+    const attrMapFirstName = config.crowi['security:passport-saml:attrMapFirstName'] || 'firstName';
+    const attrMapLastName = config.crowi['security:passport-saml:attrMapLastName'] || 'lastName';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response[attrMapId],
+      'username': response[attrMapUsername],
+      'name': `${response[attrMapFirstName]} ${response[attrMapLastName]}`,
+    };
+
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) }
+      return loginSuccess(req, res, user);
+    });
+  };
 
   const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
     return new Promise((resolve, reject) => {
@@ -372,8 +409,10 @@ module.exports = function(crowi, app) {
     loginWithGoogle,
     loginWithGitHub,
     loginWithTwitter,
+    loginWithSaml,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
     loginPassportTwitterCallback,
+    loginPassportSamlCallback,
   };
 };

+ 9 - 8
src/server/routes/login.js

@@ -1,7 +1,9 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:login')
+  const debug = require('debug')('growi:routes:login')
+    , logger = require('@alias/logger')('growi:routes:login')
+    , path = require('path')
     , async    = require('async')
     , config = crowi.getConfig()
     , mailer = crowi.getMailer()
@@ -10,7 +12,7 @@ module.exports = function(crowi, app) {
     , actions = {};
 
 
-  var clearGoogleSession = function(req) {
+  const clearGoogleSession = function(req) {
     req.session.googleAuthCode
       = req.session.googleId
       = req.session.googleEmail
@@ -18,14 +20,13 @@ module.exports = function(crowi, app) {
       = req.session.googleImage
       = null;
   };
-  var loginSuccess = function(req, res, userData) {
+  const loginSuccess = function(req, res, userData) {
     req.user = req.session.user = userData;
 
     // update lastLoginAt
     userData.updateLastLoginAt(new Date(), (err, uData) => {
       if (err) {
-        console.log(`updateLastLoginAt dumps error: ${err}`);
-        debug(`updateLastLoginAt dumps error: ${err}`);
+        logger.error(`updateLastLoginAt dumps error: ${err}`);
       }
     });
 
@@ -35,7 +36,7 @@ module.exports = function(crowi, app) {
 
     clearGoogleSession(req);
 
-    var jumpTo = req.session.jumpTo;
+    const jumpTo = req.session.jumpTo;
     if (jumpTo) {
       req.session.jumpTo = null;
       return res.redirect(jumpTo);
@@ -45,7 +46,7 @@ module.exports = function(crowi, app) {
     }
   };
 
-  var loginFailure = function(req, res) {
+  const loginFailure = function(req, res) {
     req.flash('warningMessage', 'Sign in failure.');
     return res.redirect('/login');
   };
@@ -203,7 +204,7 @@ module.exports = function(crowi, app) {
                     mailer.send({
                       to: adminUser.email,
                       subject: '[' + appTitle + ':admin] A New User Created and Waiting for Activation',
-                      template: 'admin/userWaitingActivation.txt',
+                      template: path.join(crowi.localeDir, 'en-US/admin/userWaitingActivation.txt'),
                       vars: {
                         createdUser: userData,
                         adminUser: adminUser,

+ 7 - 2
src/server/service/global-notification.js

@@ -1,4 +1,5 @@
-const debug = require('debug')('growi:service:GlobalNotification');
+const logger = require('@alias/logger')('growi:service:GlobalNotification');
+const path = require('path');
 /**
  * the service class of GlobalNotificationSetting
  */
@@ -54,6 +55,8 @@ class GlobalNotificationService {
       slack: {},
     };
 
+    logger.debug('notifyPageCreate', option);
+
     this.sendNotification(notifications, option);
   }
 
@@ -68,7 +71,7 @@ class GlobalNotificationService {
     const option = {
       mail: {
         subject: `#pageEdit - ${page.creator.username} edited ${page.path}`,
-        template: `../../locales/${lang}/notifications/pageEdit.txt`,
+        template: path.join(this.crowi.localeDir, `${lang}/notifications/pageEdit.txt`),
         vars: {
           appTitle: this.appTitle,
           path: page.path,
@@ -78,6 +81,8 @@ class GlobalNotificationService {
       slack: {},
     };
 
+    logger.debug('notifyPageEdit', option);
+
     this.sendNotification(notifications, option);
   }
 

+ 63 - 5
src/server/service/passport.js

@@ -5,6 +5,7 @@ const LdapStrategy = require('passport-ldapauth');
 const GoogleStrategy = require('passport-google-auth').Strategy;
 const GitHubStrategy = require('passport-github').Strategy;
 const TwitterStrategy = require('passport-twitter').Strategy;
+const SamlStrategy = require('passport-saml').Strategy;
 
 /**
  * the service class of Passport
@@ -33,6 +34,21 @@ class PassportService {
      */
     this.isGoogleStrategySetup = false;
 
+    /**
+     * the flag whether GitHubStrategy is set up successfully
+     */
+    this.isGitHubStrategySetup = false;
+
+    /**
+     * the flag whether TwitterStrategy is set up successfully
+     */
+    this.isTwitterStrategySetup = false;
+
+    /**
+     * the flag whether SamlStrategy is set up successfully
+     */
+    this.isSamlStrategySetup = false;
+
     /**
      * the flag whether serializer/deserializer are set up successfully
      */
@@ -271,7 +287,6 @@ class PassportService {
 
     const config = this.crowi.config;
     const Config = this.crowi.model('Config');
-    //this
     const isGoogleEnabled = Config.isEnabledPassportGoogle(config);
 
     // when disabled
@@ -317,7 +332,6 @@ class PassportService {
 
     const config = this.crowi.config;
     const Config = this.crowi.model('Config');
-    //this
     const isGitHubEnabled = Config.isEnabledPassportGitHub(config);
 
     // when disabled
@@ -343,8 +357,9 @@ class PassportService {
     this.isGitHubStrategySetup = true;
     debug('GitHubStrategy: setup is done');
   }
+
   /**
-   * reset GoogleStrategy
+   * reset GitHubStrategy
    *
    * @memberof PassportService
    */
@@ -362,7 +377,6 @@ class PassportService {
 
     const config = this.crowi.config;
     const Config = this.crowi.model('Config');
-    //this
     const isTwitterEnabled = Config.isEnabledPassportTwitter(config);
 
     // when disabled
@@ -390,7 +404,7 @@ class PassportService {
   }
 
   /**
-   * reset GoogleStrategy
+   * reset TwitterStrategy
    *
    * @memberof PassportService
    */
@@ -400,6 +414,50 @@ class PassportService {
     this.isTwitterStrategySetup = false;
   }
 
+  setupSamlStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isSamlStrategySetup) {
+      throw new Error('SamlStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    const isSamlEnabled = Config.isEnabledPassportSaml(config);
+
+    // when disabled
+    if (!isSamlEnabled) {
+      return;
+    }
+
+    debug('SamlStrategy: setting up..');
+    passport.use(new SamlStrategy({
+      path: config.crowi['security:passport-saml:path'] || process.env.SAML_CALLBACK_URI,
+      entryPoint: config.crowi['security:passport-saml:entryPoint'] || process.env.SAML_ENTRY_POINT,
+      issuer: config.crowi['security:passport-saml:issuer'] || process.env.SAML_ISSUER,
+    }, function(profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isSamlStrategySetup = true;
+    debug('SamlStrategy: setup is done');
+  }
+
+  /**
+   * reset SamlStrategy
+   *
+   * @memberof PassportService
+   */
+  resetSamlStrategy() {
+    debug('SamlStrategy: reset');
+    passport.unuse('saml');
+    this.isSamlStrategySetup = false;
+  }
+
   /**
    * setup serializer and deserializer
    *

+ 1 - 2
src/server/util/mailer.js

@@ -12,7 +12,6 @@ module.exports = function(crowi) {
     , config = crowi.getConfig()
     , mailConfig = {}
     , mailer = {}
-    , MAIL_TEMPLATE_DIR = crowi.mailDir
     ;
 
 
@@ -105,7 +104,7 @@ module.exports = function(crowi) {
     if (mailer) {
       var templateVars = config.vars || {};
       return swig.renderFile(
-        MAIL_TEMPLATE_DIR + config.template,
+        config.template,
         templateVars,
         function(err, output) {
           if (err) {

+ 6 - 1
src/server/util/swigFunctions.js

@@ -81,6 +81,11 @@ module.exports = function(crowi, app, req, locals) {
     return Config.isEnabledPassport(config) && Config.isEnabledPassportLdap(config) && !passportService.isLdapStrategySetup;
   };
 
+  locals.passportSamlLoginEnabled = function() {
+    let config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-saml:isEnabled'];
+  };
+
   locals.googleLoginEnabled = function() {
     // return false if Passport is enabled
     // because official crowi mechanism is not used.
@@ -103,7 +108,7 @@ module.exports = function(crowi, app, req, locals) {
   };
 
   locals.passportTwitterLoginEnabled = function() {
-    var config = crowi.getConfig();
+    let config = crowi.getConfig();
     return locals.isEnabledPassport() && config.crowi['security:passport-twitter:isEnabled'];
   };
 

+ 5 - 2
src/server/views/admin/customize.html

@@ -69,13 +69,16 @@
               {% include 'widget/theme-colorbox.html' with { name: 'default',  bg: '#ffffff', topbar: '#334455', theme: '#112744' } %}
               {% include 'widget/theme-colorbox.html' with { name: 'nature',   bg: '#f9fff3', topbar: '#118050', theme: '#460039' } %}
               {% include 'widget/theme-colorbox.html' with { name: 'mono-blue',   bg: '#F7FBFD', topbar: '#00587A', theme: '#00587A' } %}
+           	  {% include 'widget/theme-colorbox.html' with { name: 'wood',   bg: '#fffefb', topbar: '#aaa45f', theme: '#dddebf' } %}
+              {% include 'widget/theme-colorbox.html' with { name: 'island',   bg: '#8ecac0', topbar: '#0c2a44', theme: '#cef2ef' } %}
               {% include 'widget/theme-colorbox.html' with { name: 'kibela',   bg: '#F4F5F6', topbar: '#ffffff', theme: '#5584E1' } %}
-             </div>
+            </div>
             {# Dark Themes #}
-            <div class="d-flex">
+            <div class="d-flex mt-3">
               {% include 'widget/theme-colorbox.html' with { name: 'default-dark', bg: '#212731', topbar: '#151515', theme: '#f75b36' } %}
               {% include 'widget/theme-colorbox.html' with { name: 'future', bg: '#16282D', topbar: '#011414', theme: '#04B4AE' } %}
               {% include 'widget/theme-colorbox.html' with { name: 'blue-night', bg: '#061F2F', topbar: '#27343B', theme: '#0090C8' } %}
+              {% include 'widget/theme-colorbox.html' with { name: 'halloween',   bg: '#030003', topbar: '#cc5d1f', theme: '#e9af2b' } %}
             </div>
           </div>
 

+ 9 - 1
src/server/views/admin/security.html

@@ -130,6 +130,7 @@
               <ul>
                 <li>{{ t("security_setting.username_email_password") }}</li>
                 <li>{{ t("security_setting.ldap_auth") }}</li>
+                <li>{{ t("security_setting.saml_auth") }}</li>
                 <li>{{ t("security_setting.google_auth2") }}</li>
                 <li>{{ t("security_setting.github_auth2") }}</li>
                 <li>{{ t("security_setting.twitter_auth2") }}</li>
@@ -245,6 +246,9 @@
             <li class="active">
               <a href="#passport-ldap" data-toggle="tab" role="tab"><i class="fa fa-sitemap"></i> LDAP</a>
             </li>
+            <li>
+              <a href="#passport-saml" data-toggle="tab" role="tab"><i class="fa fa-key"></i> SAML</a>
+            </li>
             <li>
               <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i class="fa fa-google"></i> Google</a>
             </li>
@@ -264,6 +268,10 @@
               {% include './widget/passport/ldap.html' with { settingForm: settingForm } %}
             </div>
 
+            <div id="passport-saml" class="tab-pane" role="tabpanel" >
+              {% include './widget/passport/saml.html' %}
+            </div>
+
             <div id="passport-google-oauth" class="tab-pane" role="tabpanel">
               {% include './widget/passport/google-oauth.html' %}
             </div>
@@ -288,7 +296,7 @@
   </div>
 
   <script>
-    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting').each(function() {
+    $('#generalSetting, #samlSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

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

@@ -0,0 +1,157 @@
+<form action="/_api/admin/security/passport-saml" method="post" class="form-horizontal passportStrategy" id="samlSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.SAML.name") }} {{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsSamlEnabled = "settingForm[security:passport-saml:isEnabled]" %}
+  {% set isSamlEnabled = settingForm['security:passport-saml:isEnabled'] %}
+
+  <div class="form-group">
+    <label for="{{nameForIsSamlEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.SAML.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isSamlEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsSamlEnabled}}" value="true" type="radio"
+              {% if true === isSamlEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isSamlEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsSamlEnabled}}" value="false" type="radio"
+              {% if !isSamlEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-saml-hide-when-disabled" {%if !isSamlEnabled %}style="display: none;"{% endif %}>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:entryPoint]" class="col-xs-3 control-label">{{ t("security_setting.SAML.entry_point") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-saml:entryPoint]" value="{{ settingForm['security:passport-saml:entryPoint'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "SAML_ENTRY_POINT") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-saml:callbackUrl]" value="{{ settingForm['security:passport-saml:callbackUrl'] || '' }}"
+            placeholder="http(s)://${growi.host}/passport/saml/callback">
+        <p class="help-block">
+          Input <code>http(s)://${growi.host}/passport/saml/callback</code><br>
+          <small>
+            {{ t("security_setting.Use env var if empty", "SAML_ISSUER") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:issuer]" class="col-xs-3 control-label">{{ t("security_setting.SAML.issuer") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-saml:issuer]" value="{{ settingForm['security:passport-saml:issuer'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "SAML_ISSUER") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <h4>Attribute Mapping</h4>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:attrMapId]" class="col-xs-3 control-label">User ID</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" placeholder="Default: id"
+            name="settingForm[security:passport-saml:attrMapId]" value="{{ settingForm['security:passport-saml:attrMapId'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.SAML.mapping_detail", "User ID") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:attrMapUsername]" class="col-xs-3 control-label">Username</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" placeholder="Default: username"
+            name="settingForm[security:passport-saml:attrMapUsername]" value="{{ settingForm['security:passport-saml:attrMapUsername'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.SAML.mapping_detail", "Username") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-SAML" name="settingForm[security:passport-saml:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-saml:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-SAML">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:attrMapFirstName]" class="col-xs-3 control-label">First Name</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" placeholder="Default: firstName"
+            name="settingForm[security:passport-saml:attrMapFirstName]" value="{{ settingForm['security:passport-saml:attrMapFirstName'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.SAML.mapping_detail", "First Name") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-saml:attrMapLastName]" class="col-xs-3 control-label">Last Name</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" placeholder="Default: lastName"
+            name="settingForm[security:passport-saml:attrMapLastName]" value="{{ settingForm['security:passport-saml:attrMapLastName'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.SAML.mapping_detail", "Last Name") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+  </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
+</form>
+
+<script>
+  $('input[name="settingForm[security:passport-saml:isEnabled]"]').change(function() {
+    const isEnabled = ($(this).val() === "true");
+
+    if (isEnabled) {
+      $('#passport-saml-hide-when-disabled').show(400);
+    }
+    else {
+      $('#passport-saml-hide-when-disabled').hide(400);
+    }
+  });
+</script>
+

+ 13 - 12
src/server/views/admin/widget/theme-colorbox.html

@@ -1,15 +1,16 @@
-<a id="theme-option-{{name}}" href="#"
-    class="{{name}} {% if name === settingForm['customize:theme'] %}active{% endif %}"
+<div id="theme-option-{{name}}" class="theme-option-container d-flex flex-column align-items-center {% if name === settingForm['customize:theme'] %}active{% endif %}">
+  <a class="m-0" href="#"
+    class="{{name}}"
     onclick="selectTheme('{{name}}')"
     data-theme="{{ webpack_asset('styles/theme-' + name + '.css') }}">
 
-  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-    <title>{{name}}</title>
-    <g>
-      <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill="{{bg}}"></path>
-      <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill="{{topbar}}"></path>
-      <path d="M 44 15 L65 15 L65 65 L44 65 L44 15 Z" fill="{{theme}}"></path>
-    </g>
-  </svg>
-
-</a>
+    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+      <g>
+        <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill="{{bg}}"></path>
+        <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill="{{topbar}}"></path>
+        <path d="M 44 15 L65 15 L65 65 L44 65 L44 15 Z" fill="{{theme}}"></path>
+      </g>
+    </svg>
+  </a>
+  <span class="theme-option-name"><b>{{name}}</b></span>
+</div>

+ 11 - 2
src/server/views/login.html

@@ -144,8 +144,7 @@
           </form>
         </div>
         {% endif %}
-
-        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() %}
+        {% if passportGoogleLoginEnabled() || passportGitHubLoginEnabled() || passportFacebookLoginEnabled() || passportTwitterLoginEnabled() || passportSamlLoginEnabled() %}
         <hr class="mb-1">
         <div class="collapse collapse-oauth collapse-anchor">
           <div class="spacer"></div>
@@ -189,6 +188,16 @@
               <div class="small text-right">by Twitter Account</div>
             </form>
             {% endif %}
+            {% if passportSamlLoginEnabled() %}
+            <form role="form" action="/passport/saml" class="d-inline-flex flex-column">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="fcbtn btn btn-1b btn-login-oauth d-inline-flex" id="saml">
+                <span class="btn-label"><i class="fa fa-key"></i></span>
+                <span class="btn-label-text">{{ t('Sign in') }}</span>
+              </button>
+              <div class="small text-right">with SAML</div>
+            </form>
+            {% endif %}
           </div>{# ./d-flex flex-row flex-wrap #}
           <div class="spacer"></div>
         </div>

+ 71 - 14
yarn.lock

@@ -551,18 +551,18 @@ async@^0.9.0:
   version "0.9.2"
   resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
 
+async@^2.1.5, async@^2.4.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
+  dependencies:
+    lodash "^4.17.10"
+
 async@^2.3.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4"
   dependencies:
     lodash "^4.14.0"
 
-async@^2.4.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
-  dependencies:
-    lodash "^4.17.10"
-
 async@~0.2.6:
   version "0.2.10"
   resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
@@ -795,8 +795,8 @@ babel-helpers@^6.24.1:
     babel-template "^6.24.1"
 
 babel-loader@^7.1.1:
-  version "7.1.2"
-  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.2.tgz#f6cbe122710f1aa2af4d881c6d5b54358ca24126"
+  version "7.1.5"
+  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.5.tgz#e3ee0cd7394aa557e013b02d3e492bfd07aa6d68"
   dependencies:
     find-cache-dir "^1.0.0"
     loader-utils "^1.0.2"
@@ -5811,6 +5811,10 @@ node-fetch@^1.0.1:
     encoding "^0.1.11"
     is-stream "^1.0.1"
 
+node-forge@^0.7.0:
+  version "0.7.6"
+  resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
+
 node-forge@^0.7.1:
   version "0.7.1"
   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.1.tgz#9da611ea08982f4b94206b3beb4cc9665f20c300"
@@ -6380,7 +6384,20 @@ passport-oauth2@1.x.x:
     uid2 "0.0.x"
     utils-merge "1.x.x"
 
-passport-strategy@1.x, passport-strategy@1.x.x, passport-strategy@^1.0.0:
+passport-saml@^0.35.0:
+  version "0.35.0"
+  resolved "https://registry.yarnpkg.com/passport-saml/-/passport-saml-0.35.0.tgz#06a4952bde9e003923e80efa5c6faffcf7d4f7e0"
+  dependencies:
+    debug "^3.1.0"
+    passport-strategy "*"
+    q "^1.5.0"
+    xml-crypto "^0.10.1"
+    xml-encryption "^0.11.0"
+    xml2js "0.4.x"
+    xmlbuilder "^9.0.4"
+    xmldom "0.1.x"
+
+passport-strategy@*, passport-strategy@1.x, passport-strategy@1.x.x, passport-strategy@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
 
@@ -6985,7 +7002,7 @@ punycode@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
 
-q@^1.0.1, q@^1.1.2:
+q@^1.0.1, q@^1.1.2, q@^1.5.0:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
 
@@ -8347,9 +8364,9 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
 
-style-loader@^0.22.1:
-  version "0.22.1"
-  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.22.1.tgz#901ea28aac78fcc00c5075585ac07d7ef3f87a52"
+style-loader@^0.23.0:
+  version "0.23.0"
+  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.0.tgz#8377fefab68416a2e05f1cabd8c3a3acfcce74f1"
   dependencies:
     loader-utils "^1.1.0"
     schema-utils "^0.4.5"
@@ -9144,6 +9161,23 @@ x-xss-protection@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.1.0.tgz#4f1898c332deb1e7f2be1280efb3e2c53d69c1a7"
 
+xml-crypto@^0.10.1:
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/xml-crypto/-/xml-crypto-0.10.1.tgz#f832f74ccf56f24afcae1163a1fcab44d96774a8"
+  dependencies:
+    xmldom "=0.1.19"
+    xpath.js ">=0.0.3"
+
+xml-encryption@^0.11.0:
+  version "0.11.2"
+  resolved "https://registry.yarnpkg.com/xml-encryption/-/xml-encryption-0.11.2.tgz#c217f5509547e34b500b829f2c0bca85cca73a21"
+  dependencies:
+    async "^2.1.5"
+    ejs "^2.5.6"
+    node-forge "^0.7.0"
+    xmldom "~0.1.15"
+    xpath "0.0.27"
+
 xml2js@0.4.17:
   version "0.4.17"
   resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.17.tgz#17be93eaae3f3b779359c795b419705a8817e868"
@@ -9151,20 +9185,43 @@ xml2js@0.4.17:
     sax ">=0.6.0"
     xmlbuilder "^4.1.0"
 
+xml2js@0.4.x:
+  version "0.4.19"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
+  dependencies:
+    sax ">=0.6.0"
+    xmlbuilder "~9.0.1"
+
 xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-4.2.1.tgz#aa58a3041a066f90eaa16c2f5389ff19f3f461a5"
   dependencies:
     lodash "^4.0.0"
 
-xmldom@0.1.x:
+xmlbuilder@^9.0.4, xmlbuilder@~9.0.1:
+  version "9.0.7"
+  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
+
+xmldom@0.1.x, xmldom@~0.1.15:
   version "0.1.27"
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
 
+xmldom@=0.1.19:
+  version "0.1.19"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
+
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
 
+xpath.js@>=0.0.3:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/xpath.js/-/xpath.js-1.1.0.tgz#3816a44ed4bb352091083d002a383dd5104a5ff1"
+
+xpath@0.0.27:
+  version "0.0.27"
+  resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92"
+
 xss@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.3.tgz#d04bd2558fd6c29c46113824d5e8b2a910054e23"