فهرست منبع

Merge branch 'master' into feat/user-management

ryuichi-e 6 سال پیش
والد
کامیت
444d4974b1
100فایلهای تغییر یافته به همراه5722 افزوده شده و 1175 حذف شده
  1. 34 1
      CHANGES.md
  2. 1 1
      config/env.prod.js
  3. 1 0
      config/logger/config.dev.js
  4. 3 0
      config/logger/config.prod.js
  5. 9 7
      package.json
  6. 8 0
      resource/cdn-manifests.js
  7. 3 1
      resource/locales/en-US/admin/admin.json
  8. 7 0
      resource/locales/en-US/sandbox-diagrams.md
  9. 71 0
      resource/locales/en-US/sandbox-math.md
  10. 22 358
      resource/locales/en-US/sandbox.md
  11. 59 33
      resource/locales/en-US/translation.json
  12. 3 1
      resource/locales/ja/admin/admin.json
  13. 7 0
      resource/locales/ja/sandbox-diagrams.md
  14. 71 0
      resource/locales/ja/sandbox-math.md
  15. 22 357
      resource/locales/ja/sandbox.md
  16. 64 32
      resource/locales/ja/translation.json
  17. 35 0
      src/client/js/admin.jsx
  18. 2 0
      src/client/js/bootstrap.jsx
  19. 1 1
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  20. 15 0
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  21. 1 1
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  22. 1 1
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  23. 147 0
      src/client/js/components/Admin/Security/BasicSecuritySetting.jsx
  24. 41 0
      src/client/js/components/Admin/Security/FacebookSecuritySetting.jsx
  25. 218 0
      src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx
  26. 225 0
      src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx
  27. 168 0
      src/client/js/components/Admin/Security/LdapAuthTestModal.jsx
  28. 432 0
      src/client/js/components/Admin/Security/LdapSecuritySetting.jsx
  29. 203 0
      src/client/js/components/Admin/Security/LocalSecuritySetting.jsx
  30. 352 0
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  31. 551 0
      src/client/js/components/Admin/Security/SamlSecuritySetting.jsx
  32. 124 0
      src/client/js/components/Admin/Security/SecurityManagement.jsx
  33. 229 0
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  34. 226 0
      src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx
  35. 2 2
      src/client/js/components/Admin/Users/RemoveAdminButton.jsx
  36. 2 2
      src/client/js/components/Admin/Users/StatusSuspendedButton.jsx
  37. 1 1
      src/client/js/components/BookmarkButton.jsx
  38. 79 0
      src/client/js/components/Drawio.jsx
  39. 1 1
      src/client/js/components/LikeButton.jsx
  40. 60 0
      src/client/js/components/Navbar/PersonalDropdown.jsx
  41. 48 0
      src/client/js/components/Page.jsx
  42. 22 11
      src/client/js/components/Page/RevisionPath.jsx
  43. 1 1
      src/client/js/components/PageAttachment.jsx
  44. 56 151
      src/client/js/components/PageComment/Comment.jsx
  45. 24 0
      src/client/js/components/PageComment/CommentControl.jsx
  46. 1 3
      src/client/js/components/PageComment/CommentEditor.jsx
  47. 2 3
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  48. 124 0
      src/client/js/components/PageComment/ReplayComments.jsx
  49. 8 2
      src/client/js/components/PageComments.jsx
  50. 19 0
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  51. 156 0
      src/client/js/components/PageEditor/DrawioModal.jsx
  52. 3 0
      src/client/js/components/PageEditor/EditorIcon.jsx
  53. 164 0
      src/client/js/components/PageEditor/MarkdownDrawioUtil.js
  54. 14 8
      src/client/js/components/PageEditorByHackmd.jsx
  55. 6 2
      src/client/js/components/PageHistory/RevisionDiff.jsx
  56. 1 1
      src/client/js/components/RecentCreated/RecentCreated.jsx
  57. 7 0
      src/client/js/legacy/crowi.js
  58. 74 0
      src/client/js/services/AdminBasicSecurityContainer.js
  59. 11 0
      src/client/js/services/AdminCustomizeContainer.js
  60. 209 0
      src/client/js/services/AdminGeneralSecurityContainer.js
  61. 99 0
      src/client/js/services/AdminGitHubSecurityContainer.js
  62. 102 0
      src/client/js/services/AdminGoogleSecurityContainer.js
  63. 203 0
      src/client/js/services/AdminLdapSecurityContainer.js
  64. 87 0
      src/client/js/services/AdminLocalSecurityContainer.js
  65. 183 0
      src/client/js/services/AdminOidcSecurityContainer.js
  66. 198 0
      src/client/js/services/AdminSamlSecurityContainer.js
  67. 99 0
      src/client/js/services/AdminTwitterSecurityContainer.js
  68. 34 9
      src/client/js/services/AppContainer.js
  69. 2 0
      src/client/js/util/GrowiRenderer.js
  70. 148 0
      src/client/js/util/interceptor/drawio-interceptor.js
  71. 9 0
      src/client/js/util/markdown-it/drawio-viewer.js
  72. 8 1
      src/client/styles/scss/_comment.scss
  73. 0 5
      src/client/styles/scss/_comment_growi.scss
  74. 0 5
      src/client/styles/scss/_comment_kibela.scss
  75. 3 0
      src/client/styles/scss/_drawio.scss
  76. 4 17
      src/client/styles/scss/_handsontable.scss
  77. 0 5
      src/client/styles/scss/_layout_crowi_sidebar.scss
  78. 26 0
      src/client/styles/scss/_mixins.scss
  79. 23 1
      src/client/styles/scss/_page.scss
  80. 2 1
      src/client/styles/scss/style-app.scss
  81. 3 0
      src/lib/service/logger/stream.prod.js
  82. 12 0
      src/lib/util/removeNullPropertyFromObject.js
  83. 5 3
      src/linter-checker/test.js
  84. 5 3
      src/linter-checker/test.scss
  85. 4 0
      src/server/crowi/express-init.js
  86. 12 12
      src/server/crowi/index.js
  87. 0 7
      src/server/form/admin/custombehavior.js
  88. 0 7
      src/server/form/admin/customcss.js
  89. 0 12
      src/server/form/admin/customfeatures.js
  90. 0 7
      src/server/form/admin/customheader.js
  91. 0 8
      src/server/form/admin/customhighlightJsStyle.js
  92. 0 8
      src/server/form/admin/customlayout.js
  93. 0 7
      src/server/form/admin/customscript.js
  94. 0 7
      src/server/form/admin/customtheme.js
  95. 0 7
      src/server/form/admin/customtitle.js
  96. 0 10
      src/server/form/admin/securityGeneral.js
  97. 0 8
      src/server/form/admin/securityPassportBasic.js
  98. 0 10
      src/server/form/admin/securityPassportGitHub.js
  99. 0 10
      src/server/form/admin/securityPassportGoogle.js
  100. 0 23
      src/server/form/admin/securityPassportLdap.js

+ 34 - 1
CHANGES.md

@@ -1,8 +1,41 @@
 # CHANGES
 
-## v3.6.8-RC
+## v3.7.1-RC
 
+* 
+
+## v3.7.0
+
+* Feature: [Draw.io](https://www.draw.io/) Integration
+* Feature: SAML Attribute-based Login Control
+* Improvement: Reactify admin pages (Security)
+* Improvement: Behavior of pre-editing screen of HackMD when user needs to resume
+
+## v3.6.10
+
+* Fix: Redirect logic for users except for actives
+    * Introduced by 3.6.9
+
+## 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 - 1
config/env.prod.js

@@ -1,4 +1,4 @@
 module.exports = {
   NODE_ENV: 'production',
-  // FORMAT_NODE_LOG: false,
+  // FORMAT_NODE_LOG: false, // default: true
 };

+ 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',

+ 3 - 0
config/logger/config.prod.js

@@ -1,3 +1,6 @@
 module.exports = {
   default: 'info',
+
+  'growi:routes:login-passport': 'debug',
+  'growi:service:PassportService': 'debug',
 };

+ 9 - 7
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.6.8-RC",
+  "version": "3.7.1-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",
@@ -109,10 +109,11 @@
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "is-iso-date": "^0.0.1",
+    "lucene-query-parser": "^1.2.0",
     "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 +173,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,14 +185,15 @@
     "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",
     "load-css-file": "^1.0.0",
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
-    "markdown-it-blockdiag": "^1.0.2",
+    "markdown-it-blockdiag": "^1.0.3",
+    "markdown-it-drawio-viewer": "^1.1.3",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
@@ -230,7 +232,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",

+ 8 - 0
resource/cdn-manifests.js

@@ -44,6 +44,14 @@ module.exports = {
         integrity: '',
       },
     },
+    {
+      name: 'drawio-viewer',
+      url: 'https://jgraph.github.io/drawio/src/main/webapp/js/viewer.min.js',
+      args: {
+        async: true,
+        integrity: '',
+      },
+    },
     {
       name: 'codemirror-dialog',
       url: 'https://cdn.jsdelivr.net/npm/codemirror@5.48.4/addon/dialog/dialog.min.js',

+ 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.",

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 7 - 0
resource/locales/en-US/sandbox-diagrams.md


+ 71 - 0
resource/locales/en-US/sandbox-math.md

@@ -0,0 +1,71 @@
+# :pencil: Math
+
+See [MathJax](https://www.mathjax.org/).
+
+## Inline Formula
+
+When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
+  $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
+
+## The Lorenz Equations
+
+$$
+\begin{align}
+\dot{x} & = \sigma(y-x) \\
+\dot{y} & = \rho x - y - xz \\
+\dot{z} & = -\beta z + xy
+\end{align}
+$$
+
+
+## The Cauchy-Schwarz Inequality
+
+$$
+\left( \sum_{k=1}^n a_k b_k \right)^{\!\!2} \leq
+ \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)
+$$
+
+## A Cross Product Formula
+
+$$
+\mathbf{V}_1 \times \mathbf{V}_2 =
+ \begin{vmatrix}
+  \mathbf{i} & \mathbf{j} & \mathbf{k} \\
+  \frac{\partial X}{\partial u} & \frac{\partial Y}{\partial u} & 0 \\
+  \frac{\partial X}{\partial v} & \frac{\partial Y}{\partial v} & 0 \\
+ \end{vmatrix}
+$$
+
+
+## The probability of getting $\left(k\right)$ heads when flipping $\left(n\right)$ coins is:
+
+$$
+P(E) = {n \choose k} p^k (1-p)^{ n-k}
+$$
+
+## An Identity of Ramanujan
+
+$$
+\frac{1}{(\sqrt{\phi \sqrt{5}}-\phi) e^{\frac25 \pi}} =
+     1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}}
+      {1+\frac{e^{-8\pi}} {1+\ldots} } } }
+$$
+
+## A Rogers-Ramanujan Identity
+
+$$
+1 +  \frac{q^2}{(1-q)}+\frac{q^6}{(1-q)(1-q^2)}+\cdots =
+    \prod_{j=0}^{\infty}\frac{1}{(1-q^{5j+2})(1-q^{5j+3})},
+     \quad\quad \text{for $|q|<1$}.
+$$
+
+## Maxwell's Equations
+
+$$
+\begin{align}
+  \nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & = \frac{4\pi}{c}\vec{\mathbf{j}} \\
+  \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
+  \nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
+  \nabla \cdot \vec{\mathbf{B}} & = 0
+\end{align}
+$$

+ 22 - 358
resource/locales/en-US/sandbox.md

@@ -28,8 +28,11 @@
 ```
 
 ### Header 3
+
 #### Header 4
+
 ##### Header 5
+
 ###### Header 6
 
 ## Block 段落
@@ -48,7 +51,7 @@
 
 ## Br 改行
 
-改行の前に半角スペース`  `を2つ記述します。
+改行の前に半角スペース``を2つ記述します。
 ***この挙動は、オプションで変更可能です***
 
 ```
@@ -159,6 +162,7 @@ ___
 # :pencil: Typography
 
 ## 強調
+
 ### em
 
 アスタリスク`*`もしくはアンダースコア`_`1個で文字列を囲みます。
@@ -353,30 +357,30 @@ aligned    | aligned     | aligned
 
 ```
 ::: tsv
-Content Cell 	Content Cell
-Content Cell 	Content Cell
+Content Cell  Content Cell
+Content Cell  Content Cell
 :::
 ```
 
 ::: tsv
-Content Cell	Content Cell
-Content Cell	Content Cell
+Content Cell Content Cell
+Content Cell Content Cell
 :::
 
 ## TSV ヘッダ付き (crowi-plus 独自記法)
 
 ```
 ::: tsv-h
-First Header	Second Header
-Content Cell	Content Cell
-Content Cell	Content Cell
+First Header Second Header
+Content Cell Content Cell
+Content Cell Content Cell
 :::
 ```
 
 ::: tsv-h
-First Header	Second Header
-Content Cell	Content Cell
-Content Cell	Content Cell
+First Header Second Header
+Content Cell Content Cell
+Content Cell Content Cell
 :::
 
 ## CSV (crowi-plus 独自記法)
@@ -442,351 +446,11 @@ See [emojione](https://www.emojione.com/)
 :watch: :gear: :gem: :wrench: :envelope:
 
 
-# :pencil: Math
-
-See [MathJax](https://www.mathjax.org/).
-
-## Inline Formula
-
-When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
-  $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
-
-## The Lorenz Equations
-
-$$
-\begin{align}
-\dot{x} & = \sigma(y-x) \\
-\dot{y} & = \rho x - y - xz \\
-\dot{z} & = -\beta z + xy
-\end{align}
-$$
-
-
-## The Cauchy-Schwarz Inequality
-
-$$
-\left( \sum_{k=1}^n a_k b_k \right)^{\!\!2} \leq
- \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)
-$$
-
-## A Cross Product Formula
-
-$$
-\mathbf{V}_1 \times \mathbf{V}_2 =
- \begin{vmatrix}
-  \mathbf{i} & \mathbf{j} & \mathbf{k} \\
-  \frac{\partial X}{\partial u} & \frac{\partial Y}{\partial u} & 0 \\
-  \frac{\partial X}{\partial v} & \frac{\partial Y}{\partial v} & 0 \\
- \end{vmatrix}
-$$
-
-
-## The probability of getting $\left(k\right)$ heads when flipping $\left(n\right)$ coins is:
-
-$$
-P(E) = {n \choose k} p^k (1-p)^{ n-k}
-$$
-
-## An Identity of Ramanujan
-
-$$
-\frac{1}{(\sqrt{\phi \sqrt{5}}-\phi) e^{\frac25 \pi}} =
-     1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}}
-      {1+\frac{e^{-8\pi}} {1+\ldots} } } }
-$$
-
-## A Rogers-Ramanujan Identity
-
-$$
-1 +  \frac{q^2}{(1-q)}+\frac{q^6}{(1-q)(1-q^2)}+\cdots =
-    \prod_{j=0}^{\infty}\frac{1}{(1-q^{5j+2})(1-q^{5j+3})},
-     \quad\quad \text{for $|q|<1$}.
-$$
-
-## Maxwell's Equations
-
-$$
-\begin{align}
-  \nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & = \frac{4\pi}{c}\vec{\mathbf{j}} \\
-  \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
-  \nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
-  \nabla \cdot \vec{\mathbf{B}} & = 0
-\end{align}
-$$
-
-<!-- Reset MathJax -->
-<div class="clearfix"></div>
-
-
-# :pencil: UML Diagrams
-
-See [PlantUML](http://plantuml.com/).
-
-## シーケンス図
-
-@startuml
-skinparam sequenceArrowThickness 2
-skinparam roundcorner 20
-skinparam maxmessagesize 60
-skinparam sequenceParticipant underline
-
-actor User
-participant "First Class" as A
-participant "Second Class" as B
-participant "Last Class" as C
-
-User -> A: DoWork
-activate A
-
-A -> B: Create Request
-activate B
+# :heavy_plus_sign: More..
 
-B -> C: DoWork
-activate C
-C --> B: WorkDone
-destroy C
-
-B --> A: Request Created
-deactivate B
-
-A --> User: Done
-deactivate A
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-
-## クラス図
-
-@startuml
-
-class BaseClass
-
-namespace net.dummy #DDDDDD {
-    .BaseClass <|-- Person
-    Meeting o-- Person
-
-    .BaseClass <|- Meeting
-}
-
-namespace net.foo {
-  net.dummy.Person  <|- Person
-  .BaseClass <|-- Person
-
-  net.dummy.Meeting o-- Person
-}
-
-BaseClass <|-- net.unused.Person
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-
-## コンポーネント図
-
-@startuml
-
-package "Some Group" {
-  HTTP - [First Component]
-  [Another Component]
-}
-
-node "Other Groups" {
-  FTP - [Second Component]
-  [First Component] --> FTP
-}
-
-cloud {
-  [Example 1]
-}
-
-
-database "MySql" {
-  folder "This is my folder" {
-    [Folder 3]
-  }
-  frame "Foo" {
-    [Frame 4]
-  }
-}
-
-
-[Another Component] --> [Example 1]
-[Example 1] --> [Folder 3]
-[Folder 3] --> [Frame 4]
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-
-## ステート図
-
-
-@startuml
-scale 600 width
-
-[*] -> State1
-State1 --> State2 : Succeeded
-State1 --> [*] : Aborted
-State2 --> State3 : Succeeded
-State2 --> [*] : Aborted
-state State3 {
-  state "Accumulate Enough Data\nLong State Name" as long1
-  long1 : Just a test
-  [*] --> long1
-  long1 --> long1 : New Data
-  long1 --> ProcessData : Enough Data
-}
-State3 --> State3 : Failed
-State3 --> [*] : Succeeded / Save Result
-State3 --> [*] : Aborted
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-# :pencil: blockdiag
-
-See [blockdiag](http://blockdiag.com/).
-
-## blockdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: blockdiag
-blockdiag {
-   A -> B -> C -> D;
-   A -> E -> F -> G;
-}
-:::
-
-</div>
-
-## seqdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: seqdiag
-seqdiag {
-  browser  -> webserver [label = "GET /index.html"];
-  browser <-- webserver;
-  browser  -> webserver [label = "POST /blog/comment"];
-              webserver  -> database [label = "INSERT comment"];
-              webserver <-- database;
-  browser <-- webserver;
-}
-:::
-
-</div>
-
-## actdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: actdiag
-actdiag {
-  write -> convert -> image
-
-  lane user {
-     label = "User"
-     write [label = "Writing reST"];
-     image [label = "Get diagram IMAGE"];
-  }
-  lane actdiag {
-     convert [label = "Convert reST to Image"];
-  }
-}
-:::
-
-</div>
-
-## nwdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: nwdiag
-nwdiag {
-  network dmz {
-      address = "210.x.x.x/24"
-
-      web01 [address = "210.x.x.1"];
-      web02 [address = "210.x.x.2"];
-  }
-  network internal {
-      address = "172.x.x.x/24";
-
-      web01 [address = "172.x.x.1"];
-      web02 [address = "172.x.x.2"];
-      db01;
-      db02;
-  }
-}
-:::
-
-</div>
-
-## rackdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: rackdiag
-rackdiag {
-  // define height of rack
-  8U;
-
-  // define rack items
-  1: UPS [2U];
-  3: DB Server
-  4: Web Server
-  5: Web Server
-  6: Web Server
-  7: Load Balancer
-  8: L3 Switch
-}
-:::
-
-</div>
-
-## packetdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: packetdiag
-packetdiag {
-  colwidth = 32
-  node_height = 72
-
-  0-15: Source Port
-  16-31: Destination Port
-  32-63: Sequence Number
-  64-95: Acknowledgment Number
-  96-99: Data Offset
-  100-105: Reserved
-  106: URG [rotate = 270]
-  107: ACK [rotate = 270]
-  108: PSH [rotate = 270]
-  109: RST [rotate = 270]
-  110: SYN [rotate = 270]
-  111: FIN [rotate = 270]
-  112-127: Window
-  128-143: Checksum
-  144-159: Urgent Pointer
-  160-191: (Options and Padding)
-  192-223: data [colheight = 3]
-}
-:::
-
-</div>
+- Try to attach Bootstrap3 Tags?
+    - :arrow_right: [/Sandbox/Bootstrap3]
+- Try to draw Diagrams?
+    - :arrow_right: [/Sandbox/Diagrams]
+- Try to write Math Formulas?
+    - :arrow_right: [/Sandbox/Math]

+ 59 - 33
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,9 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "form_validation": {
-    "required": "<code>%s</code> is required"
+    "error_message": "Some values ​​are incorrect",
+    "required": "%s is required",
+    "invalid_syntax": "The syntax of %s is invalid."
   },
   "installer": {
     "setup": "Setup",
@@ -386,16 +389,19 @@
     "admin_and_author": "Admin and Author",
     "anyone": "Anyone",
     "Authentication mechanism settings": "Authentication Mechanism Settings",
-    "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the %s",
+    "setup_is_not_yet_complete": "Setup is not yet complete",
+    "alert_siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "xss_prevent_setting": "Prevent XSS(Cross Site Scripting)",
     "xss_prevent_setting_link": "Go to Markdown settings",
     "callback_URL": "Callback URL",
     "providerName": "Provider Name",
     "issuerHost": "Issuer Host",
     "scope": "Scope",
-    "desc_of_callback_URL": "Use it in the setting of the %s provider",
+    "desc_of_callback_URL": "Use it in the setting of the {{AuthName}} Identity provider",
     "clientID": "Client ID",
     "client_secret": "Client Secret",
+    "updated_general_security_setting": "Succeeded to update security setting",
+    "setup_not_completed_yet": "Setup not completed yet",
     "guest_mode": {
       "deny": "Deny (Registered Users Only)",
       "readonly": "Accept (Guests can read only)"
@@ -407,17 +413,20 @@
     },
     "configuration": " Configuration",
     "optional": "Optional",
-    "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
-    "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
-    "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
-    "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
-    "Use env var if empty": "Use env var <code>%s</code> if empty",
-    "Use default if both are empty": "If both ​​are empty, the default value <code>%s</code> is used.",
+    "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
+    "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
+    "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
+    "Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>email</code>.",
+    "Use env var if empty": "Use env var <code>{{env}}</code> if empty",
+    "Use default if both are empty": "If both ​​are empty, the default value <code>{{target}}</code> is used.",
     "missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
     "Local": {
-      "name": "ID/Password"
+      "name": "ID/Password",
+      "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}/code> .",
+      "enable_local": "enable ID/Password"
     },
     "ldap": {
+      "enable_ldap": "enable LDAP",
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "bind_mode": "Binding Mode",
       "bind_manager": "Manager Bind",
@@ -446,49 +455,64 @@
       "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
       "group_search_user_DN_property": "User DN Property",
       "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"
+      "test_config": "Test Saved Configuration",
+      "updated_ldap": "Succeeded to update LDAP setting"
     },
     "SAML": {
       "name": "SAML",
+      "enable_saml": "enable SAML",
       "id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
-      "mapping_detail": "Specification of mappings for %s when creating new users",
+      "mapping_detail": "Specification of mappings for {{target}} 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> ."
+      "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</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>{{env}}</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": "See <a href=\"https://lucene.apache.org/core/2_9_4/queryparsersyntax.html\" target=\"_blank\">Apache Lucene - Query Parser Syntax</a>.<h6>Supported Queries:</h6><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h6>Unsupported Queries:</h6><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul>",
+      "attr_based_login_control_rule_example": "<h6>Example</h6>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
+      "updated_saml": "Succeeded to update SAML setting"
     },
     "Basic": {
+      "enable_basic": "enable Basic",
       "name": "Basic Authentication",
       "desc_1": "Login with <code>username</code> in Authorization header.",
-      "desc_2": "User will be automatically generated if not exist."
+      "desc_2": "User will be automatically generated if not exist.",
+      "updated_basic": "Succeeded to update Basic setting"
     },
     "OAuth": {
+      "enable_oidc": "enable OIDC",
       "register": "Register for %s",
       "change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
       "Google": {
+        "enable_google": "enable Google OAuth",
         "name": "Google OAuth",
-        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_1": "Access {{link}}",
         "register_2": "Create Project if no projects exist",
         "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
-        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code>",
-        "register_5": "Copy and paste your ClientID and Client Secret above"
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
+        "register_5": "Copy and paste your ClientID and Client Secret above",
+        "updated_google": "Succeeded to update Google OAuth setting"
       },
       "Facebook": {
         "name": "Facebook OAuth"
       },
       "Twitter": {
+        "enable_twitter": "enable Twitter OAuth",
         "name": "Twitter OAuth",
-        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_1": "Access {{link}}",
         "register_2": "Sign in Twitter",
         "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
-        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code>",
-        "register_5": "Copy and paste your ClientID and Client Secret above"
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
+        "register_5": "Copy and paste your ClientID and Client Secret above",
+        "updated_twitter": "Succeeded to update Twitter OAuth setting"
       },
       "GitHub": {
+        "enable_github": "enable GitHub OAuth",
         "name": "GitHub OAuth",
-        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
-        "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code>",
-        "register_3": "Copy and paste your ClientID and Client Secret above"
+        "register_1": "Access {{link}}",
+        "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>{{url}}</code>",
+        "register_3": "Copy and paste your ClientID and Client Secret above",
+        "updated_github": "Succeeded to update GitHub OAuth setting"
       },
       "OIDC": {
         "name": "OpenID Connect",
@@ -498,7 +522,8 @@
         "mapping_detail": "Specification of mappings for %s when creating new users",
         "register_1": "Contant to OIDC IdP Administrator",
         "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
-        "register_3": "Copy and paste your ClientID and Client Secret above"
+        "register_3": "Copy and paste your ClientID and Client Secret above",
+        "updated_oidc": "Succeeded to update OpenID Connect"
       },
       "how_to": {
         "google": "How to configure Google OAuth?",
@@ -508,14 +533,15 @@
       }
     },
     "form_item_name": {
-      "security:passport-saml:entryPoint": "Entry point",
-      "security:passport-saml:issuer": "Issuer",
-      "security:passport-saml:cert": "Certificate",
-      "security:passport-saml:attrMapId": "ID",
-      "security:passport-saml:attrMapUsername": "Username",
-      "security:passport-saml:attrMapMail": "Mail Address",
-      "security:passport-saml:attrMapFirstName": "First Name",
-      "security:passport-saml:attrMapLastName": "Last Name"
+      "entryPoint": "Entry point",
+      "issuer": "Issuer",
+      "cert": "Certificate",
+      "attrMapId": "ID",
+      "attrMapUsername": "Username",
+      "attrMapMail": "Mail Address",
+      "attrMapFirstName": "First Name",
+      "attrMapLastName": "Last Name",
+      "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 スタイルが適用されています。",

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 7 - 0
resource/locales/ja/sandbox-diagrams.md


+ 71 - 0
resource/locales/ja/sandbox-math.md

@@ -0,0 +1,71 @@
+# :pencil: Math
+
+See [MathJax](https://www.mathjax.org/).
+
+## Inline Formula
+
+When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
+  $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
+
+## The Lorenz Equations
+
+$$
+\begin{align}
+\dot{x} & = \sigma(y-x) \\
+\dot{y} & = \rho x - y - xz \\
+\dot{z} & = -\beta z + xy
+\end{align}
+$$
+
+
+## The Cauchy-Schwarz Inequality
+
+$$
+\left( \sum_{k=1}^n a_k b_k \right)^{\!\!2} \leq
+ \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)
+$$
+
+## A Cross Product Formula
+
+$$
+\mathbf{V}_1 \times \mathbf{V}_2 =
+ \begin{vmatrix}
+  \mathbf{i} & \mathbf{j} & \mathbf{k} \\
+  \frac{\partial X}{\partial u} & \frac{\partial Y}{\partial u} & 0 \\
+  \frac{\partial X}{\partial v} & \frac{\partial Y}{\partial v} & 0 \\
+ \end{vmatrix}
+$$
+
+
+## The probability of getting $\left(k\right)$ heads when flipping $\left(n\right)$ coins is:
+
+$$
+P(E) = {n \choose k} p^k (1-p)^{ n-k}
+$$
+
+## An Identity of Ramanujan
+
+$$
+\frac{1}{(\sqrt{\phi \sqrt{5}}-\phi) e^{\frac25 \pi}} =
+     1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}}
+      {1+\frac{e^{-8\pi}} {1+\ldots} } } }
+$$
+
+## A Rogers-Ramanujan Identity
+
+$$
+1 +  \frac{q^2}{(1-q)}+\frac{q^6}{(1-q)(1-q^2)}+\cdots =
+    \prod_{j=0}^{\infty}\frac{1}{(1-q^{5j+2})(1-q^{5j+3})},
+     \quad\quad \text{for $|q|<1$}.
+$$
+
+## Maxwell's Equations
+
+$$
+\begin{align}
+  \nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & = \frac{4\pi}{c}\vec{\mathbf{j}} \\
+  \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
+  \nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
+  \nabla \cdot \vec{\mathbf{B}} & = 0
+\end{align}
+$$

+ 22 - 357
resource/locales/ja/sandbox.md

@@ -28,8 +28,11 @@
 ```
 
 ### 見出し3
+
 #### 見出し4
+
 ##### 見出し5
+
 ###### 見出し6
 
 ## Block 段落
@@ -48,7 +51,7 @@
 
 ## Br 改行
 
-改行の前に半角スペース`  `を2つ記述します。
+改行の前に半角スペース``を2つ記述します。
 ***この挙動は、オプションで変更可能です***
 
 ```
@@ -159,6 +162,7 @@ ___
 # :pencil: Typography
 
 ## 強調
+
 ### em
 
 アスタリスク`*`もしくはアンダースコア`_`1個で文字列を囲みます。
@@ -353,30 +357,30 @@ aligned    | aligned     | aligned
 
 ```
 ::: tsv
-Content Cell 	Content Cell
-Content Cell 	Content Cell
+Content Cell  Content Cell
+Content Cell  Content Cell
 :::
 ```
 
 ::: tsv
-Content Cell	Content Cell
-Content Cell	Content Cell
+Content Cell Content Cell
+Content Cell Content Cell
 :::
 
 ## TSV ヘッダ付き (crowi-plus 独自記法)
 
 ```
 ::: tsv-h
-First Header	Second Header
-Content Cell	Content Cell
-Content Cell	Content Cell
+First Header Second Header
+Content Cell Content Cell
+Content Cell Content Cell
 :::
 ```
 
 ::: tsv-h
-First Header	Second Header
-Content Cell	Content Cell
-Content Cell	Content Cell
+First Header Second Header
+Content Cell Content Cell
+Content Cell Content Cell
 :::
 
 ## CSV (crowi-plus 独自記法)
@@ -442,351 +446,12 @@ See [emojione](https://www.emojione.com/)
 :watch: :gear: :gem: :wrench: :envelope:
 
 
-# :pencil: Math
-
-See [MathJax](https://www.mathjax.org/).
-
-## Inline Formula
-
-When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
-  $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
-
-## The Lorenz Equations
-
-$$
-\begin{align}
-\dot{x} & = \sigma(y-x) \\
-\dot{y} & = \rho x - y - xz \\
-\dot{z} & = -\beta z + xy
-\end{align}
-$$
-
-
-## The Cauchy-Schwarz Inequality
-
-$$
-\left( \sum_{k=1}^n a_k b_k \right)^{\!\!2} \leq
- \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)
-$$
-
-## A Cross Product Formula
-
-$$
-\mathbf{V}_1 \times \mathbf{V}_2 =
- \begin{vmatrix}
-  \mathbf{i} & \mathbf{j} & \mathbf{k} \\
-  \frac{\partial X}{\partial u} & \frac{\partial Y}{\partial u} & 0 \\
-  \frac{\partial X}{\partial v} & \frac{\partial Y}{\partial v} & 0 \\
- \end{vmatrix}
-$$
-
-
-## The probability of getting $\left(k\right)$ heads when flipping $\left(n\right)$ coins is:
-
-$$
-P(E) = {n \choose k} p^k (1-p)^{ n-k}
-$$
-
-## An Identity of Ramanujan
-
-$$
-\frac{1}{(\sqrt{\phi \sqrt{5}}-\phi) e^{\frac25 \pi}} =
-     1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}}
-      {1+\frac{e^{-8\pi}} {1+\ldots} } } }
-$$
-
-## A Rogers-Ramanujan Identity
-
-$$
-1 +  \frac{q^2}{(1-q)}+\frac{q^6}{(1-q)(1-q^2)}+\cdots =
-    \prod_{j=0}^{\infty}\frac{1}{(1-q^{5j+2})(1-q^{5j+3})},
-     \quad\quad \text{for $|q|<1$}.
-$$
-
-## Maxwell's Equations
-
-$$
-\begin{align}
-  \nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & = \frac{4\pi}{c}\vec{\mathbf{j}} \\
-  \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
-  \nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
-  \nabla \cdot \vec{\mathbf{B}} & = 0
-\end{align}
-$$
-
-<!-- Reset MathJax -->
-<div class="clearfix"></div>
-
-
-# :pencil: UML Diagrams
-
-See [PlantUML](http://plantuml.com/).
-
-## シーケンス図
-
-@startuml
-skinparam sequenceArrowThickness 2
-skinparam roundcorner 20
-skinparam maxmessagesize 60
-skinparam sequenceParticipant underline
-
-actor User
-participant "First Class" as A
-participant "Second Class" as B
-participant "Last Class" as C
-
-User -> A: DoWork
-activate A
-
-A -> B: Create Request
-activate B
-
-B -> C: DoWork
-activate C
-C --> B: WorkDone
-destroy C
-
-B --> A: Request Created
-deactivate B
-
-A --> User: Done
-deactivate A
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-
-## クラス図
-
-@startuml
-
-class BaseClass
-
-namespace net.dummy #DDDDDD {
-    .BaseClass <|-- Person
-    Meeting o-- Person
-
-    .BaseClass <|- Meeting
-}
-
-namespace net.foo {
-  net.dummy.Person  <|- Person
-  .BaseClass <|-- Person
-
-  net.dummy.Meeting o-- Person
-}
-
-BaseClass <|-- net.unused.Person
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-
-## コンポーネント図
-
-@startuml
-
-package "Some Group" {
-  HTTP - [First Component]
-  [Another Component]
-}
-
-node "Other Groups" {
-  FTP - [Second Component]
-  [First Component] --> FTP
-}
-
-cloud {
-  [Example 1]
-}
-
-
-database "MySql" {
-  folder "This is my folder" {
-    [Folder 3]
-  }
-  frame "Foo" {
-    [Frame 4]
-  }
-}
-
-
-[Another Component] --> [Example 1]
-[Example 1] --> [Folder 3]
-[Folder 3] --> [Frame 4]
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-
-## ステート図
-
-
-@startuml
-scale 600 width
-
-[*] -> State1
-State1 --> State2 : Succeeded
-State1 --> [*] : Aborted
-State2 --> State3 : Succeeded
-State2 --> [*] : Aborted
-state State3 {
-  state "Accumulate Enough Data\nLong State Name" as long1
-  long1 : Just a test
-  [*] --> long1
-  long1 --> long1 : New Data
-  long1 --> ProcessData : Enough Data
-}
-State3 --> State3 : Failed
-State3 --> [*] : Succeeded / Save Result
-State3 --> [*] : Aborted
-
-@enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
-
-# :pencil: blockdiag
-
-See [blockdiag](http://blockdiag.com/).
-
-## blockdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: blockdiag
-blockdiag {
-   A -> B -> C -> D;
-   A -> E -> F -> G;
-}
-:::
-
-</div>
-
-## seqdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: seqdiag
-seqdiag {
-  browser  -> webserver [label = "GET /index.html"];
-  browser <-- webserver;
-  browser  -> webserver [label = "POST /blog/comment"];
-              webserver  -> database [label = "INSERT comment"];
-              webserver <-- database;
-  browser <-- webserver;
-}
-:::
-
-</div>
-
-## actdiag
 
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
+# :heavy_plus_sign: 更に…
 
-::: actdiag
-actdiag {
-  write -> convert -> image
-
-  lane user {
-     label = "User"
-     write [label = "Writing reST"];
-     image [label = "Get diagram IMAGE"];
-  }
-  lane actdiag {
-     convert [label = "Convert reST to Image"];
-  }
-}
-:::
-
-</div>
-
-## nwdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: nwdiag
-nwdiag {
-  network dmz {
-      address = "210.x.x.x/24"
-
-      web01 [address = "210.x.x.1"];
-      web02 [address = "210.x.x.2"];
-  }
-  network internal {
-      address = "172.x.x.x/24";
-
-      web01 [address = "172.x.x.1"];
-      web02 [address = "172.x.x.2"];
-      db01;
-      db02;
-  }
-}
-:::
-
-</div>
-
-## rackdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: rackdiag
-rackdiag {
-  // define height of rack
-  8U;
-
-  // define rack items
-  1: UPS [2U];
-  3: DB Server
-  4: Web Server
-  5: Web Server
-  6: Web Server
-  7: Load Balancer
-  8: L3 Switch
-}
-:::
-
-</div>
-
-## packetdiag
-
-<!-- Resize blockdiag -->
-<div style="max-width: 600px">
-
-::: packetdiag
-packetdiag {
-  colwidth = 32
-  node_height = 72
-
-  0-15: Source Port
-  16-31: Destination Port
-  32-63: Sequence Number
-  64-95: Acknowledgment Number
-  96-99: Data Offset
-  100-105: Reserved
-  106: URG [rotate = 270]
-  107: ACK [rotate = 270]
-  108: PSH [rotate = 270]
-  109: RST [rotate = 270]
-  110: SYN [rotate = 270]
-  111: FIN [rotate = 270]
-  112-127: Window
-  128-143: Checksum
-  144-159: Urgent Pointer
-  160-191: (Options and Padding)
-  192-223: data [colheight = 3]
-}
-:::
-
-</div>
+- Bootstrap3 のタグを使う
+    - :arrow_right: [/Sandbox/Bootstrap3]
+- 図表を書く
+    - :arrow_right: [/Sandbox/Diagrams]
+- 数式を書く
+    - :arrow_right: [/Sandbox/Math]

+ 64 - 32
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,9 @@
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "form_validation": {
-    "required": "<code>%s</code> に値を入力してください"
+    "error_message": "いくつかの値が設定されていません",
+    "required": "%sに値を入力してください",
+    "invalid_syntax": "%sの構文が不正です"
   },
   "installer": {
     "setup": "セットアップ",
@@ -363,7 +366,7 @@
   },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
-    "Fixed by env var": "環境変数 <code>%s=%s</code> により固定されています。",
+    "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
     "Register limitation": "登録の制限",
     "Register limitation desc": "新しいユーザーを登録する方法を制限します.",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
@@ -383,13 +386,16 @@
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
     "Authentication mechanism settings": "認証機構設定",
-    "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。%s から設定してください。",
+    "setup_is_not_yet_complete":"セットアップはまだ完了してません",
+    "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
-    "desc_of_callback_URL": "%s プロバイダ側の設定で利用してください。",
+    "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
+    "updated_general_security_setting": "セキュリティ設定を更新しました。",
+    "setup_not_completed_yet": "まだセットアップは完了していません。",
     "guest_mode": {
       "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
       "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
@@ -401,17 +407,20 @@
     },
     "configuration": "設定",
     "optional": "オプション",
-    "Treat username matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-    "Treat username matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
-    "Treat email matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
-    "Treat email matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
-    "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
-    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>%s</code> を利用します",
+    "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
+    "Treat email matching as identical_warn": "警告: <code>email</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Use env var if empty": "空の場合、環境変数 <code>{{env}}</code> を利用します",
+    "Use default if both are empty": "どちらの値も空の場合、デフォルト値 <code>{{target}}</code> を利用します",
     "missing mandatory configs": "以下の必須項目の値がデータベースと環境変数のどちらにも設定されていません",
     "Local": {
-      "name": "ID/Password"
+      "name": "ID/Password",
+      "note for the only env option": "現在LOCAL認証のON/OFFは環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+      "enable_local": "ID/Password を有効にする"
     },
     "ldap": {
+      "enable_ldap": "LDAP を有効にする",
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",
       "bind_manager": "管理者 Bind",
@@ -440,49 +449,71 @@
       "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> は <code>cn=group1</code> と、ユーザーの <code>uid</code> を含む <code>memberUid</code> を持つグループにヒットします(<code>ユーザーの DN プロパティー</code> がデフォルトから変更されていない場合)",
       "group_search_user_DN_property": "ユーザーの DN プロパティー",
       "group_search_user_DN_property_detail": "<code>グループ検索フィルター</code> 内の <code>&#123;&#123;dn&#125;&#125;</code> で置換される、ユーザーオブジェクトのプロパティー",
-      "test_config": "ログインテスト"
+      "test_config": "ログインテスト",
+      "updated_ldap": "LDAP設定 を更新しました"
     },
     "SAML": {
       "name": "SAML",
+      "enable_saml": "SAML を有効にする",
       "id_detail": "SAML Identity プロバイダ内で一意に識別可能な値を格納している属性",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
-      "mapping_detail": "新規ユーザーの%sに関連付ける属性",
+      "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
       "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に変更もしくは削除してください"
+      "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します",
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</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": "See <a href=\"https://lucene.apache.org/core/2_9_4/queryparsersyntax.html\" target=\"_blank\">Apache Lucene - Query Parser Syntax</a>.<h6>利用可能なクエリ:</h6><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h6>利用不可なクエリ:</h6><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul>",
+      "attr_based_login_control_rule_example": "<h6>Example</h6>ルールに <code>(Department: A || Department: B) && Position: Leader</code> を指定した場合, <code>Department: A</code> または <code>Department: B</code> のどちらかに該当し、かつ <code>Position: Leader</code> を持つユーザーにログインを<strong>許可</strong>します。"
     },
     "Basic": {
+      "enable_basic": "Basic を有効にする",
       "name": "Basic 認証",
       "desc_1": "Authorization ヘッダに格納されている <code>username</code> でログインします。",
-      "desc_2": "ユーザーが存在しなかった場合は自動生成します。"
+      "desc_2": "ユーザーが存在しなかった場合は自動生成します。",
+      "updated_basic": "Basic認証 を更新しました"
     },
     "OAuth": {
+      "enable_oidc": "OIDC を有効にする",
       "register": "%sに登録",
       "change_redirect_url": "承認済みのリダイレクトURLに、 <code>%s</code> を入力",
       "Google": {
+        "enable_google": "Google OAuth を有効にする",
         "name": "Google OAuth",
-        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_1": "{{link}}へアクセス",
         "register_2": "プロジェクトがない場合はプロジェクトを作成",
         "register_3": "認証情報を作成 &rightarrow; OAuthクライアントID &rightarrow; ウェブアプリケーションを選択",
-        "register_4": "承認済みのリダイレクトURIを<code>%s</code>としてGrowiを登録",
-        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
+        "register_4": "承認済みのリダイレクトURIを<code>{{url}}</code>としてGrowiを登録",
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
+        "updated_google": "Google OAuth を更新しました"
       },
       "Facebook": {
         "name": "Facebook OAuth"
       },
       "Twitter": {
+        "enable_twitter": "Twitter OAuth を有効にする",
         "name": "Twitter OAuth",
-        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_1": "{{link}} へアクセス",
         "register_2": "Twitterにサインイン",
         "register_3": "Create New Appをクリック &rightarrow; Application Detailsの各項目を入力",
         "register_4": "Create your Twitter Applicationで作成",
-        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力",
+        "updated_twitter": "Twitter OAuth を更新しました"
       },
       "GitHub": {
+        "enable_github": "GitHub OAuth を有効にする",
         "name": "GitHub OAuth",
-        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
-        "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録",
-        "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
+        "register_1": "{{link}} へアクセス",
+        "register_2": "\"Authorization callback URL\"を<code>{{url}}</code>としてGrowiを登録",
+        "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力",
+        "updated_github": "GitHub OAuth を更新しました"
+      },
+      "OIDC": {
+        "name": "OpenID Connect",
+        "id_detail": "OIDC claims で一意に識別可能な値を格納している属性",
+        "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
+        "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
+        "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
+        "updated_oidc": "OpenID Connect を更新しました"
       },
       "how_to": {
         "google": "Google OAuth の設定方法",
@@ -491,14 +522,15 @@
       }
     },
     "form_item_name": {
-      "security:passport-saml:entryPoint": "エントリーポイント",
-      "security:passport-saml:issuer": "発行者",
-      "security:passport-saml:cert": "証明書",
-      "security:passport-saml:attrMapId": "ID",
-      "security:passport-saml:attrMapUsername": "ユーザー名",
-      "security:passport-saml:attrMapMail": "メールアドレス",
-      "security:passport-saml:attrMapFirstName": "姓",
-      "security:passport-saml:attrMapLastName": "名"
+      "entryPoint": "エントリーポイント",
+      "issuer": "発行者",
+      "cert": "証明書",
+      "attrMapId": "ID",
+      "attrMapUsername": "ユーザー名",
+      "attrMapMail": "メールアドレス",
+      "attrMapFirstName": "姓",
+      "attrMapLastName": "名",
+      "ABLCRule": "ルール"
     }
   },
   "notification_setting": {

+ 35 - 0
src/client/js/admin.jsx

@@ -12,6 +12,7 @@ import ManageGlobalNotification from './components/Admin/Notification/ManageGlob
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
 import AppSettingsPage from './components/Admin/App/AppSettingsPage';
+import SecurityManagement from './components/Admin/Security/SecurityManagement';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
@@ -27,6 +28,15 @@ import AdminUsersContainer from './services/AdminUsersContainer';
 import AdminAppContainer from './services/AdminAppContainer';
 import AdminMarkDownContainer from './services/AdminMarkDownContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
+import AdminGeneralSecurityContainer from './services/AdminGeneralSecurityContainer';
+import AdminLdapSecurityContainer from './services/AdminLdapSecurityContainer';
+import AdminLocalSecurityContainer from './services/AdminLocalSecurityContainer';
+import AdminSamlSecurityContainer from './services/AdminSamlSecurityContainer';
+import AdminOidcSecurityContainer from './services/AdminOidcSecurityContainer';
+import AdminBasicSecurityContainer from './services/AdminBasicSecurityContainer';
+import AdminGoogleSecurityContainer from './services/AdminGoogleSecurityContainer';
+import AdminGitHubSecurityContainer from './services/AdminGitHubSecurityContainer';
+import AdminTwitterSecurityContainer from './services/AdminTwitterSecurityContainer';
 import AdminNotificationContainer from './services/AdminNotificationContainer';
 
 import { appContainer, componentMappings } from './bootstrap';
@@ -97,3 +107,28 @@ Object.keys(componentMappings).forEach((key) => {
     );
   }
 });
+
+const adminSecuritySettingElem = document.getElementById('admin-security-setting');
+if (adminSecuritySettingElem != null) {
+  const adminGeneralSecurityContainer = new AdminGeneralSecurityContainer(appContainer);
+  const adminLocalSecurityContainer = new AdminLocalSecurityContainer(appContainer);
+  const adminLdapSecurityContainer = new AdminLdapSecurityContainer(appContainer);
+  const adminSamlSecurityContainer = new AdminSamlSecurityContainer(appContainer);
+  const adminOidcSecurityContainer = new AdminOidcSecurityContainer(appContainer);
+  const adminBasicSecurityContainer = new AdminBasicSecurityContainer(appContainer);
+  const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer(appContainer);
+  const adminGitHubSecurityContainer = new AdminGitHubSecurityContainer(appContainer);
+  const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer(appContainer);
+  const adminSecurityContainers = [
+    adminGeneralSecurityContainer, adminLocalSecurityContainer, adminLdapSecurityContainer, adminSamlSecurityContainer,
+    adminOidcSecurityContainer, adminBasicSecurityContainer, adminGoogleSecurityContainer, adminGitHubSecurityContainer, adminTwitterSecurityContainer,
+  ];
+  ReactDOM.render(
+    <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
+      <I18nextProvider i18n={i18n}>
+        <SecurityManagement />
+      </I18nextProvider>
+    </Provider>,
+    adminSecuritySettingElem,
+  );
+}

+ 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 />,
 };

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

@@ -27,7 +27,7 @@ class AppSettingsPage extends React.Component {
     }
     catch (err) {
       toastError(err);
-      adminAppContainer.setState({ retrieveError: err });
+      adminAppContainer.setState({ retrieveError: err.message });
       logger.error(err);
     }
   }

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

@@ -133,6 +133,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} />
       </React.Fragment>
     );

+ 1 - 1
src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx

@@ -25,7 +25,7 @@ class MarkdownSetting extends React.Component {
     }
     catch (err) {
       toastError(err);
-      adminMarkDownContainer.setState({ retrieveError: err });
+      adminMarkDownContainer.setState({ retrieveError: err.message });
       logger.error(err);
     }
 

+ 1 - 1
src/client/js/components/Admin/MarkdownSetting/XssForm.jsx

@@ -27,7 +27,7 @@ class XssForm extends React.Component {
 
     try {
       await this.props.adminMarkDownContainer.updateXssSetting();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.xss_desc') }));
+      toastSuccess(t('toaster.update_successed', { target: t('admin:markdown_setting.xss_header') }));
     }
     catch (err) {
       toastError(err);

+ 147 - 0
src/client/js/components/Admin/Security/BasicSecuritySetting.jsx

@@ -0,0 +1,147 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminBasicSecurityContainer from '../../../services/AdminBasicSecurityContainer';
+
+class BasicSecurityManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isRetrieving: true,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminBasicSecurityContainer } = this.props;
+
+    try {
+      await adminBasicSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+  async onClickSubmit() {
+    const { t, adminBasicSecurityContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminBasicSecurityContainer.updateBasicSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.Basic.updated_basic'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminBasicSecurityContainer } = this.props;
+    const { isBasicEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+    return (
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          { t('security_setting.Basic.name') }
+        </h2>
+
+        {this.state.retrieveError != null && (
+        <div className="alert alert-danger">
+          <p>{t('Error occurred')} : {this.state.err}</p>
+        </div>
+        )}
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>{t('security_setting.Basic.name')}</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isBasicEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isBasicEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsBasicEnabled() }}
+              />
+              <label htmlFor="isBasicEnabled">
+                { t('security_setting.Basic.enable_basic') }
+              </label>
+            </div>
+            <p className="help-block">
+              <small>
+                <span dangerouslySetInnerHTML={{ __html: t('security_setting.Basic.desc_1') }} /><br />
+                { t('security_setting.Basic.desc_2')}
+              </small>
+            </p>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('basic') && isBasicEnabled)
+            && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        {isBasicEnabled && (
+        <React.Fragment>
+          <div className="row mb-5">
+            <div className="col-xs-offset-3 col-xs-6 text-left">
+              <div className="checkbox checkbox-success">
+                <input
+                  id="bindByEmail-basic"
+                  type="checkbox"
+                  checked={adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                  onChange={() => { adminBasicSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                />
+                <label
+                  htmlFor="bindByEmail-basic"
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical', 'username') }}
+                />
+              </div>
+              <p className="help-block">
+                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn', 'username') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="col-xs-offset-4 col-xs-5">
+              <button type="button" className="btn btn-primary" disabled={adminBasicSecurityContainer.state.retrieveError != null} onClick={this.onClickSubmit}>
+                {t('Update')}
+              </button>
+            </div>
+          </div>
+
+        </React.Fragment>
+        )}
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+BasicSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,
+};
+
+const OidcSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(BasicSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminBasicSecurityContainer]);
+};
+
+export default withTranslation()(OidcSecurityManagementWrapper);

+ 41 - 0
src/client/js/components/Admin/Security/FacebookSecuritySetting.jsx

@@ -0,0 +1,41 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+
+class FacebookSecurityManagement extends React.Component {
+
+  render() {
+    const { t } = this.props;
+    return (
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          Facebook OAuth { t('security_setting.configuration') }
+        </h2>
+
+        <p className="well">(TBD)</p>
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+
+FacebookSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+};
+
+const TwitterSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(FacebookSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer]);
+};
+
+export default withTranslation()(TwitterSecurityManagementWrapper);

+ 218 - 0
src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx

@@ -0,0 +1,218 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '../../../services/AdminGitHubSecurityContainer';
+
+class GitHubSecurityManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isRetrieving: true,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminGitHubSecurityContainer } = this.props;
+
+    try {
+      await adminGitHubSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+  async onClickSubmit() {
+    const { t, adminGitHubSecurityContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminGitHubSecurityContainer.updateGitHubSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.OAuth.GitHub.updated_github'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminGitHubSecurityContainer } = this.props;
+    const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          {t('security_setting.OAuth.GitHub.name')}
+        </h2>
+
+        {this.state.retrieveError != null && (
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {this.state.err}</p>
+          </div>
+        )}
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>{t('security_setting.OAuth.GitHub.name')}</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isGitHubEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isGitHubEnabled || false}
+                onChange={() => { adminGeneralSecurityContainer.switchIsGitHubOAuthEnabled() }}
+              />
+              <label htmlFor="isGitHubEnabled">
+                {t('security_setting.OAuth.GitHub.enable_github')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('github') && isGitHubEnabled)
+              && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              value={adminGitHubSecurityContainer.state.appSiteUrl}
+              readOnly
+            />
+            <p className="help-block small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+              <div className="alert alert-danger">
+                <i
+                  className="icon-exclamation"
+                  // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+                />
+              </div>
+            )}
+          </div>
+        </div>
+
+
+        {isGitHubEnabled && (
+          <React.Fragment>
+
+            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
+
+            <div className="row mb-5">
+              <label htmlFor="githubClientId" className="col-xs-3 text-right">{t('security_setting.clientID')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="githubClientId"
+                  value={adminGitHubSecurityContainer.state.githubClientId || ''}
+                  onChange={e => adminGitHubSecurityContainer.changeGitHubClientId(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_ID' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="githubClientSecret" className="col-xs-3 text-right">{t('security_setting.client_secret')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="githubClientSecret"
+                  defaultValue={adminGitHubSecurityContainer.state.githubClientSecret || ''}
+                  onChange={e => adminGitHubSecurityContainer.changeGitHubClientSecret(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_SECRET' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByUserNameGitHub"
+                    type="checkbox"
+                    checked={adminGitHubSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminGitHubSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserNameGitHub"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row my-3">
+              <div className="col-xs-offset-3 col-xs-5">
+                <div className="btn btn-primary" disabled={adminGitHubSecurityContainer.state.retrieveError != null} onClick={this.onClickSubmit}>
+                  {t('Update')}
+                </div>
+              </div>
+            </div>
+
+          </React.Fragment>
+        )}
+
+        <hr />
+
+        <div style={{ minHeight: '300px' }}>
+          <h4>
+            <i className="icon-question" aria-hidden="true"></i>
+            <a href="#collapseHelpForGitHubOauth" data-toggle="collapse"> {t('security_setting.OAuth.how_to.github')}</a>
+          </h4>
+          <ol id="collapseHelpForGitHubOauth" className="collapse">
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_1', { link: '<a href="https://github.com/settings/developers" target=_blank>GitHub Developer Settings</a>' }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_2', { url: adminGitHubSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_3') }} />
+          </ol>
+        </div>
+
+      </React.Fragment>
+
+
+    );
+  }
+
+}
+
+
+GitHubSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
+};
+
+const GitHubSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(GitHubSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminGitHubSecurityContainer]);
+};
+
+export default withTranslation()(GitHubSecurityManagementWrapper);

+ 225 - 0
src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -0,0 +1,225 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminGoogleSecurityContainer from '../../../services/AdminGoogleSecurityContainer';
+
+class GoogleSecurityManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isRetrieving: true,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminGoogleSecurityContainer } = this.props;
+
+    try {
+      await adminGoogleSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+  async onClickSubmit() {
+    const { t, adminGoogleSecurityContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminGoogleSecurityContainer.updateGoogleSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.OAuth.Google.updated_google'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminGoogleSecurityContainer } = this.props;
+    const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          {t('security_setting.OAuth.Google.name')}
+        </h2>
+
+        {this.state.retrieveError != null && (
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {this.state.err}</p>
+          </div>
+        )}
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>{t('security_setting.OAuth.Google.name')}</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isGoogleEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isGoogleEnabled || false}
+                onChange={() => { adminGeneralSecurityContainer.switchIsGoogleOAuthEnabled() }}
+              />
+              <label htmlFor="isGoogleEnabled">
+                {t('security_setting.OAuth.Google.enable_google')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('google') && isGoogleEnabled)
+              && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              value={adminGoogleSecurityContainer.state.callbackUrl}
+              readOnly
+            />
+            <p className="help-block small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+              <div className="alert alert-danger">
+                <i
+                  className="icon-exclamation"
+                  // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+                />
+              </div>
+            )}
+          </div>
+        </div>
+
+
+        {isGoogleEnabled && (
+          <React.Fragment>
+
+            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
+
+            <div className="row mb-5">
+              <label htmlFor="googleClientId" className="col-xs-3 text-right">{t('security_setting.clientID')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="googleClientId"
+                  defaultValue={adminGoogleSecurityContainer.state.googleClientId || ''}
+                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientId(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_ID' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="googleClientSecret" className="col-xs-3 text-right">{t('security_setting.client_secret')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="googleClientSecret"
+                  defaultValue={adminGoogleSecurityContainer.state.googleClientSecret || ''}
+                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientSecret(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_SECRET' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByUserNameGoogle"
+                    type="checkbox"
+                    checked={adminGoogleSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminGoogleSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserNameGoogle"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row my-3">
+              <div className="col-xs-offset-3 col-xs-5">
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={adminGoogleSecurityContainer.state.retrieveError != null}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('Update')}
+                </button>
+              </div>
+            </div>
+
+          </React.Fragment>
+        )}
+
+        <hr />
+
+        <div style={{ minHeight: '300px' }}>
+          <h4>
+            <i className="icon-question" aria-hidden="true"></i>
+            <a href="#collapseHelpForGoogleOauth" data-toggle="collapse"> {t('security_setting.OAuth.how_to.google')}</a>
+          </h4>
+          <ol id="collapseHelpForGoogleOauth" className="collapse">
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_1', { link: '<a href="https://console.cloud.google.com/apis/credentials" target=_blank>Google Cloud Platform API Manager</a>' }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_2') }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_3') }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_4', { url: adminGoogleSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_5') }} />
+          </ol>
+        </div>
+
+      </React.Fragment>
+
+
+    );
+  }
+
+}
+
+
+GoogleSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
+};
+
+const GoogleSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(GoogleSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminGoogleSecurityContainer]);
+};
+
+export default withTranslation()(GoogleSecurityManagementWrapper);

+ 168 - 0
src/client/js/components/Admin/Security/LdapAuthTestModal.jsx

@@ -0,0 +1,168 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
+
+const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
+
+class LdapAuthTestModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      username: '',
+      password: '',
+      logs: '',
+      errorMessage: null,
+      successMessage: null,
+    };
+
+    this.onChangeUsername = this.onChangeUsername.bind(this);
+    this.onChangePassword = this.onChangePassword.bind(this);
+    this.addLogs = this.addLogs.bind(this);
+    this.testLdapCredentials = this.testLdapCredentials.bind(this);
+  }
+
+  /**
+   * Change username
+   */
+  onChangeUsername(username) {
+    this.setState({ username });
+  }
+
+  /**
+   * Change password
+   */
+  onChangePassword(password) {
+    this.setState({ password });
+  }
+
+  /**
+   * add logs
+   */
+  addLogs(log) {
+    const newLog = `${new Date()} - ${log}\n\n`;
+    this.setState({
+      logs: `${newLog}${this.state.logs}`,
+    });
+  }
+
+  /**
+   * Test ldap auth
+   */
+  async testLdapCredentials() {
+    try {
+      const response = await this.props.appContainer.apiPost('/login/testLdap', {
+        loginForm: {
+          username: this.state.username,
+          password: this.state.password,
+        },
+      });
+
+      // add logs
+      if (response.err) {
+        toastError(response.err);
+        this.addLogs(response.err);
+      }
+
+      if (response.status === 'warning') {
+        this.addLogs(response.message);
+        this.setState({ errorMessage: response.message, successMessage: null });
+      }
+
+      if (response.status === 'success') {
+        toastSuccess(response.message);
+        this.setState({ successMessage: response.message, errorMessage: null });
+      }
+
+      if (response.ldapConfiguration) {
+        const prettified = JSON.stringify(response.ldapConfiguration.server, undefined, 4);
+        this.addLogs(`LDAP Configuration : ${prettified}`);
+      }
+      if (response.ldapAccountInfo) {
+        const prettified = JSON.stringify(response.ldapAccountInfo, undefined, 4);
+        this.addLogs(`Retrieved LDAP Account : ${prettified}`);
+      }
+
+    }
+    // Catch server communication error
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
+        <Modal.Header className="modal-header" closeButton>
+          <Modal.Title>
+            Test LDAP Account
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          {this.state.successMessage != null && <div className="alert alert-success">{this.state.successMessage}</div>}
+          {this.state.errorMessage != null && <div className="alert alert-warning">{this.state.errorMessage}</div>}
+          <div className="row p-3">
+            <label htmlFor="username" className="col-xs-3 text-right">{t('username')}</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                name="username"
+                value={this.state.username}
+                onChange={(e) => { this.onChangeUsername(e.target.value) }}
+              />
+            </div>
+          </div>
+          <div className="row p-3">
+            <label htmlFor="password" className="col-xs-3 text-right">{t('Password')}</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="password"
+                name="password"
+                value={this.state.password}
+                onChange={(e) => { this.onChangePassword(e.target.value) }}
+              />
+            </div>
+          </div>
+          <div>
+            <h5>Logs</h5>
+            <textarea id="taLogs" className="col-xs-12" rows="4" value={this.state.logs} readOnly />
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <button type="button" className="btn btn-default mt-3 col-xs-offset-5 col-xs-2" onClick={this.testLdapCredentials}>Test</button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+
+LdapAuthTestModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+};
+
+const LdapAuthTestModalWrapper = (props) => {
+  return createSubscribedElement(LdapAuthTestModal, props, [AppContainer, AdminLdapSecurityContainer]);
+};
+
+export default withTranslation()(LdapAuthTestModalWrapper);

+ 432 - 0
src/client/js/components/Admin/Security/LdapSecuritySetting.jsx

@@ -0,0 +1,432 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
+import LdapAuthTestModal from './LdapAuthTestModal';
+
+
+class LdapSecuritySetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isRetrieving: true,
+      isLdapAuthTestModalShown: false,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+    this.openLdapAuthTestModal = this.openLdapAuthTestModal.bind(this);
+    this.closeLdapAuthTestModal = this.closeLdapAuthTestModal.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminLdapSecurityContainer } = this.props;
+
+    try {
+      await adminLdapSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+  async onClickSubmit() {
+    const { t, adminLdapSecurityContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminLdapSecurityContainer.updateLdapSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.ldap.updated_ldap'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  openLdapAuthTestModal() {
+    this.setState({ isLdapAuthTestModalShown: true });
+  }
+
+  closeLdapAuthTestModal() {
+    this.setState({ isLdapAuthTestModalShown: false });
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminLdapSecurityContainer } = this.props;
+    const { isLdapEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+    return (
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          LDAP
+        </h2>
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>Use LDAP</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isLdapEnabled"
+                type="checkbox"
+                checked={isLdapEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
+              />
+              <label htmlFor="isLdapEnabled">
+                {t('security_setting.ldap.enable_ldap')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isLdapEnabled)
+              && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+
+        {isLdapEnabled && (
+          <React.Fragment>
+
+            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
+
+            <div className="row mb-5">
+              <label htmlFor="serverUrl" className="col-xs-3 control-label text-right">Server URL</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="serverUrl"
+                  defaultValue={adminLdapSecurityContainer.state.serverUrl || ''}
+                  onChange={e => adminLdapSecurityContainer.changeServerUrl(e.target.value)}
+                />
+                <small>
+                  <p
+                    className="help-block"
+                    // eslint-disable-next-line react/no-danger
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.server_url_detail') }}
+                  />
+                  {t('security_setting.example')}: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
+                </small>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <strong className="col-xs-3 text-right">{t('security_setting.ldap.bind_mode')}</strong>
+              <div className="col-xs-6 text-left">
+                <div className="my-0 btn-group">
+                  <div className="dropdown">
+                    <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                      {adminLdapSecurityContainer.state.isUserBind
+                        ? <span className="pull-left">{t('security_setting.ldap.bind_user')}</span>
+                        : <span className="pull-left">{t('security_setting.ldap.bind_manager')}</span>}
+                      <span className="bs-caret pull-right">
+                        <span className="caret" />
+                      </span>
+                    </button>
+                    {/* TODO adjust dropdown after BS4 */}
+                    <ul className="dropdown-menu" role="menu">
+                      <li key="user" role="presentation" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(true) }}>
+                        <a role="menuitem">{t('security_setting.ldap.bind_user')}</a>
+                      </li>
+                      <li key="manager" role="presentation" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(false) }}>
+                        <a role="menuitem">{t('security_setting.ldap.bind_manager')}</a>
+                      </li>
+                    </ul>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <strong className="col-xs-3 text-right">Bind DN</strong>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="bindDN"
+                  defaultValue={adminLdapSecurityContainer.state.ldapBindDN || ''}
+                  onChange={e => adminLdapSecurityContainer.changeBindDN(e.target.value)}
+                />
+                {(adminLdapSecurityContainer.state.isUserBind === true) ? (
+                  <p className="help-block passport-ldap-userbind">
+                    <small>
+                      {t('security_setting.ldap.bind_DN_user_detail1')}<br />
+                      {/* eslint-disable-next-line react/no-danger */}
+                      <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.bind_DN_user_detail2') }} /><br />
+                      {t('security_setting.example')}1: <code>uid={'{{ username }}'},dc=domain,dc=com</code><br />
+                      {t('security_setting.example')}2: <code>{'{{ username }}'}@domain.com</code>
+                    </small>
+                  </p>
+                )
+                  : (
+                    <p className="help-block passport-ldap-managerbind">
+                      <small>
+                        {t('security_setting.ldap.bind_DN_manager_detail')}<br />
+                        {t('security_setting.example')}1: <code>uid=admin,dc=domain,dc=com</code><br />
+                        {t('security_setting.example')}2: <code>admin@domain.com</code>
+                      </small>
+                    </p>
+                  )}
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="bindDNPassword" className="col-xs-3 text-right">{t('security_setting.ldap.bind_DN_password')}</label>
+              <div className="col-xs-6">
+                {(adminLdapSecurityContainer.state.isUserBind) ? (
+                  <p className="help-block passport-ldap-userbind">
+                    <small>
+                      {t('security_setting.ldap.bind_DN_password_user_detail')}
+                    </small>
+                  </p>
+                )
+                  : (
+                    <>
+                      <p className="help-block passport-ldap-managerbind">
+                        <small>
+                          {t('security_setting.ldap.bind_DN_password_manager_detail')}
+                        </small>
+                      </p>
+                      <input
+                        className="form-control passport-ldap-managerbind"
+                        type="password"
+                        name="bindDNPassword"
+                        defaultValue={adminLdapSecurityContainer.state.ldapBindDNPassword || ''}
+                        onChange={e => adminLdapSecurityContainer.changeBindDNPassword(e.target.value)}
+                      />
+                    </>
+                  )}
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <strong className="col-xs-3 text-right">{t('security_setting.ldap.search_filter')}</strong>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="searchFilter"
+                  defaultValue={adminLdapSecurityContainer.state.ldapSearchFilter || ''}
+                  onChange={e => adminLdapSecurityContainer.changeSearchFilter(e.target.value)}
+                />
+                <p className="help-block">
+                  <small>
+                    {t('security_setting.ldap.search_filter_detail1')}<br />
+                    {/* eslint-disable-next-line react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.search_filter_detail2') }} /><br />
+                    {/* eslint-disable-next-line react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.search_filter_detail3') }} />
+                  </small>
+                </p>
+                <p className="help-block">
+                  <small>
+                    {t('security_setting.example')}1 - {t('security_setting.ldap.search_filter_example1')}:
+                    <code>(|(uid={'{{ username }}'})(mail={'{{ username }}'}))</code><br />
+                    {t('security_setting.example')}2 - {t('security_setting.ldap.search_filter_example2')}:
+                    <code>(sAMAccountName={'{{ username }}'})</code>
+                  </small>
+                </p>
+              </div>
+            </div>
+
+            <h3 className="alert-anchor border-bottom">
+              Attribute Mapping ({t('security_setting.optional')})
+            </h3>
+
+            <div className="row mb-5">
+              <strong htmlFor="attrMapUsername" className="col-xs-3 text-right">{t('username')}</strong>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  placeholder="Default: uid"
+                  name="attrMapUsername"
+                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapUsername || ''}
+                  onChange={e => adminLdapSecurityContainer.changeAttrMapUsername(e.target.value)}
+                />
+                <p className="help-block">
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.username_detail') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="isSameUsernameTreatedAsIdenticalUser"
+                    type="checkbox"
+                    checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="isSameUsernameTreatedAsIdenticalUser"
+                    // eslint-disable-next-line react/no-danger
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <strong htmlFor="attrMapMail" className="col-xs-3 text-right">{t('Email')}</strong>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  placeholder="Default: mail"
+                  name="attrMapMail"
+                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapMail || ''}
+                  onChange={e => adminLdapSecurityContainer.changeAttrMapMail(e.target.value)}
+                />
+                <p className="help-block">
+                  <small>
+                    {t('security_setting.ldap.mail_detail')}
+                  </small>
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <strong htmlFor="attrMapName" className="col-xs-3 text-right">{t('Name')}</strong>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="attrMapName"
+                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapName || ''}
+                  onChange={e => adminLdapSecurityContainer.changeAttrMapName(e.target.value)}
+                />
+                <p className="help-block">
+                  <small>
+                    {t('security_setting.ldap.name_detail')}
+                  </small>
+                </p>
+              </div>
+            </div>
+
+
+            <h3 className="alert-anchor border-bottom">
+              {t('security_setting.ldap.group_search_filter')} ({t('security_setting.optional')})
+            </h3>
+
+            <div className="row mb-5">
+              <strong htmlFor="groupSearchBase" className="col-xs-3 text-right">{t('security_setting.ldap.group_search_base_DN')}</strong>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="groupSearchBase"
+                  defaultValue={adminLdapSecurityContainer.state.ldapGroupSearchBase || ''}
+                  onChange={e => adminLdapSecurityContainer.changeGroupSearchBase(e.target.value)}
+                />
+                <p className="help-block">
+                  <small>
+                    {/* eslint-disable-next-line react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_base_DN_detail') }} /><br />
+                    {t('security_setting.example')}: <code>ou=groups,dc=domain,dc=com</code>
+                  </small>
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <strong htmlFor="groupSearchFilter" className="col-xs-3 text-right">{t('security_setting.ldap.group_search_filter')}</strong>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="groupSearchFilter"
+                  defaultValue={adminLdapSecurityContainer.state.ldapGroupSearchFilter || ''}
+                  onChange={e => adminLdapSecurityContainer.changeGroupSearchFilter(e.target.value)}
+                />
+                <p className="help-block">
+                  <small>
+                    {/* eslint-disable react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail1') }} /><br />
+                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail2') }} /><br />
+                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail3') }} />
+                    {/* eslint-enable react/no-danger */}
+                  </small>
+                </p>
+                <p className="help-block">
+                  <small>
+                    {t('security_setting.example')}:
+                    {/* eslint-disable-next-line react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail4') }} />
+                  </small>
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="groupDnProperty" className="col-xs-3 text-right">{t('security_setting.ldap.group_search_user_DN_property')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  placeholder="Default: uid"
+                  name="groupDnProperty"
+                  defaultValue={adminLdapSecurityContainer.state.ldapGroupDnProperty || ''}
+                  onChange={e => adminLdapSecurityContainer.changeGroupDnProperty(e.target.value)}
+                />
+                <p className="help-block">
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_user_DN_property_detail') }} />
+                </p>
+              </div>
+            </div>
+            <div className="row my-3">
+              <div className="col-xs-offset-3 col-xs-5">
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={adminLdapSecurityContainer.state.retrieveError != null}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('Update')}
+                </button>
+                <button type="button" className="btn btn-default ml-2" onClick={this.openLdapAuthTestModal}>{t('security_setting.ldap.test_config')}</button>
+              </div>
+            </div>
+
+          </React.Fragment>
+        )}
+
+
+        <LdapAuthTestModal isOpen={this.state.isLdapAuthTestModalShown} onClose={this.closeLdapAuthTestModal} />
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+LdapSecuritySetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
+};
+
+const LdapSecuritySettingWrapper = (props) => {
+  return createSubscribedElement(LdapSecuritySetting, props, [AppContainer, AdminGeneralSecurityContainer, AdminLdapSecurityContainer]);
+};
+
+export default withTranslation()(LdapSecuritySettingWrapper);

+ 203 - 0
src/client/js/components/Admin/Security/LocalSecuritySetting.jsx

@@ -0,0 +1,203 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminLocalSecurityContainer from '../../../services/AdminLocalSecurityContainer';
+
+class LocalSecuritySetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isRetrieving: true,
+    };
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminLocalSecurityContainer } = this.props;
+
+    try {
+      await adminLocalSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+
+  async onClickSubmit() {
+    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
+    try {
+      await adminLocalSecurityContainer.updateLocalSecuritySetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
+    const { registrationMode } = adminLocalSecurityContainer.state;
+    const { isLocalEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+
+    return (
+      <React.Fragment>
+        {this.state.retrieveError != null && (
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {this.state.err}</p>
+          </div>
+        )}
+        <h2 className="alert-anchor border-bottom">
+          {t('security_setting.Local.name')}
+        </h2>
+
+        {adminLocalSecurityContainer.state.useOnlyEnvVars && (
+          <p
+            className="alert alert-info"
+            // eslint-disable-next-line max-len
+            dangerouslySetInnerHTML={{ __html: t('security_setting.Local.note for the only env option', { env: 'LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+          />
+        )}
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>{t('security_setting.Local.name')}</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isLocalEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isLocalEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsLocalEnabled() }}
+                disabled={adminLocalSecurityContainer.state.useOnlyEnvVars}
+              />
+              <label htmlFor="isLocalEnabled">
+                {t('security_setting.Local.enable_local')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && isLocalEnabled)
+            && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        {isLocalEnabled && (
+          <React.Fragment>
+
+            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
+
+            <div className="row mb-5">
+              <strong className="col-xs-3 text-right">{t('Register limitation')}</strong>
+              <div className="col-xs-9 text-left">
+                <div className="my-0 btn-group">
+                  <div className="dropdown">
+                    <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                      {registrationMode === 'Open' && <span className="pull-left">{t('security_setting.registration_mode.open')}</span>}
+                      {registrationMode === 'Restricted' && <span className="pull-left">{t('security_setting.registration_mode.restricted')}</span>}
+                      {registrationMode === 'Closed' && <span className="pull-left">{t('security_setting.registration_mode.closed')}</span>}
+                      <span className="bs-caret pull-right">
+                        <span className="caret" />
+                      </span>
+                    </button>
+                    {/* TODO adjust dropdown after BS4 */}
+                    <ul className="dropdown-menu" role="menu">
+                      <li
+                        key="Open"
+                        role="presentation"
+                        type="button"
+                        onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Open') }}
+                      >
+                        <a role="menuitem">{t('security_setting.registration_mode.open')}</a>
+                      </li>
+                      <li
+                        key="Restricted"
+                        role="presentation"
+                        type="button"
+                        onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Restricted') }}
+                      >
+                        <a role="menuitem">{t('security_setting.registration_mode.restricted')}</a>
+                      </li>
+                      <li
+                        key="Closed"
+                        role="presentation"
+                        type="button"
+                        onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Closed') }}
+                      >
+                        <a role="menuitem">{t('security_setting.registration_mode.closed')}</a>
+                      </li>
+                    </ul>
+                  </div>
+                  <p className="help-block">
+                    {t('security_setting.Register limitation desc')}
+                  </p>
+                </div>
+              </div>
+            </div>
+            <div className="row mb-5">
+              <strong className="col-xs-3 text-right" dangerouslySetInnerHTML={{ __html: t('The whitelist of registration permission E-mail address') }} />
+              <div className="col-xs-6">
+                <div>
+                  <textarea
+                    className="form-control"
+                    type="textarea"
+                    name="registrationWhiteList"
+                    defaultValue={adminLocalSecurityContainer.state.registrationWhiteList.join('\n')}
+                    onChange={e => adminLocalSecurityContainer.changeRegistrationWhiteList(e.target.value)}
+                  />
+                  <p className="help-block small">{t('security_setting.restrict_emails')}<br />{t('security_setting.for_instance')}
+                    <code>@growi.org</code>{t('security_setting.only_those')}<br />
+                    {t('security_setting.insert_single')}
+                  </p>
+                </div>
+              </div>
+            </div>
+
+            <div className="row my-3">
+              <div className="col-xs-offset-3 col-xs-5">
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={adminLocalSecurityContainer.state.retrieveError != null}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('Update')}
+                </button>
+              </div>
+            </div>
+          </React.Fragment>
+        )}
+
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+LocalSecuritySetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
+};
+
+const LocalSecuritySettingWrapper = (props) => {
+  return createSubscribedElement(LocalSecuritySetting, props, [AppContainer, AdminGeneralSecurityContainer, AdminLocalSecurityContainer]);
+};
+
+export default withTranslation()(LocalSecuritySettingWrapper);

+ 352 - 0
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -0,0 +1,352 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminOidcSecurityContainer from '../../../services/AdminOidcSecurityContainer';
+
+class OidcSecurityManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isRetrieving: true,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminOidcSecurityContainer } = this.props;
+
+    try {
+      await adminOidcSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+  async onClickSubmit() {
+    const { t, adminOidcSecurityContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminOidcSecurityContainer.updateOidcSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.OAuth.OIDC.updated_oidc'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminOidcSecurityContainer } = this.props;
+    const { isOidcEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          {t('security_setting.OAuth.OIDC.name')}
+        </h2>
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>{t('security_setting.OAuth.OIDC.name')}</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isOidcEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isOidcEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
+              />
+              <label htmlFor="isOidcEnabled">
+                {t('security_setting.OAuth.enable_oidc')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('oidc') && isOidcEnabled)
+              && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              value={adminOidcSecurityContainer.state.callbackUrl}
+              readOnly
+            />
+            <p className="help-block small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+              <div className="alert alert-danger">
+                <i
+                  className="icon-exclamation"
+                  // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+                />
+              </div>
+            )}
+          </div>
+        </div>
+
+        {isOidcEnabled && (
+          <React.Fragment>
+
+            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcProviderName" className="col-xs-3 text-right">{t('security_setting.providerName')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcProviderName"
+                  defaultValue={adminOidcSecurityContainer.state.oidcProviderName || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcProviderName(e.target.value)}
+                />
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcIssuerHost" className="col-xs-3 text-right">{t('security_setting.issuerHost')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcIssuerHost"
+                  defaultValue={adminOidcSecurityContainer.state.oidcIssuerHost || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcIssuerHost(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_ISSUER_HOST' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcClientId" className="col-xs-3 text-right">{t('security_setting.clientID')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcClientId"
+                  defaultValue={adminOidcSecurityContainer.state.oidcClientId || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcClientId(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_ID' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcClientSecret" className="col-xs-3 text-right">{t('security_setting.client_secret')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcClientSecret"
+                  defaultValue={adminOidcSecurityContainer.state.oidcClientSecret || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcClientSecret(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_SECRET' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <h3 className="alert-anchor border-bottom">
+              Attribute Mapping ({t('security_setting.optional')})
+            </h3>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcAttrMapId" className="col-xs-3 text-right">Identifier</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapId"
+                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapId || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapId(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.id_detail') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcAttrMapUserName" className="col-xs-3 text-right">{t('username')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapUserName"
+                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapUserName || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapUserName(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.username_detail') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcAttrMapName" className="col-xs-3 text-right">{t('Name')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapName"
+                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapName || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapName(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.name_detail') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="oidcAttrMapEmail" className="col-xs-3 text-right">{t('Email')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapEmail"
+                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapEmail || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapEmail(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.mapping_detail', { target: t('Email') }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  defaultValue={adminOidcSecurityContainer.state.callbackUrl || ''}
+                  readOnly
+                />
+                <p className="help-block small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+                {!adminGeneralSecurityContainer.state.appSiteUrl && (
+                  <div className="alert alert-danger">
+                    <i
+                      className="icon-exclamation"
+                      // eslint-disable-next-line max-len
+                      dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+                    />
+                  </div>
+                )}
+              </div>
+            </div>
+
+            <div className="row mb-3">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByUserName-oidc"
+                    type="checkbox"
+                    checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserName-oidc"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByEmail-oidc"
+                    type="checkbox"
+                    checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                    onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByEmail-oidc"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row my-3">
+              <div className="col-xs-offset-3 col-xs-5">
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={adminOidcSecurityContainer.state.retrieveError != null}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('Update')}
+                </button>
+              </div>
+            </div>
+          </React.Fragment>
+        )}
+
+
+        <hr />
+
+        <div style={{ minHeight: '300px' }}>
+          <h4>
+            <i className="icon-question" aria-hidden="true" />
+            <a href="#collapseHelpForOidcOauth" data-toggle="collapse"> {t('security_setting.OAuth.how_to.oidc')}</a>
+          </h4>
+          <ol id="collapseHelpForOidcOauth" className="collapse">
+            <li>{t('security_setting.OAuth.OIDC.register_1')}</li>
+            <li>{t('security_setting.OAuth.OIDC.register_2')}</li>
+            <li>{t('security_setting.OAuth.OIDC.register_3')}</li>
+          </ol>
+        </div>
+
+      </React.Fragment>
+    );
+  }
+
+}
+
+OidcSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
+};
+
+const OidcSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(OidcSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminOidcSecurityContainer]);
+};
+
+export default withTranslation()(OidcSecurityManagementWrapper);

+ 551 - 0
src/client/js/components/Admin/Security/SamlSecuritySetting.jsx

@@ -0,0 +1,551 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminSamlSecurityContainer from '../../../services/AdminSamlSecurityContainer';
+
+class SamlSecurityManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isRetrieving: true,
+      envEntryPoint: '',
+      envIssuer: '',
+      envCert: '',
+      envAttrMapId: '',
+      envAttrMapUsername: '',
+      envAttrMapMail: '',
+      envAttrMapFirstName: '',
+      envAttrMapLastName: '',
+      envABLCRule: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminSamlSecurityContainer } = this.props;
+
+    try {
+      const samlAuth = await adminSamlSecurityContainer.retrieveSecurityData();
+      this.setState({
+        envEntryPoint: samlAuth.samlEnvVarEntryPoint,
+        envIssuer: samlAuth.samlEnvVarIssuer,
+        envCert: samlAuth.samlEnvVarCert,
+        envAttrMapId: samlAuth.samlEnvVarAttrMapId,
+        envAttrMapUsername: samlAuth.samlEnvVarAttrMapUsername,
+        envAttrMapMail: samlAuth.samlEnvVarAttrMapMail,
+        envAttrMapFirstName: samlAuth.samlEnvVarAttrMapFirstName,
+        envAttrMapLastName: samlAuth.samlEnvVarAttrMapLastName,
+        envABLCRule: samlAuth.samlEnvVarABLCRule,
+      });
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+  async onClickSubmit() {
+    const { t, adminSamlSecurityContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminSamlSecurityContainer.updateSamlSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.SAML.updated_saml'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminSamlSecurityContainer } = this.props;
+    const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
+    const { isSamlEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+    return (
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          {t('security_setting.SAML.name')}
+        </h2>
+
+        {useOnlyEnvVars && (
+          <p
+            className="alert alert-info"
+            dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.note for the only env option', { env: 'SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+          />
+        )}
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>{t('security_setting.SAML.name')}</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isSamlEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isSamlEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsSamlEnabled() }}
+                disabled={adminSamlSecurityContainer.state.useOnlyEnvVars}
+              />
+              <label htmlFor="isSamlEnabled">
+                {t('security_setting.SAML.enable_saml')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isSamlEnabled)
+              && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminSamlSecurityContainer.state.callbackUrl}
+              readOnly
+            />
+            <p className="help-block small">{t('security_setting.desc_of_callback_URL', { AuthName: 'SAML Identity' })}</p>
+            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+              <div className="alert alert-danger">
+                <i
+                  className="icon-exclamation"
+                  // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+                />
+              </div>
+            )}
+          </div>
+        </div>
+
+        {isSamlEnabled && (
+          <React.Fragment>
+
+            {(adminSamlSecurityContainer.state.missingMandatoryConfigKeys.length !== 0) && (
+              <div className="alert alert-danger">
+                {t('security_setting.missing mandatory configs')}
+                <ul>
+                  {adminSamlSecurityContainer.state.missingMandatoryConfigKeys.map((configKey) => {
+                    const key = configKey.replace('security:passport-saml:', '');
+                    return <li key={configKey}>{t(`security_setting.form_item_name.${key}`)}</li>;
+                  })}
+                </ul>
+              </div>
+            )}
+
+
+            <h3 className="alert-anchor border-bottom">
+              Basic Settings
+            </h3>
+
+            <table className={`table settings-table ${adminSamlSecurityContainer.state.useOnlyEnvVars && 'use-only-env-vars'}`}>
+              <colgroup>
+                <col className="item-name" />
+                <col className="from-db" />
+                <col className="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.entryPoint')}</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      name="samlEntryPoint"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlEntryPoint}
+                      onChange={e => adminSamlSecurityContainer.changeSamlEntryPoint(e.target.value)}
+                    />
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envEntryPoint || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ENTRY_POINT' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{t('security_setting.form_item_name.issuer')}</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      name="samlEnvVarissuer"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlIssuer}
+                      onChange={e => adminSamlSecurityContainer.changeSamlIssuer(e.target.value)}
+                    />
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envIssuer || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ISSUER' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{t('security_setting.form_item_name.cert')}</th>
+                  <td>
+                    <textarea
+                      className="form-control input-sm"
+                      type="text"
+                      rows="5"
+                      name="samlCert"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlCert}
+                      onChange={e => adminSamlSecurityContainer.changeSamlCert(e.target.value)}
+                    />
+                    <p className="help-block">
+                      <small>
+                        {t('security_setting.SAML.cert_detail')}
+                      </small>
+                    </p>
+                    <div>
+                      <small>
+                        e.g.
+                        <pre>{`-----BEGIN CERTIFICATE-----
+MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
+UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
+...
+crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
+pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
+-----END CERTIFICATE-----
+                        `}
+                        </pre>
+                      </small>
+                    </div>
+                  </td>
+                  <td>
+                    <textarea
+                      className="form-control input-sm"
+                      type="text"
+                      rows="5"
+                      readOnly
+                      value={this.state.envCert || ''}
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_CERT' }) }} />
+                    </p>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+
+            <h3 className="alert-anchor border-bottom">
+              Attribute Mapping
+            </h3>
+
+            <table className={`table settings-table ${adminSamlSecurityContainer.state.useOnlyEnvVars && 'use-only-env-vars'}`}>
+              <colgroup>
+                <col className="item-name" />
+                <col className="from-db" />
+                <col className="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.attrMapId')}</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapId}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapId(e.target.value)}
+                    />
+                    <p className="help-block">
+                      <small>
+                        {t('security_setting.SAML.id_detail')}
+                      </small>
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envAttrMapId || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_ID' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{t('security_setting.form_item_name.attrMapUsername')}</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapUsername}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapUserName(e.target.value)}
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.username_detail') }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envAttrMapUsername || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_USERNAME' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{t('security_setting.form_item_name.attrMapMail')}</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapMail}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapMail(e.target.value)}
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.mapping_detail', { target: 'Email' }) }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envAttrMapMail || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_MAIL' }) }} />
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{t('security_setting.form_item_name.attrMapFirstName')}</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapFirstName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapFirstName(e.target.value)}
+                    />
+                    <p className="help-block">
+                      {/* eslint-disable-next-line max-len */}
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.mapping_detail', { target: t('security_setting.form_item_name.attrMapFirstName') }) }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envAttrMapFirstName || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small>
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_FIRST_NAME' }) }} />
+                        <br />
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.Use default if both are empty', { target: 'firstName' }) }} />
+                      </small>
+                    </p>
+                  </td>
+                </tr>
+                <tr>
+                  <th>{t('security_setting.form_item_name.attrMapLastName')}</th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      readOnly={useOnlyEnvVars}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapLastName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapLastName(e.target.value)}
+                    />
+                    <p className="help-block">
+                      {/* eslint-disable-next-line max-len */}
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.mapping_detail', { target: t('security_setting.form_item_name.attrMapLastName') }) }} />
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envAttrMapLastName || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small>
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_LAST_NAME' }) }} />
+                        <br />
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.Use default if both are empty', { target: 'lastName' }) }} />
+                      </small>
+                    </p>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+
+            <h3 className="alert-anchor border-bottom">
+              Attribute Mapping Options
+            </h3>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByUserName-SAML"
+                    type="checkbox"
+                    checked={adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminSamlSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserName-SAML"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByEmail-SAML"
+                    type="checkbox"
+                    checked={adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                    onChange={() => { adminSamlSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByEmail-SAML"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <h3 className="alert-anchor border-bottom">
+              Attribute-based Login Control
+            </h3>
+
+            <p className="help-block">
+              <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_detail') }} />
+            </p>
+
+            <table className={`table settings-table ${useOnlyEnvVars && 'use-only-env-vars'}`}>
+              <colgroup>
+                <col className="item-name" />
+                <col className="from-db" />
+                <col className="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.ABLCRule') }
+                  </th>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      defaultValue={adminSamlSecurityContainer.state.samlABLCRule || ''}
+                      onChange={(e) => { adminSamlSecurityContainer.changeSamlABLCRule(e.target.value) }}
+                      readOnly={useOnlyEnvVars}
+                    />
+                    <p className="help-block">
+                      <small>
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_rule_detail') }} />
+                        <span dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.attr_based_login_control_rule_example') }} />
+                      </small>
+                    </p>
+                  </td>
+                  <td>
+                    <input
+                      className="form-control"
+                      type="text"
+                      value={this.state.envABLCRule || ''}
+                      readOnly
+                    />
+                    <p className="help-block">
+                      <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_ABLC_RULE' }) }} />
+                    </p>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+
+            <div className="row my-3">
+              <div className="col-xs-offset-3 col-xs-5">
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={adminSamlSecurityContainer.state.retrieveError != null}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('Update')}
+                </button>
+              </div>
+            </div>
+
+          </React.Fragment>
+        )}
+
+      </React.Fragment>
+    );
+
+  }
+
+}
+
+SamlSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,
+};
+
+const SamlSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(SamlSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminSamlSecurityContainer]);
+};
+
+export default withTranslation()(SamlSecurityManagementWrapper);

+ 124 - 0
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -0,0 +1,124 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import LdapSecuritySetting from './LdapSecuritySetting';
+import LocalSecuritySetting from './LocalSecuritySetting';
+import SamlSecuritySetting from './SamlSecuritySetting';
+import OidcSecuritySetting from './OidcSecuritySetting';
+import SecuritySetting from './SecuritySetting';
+import BasicSecuritySetting from './BasicSecuritySetting';
+import GoogleSecuritySetting from './GoogleSecuritySetting';
+import GitHubSecuritySetting from './GitHubSecuritySetting';
+import TwitterSecuritySetting from './TwitterSecuritySetting';
+import FacebookSecuritySetting from './FacebookSecuritySetting';
+
+class SecurityManagement extends React.Component {
+
+  constructor(props) {
+    super();
+
+  }
+
+  render() {
+    const { t } = this.props;
+    return (
+      <Fragment>
+        <div>
+          <SecuritySetting />
+        </div>
+
+        {/* XSS configuration link */}
+        <div className="mb-5">
+          <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>
+          <div className="text-center">
+            <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
+              <i className="fa-fw icon-login"></i> {t('security_setting.xss_prevent_setting_link')}
+            </a>
+          </div>
+        </div>
+
+        {/* TODO GW-226 adapt BS4 */}
+        <div className="auth-mechanism-configurations m-t-10">
+          <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
+          <div className="passport-settings">
+            <ul className="nav nav-tabs" role="tablist">
+              <li className="active">
+                <a href="#passport-local" data-toggle="tab" role="tab"><i className="fa fa-users"></i> ID/Pass</a>
+              </li>
+              <li>
+                <a href="#passport-ldap" data-toggle="tab" role="tab"><i className="fa fa-sitemap"></i> LDAP</a>
+              </li>
+              <li>
+                <a href="#passport-saml" data-toggle="tab" role="tab"><i className="fa fa-key"></i> SAML</a>
+              </li>
+              <li>
+                <a href="#passport-oidc" data-toggle="tab" role="tab"><i className="fa fa-openid"></i> OIDC</a>
+              </li>
+              <li>
+                <a href="#passport-basic" data-toggle="tab" role="tab"><i className="fa fa-lock"></i> Basic</a>
+              </li>
+              <li>
+                <a href="#passport-google-oauth" data-toggle="tab" role="tab"><i className="fa fa-google"></i> Google</a>
+              </li>
+              <li>
+                <a href="#passport-github" data-toggle="tab" role="tab"><i className="fa fa-github"></i> GitHub</a>
+              </li>
+              <li>
+                <a href="#passport-twitter" data-toggle="tab" role="tab"><i className="fa fa-twitter"></i> Twitter</a>
+              </li>
+              <li className="tbd">
+                <a href="#passport-facebook" data-toggle="tab" role="tab"><i className="fa fa-facebook"></i> (TBD) Facebook</a>
+              </li>
+            </ul>
+            <div className="tab-content p-t-10">
+              <div id="passport-local" className="tab-pane active" role="tabpanel">
+                <LocalSecuritySetting />
+              </div>
+              <div id="passport-ldap" className="tab-pane" role="tabpanel">
+                <LdapSecuritySetting />
+              </div>
+              <div id="passport-saml" className="tab-pane" role="tabpanel">
+                <SamlSecuritySetting />
+              </div>
+              <div id="passport-oidc" className="tab-pane" role="tabpanel">
+                <OidcSecuritySetting />
+              </div>
+              <div id="passport-basic" className="tab-pane" role="tabpanel">
+                <BasicSecuritySetting />
+              </div>
+              <div id="passport-google-oauth" className="tab-pane" role="tabpanel">
+                <GoogleSecuritySetting />
+              </div>
+              <div id="passport-github" className="tab-pane" role="tabpanel">
+                <GitHubSecuritySetting />
+              </div>
+              <div id="passport-twitter" className="tab-pane" role="tabpanel">
+                <TwitterSecuritySetting />
+              </div>
+              <div id="passport-facebook" className="tab-pane" role="tabpanel">
+                <FacebookSecuritySetting />
+              </div>
+            </div>
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+SecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  csrf: PropTypes.string,
+};
+
+const SecurityManagementWrapper = (props) => {
+  return createSubscribedElement(SecurityManagement, props, [AppContainer]);
+};
+
+export default withTranslation()(SecurityManagementWrapper);

+ 229 - 0
src/client/js/components/Admin/Security/SecuritySetting.jsx

@@ -0,0 +1,229 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+
+class SecuritySetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      retrieveError: null,
+    };
+    this.putSecuritySetting = this.putSecuritySetting.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminGeneralSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ retrieveError: err.message });
+    }
+  }
+
+  async putSecuritySetting() {
+    const { t, adminGeneralSecurityContainer } = this.props;
+    try {
+      await adminGeneralSecurityContainer.updateGeneralSecuritySetting();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer } = this.props;
+    const { currentRestrictGuestMode, currentPageCompleteDeletionAuthority } = adminGeneralSecurityContainer.state;
+
+    return (
+      <React.Fragment>
+        <fieldset>
+          <h2 className="alert-anchor border-bottom">
+            {t('security_settings')}
+          </h2>
+          {this.state.retrieveError != null && (
+            <div className="alert alert-danger">
+              <p>{t('Error occurred')} : {this.state.retrieveError}</p>
+            </div>
+          )}
+          <div className="row">
+            <strong className="col-xs-3 text-right"> {t('security_setting.Guest Users Access')} </strong>
+            <div className="col-xs-9 text-left">
+              <div className="my-0 btn-group">
+                <div className="dropdown">
+                  <button
+                    className={`btn btn-default dropdown-toggle w-100 ${adminGeneralSecurityContainer.isWikiModeForced && 'disabled'}`}
+                    type="button"
+                    data-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="false"
+                  >
+                    <span className="pull-left">
+                      {currentRestrictGuestMode === 'Deny' && t('security_setting.guest_mode.deny')}
+                      {currentRestrictGuestMode === 'Readonly' && t('security_setting.guest_mode.readonly')}
+                    </span>
+                    <span className="bs-caret pull-right">
+                      <span className="caret" />
+                    </span>
+                  </button>
+                  {/* TODO adjust dropdown after BS4 */}
+                  <ul className="dropdown-menu" role="menu">
+                    <li
+                      key="Deny"
+                      role="presentation"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Deny') }}
+                    >
+                      <a role="menuitem">{t('security_setting.guest_mode.deny')}</a>
+                    </li>
+                    <li
+                      key="Readonly"
+                      role="presentation"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Readonly') }}
+                    >
+                      <a role="menuitem">{t('security_setting.guest_mode.readonly')}</a>
+                    </li>
+                  </ul>
+                </div>
+              </div>
+            </div>
+          </div>
+          {adminGeneralSecurityContainer.isWikiModeForced && (
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <p className="alert alert-warning mt-2 text-left">
+                  <i className="icon-exclamation icon-fw">
+                  </i><b>FIXED</b><br />
+                  <b
+                    dangerouslySetInnerHTML={{
+                    __html: t('security_setting.Fixed by env var',
+                    { forcewikimode: 'FORCE_WIKI_MODE', wikimode: adminGeneralSecurityContainer.state.wikiMode }),
+                    }}
+                  />
+                </p>
+              </div>
+            </div>
+          )}
+          <div className="row mb-5">
+            <strong className="col-xs-3 text-right" dangerouslySetInnerHTML={{ __html: t('security_setting.page_listing_1') }} />
+            <div className="col-xs-6 text-left">
+              <div className="checkbox checkbox-success">
+                <input
+                  id="isShowRestrictedByOwner"
+                  type="checkbox"
+                  checked={adminGeneralSecurityContainer.state.isShowRestrictedByOwner}
+                  onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByOwner() }}
+                />
+                <label htmlFor="isShowRestrictedByOwner">
+                  {t('security_setting.page_listing_1_desc')}
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <strong className="col-xs-3 text-right" dangerouslySetInnerHTML={{ __html: t('security_setting.page_listing_2') }} />
+            <div className="col-xs-6 text-left">
+              <div className="checkbox checkbox-success">
+                <input
+                  id="isShowRestrictedByGroup"
+                  type="checkbox"
+                  checked={adminGeneralSecurityContainer.state.isShowRestrictedByGroup}
+                  onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByGroup() }}
+                />
+                <label htmlFor="isShowRestrictedByGroup">
+                  {t('security_setting.page_listing_2_desc')}
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <div className="row mb-5">
+            <strong className="col-xs-3 text-right"> {t('security_setting.complete_deletion')} </strong>
+            <div className="col-xs-9 text-left">
+              <div className="my-0 btn-group">
+                <div className="dropdown">
+                  <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span className="pull-left">
+                      {currentPageCompleteDeletionAuthority === 'anyOne' && t('security_setting.anyone')}
+                      {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
+                      {(currentPageCompleteDeletionAuthority === 'adminAndAuthor' || currentPageCompleteDeletionAuthority == null)
+                        && t('security_setting.admin_and_author')}
+                    </span>
+                    <span className="bs-caret pull-right">
+                      <span className="caret" />
+                    </span>
+                  </button>
+                  {/* TODO adjust dropdown after BS4 */}
+                  <ul className="dropdown-menu" role="menu">
+                    <li
+                      key="anyone"
+                      role="presentation"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('anyOne') }}
+                    >
+                      <a role="menuitem">{t('security_setting.anyone')}</a>
+                    </li>
+                    <li
+                      key="admin_only"
+                      role="presentation"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminOnly') }}
+                    >
+                      <a role="menuitem">{t('security_setting.admin_only')}</a>
+                    </li>
+                    <li
+                      key="admin_and_author"
+                      role="presentation"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminAndAuthor') }}
+                    >
+                      <a role="menuitem">{t('security_setting.admin_and_author')}</a>
+                    </li>
+                  </ul>
+                </div>
+                <p className="help-block small">
+                  {t('security_setting.complete_deletion_explain')}
+                </p>
+              </div>
+            </div>
+          </div>
+          <div className="row my-3">
+            <div className="col-xs-offset-3 col-xs-5">
+              <button type="submit" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.putSecuritySetting}>
+                {t('Update')}
+              </button>
+            </div>
+          </div>
+        </fieldset>
+      </React.Fragment>
+    );
+  }
+
+}
+
+SecuritySetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  csrf: PropTypes.string,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+};
+
+const SecuritySettingWrapper = (props) => {
+  return createSubscribedElement(SecuritySetting, props, [AppContainer, AdminGeneralSecurityContainer]);
+};
+
+export default withTranslation()(SecuritySettingWrapper);

+ 226 - 0
src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx

@@ -0,0 +1,226 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminTwitterSecurityContainer from '../../../services/AdminTwitterSecurityContainer';
+
+class TwitterSecurityManagement extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      retrieveError: null,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminTwitterSecurityContainer } = this.props;
+
+    try {
+      await adminTwitterSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.setState({ isRetrieving: false });
+  }
+
+  async onClickSubmit() {
+    const { t, adminTwitterSecurityContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      await adminTwitterSecurityContainer.updateTwitterSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.OAuth.Twitter.updated_twitter'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminTwitterSecurityContainer } = this.props;
+    const { isTwitterEnabled } = adminGeneralSecurityContainer.state;
+
+    if (this.state.isRetrieving) {
+      return null;
+    }
+    return (
+
+      <React.Fragment>
+
+        <h2 className="alert-anchor border-bottom">
+          {t('security_setting.OAuth.Twitter.name')}
+        </h2>
+
+        {this.state.retrieveError != null && (
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {this.state.err}</p>
+          </div>
+        )}
+
+        <div className="row mb-5">
+          <div className="col-xs-3 my-3 text-right">
+            <strong>{t('security_setting.OAuth.Twitter.name')}</strong>
+          </div>
+          <div className="col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isTwitterEnabled"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isTwitterEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsTwitterOAuthEnabled() }}
+              />
+              <label htmlFor="isTwitterEnabled">
+                {t('security_setting.OAuth.Twitter.enable_twitter')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('twitter') && isTwitterEnabled)
+              && <div className="label label-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              value={adminTwitterSecurityContainer.state.callbackUrl}
+              readOnly
+            />
+            <p className="help-block small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+              <div className="alert alert-danger">
+                <i
+                  className="icon-exclamation"
+                  // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+                />
+              </div>
+            )}
+          </div>
+        </div>
+
+
+        {isTwitterEnabled && (
+          <React.Fragment>
+
+            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
+
+            <div className="row mb-5">
+              <label htmlFor="TwitterConsumerId" className="col-xs-3 text-right">{t('security_setting.clientID')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="TwitterConsumerId"
+                  defaultValue={adminTwitterSecurityContainer.state.twitterConsumerKey || ''}
+                  onChange={e => adminTwitterSecurityContainer.changeTwitterConsumerKey(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_TWITTER_CONSUMER_KEY' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <label htmlFor="TwitterConsumerSecret" className="col-xs-3 text-right">{t('security_setting.client_secret')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="TwitterConsumerSecret"
+                  defaultValue={adminTwitterSecurityContainer.state.twitterConsumerSecret || ''}
+                  onChange={e => adminTwitterSecurityContainer.changeTwitterConsumerSecret(e.target.value)}
+                />
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_TWITTER_CONSUMER_SECRET' }) }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByUserNameTwitter"
+                    type="checkbox"
+                    checked={adminTwitterSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminTwitterSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserNameTwitter"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row my-3">
+              <div className="col-xs-offset-3 col-xs-5">
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={adminTwitterSecurityContainer.state.retrieveError != null}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('Update')}
+                </button>
+              </div>
+            </div>
+
+          </React.Fragment>
+        )}
+
+        <hr />
+
+        <div style={{ minHeight: '300px' }}>
+          <h4>
+            <i className="icon-question" aria-hidden="true"></i>
+            <a href="#collapseHelpForTwitterOauth" data-toggle="collapse"> {t('security_setting.OAuth.how_to.twitter')}</a>
+          </h4>
+          <ol id="collapseHelpForTwitterOauth" className="collapse">
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Twitter.register_1', { link: '<a href="https://apps.twitter.com/" target=_blank>Twitter Application Management</a>' }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Twitter.register_2') }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Twitter.register_3') }} />
+            {/* eslint-disable-next-line max-len */}
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Twitter.register_4', { url: adminTwitterSecurityContainer.state.callbackUrl }) }} />
+            <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Twitter.register_5') }} />
+          </ol>
+        </div>
+
+      </React.Fragment>
+
+
+    );
+  }
+
+}
+
+
+TwitterSecurityManagement.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminTwitterSecurityContainer: PropTypes.instanceOf(AdminTwitterSecurityContainer).isRequired,
+};
+
+const TwitterSecurityManagementWrapper = (props) => {
+  return createSubscribedElement(TwitterSecurityManagement, props, [AppContainer, AdminGeneralSecurityContainer, AdminTwitterSecurityContainer]);
+};
+
+export default withTranslation()(TwitterSecurityManagementWrapper);

+ 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>
     );

+ 1 - 1
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() {

+ 79 - 0
src/client/js/components/Drawio.jsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+class Drawio extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.drawioContainer = React.createRef();
+
+    this.style = {
+      borderRadius: 3,
+      border: '1px solid #d7d7d7',
+      margin: '20px 0',
+    };
+
+    this.isPreview = this.props.isPreview;
+    this.drawioContent = this.props.drawioContent;
+
+    this.onEdit = this.onEdit.bind(this);
+  }
+
+  onEdit() {
+    if (window.crowi != null) {
+      window.crowi.launchDrawioModal('page',
+        this.props.rangeLineNumberOfMarkdown.beginLineNumber,
+        this.props.rangeLineNumberOfMarkdown.endLineNumber);
+    }
+  }
+
+  componentDidMount() {
+    const DrawioViewer = window.GraphViewer;
+    if (DrawioViewer != null) {
+      DrawioViewer.processElements();
+    }
+  }
+
+  renderContents() {
+    return this.drawioContent;
+  }
+
+  render() {
+    return (
+      <div className="editable-with-drawio position-relative">
+        { !this.isPreview
+          && (
+          <button type="button" className="drawio-iframe-trigger position-absolute btn" onClick={this.onEdit}>
+            <i className="icon-note mr-1"></i>{this.props.t('Edit')}
+          </button>
+          )
+        }
+        <div
+          className="drawio"
+          style={this.style}
+          ref={(c) => { this.drawioContainer = c }}
+          onScroll={(event) => {
+            event.preventDefault();
+          }}
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: this.renderContents() }}
+        >
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Drawio.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.object.isRequired,
+  drawioContent: PropTypes.any.isRequired,
+  isPreview: PropTypes.bool,
+  rangeLineNumberOfMarkdown: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(Drawio);

+ 1 - 1
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() {

+ 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);

+ 48 - 0
src/client/js/components/Page.jsx

@@ -11,7 +11,9 @@ import MarkdownTable from '../models/MarkdownTable';
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
+import DrawioModal from './PageEditor/DrawioModal';
 import mtu from './PageEditor/MarkdownTableUtil';
+import mdu from './PageEditor/MarkdownDrawioUtil';
 
 const logger = loggerFactory('growi:Page');
 
@@ -22,11 +24,13 @@ class Page extends React.Component {
 
     this.state = {
       currentTargetTableArea: null,
+      currentTargetDrawioArea: null,
     };
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
+    this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
   }
 
   componentWillMount() {
@@ -45,6 +49,19 @@ class Page extends React.Component {
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
+  /**
+   * launch DrawioModal with data specified by arguments
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  launchDrawioModal(beginLineNumber, endLineNumber) {
+    const markdown = this.props.pageContainer.state.markdown;
+    const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber, endLineNumber);
+    const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
+    this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
+    this.drawioModal.show(drawioData);
+  }
+
   async saveHandlerForHandsontableModal(markdownTable) {
     const { pageContainer, editorContainer } = this.props;
     const optionsToSave = editorContainer.getCurrentOptionsToSave();
@@ -75,6 +92,36 @@ class Page extends React.Component {
     }
   }
 
+  async saveHandlerForDrawioModal(drawioData) {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    const newMarkdown = mdu.replaceDrawioInMarkdown(
+      drawioData,
+      this.props.pageContainer.state.markdown,
+      this.state.currentTargetDrawioArea.beginLineNumber,
+      this.state.currentTargetDrawioArea.endLineNumber,
+    );
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+    finally {
+      this.setState({ currentTargetDrawioArea: null });
+    }
+  }
+
   render() {
     const isMobile = this.props.appContainer.isMobile;
     const { markdown } = this.props.pageContainer.state;
@@ -83,6 +130,7 @@ class Page extends React.Component {
       <div className={isMobile ? 'page-mobile' : ''}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
         <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
+        <DrawioModal ref={(c) => { this.drawioModal = c }} onSave={this.saveHandlerForDrawioModal} />
       </div>
     );
   }

+ 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() {

+ 56 - 151
src/client/js/components/PageComment/Comment.jsx

@@ -3,10 +3,8 @@ import PropTypes from 'prop-types';
 
 import { format, formatDistanceStrict } from 'date-fns';
 
-import Button from 'react-bootstrap/es/Button';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
-import Collapse from 'react-bootstrap/es/Collapse';
 
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
@@ -16,6 +14,7 @@ import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
+import CommentControl from './CommentControl';
 
 /**
  *
@@ -25,15 +24,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');
@@ -42,23 +40,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() {
@@ -66,7 +80,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() {
@@ -99,18 +113,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() {
@@ -134,120 +142,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) });
-
-  }
-
-  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
-        bsStyle="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 in={this.state.isOlderRepliesShown}>
-              <div>{hiddenElements}</div>
-            </Collapse>
-            <div className="text-center">{toggleButton}</div>
-          </div>
-        ) }
+  async renderHtml() {
 
-        {shownElements}
-      </React.Fragment>
-    );
-  }
+    const { growiRenderer, appContainer } = this.props;
+    const { interceptorManager } = appContainer;
+    const context = this.currentRenderingContext;
 
-  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() {
@@ -259,8 +170,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 commentDate = formatDistanceStrict(createdAt, new Date());
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
@@ -284,7 +193,7 @@ class Comment extends React.Component {
     return (
       <React.Fragment>
 
-        {showReEditor ? (
+        {this.state.isReEdit ? (
           <CommentEditor
             growiRenderer={this.growiRenderer}
             currentCommentId={commentId}
@@ -305,19 +214,19 @@ class Comment extends React.Component {
                 <OverlayTrigger overlay={commentDateTooltip} placement="bottom">
                   <span><a href={`#${commentId}`}>{commentDate}</a></span>
                 </OverlayTrigger>
-                { isEdited && (
-                  <OverlayTrigger overlay={editedDateTooltip} placement="bottom">
-                    <span>&nbsp;(edited)</span>
-                  </OverlayTrigger>
-                ) }
+                {isEdited && (
+                <OverlayTrigger overlay={editedDateTooltip} placement="bottom">
+                  <span>&nbsp;(edited)</span>
+                </OverlayTrigger>
+                  )}
                 <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>
     );
@@ -339,10 +248,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

@@ -212,8 +212,6 @@ class CommentEditor extends React.Component {
 
   render() {
     const { appContainer, commentContainer } = this.props;
-    const username = appContainer.me;
-    const user = appContainer.findUser(username);
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const emojiStrategy = appContainer.getEmojiStrategy();
 
@@ -236,7 +234,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/);

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

@@ -0,0 +1,124 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Button from 'react-bootstrap/es/Button';
+import Collapse from 'react-bootstrap/es/Collapse';
+
+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

@@ -14,6 +14,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';
 
 
 /**
@@ -127,7 +128,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) {
@@ -138,11 +139,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

+ 19 - 0
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -17,8 +17,10 @@ import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
+import mdu from './MarkdownDrawioUtil';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
+import DrawioModal from './DrawioModal';
 
 const loadScript = require('simple-load-script');
 const loadCssSync = require('load-css-file');
@@ -94,6 +96,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
+    this.showDrawioHandler = this.showDrawioHandler.bind(this);
   }
 
   init() {
@@ -647,6 +650,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
+  showDrawioHandler() {
+    this.drawioIFrame.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
+  }
+
   getNavbarItems() {
     return [
       /* eslint-disable max-len */
@@ -746,6 +753,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
       >
         <EditorIcon icon="Table" />
       </Button>,
+      <Button
+        key="nav-item-drawio"
+        bsSize="small"
+        title="draw.io"
+        onClick={this.showDrawioHandler}
+      >
+        <EditorIcon icon="Drawio" />
+      </Button>,
       /* eslint-able max-len */
     ];
   }
@@ -824,6 +839,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
           ref={(c) => { this.handsontableModal = c }}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
         />
+        <DrawioModal
+          ref={(c) => { this.drawioIFrame = c }}
+          onSave={(drawioData) => { return mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData) }}
+        />
 
       </React.Fragment>
     );

+ 156 - 0
src/client/js/components/PageEditor/DrawioModal.jsx

@@ -0,0 +1,156 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import i18next from 'i18next';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+export default class DrawioModal extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      show: false,
+      drawioMxFile: '',
+    };
+
+    this.drawioIFrame = React.createRef();
+
+    this.headerColor = '#334455';
+    this.fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+
+    this.init = this.init.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.receiveFromDrawio = this.receiveFromDrawio.bind(this);
+    this.drawioURL = this.drawioURL.bind(this);
+  }
+
+  init(drawioMxFile) {
+    const initDrawioMxFile = drawioMxFile;
+    this.setState(
+      {
+        drawioMxFile: initDrawioMxFile,
+      },
+    );
+  }
+
+  show(drawioMxFile) {
+    this.init(drawioMxFile);
+
+    window.addEventListener('message', this.receiveFromDrawio);
+    this.setState({ show: true });
+  }
+
+  hide() {
+    this.setState({
+      show: false,
+    });
+  }
+
+  cancel() {
+    this.hide();
+  }
+
+  receiveFromDrawio(event) {
+    if (event.data === 'ready') {
+      event.source.postMessage(this.state.drawioMxFile, '*');
+      return;
+    }
+
+    if (event.data === '{"event":"configure"}') {
+      if (event.source == null) {
+        return;
+      }
+
+      // refs:
+      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
+      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
+      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
+      event.source.postMessage(JSON.stringify({
+        action: 'configure',
+        config: {
+          css: `
+          .geMenubarContainer { background-color: ${this.headerColor} !important; }
+          .geMenubar { background-color: ${this.headerColor} !important; }
+          .geEditor { font-family: ${this.fontFamily} !important; }
+          html td.mxPopupMenuItem {
+            font-family: ${this.fontFamily} !important;
+            font-size: 8pt !important;
+          }
+          `,
+          customFonts: ['Lato', 'Charter'],
+        },
+      }), '*');
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
+      if (event.data.length > 0) {
+        const parser = new DOMParser();
+        const dom = parser.parseFromString(event.data, 'text/xml');
+        const value = dom.getElementsByTagName('diagram')[0].innerHTML;
+        this.props.onSave(value);
+      }
+
+      window.removeEventListener('message', this.receiveFromDrawio);
+      this.hide();
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.length === 0) {
+      window.removeEventListener('message', this.receiveFromDrawio);
+      this.hide();
+
+      return;
+    }
+
+    // NOTHING DONE. (Receive unknown iframe message.)
+  }
+
+  drawioURL() {
+    const url = new URL('https://www.draw.io/');
+
+    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
+    url.searchParams.append('spin', 1);
+    url.searchParams.append('embed', 1);
+    url.searchParams.append('lang', i18next.language);
+    url.searchParams.append('ui', 'atlas');
+    url.searchParams.append('configure', 1);
+
+    return url;
+  }
+
+  render() {
+    return (
+      // <Modal show={this.state.show} onHide={this.cancel} bsSize="large" dialogClassName={dialogClassName} keyboard={false}>
+      <Modal show={this.state.show} onHide={this.cancel} dialogClassName="drawio-modal" bsSize="large" keyboard={false}>
+        <Modal.Body className="p-0">
+          {/* Loading spinner */}
+          <div className="w-100 h-100 position-absolute d-flex">
+            <div className="mx-auto my-auto">
+              <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
+            </div>
+          </div>
+          {/* iframe */}
+          <div className="w-100 h-100 position-absolute d-flex">
+            { this.state.show && (
+              <iframe
+                ref={(c) => { this.drawioIFrame = c }}
+                src={this.drawioURL()}
+                className="border-0 flex-grow-1"
+              >
+              </iframe>
+            ) }
+          </div>
+        </Modal.Body>
+      </Modal>
+    );
+  }
+
+}
+
+DrawioModal.propTypes = {
+  onSave: PropTypes.func,
+};

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 3 - 0
src/client/js/components/PageEditor/EditorIcon.jsx


+ 164 - 0
src/client/js/components/PageEditor/MarkdownDrawioUtil.js

@@ -0,0 +1,164 @@
+/**
+ * Utility for markdown drawio
+ */
+class MarkdownDrawioUtil {
+
+  constructor() {
+    this.lineBeginPartOfDrawioRE = /^:::(\s.*)drawio$/;
+    this.lineEndPartOfDrawioRE = /^:::$/;
+  }
+
+  /**
+   * return the postion of the BOD(beginning of drawio)
+   * (If the cursor is not in a drawio block, return its position)
+   */
+  getBod(editor) {
+    const curPos = editor.getCursor();
+    const firstLine = editor.getDoc().firstLine();
+
+    if (this.lineBeginPartOfDrawioRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: 0 };
+    }
+
+    let line = curPos.line - 1;
+    let isFound = false;
+    for (; line >= firstLine; line--) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineBeginPartOfDrawioRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineEndPartOfDrawioRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return { line: curPos.line, ch: curPos.ch };
+    }
+
+    const bodLine = Math.max(firstLine, line);
+    return { line: bodLine, ch: 0 };
+  }
+
+  /**
+   * return the postion of the EOD(end of drawio)
+   * (If the cursor is not in a drawio block, return its position)
+   */
+  getEod(editor) {
+    const curPos = editor.getCursor();
+    const lastLine = editor.getDoc().lastLine();
+
+    if (this.lineEndPartOfDrawioRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: editor.getDoc().getLine(curPos.line).length };
+    }
+
+    let line = curPos.line + 1;
+    let isFound = false;
+    for (; line <= lastLine; line++) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineEndPartOfDrawioRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineBeginPartOfDrawioRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return { line: curPos.line, ch: curPos.ch };
+    }
+
+    const eodLine = Math.min(line, lastLine);
+    const lineLength = editor.getDoc().getLine(eodLine).length;
+    return { line: eodLine, ch: lineLength };
+  }
+
+  /**
+   * return boolean value whether the cursor position is in a drawio
+   */
+  isInDrawioBlock(editor) {
+    const bod = this.getBod(editor);
+    const eod = this.getEod(editor);
+
+    return (JSON.stringify(bod) !== JSON.stringify(eod));
+  }
+
+  /**
+   * return drawioData instance where the cursor is
+   * (If the cursor is not in a drawio block, return current line)
+   */
+  getMarkdownDrawioMxfile(editor) {
+    const curPos = editor.getCursor();
+
+    if (this.isInDrawioBlock(editor)) {
+      const bod = this.getBod(editor);
+      const eod = this.getEod(editor);
+
+      // skip block begin sesion("::: drawio")
+      bod.line++;
+      // skip block end sesion(":::")
+      eod.line--;
+      eod.ch = editor.getDoc().getLine(eod.line).length;
+
+      return editor.getDoc().getRange(bod, eod);
+    }
+
+    return editor.getDoc().getLine(curPos.line);
+  }
+
+  replaceFocusedDrawioWithEditor(editor, drawioData) {
+    const curPos = editor.getCursor();
+    const drawioBlock = ['::: drawio', drawioData.toString(), ':::'].join('\n');
+    let beginPos;
+    let endPos;
+
+    if (this.isInDrawioBlock(editor)) {
+      beginPos = this.getBod(editor);
+      endPos = this.getEod(editor);
+    }
+    else {
+      beginPos = { line: curPos.line, ch: curPos.ch };
+      endPos = { line: curPos.line, ch: curPos.ch };
+    }
+
+    editor.getDoc().replaceRange(drawioBlock, beginPos, endPos);
+  }
+
+  /**
+   * return markdown where the drawioData specified by line number params is replaced to the drawioData specified by drawioData param
+   * @param {string} drawioData
+   * @param {string} markdown
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  replaceDrawioInMarkdown(drawioData, markdown, beginLineNumber, endLineNumber) {
+    const splitMarkdown = markdown.split(/\r\n|\r|\n/);
+    const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber);
+    const markdownAfterDrawio = splitMarkdown.slice(endLineNumber);
+
+    let newMarkdown = '';
+    if (markdownBeforeDrawio.length > 0) {
+      newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
+      newMarkdown += '::: drawio\n';
+    }
+    newMarkdown += drawioData;
+    if (markdownAfterDrawio.length > 0) {
+      newMarkdown += '\n:::';
+      newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
+    }
+
+    return newMarkdown;
+  }
+
+}
+
+// singleton pattern
+const instance = new MarkdownDrawioUtil();
+Object.freeze(instance);
+export default instance;

+ 14 - 8
src/client/js/components/PageEditorByHackmd.jsx

@@ -61,6 +61,16 @@ class PageEditorByHackmd extends React.Component {
     return envVars.HACKMD_URI;
   }
 
+  get isResume() {
+    const { pageContainer } = this.props;
+    const {
+      pageIdOnHackmd, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
+    } = pageContainer.state;
+
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    return (isPageExistsOnHackmd && hasDraftOnHackmd) || isHackmdDraftUpdatingInRealtime;
+  }
+
   /**
    * Start integration with HackMD
    */
@@ -217,11 +227,9 @@ class PageEditorByHackmd extends React.Component {
     const hackmdUri = this.getHackmdUri();
     const { pageContainer } = this.props;
     const {
-      pageIdOnHackmd, revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd,
+      revisionId, revisionIdHackmdSynced, remoteRevisionId,
     } = pageContainer.state;
 
-    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
-    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
     let content;
 
@@ -238,7 +246,7 @@ class PageEditorByHackmd extends React.Component {
     /*
      * Resume to edit or discard changes
      */
-    else if (isResume) {
+    else if (this.isResume) {
       const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
       content = (
@@ -331,11 +339,9 @@ class PageEditorByHackmd extends React.Component {
     const hackmdUri = this.getHackmdUri();
     const { pageContainer } = this.props;
     const {
-      markdown, pageIdOnHackmd, hasDraftOnHackmd,
+      markdown, pageIdOnHackmd,
     } = pageContainer.state;
 
-    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
-    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
     let content;
 
@@ -345,7 +351,7 @@ class PageEditorByHackmd extends React.Component {
           ref={(c) => { this.hackmdEditor = c }}
           hackmdUri={hackmdUri}
           pageIdOnHackmd={pageIdOnHackmd}
-          initializationMarkdown={isResume ? null : markdown}
+          initializationMarkdown={this.isResume ? null : markdown}
           onChange={this.hackmdEditorChangeHandler}
           onSaveWithShortcut={(document) => {
             this.onSaveWithShortcut(document);

+ 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

@@ -596,6 +596,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) {
@@ -619,7 +624,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;

+ 74 - 0
src/client/js/services/AdminBasicSecurityContainer.js

@@ -0,0 +1,74 @@
+import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
+
+/**
+ * Service container for admin security page (BasicSecuritySetting.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminBasicSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      isSameUsernameTreatedAsIdenticalUser: false,
+    };
+
+  }
+
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { basicAuth } = response.data.securityParams;
+      this.setState({
+        isSameUsernameTreatedAsIdenticalUser: basicAuth.isSameUsernameTreatedAsIdenticalUser,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminBasicSecurityContainer';
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Update basicSetting
+   */
+  async updateBasicSetting() {
+    let requestParams = { isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/basic', requestParams);
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+    });
+    return response;
+  }
+
+}

+ 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) {

+ 209 - 0
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -0,0 +1,209 @@
+import { Container } from 'unstated';
+
+import { toastError } from '../util/apiNotification';
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+/**
+ * Service container for admin security page (SecuritySetting.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminGeneralSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      wikiMode: '',
+      currentRestrictGuestMode: 'Deny',
+      currentPageCompleteDeletionAuthority: 'adminOnly',
+      isShowRestrictedByOwner: false,
+      isShowRestrictedByGroup: false,
+      appSiteUrl: appContainer.config.crowi.url || '',
+      isLocalEnabled: false,
+      isLdapEnabled: false,
+      isSamlEnabled: false,
+      isOidcEnabled: false,
+      isBasicEnabled: false,
+      isGoogleEnabled: false,
+      isGitHubEnabled: false,
+      isTwitterEnabled: false,
+      setupStrategies: [],
+    };
+
+  }
+
+  async retrieveSecurityData() {
+    await this.retrieveSetupStratedies();
+    const response = await this.appContainer.apiv3.get('/security-setting/');
+    const { generalSetting, generalAuth } = response.data.securityParams;
+    this.setState({
+      currentPageCompleteDeletionAuthority: generalSetting.pageCompleteDeletionAuthority,
+      isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
+      isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
+      wikiMode: generalSetting.wikiMode,
+      isLocalEnabled: generalAuth.isLocalEnabled,
+      isLdapEnabled: generalAuth.isLdapEnabled,
+      isSamlEnabled: generalAuth.isSamlEnabled,
+      isOidcEnabled: generalAuth.isOidcEnabled,
+      isBasicEnabled: generalAuth.isBasicEnabled,
+      isGoogleEnabled: generalAuth.isGoogleEnabled,
+      isGitHubEnabled: generalAuth.isGitHubEnabled,
+      isTwitterEnabled: generalAuth.isTwitterEnabled,
+    });
+  }
+
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminGeneralSecurityContainer';
+  }
+
+  /**
+   * get isWikiModeForced
+   * @return {bool} isWikiModeForced
+   */
+  get isWikiModeForced() {
+    return this.state.wikiMode === 'public' || this.state.wikiMode === 'private';
+  }
+
+  /**
+   * Change restrictGuestMode
+   */
+  changeRestrictGuestMode(restrictGuestModeLabel) {
+    this.setState({ currentRestrictGuestMode: restrictGuestModeLabel });
+  }
+
+  /**
+   * Change pageCompleteDeletionAuthority
+   */
+  changePageCompleteDeletionAuthority(pageCompleteDeletionAuthorityLabel) {
+    this.setState({ currentPageCompleteDeletionAuthority: pageCompleteDeletionAuthorityLabel });
+  }
+
+  /**
+   * Switch showRestrictedByOwner
+   */
+  switchIsShowRestrictedByOwner() {
+    this.setState({ isShowRestrictedByOwner:  !this.state.isShowRestrictedByOwner });
+  }
+
+  /**
+   * Switch showRestrictedByGroup
+   */
+  switchIsShowRestrictedByGroup() {
+    this.setState({ isShowRestrictedByGroup:  !this.state.isShowRestrictedByGroup });
+  }
+
+  /**
+   * Update restrictGuestMode
+   * @memberOf AdminGeneralSecuritySContainer
+   * @return {string} Appearance
+   */
+  async updateGeneralSecuritySetting() {
+
+    let requestParams = {
+      restrictGuestMode: this.state.currentRestrictGuestMode,
+      pageCompleteDeletionAuthority: this.state.currentPageCompleteDeletionAuthority,
+      hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
+      hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
+    };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/general-setting', requestParams);
+    const { securitySettingParams } = response.data;
+    return securitySettingParams;
+  }
+
+  /**
+   * Switch authentication
+   */
+  async switchAuthentication(stateVariableName, authId) {
+    const isEnabled = !this.state[stateVariableName];
+    try {
+      await this.appContainer.apiv3.put('/security-setting/authentication/enabled', {
+        isEnabled,
+        authId,
+      });
+      await this.retrieveSetupStratedies();
+      this.setState({ [stateVariableName]: isEnabled });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  /**
+   * Retrieve SetupStratedies
+   */
+  async retrieveSetupStratedies() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/authentication');
+      const { setupStrategies } = response.data;
+      this.setState({ setupStrategies });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  /**
+   * Switch local enabled
+   */
+  async switchIsLocalEnabled() {
+    this.switchAuthentication('isLocalEnabled', 'local');
+  }
+
+  /**
+   * Switch LDAP enabled
+   */
+  async switchIsLdapEnabled() {
+    this.switchAuthentication('isLdapEnabled', 'ldap');
+  }
+
+  /**
+   * Switch SAML enabled
+   */
+  async switchIsSamlEnabled() {
+    this.switchAuthentication('isSamlEnabled', 'saml');
+  }
+
+  /**
+   * Switch Oidc enabled
+   */
+  async switchIsOidcEnabled() {
+    this.switchAuthentication('isOidcEnabled', 'oidc');
+  }
+
+  /**
+   * Switch Basic enabled
+   */
+  async switchIsBasicEnabled() {
+    this.switchAuthentication('isBasicEnabled', 'basic');
+  }
+
+  /**
+   * Switch GoogleOAuth enabled
+   */
+  async switchIsGoogleOAuthEnabled() {
+    this.switchAuthentication('isGoogleEnabled', 'google');
+  }
+
+  /**
+   * Switch GitHubOAuth enabled
+   */
+  async switchIsGitHubOAuthEnabled() {
+    this.switchAuthentication('isGitHubEnabled', 'github');
+  }
+
+  /**
+   * Switch TwitterOAuth enabled
+   */
+  async switchIsTwitterOAuthEnabled() {
+    this.switchAuthentication('isTwitterEnabled', 'twitter');
+  }
+
+}

+ 99 - 0
src/client/js/services/AdminGitHubSecurityContainer.js

@@ -0,0 +1,99 @@
+import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+import { pathUtils } from 'growi-commons';
+import urljoin from 'url-join';
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+const logger = loggerFactory('growi:security:AdminGitHubSecurityContainer');
+
+/**
+ * Service container for admin security page (GitHubSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminGitHubSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      appSiteUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/github/callback'),
+      githubClientId: '',
+      githubClientSecret: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+    };
+
+  }
+
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { githubOAuth } = response.data.securityParams;
+      this.setState({
+        githubClientId: githubOAuth.githubClientId,
+        githubClientSecret: githubOAuth.githubClientSecret,
+        isSameUsernameTreatedAsIdenticalUser: githubOAuth.isSameUsernameTreatedAsIdenticalUser,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminGitHubSecurityContainer';
+  }
+
+  /**
+   * Change githubClientId
+   */
+  changeGitHubClientId(value) {
+    this.setState({ githubClientId: value });
+  }
+
+  /**
+   * Change githubClientSecret
+   */
+  changeGitHubClientSecret(value) {
+    this.setState({ githubClientSecret: value });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Update githubSetting
+   */
+  async updateGitHubSetting() {
+    const { githubClientId, githubClientSecret, isSameUsernameTreatedAsIdenticalUser } = this.state;
+
+    let requestParams = { githubClientId, githubClientSecret, isSameUsernameTreatedAsIdenticalUser };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/github-oauth', requestParams);
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      githubClientId: securitySettingParams.githubClientId,
+      githubClientSecret: securitySettingParams.githubClientSecret,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+    });
+    return response;
+  }
+
+}

+ 102 - 0
src/client/js/services/AdminGoogleSecurityContainer.js

@@ -0,0 +1,102 @@
+import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+import { pathUtils } from 'growi-commons';
+import urljoin from 'url-join';
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+const logger = loggerFactory('growi:security:AdminGoogleSecurityContainer');
+
+/**
+ * Service container for admin security page (GoogleSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminGoogleSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/google/callback'),
+      googleClientId: '',
+      googleClientSecret: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+    };
+
+
+  }
+
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { googleOAuth } = response.data.securityParams;
+      this.setState({
+        googleClientId: googleOAuth.googleClientId,
+        googleClientSecret: googleOAuth.googleClientSecret,
+        isSameUsernameTreatedAsIdenticalUser: googleOAuth.isSameUsernameTreatedAsIdenticalUser,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminGoogleSecurityContainer';
+  }
+
+  /**
+   * Change googleClientId
+   */
+  changeGoogleClientId(value) {
+    this.setState({ googleClientId: value });
+  }
+
+  /**
+   * Change googleClientSecret
+   */
+  changeGoogleClientSecret(value) {
+    this.setState({ googleClientSecret: value });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Update googleSetting
+   */
+  async updateGoogleSetting() {
+    const { googleClientId, googleClientSecret, isSameUsernameTreatedAsIdenticalUser } = this.state;
+
+    let requestParams = {
+      googleClientId, googleClientSecret, isSameUsernameTreatedAsIdenticalUser,
+    };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/google-oauth', requestParams);
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      googleClientId: securitySettingParams.googleClientId,
+      googleClientSecret: securitySettingParams.googleClientSecret,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+    });
+    return response;
+  }
+
+}

+ 203 - 0
src/client/js/services/AdminLdapSecurityContainer.js

@@ -0,0 +1,203 @@
+import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
+
+/**
+ * Service container for admin security page (SecurityLdapSetting.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminLdapSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      serverUrl: '',
+      isUserBind: false,
+      ldapBindDN: '',
+      ldapBindDNPassword: '',
+      ldapSearchFilter: '',
+      ldapAttrMapUsername: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+      ldapAttrMapMail: '',
+      ldapAttrMapName: '',
+      ldapGroupSearchBase: '',
+      ldapGroupSearchFilter: '',
+      ldapGroupDnProperty: '',
+    };
+
+  }
+
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { ldapAuth } = response.data.securityParams;
+      this.setState({
+        serverUrl: ldapAuth.serverUrl,
+        isUserBind: ldapAuth.isUserBind,
+        ldapBindDN: ldapAuth.ldapBindDN,
+        ldapBindDNPassword: ldapAuth.ldapBindDNPassword,
+        ldapSearchFilter: ldapAuth.ldapSearchFilter,
+        ldapAttrMapUsername: ldapAuth.ldapAttrMapUsername,
+        isSameUsernameTreatedAsIdenticalUser: ldapAuth.isSameUsernameTreatedAsIdenticalUser,
+        ldapAttrMapMail: ldapAuth.ldapAttrMapMail,
+        ldapAttrMapName: ldapAuth.ldapAttrMapName,
+        ldapGroupSearchBase: ldapAuth.ldapGroupSearchBase,
+        ldapGroupSearchFilter: ldapAuth.ldapGroupSearchFilter,
+        ldapGroupDnProperty: ldapAuth.ldapGroupDnProperty,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+  }
+
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminLdapSecurityContainer';
+  }
+
+  /**
+   * Change serverUrl
+   */
+  changeServerUrl(serverUrl) {
+    this.setState({ serverUrl });
+  }
+
+  /**
+   * Change ldapBindMode
+   */
+  changeLdapBindMode() {
+    this.setState({ isUserBind: !this.state.isUserBind });
+  }
+
+  /**
+   * Change bindDN
+   */
+  changeBindDN(ldapBindDN) {
+    this.setState({ ldapBindDN });
+  }
+
+  /**
+   * Change bindDNPassword
+   */
+  changeBindDNPassword(ldapBindDNPassword) {
+    this.setState({ ldapBindDNPassword });
+  }
+
+  /**
+   * Change ldapSearchFilter
+   */
+  changeSearchFilter(ldapSearchFilter) {
+    this.setState({ ldapSearchFilter });
+  }
+
+  /**
+   * Change ldapAttrMapUsername
+   */
+  changeAttrMapUsername(ldapAttrMapUsername) {
+    this.setState({ ldapAttrMapUsername });
+  }
+
+  /**
+   * Switch is same username treated as identical user
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Change ldapAttrMapMail
+   */
+  changeAttrMapMail(ldapAttrMapMail) {
+    this.setState({ ldapAttrMapMail });
+  }
+
+  /**
+   * Change ldapAttrMapName
+   */
+  changeAttrMapName(ldapAttrMapName) {
+    this.setState({ ldapAttrMapName });
+  }
+
+  /**
+   * Change ldapGroupSearchBase
+   */
+  changeGroupSearchBase(ldapGroupSearchBase) {
+    this.setState({ ldapGroupSearchBase });
+  }
+
+  /**
+   * Change ldapGroupSearchFilter
+   */
+  changeGroupSearchFilter(ldapGroupSearchFilter) {
+    this.setState({ ldapGroupSearchFilter });
+  }
+
+  /**
+   * Change ldapGroupDnProperty
+   */
+  changeGroupDnProperty(ldapGroupDnProperty) {
+    this.setState({ ldapGroupDnProperty });
+  }
+
+  /**
+   * Update ldap option
+   */
+  async updateLdapSetting() {
+    const {
+      serverUrl, isUserBind, ldapBindDN, ldapBindDNPassword, ldapSearchFilter, ldapAttrMapUsername, isSameUsernameTreatedAsIdenticalUser,
+      ldapAttrMapMail, ldapAttrMapName, ldapGroupSearchBase, ldapGroupSearchFilter, ldapGroupDnProperty,
+    } = this.state;
+
+    let requestParams = {
+      serverUrl,
+      isUserBind,
+      ldapBindDN,
+      ldapBindDNPassword,
+      ldapSearchFilter,
+      ldapAttrMapUsername,
+      isSameUsernameTreatedAsIdenticalUser,
+      ldapAttrMapMail,
+      ldapAttrMapName,
+      ldapGroupSearchBase,
+      ldapGroupSearchFilter,
+      ldapGroupDnProperty,
+    };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/ldap', requestParams);
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      serverUrl: securitySettingParams.serverUrl,
+      isUserBind: securitySettingParams.isUserBind,
+      ldapBindDN: securitySettingParams.ldapBindDN,
+      ldapBindDNPassword: securitySettingParams.ldapBindDNPassword,
+      ldapSearchFilter: securitySettingParams.ldapSearchFilter,
+      ldapAttrMapUsername: securitySettingParams.ldapAttrMapUsername,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      ldapAttrMapMail: securitySettingParams.ldapAttrMapMail,
+      ldapAttrMapName: securitySettingParams.ldapAttrMapName,
+      ldapGroupSearchBase: securitySettingParams.ldapGroupSearchBase,
+      ldapGroupSearchFilter: securitySettingParams.ldapGroupSearchFilter,
+      ldapGroupDnProperty: securitySettingParams.ldapGroupDnProperty,
+    });
+    return response;
+  }
+
+}

+ 87 - 0
src/client/js/services/AdminLocalSecurityContainer.js

@@ -0,0 +1,87 @@
+import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:AdminLocalSecurityContainer');
+/**
+ * Service container for admin security page (LocalSecuritySetting.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminLocalSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      registrationMode: 'Open',
+      registrationWhiteList: [],
+      useOnlyEnvVars: false,
+    };
+
+  }
+
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { localSetting } = response.data.securityParams;
+      this.setState({
+        useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
+        registrationMode: localSetting.registrationMode,
+        registrationWhiteList: localSetting.registrationWhiteList,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminLocalSecurityContainer';
+  }
+
+
+  /**
+   * Change registration mode
+   */
+  changeRegistrationMode(value) {
+    this.setState({ registrationMode: value });
+  }
+
+  /**
+   * Change registration white list
+   */
+  changeRegistrationWhiteList(value) {
+    this.setState({ registrationWhiteList: value.split('\n') });
+  }
+
+  /**
+   * update local security setting
+   */
+  async updateLocalSecuritySetting() {
+    const { registrationWhiteList } = this.state;
+    const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
+      registrationMode: this.state.registrationMode,
+      registrationWhiteList,
+    });
+
+    const { localSettingParams } = response.data;
+
+    this.setState({
+      registrationMode: localSettingParams.registrationMode,
+      registrationWhiteList: localSettingParams.registrationWhiteList,
+    });
+
+    return localSettingParams;
+  }
+
+
+}

+ 183 - 0
src/client/js/services/AdminOidcSecurityContainer.js

@@ -0,0 +1,183 @@
+import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+import { pathUtils } from 'growi-commons';
+import urljoin from 'url-join';
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+const logger = loggerFactory('growi:services:AdminLdapSecurityContainer');
+
+/**
+ * Service container for admin security page (OidcSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminOidcSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/oidc/callback'),
+      oidcProviderName: '',
+      oidcIssuerHost: '',
+      oidcClientId: '',
+      oidcClientSecret: '',
+      oidcAttrMapId: '',
+      oidcAttrMapUserName: '',
+      oidcAttrMapName: '',
+      oidcAttrMapEmail: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+      isSameEmailTreatedAsIdenticalUser: false,
+    };
+
+  }
+
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { oidcAuth } = response.data.securityParams;
+      this.setState({
+        oidcProviderName: oidcAuth.oidcProviderName,
+        oidcIssuerHost: oidcAuth.oidcIssuerHost,
+        oidcClientId: oidcAuth.oidcClientId,
+        oidcClientSecret: oidcAuth.oidcClientSecret,
+        oidcAttrMapId: oidcAuth.oidcAttrMapId,
+        oidcAttrMapUserName: oidcAuth.oidcAttrMapUserName,
+        oidcAttrMapName: oidcAuth.oidcAttrMapName,
+        oidcAttrMapEmail: oidcAuth.oidcAttrMapEmail,
+        isSameUsernameTreatedAsIdenticalUser: oidcAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser: oidcAuth.isSameEmailTreatedAsIdenticalUser,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminOidcSecurityContainer';
+  }
+
+  /**
+   * Change oidcProviderName
+   */
+  changeOidcProviderName(inputValue) {
+    this.setState({ oidcProviderName: inputValue });
+  }
+
+  /**
+   * Change oidcIssuerHost
+   */
+  changeOidcIssuerHost(inputValue) {
+    this.setState({ oidcIssuerHost: inputValue });
+  }
+
+  /**
+   * Change oidcClientId
+   */
+  changeOidcClientId(inputValue) {
+    this.setState({ oidcClientId: inputValue });
+  }
+
+  /**
+   * Change oidcClientSecret
+   */
+  changeOidcClientSecret(inputValue) {
+    this.setState({ oidcClientSecret: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapId
+   */
+  changeOidcAttrMapId(inputValue) {
+    this.setState({ oidcAttrMapId: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapUserName
+   */
+  changeOidcAttrMapUserName(inputValue) {
+    this.setState({ oidcAttrMapUserName: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapName
+   */
+  changeOidcAttrMapName(inputValue) {
+    this.setState({ oidcAttrMapName: inputValue });
+  }
+
+  /**
+   * Change oidcAttrMapEmail
+   */
+  changeOidcAttrMapEmail(inputValue) {
+    this.setState({ oidcAttrMapEmail: inputValue });
+  }
+
+  /**
+   * Switch sameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Switch sameEmailTreatedAsIdenticalUser
+   */
+  switchIsSameEmailTreatedAsIdenticalUser() {
+    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Update OpenID Connect
+   */
+  async updateOidcSetting() {
+    const {
+      oidcProviderName, oidcIssuerHost, oidcClientId, oidcClientSecret, oidcAttrMapId, oidcAttrMapUserName,
+      oidcAttrMapName, oidcAttrMapEmail, isSameUsernameTreatedAsIdenticalUser, isSameEmailTreatedAsIdenticalUser,
+    } = this.state;
+
+    let requestParams = {
+      oidcProviderName,
+      oidcIssuerHost,
+      oidcClientId,
+      oidcClientSecret,
+      oidcAttrMapId,
+      oidcAttrMapUserName,
+      oidcAttrMapName,
+      oidcAttrMapEmail,
+      isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser,
+    };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/oidc', requestParams);
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      oidcProviderName: securitySettingParams.oidcProviderName,
+      oidcIssuerHost: securitySettingParams.oidcIssuerHost,
+      oidcClientId: securitySettingParams.oidcClientId,
+      oidcClientSecret: securitySettingParams.oidcClientSecret,
+      oidcAttrMapId: securitySettingParams.oidcAttrMapId,
+      oidcAttrMapUserName: securitySettingParams.oidcAttrMapUserName,
+      oidcAttrMapName: securitySettingParams.oidcAttrMapName,
+      oidcAttrMapEmail: securitySettingParams.oidcAttrMapEmail,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+    });
+    return response;
+  }
+
+}

+ 198 - 0
src/client/js/services/AdminSamlSecurityContainer.js

@@ -0,0 +1,198 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { pathUtils } from 'growi-commons';
+import urljoin from 'url-join';
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+const logger = loggerFactory('growi:security:AdminSamlSecurityContainer');
+
+/**
+ * Service container for admin security page (SecuritySamlSetting.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminSamlSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      // TODO GW-1324 ABLCRure DB value takes precedence
+      useOnlyEnvVars: false,
+      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/saml/callback'),
+      missingMandatoryConfigKeys: [],
+      samlEntryPoint: '',
+      samlIssuer: '',
+      samlCert: '',
+      samlAttrMapId: '',
+      samlAttrMapUsername: '',
+      samlAttrMapMail: '',
+      samlAttrMapFirstName: '',
+      samlAttrMapLastName: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+      isSameEmailTreatedAsIdenticalUser: false,
+      samlABLCRule: '',
+    };
+
+  }
+
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { samlAuth } = response.data.securityParams;
+      this.setState({
+        missingMandatoryConfigKeys: samlAuth.missingMandatoryConfigKeys,
+        useOnlyEnvVars: samlAuth.useOnlyEnvVarsForSomeOptions,
+        samlEntryPoint: samlAuth.samlEntryPoint,
+        samlIssuer: samlAuth.samlIssuer,
+        samlCert: samlAuth.samlCert,
+        samlAttrMapId: samlAuth.samlAttrMapId,
+        samlAttrMapUsername: samlAuth.samlAttrMapUsername,
+        samlAttrMapMail: samlAuth.samlAttrMapMail,
+        samlAttrMapFirstName: samlAuth.samlAttrMapFirstName,
+        samlAttrMapLastName: samlAuth.samlAttrMapLastName,
+        isSameUsernameTreatedAsIdenticalUser: samlAuth.isSameUsernameTreatedAsIdenticalUser,
+        isSameEmailTreatedAsIdenticalUser: samlAuth.isSameEmailTreatedAsIdenticalUser,
+        samlABLCRule: samlAuth.samlABLCRule,
+      });
+      return samlAuth;
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminSamlSecurityContainer';
+  }
+
+  /**
+   * Change samlEntryPoint
+   */
+  changeSamlEntryPoint(inputValue) {
+    this.setState({ samlEntryPoint: inputValue });
+  }
+
+  /**
+   * Change samlIssuer
+   */
+  changeSamlIssuer(inputValue) {
+    this.setState({ samlIssuer: inputValue });
+  }
+
+  /**
+   * Change samlCert
+   */
+  changeSamlCert(inputValue) {
+    this.setState({ samlCert: inputValue });
+  }
+
+  /**
+   * Change samlAttrMapId
+   */
+  changeSamlAttrMapId(inputValue) {
+    this.setState({ samlAttrMapId: inputValue });
+  }
+
+  /**
+   * Change samlAttrMapUsername
+   */
+  changeSamlAttrMapUserName(inputValue) {
+    this.setState({ samlAttrMapUsername: inputValue });
+  }
+
+  /**
+   * Change samlAttrMapMail
+   */
+  changeSamlAttrMapMail(inputValue) {
+    this.setState({ samlAttrMapMail: inputValue });
+  }
+
+  /**
+   * Change samlAttrMapFirstName
+   */
+  changeSamlAttrMapFirstName(inputValue) {
+    this.setState({ samlAttrMapFirstName: inputValue });
+  }
+
+  /**
+   * Change samlAttrMapLastName
+   */
+  changeSamlAttrMapLastName(inputValue) {
+    this.setState({ samlAttrMapLastName: inputValue });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Switch isSameEmailTreatedAsIdenticalUser
+   */
+  switchIsSameEmailTreatedAsIdenticalUser() {
+    this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Change samlABLCRule
+   */
+  changeSamlABLCRule(inputValue) {
+    this.setState({ samlABLCRule: inputValue });
+  }
+
+  /**
+   * Update saml option
+   */
+  async updateSamlSetting() {
+
+    let requestParams = {
+      entryPoint: this.state.samlEntryPoint,
+      issuer: this.state.samlIssuer,
+      cert: this.state.samlCert,
+      attrMapId: this.state.samlAttrMapId,
+      attrMapUsername: this.state.samlAttrMapUsername,
+      attrMapMail: this.state.samlAttrMapMail,
+      attrMapFirstName: this.state.samlAttrMapFirstName,
+      attrMapLastName: this.state.samlAttrMapLastName,
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
+      ABLCRule: this.state.samlABLCRule,
+    };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/saml', requestParams);
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      missingMandatoryConfigKeys: securitySettingParams.missingMandatoryConfigKeys,
+      samlEntryPoint: securitySettingParams.samlEntryPoint,
+      samlIssuer: securitySettingParams.samlIssuer,
+      samlCert: securitySettingParams.samlCert,
+      samlAttrMapId: securitySettingParams.samlAttrMapId,
+      samlAttrMapUsername: securitySettingParams.samlAttrMapUsername,
+      samlAttrMapMail: securitySettingParams.samlAttrMapMail,
+      samlAttrMapFirstName: securitySettingParams.samlAttrMapFirstName,
+      samlAttrMapLastName: securitySettingParams.samlAttrMapLastName,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser,
+      samlABLCRule: securitySettingParams.samlABLCRule,
+    });
+    return response;
+  }
+
+}

+ 99 - 0
src/client/js/services/AdminTwitterSecurityContainer.js

@@ -0,0 +1,99 @@
+import { Container } from 'unstated';
+import loggerFactory from '@alias/logger';
+
+import { pathUtils } from 'growi-commons';
+import urljoin from 'url-join';
+import removeNullPropertyFromObject from '../../../lib/util/removeNullPropertyFromObject';
+
+const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
+
+/**
+ * Service container for admin security page (TwitterSecurityManagement.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminTwitterSecurityContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/twitter/callback'),
+      twitterConsumerKey: '',
+      twitterConsumerSecret: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+    };
+
+    this.updateTwitterSetting = this.updateTwitterSetting.bind(this);
+  }
+
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/security-setting/');
+      const { twitterOAuth } = response.data.securityParams;
+      this.setState({
+        twitterConsumerKey: twitterOAuth.twitterConsumerKey,
+        twitterConsumerSecret: twitterOAuth.twitterConsumerSecret,
+        isSameUsernameTreatedAsIdenticalUser: twitterOAuth.isSameUsernameTreatedAsIdenticalUser,
+      });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to fetch data');
+    }
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminTwitterSecurityContainer';
+  }
+
+  /**
+   * Change twitterConsumerKey
+   */
+  changeTwitterConsumerKey(value) {
+    this.setState({ twitterConsumerKey: value });
+  }
+
+  /**
+   * Change twitterConsumerSecret
+   */
+  changeTwitterConsumerSecret(value) {
+    this.setState({ twitterConsumerSecret: value });
+  }
+
+  /**
+   * Switch isSameUsernameTreatedAsIdenticalUser
+   */
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
+  }
+
+  /**
+   * Update twitterSetting
+   */
+  async updateTwitterSetting() {
+    const { twitterConsumerKey, twitterConsumerSecret, isSameUsernameTreatedAsIdenticalUser } = this.state;
+
+    let requestParams = { twitterConsumerKey, twitterConsumerSecret, isSameUsernameTreatedAsIdenticalUser };
+
+    requestParams = await removeNullPropertyFromObject(requestParams);
+    const response = await this.appContainer.apiv3.put('/security-setting/twitter-oauth', requestParams);
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      twitterConsumerKey: securitySettingParams.twitterConsumerKey,
+      twitterConsumerSecret: securitySettingParams.twitterConsumerSecret,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+    });
+    return response;
+  }
+
+}

+ 34 - 9
src/client/js/services/AppContainer.js

@@ -13,6 +13,10 @@ import {
   RestoreCodeBlockInterceptor,
 } from '../util/interceptor/detach-code-blocks';
 
+import {
+  DrawioInterceptor,
+} from '../util/interceptor/drawio-interceptor';
+
 import i18nFactory from '../util/i18n';
 import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
 
@@ -31,13 +35,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('.main-container.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);
@@ -48,6 +56,7 @@ export default class AppContainer extends Container {
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
+    this.interceptorManager.addInterceptor(new DrawioInterceptor(this), 20);
     this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
 
     const userlang = body.dataset.userlang;
@@ -107,6 +116,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,22 +294,24 @@ export default class AppContainer extends Container {
     return users;
   }
 
-  findUser(username) {
-    if (this.userByName && this.userByName[username]) {
-      return this.userByName[username];
+  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
+    let targetComponent;
+    switch (componentKind) {
+      case 'page':
+        targetComponent = this.getComponentInstance('Page');
+        break;
     }
-
-    return null;
+    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
   }
 
-  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
+  launchDrawioModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     switch (componentKind) {
       case 'page':
         targetComponent = this.getComponentInstance('Page');
         break;
     }
-    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
+    targetComponent.launchDrawioModal(beginLineNumber, endLineNumber);
   }
 
   async apiGet(path, params) {

+ 2 - 0
src/client/js/util/GrowiRenderer.js

@@ -14,6 +14,7 @@ import TableConfigurer from './markdown-it/table';
 import TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
 import BlockdiagConfigurer from './markdown-it/blockdiag';
+import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
 import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
 import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
@@ -67,6 +68,7 @@ export default class GrowiRenderer {
       new HeaderConfigurer(appContainer),
       new EmojiConfigurer(appContainer),
       new MathJaxConfigurer(appContainer),
+      new DrawioViewerConfigurer(appContainer),
       new PlantUMLConfigurer(appContainer),
       new BlockdiagConfigurer(appContainer),
     ];

+ 148 - 0
src/client/js/util/interceptor/drawio-interceptor.js

@@ -0,0 +1,148 @@
+/* eslint-disable import/prefer-default-export */
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { BasicInterceptor } from 'growi-commons';
+import Drawio from '../../components/Drawio';
+
+/**
+ * The interceptor for draw.io
+ *
+ *  replace draw.io tag (render by markdown-it-drawio-viewer) to a React target element
+ */
+export class DrawioInterceptor extends BasicInterceptor {
+
+  constructor(appContainer) {
+    super();
+
+    this.previousPreviewContext = null;
+    this.appContainer = appContainer;
+
+    const DrawioViewer = window.GraphViewer;
+    if (DrawioViewer != null) {
+      // viewer.min.js の Resize による Scroll イベントを抑止するために無効化する
+      DrawioViewer.useResizeSensor = false;
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  isInterceptWhen(contextName) {
+    return (
+      contextName === 'preRenderHtml'
+      || contextName === 'preRenderPreviewHtml'
+      || contextName === 'postRenderHtml'
+      || contextName === 'postRenderPreviewHtml'
+    );
+  }
+
+  /**
+   * @inheritdoc
+   */
+  isProcessableParallel() {
+    return false;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async process(contextName, ...args) {
+    const context = Object.assign(args[0]); // clone
+
+    if (contextName === 'preRenderHtml' || contextName === 'preRenderPreviewHtml') {
+      return this.drawioPreRender(contextName, context);
+    }
+
+    if (contextName === 'postRenderHtml' || contextName === 'postRenderPreviewHtml') {
+      this.drawioPostRender(contextName, context);
+      return;
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  createRandomStr(length) {
+    const bag = 'abcdefghijklmnopqrstuvwxyz0123456789';
+    let generated = '';
+    for (let i = 0; i < length; i++) {
+      generated += bag[Math.floor(Math.random() * bag.length)];
+    }
+    return generated;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  drawioPreRender(contextName, context) {
+    const div = document.createElement('div');
+    div.innerHTML = context.parsedHTML;
+
+    context.DrawioMap = {};
+    Array.from(div.querySelectorAll('.mxgraph')).forEach((element) => {
+      const domId = `mxgraph-${this.createRandomStr(8)}`;
+
+      context.DrawioMap[domId] = {
+        rangeLineNumberOfMarkdown: {
+          beginLineNumber: element.parentNode.dataset.beginLineNumberOfMarkdown,
+          endLineNumber: element.parentNode.dataset.endLineNumberOfMarkdown,
+        },
+        contentHtml: element.outerHTML,
+      };
+      element.outerHTML = `<div id="${domId}"></div>`;
+    });
+    context.parsedHTML = div.innerHTML;
+
+    // unmount
+    if (contextName === 'preRenderPreviewHtml') {
+      this.unmountPreviousReactDOMs(context);
+    }
+
+    // resolve
+    return context;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  drawioPostRender(contextName, context) {
+    const isPreview = (contextName === 'postRenderPreviewHtml');
+
+    Object.keys(context.DrawioMap).forEach((domId) => {
+      const elem = document.getElementById(domId);
+      if (elem) {
+        this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
+      }
+    });
+  }
+
+  /**
+   * @inheritdoc
+   */
+  renderReactDOM(drawioMapEntry, elem, isPreview) {
+    ReactDOM.render(
+      // eslint-disable-next-line react/jsx-filename-extension
+      <Drawio
+        appContainer={this.appContainer}
+        drawioContent={drawioMapEntry.contentHtml}
+        isPreview={isPreview}
+        rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
+      />,
+      elem,
+    );
+  }
+
+  /**
+   * @inheritdoc
+   */
+  unmountPreviousReactDOMs(newContext) {
+    if (this.previousPreviewContext != null) {
+      Array.from(document.querySelectorAll('.mxgraph')).forEach((element) => {
+        ReactDOM.unmountComponentAtNode(element);
+      });
+    }
+
+    this.previousPreviewContext = newContext;
+  }
+
+}

+ 9 - 0
src/client/js/util/markdown-it/drawio-viewer.js

@@ -0,0 +1,9 @@
+export default class DrawioViewerConfigurer {
+
+  configure(md) {
+    md.use(require('markdown-it-drawio-viewer'), {
+      marker: ':::',
+    });
+  }
+
+}

+ 8 - 1
src/client/styles/scss/_comment.scss

@@ -1,11 +1,17 @@
 .main-container {
   .page-comment-main {
+    pointer-events: auto;
+
     // delete button
     .page-comment-control {
       position: absolute;
       top: 0;
       right: 0;
-      display: none; // default hidden
+      visibility: hidden;
+    }
+
+    &:hover > .page-comment-control {
+      visibility: visible;
     }
   }
 
@@ -24,6 +30,7 @@
 .page-comment {
   padding-top: 50px;
   margin-top: -50px;
+  pointer-events: none;
 }
 
 .main-container {

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

@@ -103,11 +103,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

@@ -105,11 +105,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 {

+ 3 - 0
src/client/styles/scss/_drawio.scss

@@ -0,0 +1,3 @@
+.drawio-modal {
+  @include expand-modal-fullscreen(false, false);
+}

+ 4 - 17
src/client/styles/scss/_handsontable.scss

@@ -10,24 +10,11 @@
 
 // expanded window layout
 .handsontable-modal.handsontable-modal-expanded {
-  // full-screen modal
-  width: 97%;
-  height: 95%;
-  .modal-content {
-    height: 95%;
-  }
-
-  // expand .modal-body (with calculating height)
-  .modal-body {
-    $modal-header: 54px;
-    $modal-footer: 46px;
-    $margin: $modal-header + $modal-footer;
-    height: calc(100% - #{$margin});
+  @include expand-modal-fullscreen(true, true);
 
-    // expand .hot-table-container (with flexbox)
-    .hot-table-container {
-      flex: 1;
-    }
+  // expand .hot-table-container (with flexbox)
+  .hot-table-container {
+    flex: 1;
   }
 }
 

+ 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;
-          }
         }
       }
     }

+ 26 - 0
src/client/styles/scss/_mixins.scss

@@ -83,3 +83,29 @@
     }
   }
 }
+
+@mixin expand-modal-fullscreen($hasModalHeader: true, $hasModalFooter: true) {
+  // full-screen modal
+  width: auto;
+  height: calc(100vh - 30px);
+  margin: 15px;
+
+  .modal-content {
+    height: calc(100vh - 30px);
+  }
+
+  // expand .modal-body (with calculating height)
+  .modal-body {
+    $modal-header: 54px;
+    $modal-footer: 46px;
+
+    $margin: 0px;
+    @if $hasModalHeader {
+      $margin: $margin + $modal-header;
+    }
+    @if $hasModalFooter {
+      $margin: $margin + $modal-footer;
+    }
+    height: calc(100% - #{$margin});
+  }
+}

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

@@ -1,5 +1,5 @@
 // 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
@@ -209,6 +209,28 @@
   }
 }
 
+/**
+ * for drawio with drawio iframe button
+ */
+.editable-with-drawio {
+  .drawio-iframe-trigger {
+    top: 11px;
+    right: 10px;
+    z-index: 14;
+    font-size: 12px;
+    line-height: 1;
+    color: $linktext;
+    background-color: transparent;
+    border: 1px solid $linktext;
+    opacity: 1;
+
+    &:hover {
+      color: $white;
+      background-color: $linktext;
+    }
+  }
+}
+
 /*
  * for Presentation
  */

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

@@ -18,6 +18,7 @@
 @import 'comment_crowi';
 @import 'comment_growi';
 @import 'comment_kibela';
+@import 'drawio';
 @import 'navbar_kibela';
 @import 'create-page';
 @import 'create-template';
@@ -48,7 +49,7 @@
 /*
  * for Guest User Mode
  */
-.dropdown-disabled {
+.dropdown-toggle.dropdown-toggle-disabled {
   cursor: not-allowed;
 }
 

+ 3 - 0
src/lib/service/logger/stream.prod.js

@@ -17,6 +17,9 @@ else {
     const bunyanFormat = require('bunyan-format');
     stream = bunyanFormat({ outputMode: 'long' });
   }
+  else {
+    stream = process.stdout;
+  }
 }
 
 module.exports = stream;

+ 12 - 0
src/lib/util/removeNullPropertyFromObject.js

@@ -0,0 +1,12 @@
+// remove property if value is null
+
+const removeNullPropertyFromObject = (object) => {
+
+  for (const [key, value] of Object.entries(object)) {
+    if (value == null) { delete object[key] }
+  }
+
+  return object;
+};
+
+module.exports = removeNullPropertyFromObject;

+ 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 registerSafeRedirect = 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(registerSafeRedirect);
+
   const middlewares = require('../util/middlewares')(crowi, app);
 
   app.use(middlewares.swigFilters(swig));

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

@@ -130,19 +130,19 @@ Crowi.prototype.initForTest = async function() {
   ]);
 
   await Promise.all([
-  //   this.scanRuntimeVersions(),
-  //   this.setupPassport(),
-  //   this.setupSearcher(),
-  //   this.setupMailer(),
-  //   this.setupSlack(),
-  //   this.setupCsrf(),
-  //   this.setUpFileUpload(),
+    // this.scanRuntimeVersions(),
+    this.setupPassport(),
+    // this.setupSearcher(),
+    // this.setupMailer(),
+    // this.setupSlack(),
+    // this.setupCsrf(),
+    // this.setUpFileUpload(),
     this.setUpAcl(),
-  //   this.setUpCustomize(),
-  //   this.setUpRestQiitaAPI(),
-  //   this.setupUserGroup(),
-  //   this.setupExport(),
-  //   this.setupImport(),
+    // this.setUpCustomize(),
+    // this.setUpRestQiitaAPI(),
+    // this.setupUserGroup(),
+    // this.setupExport(),
+    // this.setupImport(),
   ]);
 
   // globalNotification depends on slack and mailer

+ 0 - 7
src/server/form/admin/custombehavior.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:behavior]'),
-);

+ 0 - 7
src/server/form/admin/customcss.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:css]'),
-);

+ 0 - 12
src/server/form/admin/customfeatures.js

@@ -1,12 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:isEnabledTimeline]').trim().toBooleanStrict(),
-  field('settingForm[customize:isEnabledDeleteCompletely]').trim().toBooleanStrict(),
-  field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
-  field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict(),
-  field('settingForm[customize:showRecentCreatedNumber]').trim().toInt(),
-  field('settingForm[customize:isEnabledStaleNotification]').trim().toBooleanStrict(),
-);

+ 0 - 7
src/server/form/admin/customheader.js

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

+ 0 - 8
src/server/form/admin/customhighlightJsStyle.js

@@ -1,8 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:highlightJsStyle]'),
-  field('settingForm[customize:highlightJsStyleBorder]').trim().toBooleanStrict(),
-);

+ 0 - 8
src/server/form/admin/customlayout.js

@@ -1,8 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:layout]'),
-  field('settingForm[customize:theme]'),
-);

+ 0 - 7
src/server/form/admin/customscript.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:script]'),
-);

+ 0 - 7
src/server/form/admin/customtheme.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:theme]'),
-);

+ 0 - 7
src/server/form/admin/customtitle.js

@@ -1,7 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[customize:title]'),
-);

+ 0 - 10
src/server/form/admin/securityGeneral.js

@@ -1,10 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[security:restrictGuestMode]'),
-  field('settingForm[security:list-policy:hideRestrictedByOwner]').trim().toBooleanStrict(),
-  field('settingForm[security:list-policy:hideRestrictedByGroup]').trim().toBooleanStrict(),
-  field('settingForm[security:pageCompleteDeletionAuthority]'),
-);

+ 0 - 8
src/server/form/admin/securityPassportBasic.js

@@ -1,8 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[security:passport-basic:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-basic:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
-);

+ 0 - 10
src/server/form/admin/securityPassportGitHub.js

@@ -1,10 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[security:passport-github:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-github:clientId]').trim(),
-  field('settingForm[security:passport-github:clientSecret]').trim(),
-  field('settingForm[security:passport-github:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
-);

+ 0 - 10
src/server/form/admin/securityPassportGoogle.js

@@ -1,10 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[security:passport-google:isEnabled]').trim().toBooleanStrict().required(),
-  field('settingForm[security:passport-google:clientId]').trim(),
-  field('settingForm[security:passport-google:clientSecret]').trim(),
-  field('settingForm[security:passport-google:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
-);

+ 0 - 23
src/server/form/admin/securityPassportLdap.js

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است