Explorar el Código

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

sooouh hace 6 años
padre
commit
c5deb7e69e
Se han modificado 100 ficheros con 5669 adiciones y 1103 borrados
  1. 1 0
      .eslintrc.js
  2. 14 1
      CHANGES.md
  3. 1 1
      config/env.prod.js
  4. 1 0
      config/logger/config.dev.js
  5. 3 0
      config/logger/config.prod.js
  6. 0 1
      config/webpack.common.js
  7. 6 3
      package.json
  8. 10 1
      resource/cdn-manifests.js
  9. 7 0
      resource/locales/en-US/sandbox-diagrams.md
  10. 71 0
      resource/locales/en-US/sandbox-math.md
  11. 22 358
      resource/locales/en-US/sandbox.md
  12. 58 38
      resource/locales/en-US/translation.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. 63 37
      resource/locales/ja/translation.json
  17. 35 0
      src/client/js/admin.jsx
  18. 3 0
      src/client/js/app.jsx
  19. 1 1
      src/client/js/bootstrap.jsx
  20. 2 2
      src/client/js/components/Admin/App/AppSetting.jsx
  21. 1 1
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  22. 1 1
      src/client/js/components/Admin/App/PluginSetting.jsx
  23. 1 1
      src/client/js/components/Admin/Customize/CustomizeFunctionOption.jsx
  24. 0 1
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  25. 1 1
      src/client/js/components/Admin/Customize/CustomizeHighlightSetting.jsx
  26. 0 2
      src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx
  27. 9 8
      src/client/js/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  28. 1 1
      src/client/js/components/Admin/ImportData/GrowiArchive/ErrorViewer.jsx
  29. 1 1
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  30. 1 1
      src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  31. 2 2
      src/client/js/components/Admin/ImportDataPage.jsx
  32. 2 2
      src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx
  33. 1 1
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  34. 1 1
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  35. 1 1
      src/client/js/components/Admin/Notification/GlobalNotificationList.jsx
  36. 6 0
      src/client/js/components/Admin/Notification/ManageGlobalNotification.jsx
  37. 1 1
      src/client/js/components/Admin/Notification/NotificationDeleteModal.jsx
  38. 2 2
      src/client/js/components/Admin/Notification/SlackAppConfiguration.jsx
  39. 4 2
      src/client/js/components/Admin/Notification/TriggerEventCheckBox.jsx
  40. 147 0
      src/client/js/components/Admin/Security/BasicSecuritySetting.jsx
  41. 41 0
      src/client/js/components/Admin/Security/FacebookSecuritySetting.jsx
  42. 218 0
      src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx
  43. 225 0
      src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx
  44. 171 0
      src/client/js/components/Admin/Security/LdapAuthTestModal.jsx
  45. 455 0
      src/client/js/components/Admin/Security/LdapSecuritySetting.jsx
  46. 190 0
      src/client/js/components/Admin/Security/LocalSecuritySetting.jsx
  47. 354 0
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  48. 553 0
      src/client/js/components/Admin/Security/SamlSecuritySetting.jsx
  49. 183 0
      src/client/js/components/Admin/Security/SecurityManagement.jsx
  50. 200 0
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  51. 226 0
      src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx
  52. 1 1
      src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx
  53. 2 2
      src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx
  54. 1 1
      src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx
  55. 1 1
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  56. 1 1
      src/client/js/components/Admin/Users/ExternalAccountTable.jsx
  57. 1 1
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  58. 4 4
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  59. 79 0
      src/client/js/components/Drawio.jsx
  60. 98 93
      src/client/js/components/InstallerForm.jsx
  61. 84 10
      src/client/js/components/Navbar/PersonalDropdown.jsx
  62. 48 0
      src/client/js/components/Page.jsx
  63. 1 1
      src/client/js/components/Page/CopyDropdown.jsx
  64. 1 1
      src/client/js/components/Page/RevisionPath.jsx
  65. 1 1
      src/client/js/components/Page/TagEditor.jsx
  66. 2 2
      src/client/js/components/Page/TagLabels.jsx
  67. 1 1
      src/client/js/components/PageAttachment/DeleteAttachmentModal.jsx
  68. 2 2
      src/client/js/components/PageComment/Comment.jsx
  69. 1 1
      src/client/js/components/PageComment/DeleteCommentModal.jsx
  70. 19 0
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  71. 159 0
      src/client/js/components/PageEditor/DrawioModal.jsx
  72. 1 1
      src/client/js/components/PageEditor/Editor.jsx
  73. 3 0
      src/client/js/components/PageEditor/EditorIcon.jsx
  74. 1 1
      src/client/js/components/PageEditor/HandsontableModal.jsx
  75. 164 0
      src/client/js/components/PageEditor/MarkdownDrawioUtil.js
  76. 14 8
      src/client/js/components/PageEditorByHackmd.jsx
  77. 1 1
      src/client/js/components/PageHistory/PageRevisionList.jsx
  78. 1 1
      src/client/js/components/SavePageControls/GrantSelector.jsx
  79. 1 1
      src/client/js/components/SearchPage.jsx
  80. 1 1
      src/client/js/components/SearchPage/DeletePageListModal.jsx
  81. 0 5
      src/client/js/components/SearchPage/SearchResult.jsx
  82. 41 16
      src/client/js/components/TableOfContents.jsx
  83. 0 23
      src/client/js/legacy/crowi.js
  84. 74 0
      src/client/js/services/AdminBasicSecurityContainer.js
  85. 209 0
      src/client/js/services/AdminGeneralSecurityContainer.js
  86. 99 0
      src/client/js/services/AdminGitHubSecurityContainer.js
  87. 102 0
      src/client/js/services/AdminGoogleSecurityContainer.js
  88. 203 0
      src/client/js/services/AdminLdapSecurityContainer.js
  89. 87 0
      src/client/js/services/AdminLocalSecurityContainer.js
  90. 183 0
      src/client/js/services/AdminOidcSecurityContainer.js
  91. 198 0
      src/client/js/services/AdminSamlSecurityContainer.js
  92. 99 0
      src/client/js/services/AdminTwitterSecurityContainer.js
  93. 83 0
      src/client/js/services/AppContainer.js
  94. 2 0
      src/client/js/util/GrowiRenderer.js
  95. 148 0
      src/client/js/util/interceptor/drawio-interceptor.js
  96. 9 0
      src/client/js/util/markdown-it/drawio-viewer.js
  97. 41 38
      src/client/styles/agile-admin/inverse/colors/_apply-colors-dark.scss
  98. 0 29
      src/client/styles/agile-admin/inverse/colors/default-dark.scss
  99. 0 21
      src/client/styles/agile-admin/inverse/colors/default.scss
  100. 0 4
      src/client/styles/scss/_admin.scss

+ 1 - 0
.eslintrc.js

@@ -13,6 +13,7 @@ module.exports = {
     jquery: true,
     emojione: true,
     hljs: true,
+    ScrollPosStyler: true,
     window: true,
   },
   plugins: [

+ 14 - 1
CHANGES.md

@@ -1,8 +1,21 @@
 # CHANGES
 
+## v4.0.0-RC
+
+* Support: Upgrade libs
+    * bootstrap
+
+## v3.7.0-RC
+
+* 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
 

+ 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

@@ -33,4 +33,5 @@ module.exports = {
   'growi:app': 'debug',
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
+  // 'growi:TableOfContents': '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',
 };

+ 0 - 1
config/webpack.common.js

@@ -34,7 +34,6 @@ module.exports = (options) => {
       'styles/style-presentation':    './src/client/styles/scss/style-presentation.scss',
       // themes
       'styles/theme-default':         './src/client/styles/scss/theme/default.scss',
-      // 'styles/theme-default-dark':    './src/client/styles/scss/theme/default-dark.scss',
       // 'styles/theme-nature':          './src/client/styles/scss/theme/nature.scss',
       // 'styles/theme-mono-blue':       './src/client/styles/scss/theme/mono-blue.scss',
       // 'styles/theme-future':          './src/client/styles/scss/theme/future.scss',

+ 6 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.6.9-RC",
+  "version": "3.7.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -109,6 +109,7 @@
     "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",
@@ -161,7 +162,7 @@
     "babel-loader": "^8.0.6",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-transform-imports": "^2.0.0",
-    "bootstrap": "^4.3.1",
+    "bootstrap": "^4.4.1",
     "browser-bunyan": "^1.3.0",
     "browser-sync": "^2.26.3",
     "bunyan-debug": "^2.0.0",
@@ -191,7 +192,8 @@
     "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",
@@ -229,6 +231,7 @@
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^2.0.3",
+    "sticky-events": "^3.1.3",
     "style-loader": "^1.0.0",
     "stylelint": "^13.2.0",
     "stylelint-config-recess-order": "^2.0.1",

+ 10 - 1
resource/cdn-manifests.js

@@ -2,7 +2,8 @@ module.exports = {
   js: [
     {
       name: 'basis',
-      url: 'https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.4.0,npm/popper.js@1.15.0,npm/bootstrap@4.3.1/dist/js/bootstrap.min.js',
+      // eslint-disable-next-line max-len
+      url: 'https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.4.0,npm/popper.js@1.15.0,npm/bootstrap@4.4.1/dist/js/bootstrap.min.js,npm/scrollpos-styler@0.7.1,npm/jquery-slimscroll@1.3.8/jquery.slimscroll.min.js',
       groups: ['basis'],
       args: {
         integrity: '',
@@ -44,6 +45,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',

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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]

+ 58 - 38
resource/locales/en-US/translation.json

@@ -61,7 +61,7 @@
   "No diff": "No diff",
   "Shrink versions that have no diffs": "Shrink versions that have no diffs",
   "User ID": "User ID",
-  "Home": "Home",
+  "User's Home": "User's Home",
   "User Settings": "User Settings",
   "User Information": "User Information",
   "Basic Info": "Basic Info",
@@ -124,8 +124,9 @@
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
   "form_validation": {
-    "required": "<code>%s</code> is required",
-    "invalid_syntax": "The syntax of <code>%s</code> is invalid."
+    "error_message": "Some values ​​are incorrect",
+    "required": "%s is required",
+    "invalid_syntax": "The syntax of %s is invalid."
   },
   "installer": {
     "setup": "Setup",
@@ -388,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)"
@@ -409,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",
@@ -448,52 +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": "A rule is written in the form of concatenating <code>attribute name = value</code> with <code>|</code> and <code>&</code>. The or operator (|) has a lower precedence than the and operator (&). If operator precedence is equal, left to right associativity is used.",
-      "attr_based_login_control_rule_example": "For example, if a rule is <code>Department = A | Department = B & Position = Leader</code>, users with <code>Department</code> as <code>A</code> or users with <code>Department</code> as <code>B</code> and <code>Position</code> as <code>Leader</code> <strong>can</strong> sign in."
+      "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",
@@ -503,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?",
@@ -513,15 +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",
-      "security:passport-saml:ABLCRule": "Rule"
+      "entryPoint": "Entry point",
+      "issuer": "Issuer",
+      "cert": "Certificate",
+      "attrMapId": "ID",
+      "attrMapUsername": "Username",
+      "attrMapMail": "Mail Address",
+      "attrMapFirstName": "First Name",
+      "attrMapLastName": "Last Name",
+      "ABLCRule": "Rule"
     }
   },
   "notification_setting": {

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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]

+ 63 - 37
resource/locales/ja/translation.json

@@ -61,7 +61,7 @@
   "No diff": "差分なし",
   "Shrink versions that have no diffs": "差分のないバージョンをコンパクトに表示する",
   "User ID": "ユーザーID",
-  "Home": "ホーム",
+  "User's Home": "ユーザーホーム",
   "User Settings": "ユーザー設定",
   "User Information": "ユーザー情報",
   "Basic Info": "ユーザーの基本情報",
@@ -123,8 +123,9 @@
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
   "form_validation": {
-    "required": "<code>%s</code> に値を入力してください",
-    "invalid_syntax": "<code>%s</code> の構文が不正です"
+    "error_message": "いくつかの値が設定されていません",
+    "required": "%sに値を入力してください",
+    "invalid_syntax": "%sの構文が不正です"
   },
   "installer": {
     "setup": "セットアップ",
@@ -365,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>ホワイトリスト",
@@ -385,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": "許可 (ゲストユーザーも閲覧のみ可能)"
@@ -403,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",
@@ -442,52 +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": "<code>属性名 = 値</code> を <code>|</code>(論理和)、 <code>&</code>(論理積) で連結した形式で記述してください。演算子の優先順位は論理積が論理和より高く、各演算子の結合規則は左から右です。",
-      "attr_based_login_control_rule_example": "例えば <code>Department=A | Department=B & Position=Leader</code> だと <code>Department</code> が <code>A</code> の場合, もしくは<code>Department</code> が <code>B</code> かつ <code>Position</code> が <code>Leader</code> の場合にログインを<strong>許可</strong>します。"
+      "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 の設定方法",
@@ -496,15 +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": "名",
-      "security:passport-saml:ABLCRule": "ルール"
+      "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,
+  );
+}

+ 3 - 0
src/client/js/app.jsx

@@ -125,3 +125,6 @@ $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
     </I18nextProvider>, document.getElementById('revision-history'),
   );
 });
+
+// initialize scrollpos-styler
+ScrollPosStyler.init();

+ 1 - 1
src/client/js/bootstrap.jsx

@@ -27,7 +27,7 @@ const websocketContainer = new WebsocketContainer(appContainer);
 
 logger.info('unstated containers have been initialized');
 
-appContainer.initPlugins();
+appContainer.init();
 appContainer.injectToWindow();
 
 /**

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

@@ -69,7 +69,7 @@ class AppSetting extends React.Component {
         <div className="row form-group mb-5">
           <label className="col-3 col-form-label">{t('admin:app_setting.default_language')}</label>
           <div className="col-6">
-            <div className="custom-control custom-radio d-inline">
+            <div className="custom-control custom-radio custom-control-inline">
               <input
                 type="radio"
                 id="radioLangEn"
@@ -81,7 +81,7 @@ class AppSetting extends React.Component {
               />
               <label className="custom-control-label" htmlFor="radioLangEn">{t('English')}</label>
             </div>
-            <div className="custom-control custom-radio d-inline">
+            <div className="custom-control custom-radio custom-control-inline">
               <input
                 type="radio"
                 id="radioLangJa"

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

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

@@ -43,7 +43,7 @@ class PluginSetting extends React.Component {
 
         <div className="row form-group mb-5">
           <div className="offset-3 col-6 text-left">
-            <div className="custom-control custom-switch checkbox-success">
+            <div className="custom-control custom-switch custom-checkbox-success">
               <input
                 id="isEnabledPlugins"
                 className="custom-control-input"

+ 1 - 1
src/client/js/components/Admin/Customize/CustomizeFunctionOption.jsx

@@ -7,7 +7,7 @@ class CustomizeFunctionOption extends React.PureComponent {
   render() {
     return (
       <React.Fragment>
-        <div className="custom-control custom-switch checkbox-success">
+        <div className="custom-control custom-switch custom-checkbox-success">
           <input
             className="custom-control-input"
             type="checkbox"

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

@@ -116,7 +116,6 @@ class CustomizeBehaviorSetting extends React.Component {
                   <DropdownToggle className="text-right col-6" caret>
                     <span className="float-left">{adminCustomizeContainer.state.currentRecentCreatedLimit}</span>
                   </DropdownToggle>
-                  {/* TODO adjust dropdown after BS4 */}
                   <DropdownMenu className="dropdown-menu" role="menu">
                     <DropdownItem key={10} role="presentation" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(10) }}>
                       <a role="menuitem">10</a>

+ 1 - 1
src/client/js/components/Admin/Customize/CustomizeHighlightSetting.jsx

@@ -114,7 +114,7 @@ class CustomizeHighlightSetting extends React.Component {
 
             <div className="form-group row">
               <div className="offset-3 col-6 text-left">
-                <div className="custom-control custom-switch checkbox-success">
+                <div className="custom-control custom-switch custom-checkbox-success">
                   <input
                     type="checkbox"
                     className="custom-control-input"

+ 0 - 2
src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -32,8 +32,6 @@ class CustomizeThemeOptions extends React.Component {
     }];
 
     const darkTheme = [{
-      name: 'default-dark', bg: '#212731', topbar: '#151515', theme: '#f75b36',
-    }, {
       name: 'future', bg: '#16282D', topbar: '#011414', theme: '#04B4AE',
     }, {
       name: 'blue-night', bg: '#061F2F', topbar: '#27343B', theme: '#0090C8',

+ 9 - 8
src/client/js/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -142,23 +142,24 @@ class SelectCollectionsModal extends React.Component {
   }
 
   renderCheckboxes(collectionNames, color) {
-    const checkboxColor = color ? `checkbox-${color}` : 'checkbox-info';
+    const checkboxColor = color ? `custom-checkbox-${color}` : 'custom-checkbox-info';
 
     return (
-      <div className={`checkbox ${checkboxColor}`}>
+      <div className={`custom-control custom-checkbox ${checkboxColor}`}>
         <div className="row">
           {collectionNames.map((collectionName) => {
             return (
               <div className="col-sm-6 my-1" key={collectionName}>
                 <input
                   type="checkbox"
+                  className="custom-control-input"
                   id={collectionName}
                   name={collectionName}
                   value={collectionName}
                   checked={this.state.selectedCollections.has(collectionName)}
                   onChange={this.toggleCheckbox}
                 />
-                <label className="text-capitalize form-check-label ml-3" htmlFor={collectionName}>
+                <label className="text-capitalize custom-control-label ml-3" htmlFor={collectionName}>
                   {collectionName}
                 </label>
               </div>
@@ -174,7 +175,7 @@ class SelectCollectionsModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader toggle={this.props.onClose}>
+        <ModalHeader tag="h4" toggle={this.props.onClose}>
           {t('admin:export_management.export_collections')}
         </ModalHeader>
 
@@ -192,26 +193,26 @@ class SelectCollectionsModal extends React.Component {
             </div>
             <div className="row mt-4">
               <div className="col-sm-12">
-                <legend>Page Collections</legend>
+                <h3 className="admin-setting-header">Page Collections</h3>
                 {this.renderGroups(GROUPS_PAGE)}
               </div>
             </div>
             <div className="row mt-4">
               <div className="col-sm-12">
-                <legend>User Collections</legend>
+                <h3 className="admin-setting-header">User Collections</h3>
                 {this.renderGroups(GROUPS_USER, 'danger')}
                 {this.renderWarnForUser()}
               </div>
             </div>
             <div className="row mt-4">
               <div className="col-sm-12">
-                <legend>Config Collections</legend>
+                <h3 className="admin-setting-header">Config Collections</h3>
                 {this.renderGroups(GROUPS_CONFIG)}
               </div>
             </div>
             <div className="row mt-4">
               <div className="col-sm-12">
-                <legend>Other Collections</legend>
+                <h3 className="admin-setting-header">Other Collections</h3>
                 {this.renderOthers()}
               </div>
             </div>

+ 1 - 1
src/client/js/components/Admin/ImportData/GrowiArchive/ErrorViewer.jsx

@@ -21,7 +21,7 @@ class ErrorViewer extends React.Component {
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader toggle={this.props.onClose} className="bg-danger text-white">
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-danger text-white">
           Errors
         </ModalHeader>
         <ModalBody>

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

@@ -204,7 +204,7 @@ class ImportCollectionConfigurationModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} onEnter={this.initialize}>
-        <ModalHeader toggle={this.props.onClose}>
+        <ModalHeader tag="h4" toggle={this.props.onClose}>
           {`'${collectionName}'`} Configuration
         </ModalHeader>
 

+ 1 - 1
src/client/js/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -345,7 +345,7 @@ class ImportForm extends React.Component {
     return (
       <div className="mt-4 row">
         <div className="col-12">
-          <legend>{groupName} Collections</legend>
+          <h3 className="admin-setting-header">{groupName} Collections</h3>
           { wellContent != null && (
             <div className="card well small" role="alert">
               <ul>

+ 2 - 2
src/client/js/components/Admin/ImportDataPage.jsx

@@ -143,7 +143,7 @@ class ImportDataPage extends React.Component {
           role="form"
         >
           <fieldset>
-            <legend>{t('admin:importer_management.import_from', { from: 'esa.io' })}</legend>
+            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'esa.io' })}</h2>
             <table className="table table-bordered table-mapping">
               <thead>
                 <tr>
@@ -233,7 +233,7 @@ class ImportDataPage extends React.Component {
           role="form"
         >
           <fieldset>
-            <legend>{t('admin:importer_management.import_from', { from: 'Qiita:Team' })}</legend>
+            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'Qiita:Team' })}</h2>
             <table className="table table-bordered table-mapping">
               <thead>
                 <tr>

+ 2 - 2
src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx

@@ -46,7 +46,7 @@ class LineBreakForm extends React.Component {
     return (
       <div className="form-group text-left my-3">
         <div className="col-8 offset-4">
-          <div className="custom-control custom-switch checkbox-success">
+          <div className="custom-control custom-switch custom-checkbox-success">
             <input
               type="checkbox"
               className="custom-control-input"
@@ -73,7 +73,7 @@ class LineBreakForm extends React.Component {
     return (
       <div className="form-group text-left my-3">
         <div className="col-8 offset-4">
-          <div className="custom-control custom-switch checkbox-success">
+          <div className="custom-control custom-switch custom-checkbox-success">
             <input
               type="checkbox"
               className="custom-control-input"

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

@@ -26,7 +26,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

@@ -133,7 +133,7 @@ class XssForm extends React.Component {
         <fieldset className="col-12">
           <div className="form-group">
             <div className="col-8 offset-4 my-3">
-              <div className="custom-control custom-switch checkbox-success">
+              <div className="custom-control custom-switch custom-checkbox-success">
                 <input
                   type="checkbox"
                   className="custom-control-input"

+ 1 - 1
src/client/js/components/Admin/Notification/GlobalNotificationList.jsx

@@ -76,7 +76,7 @@ class GlobalNotificationList extends React.Component {
           return (
             <tr key={notification._id}>
               <td className="align-middle td-abs-center">
-                <div className="custom-control custom-switch checkbox-success">
+                <div className="custom-control custom-switch custom-checkbox-success">
                   <input
                     type="checkbox"
                     className="custom-control-input"

+ 6 - 0
src/client/js/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -200,6 +200,7 @@ class ManageGlobalNotification extends React.Component {
             <div className="form-group">
               <h3>{t('notification_setting.trigger_events')}</h3>
               <TriggerEventCheckBox
+                checkbox="success"
                 event="pageCreate"
                 checked={this.state.triggerEvents.has('pageCreate')}
                 onChange={() => this.onChangeTriggerEvents('pageCreate')}
@@ -209,6 +210,7 @@ class ManageGlobalNotification extends React.Component {
                 </span>
               </TriggerEventCheckBox>
               <TriggerEventCheckBox
+                checkbox="warning"
                 event="pageEdit"
                 checked={this.state.triggerEvents.has('pageEdit')}
                 onChange={() => this.onChangeTriggerEvents('pageEdit')}
@@ -218,6 +220,7 @@ class ManageGlobalNotification extends React.Component {
                 </span>
               </TriggerEventCheckBox>
               <TriggerEventCheckBox
+                checkbox="warning"
                 event="pageMove"
                 checked={this.state.triggerEvents.has('pageMove')}
                 onChange={() => this.onChangeTriggerEvents('pageMove')}
@@ -227,6 +230,7 @@ class ManageGlobalNotification extends React.Component {
                 </span>
               </TriggerEventCheckBox>
               <TriggerEventCheckBox
+                checkbox="danger"
                 event="pageDelete"
                 checked={this.state.triggerEvents.has('pageDelete')}
                 onChange={() => this.onChangeTriggerEvents('pageDelete')}
@@ -236,6 +240,7 @@ class ManageGlobalNotification extends React.Component {
                 </span>
               </TriggerEventCheckBox>
               <TriggerEventCheckBox
+                checkbox="info"
                 event="pageLike"
                 checked={this.state.triggerEvents.has('pageLike')}
                 onChange={() => this.onChangeTriggerEvents('pageLike')}
@@ -245,6 +250,7 @@ class ManageGlobalNotification extends React.Component {
                 </span>
               </TriggerEventCheckBox>
               <TriggerEventCheckBox
+                checkbox="secondary"
                 event="comment"
                 checked={this.state.triggerEvents.has('comment')}
                 onChange={() => this.onChangeTriggerEvents('comment')}

+ 1 - 1
src/client/js/components/Admin/Notification/NotificationDeleteModal.jsx

@@ -12,7 +12,7 @@ class NotificationDeleteModal extends React.PureComponent {
     const { t, notificationForConfiguration } = this.props;
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader toggle={this.props.onClose} className="modal-header" closeButton>
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="modal-header">
           <i className="icon icon-fire"></i> Delete Global Notification Setting
         </ModalHeader>
         <ModalBody>

+ 2 - 2
src/client/js/components/Admin/Notification/SlackAppConfiguration.jsx

@@ -50,7 +50,7 @@ class SlackAppConfiguration extends React.Component {
                 aria-haspopup="true"
                 aria-expanded="true"
               >
-                {`Slack ${adminNotificationContainer.state.selectSlackOption}`} <span className="caret"></span>
+                {`Slack ${adminNotificationContainer.state.selectSlackOption}`}
               </button>
               <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
                 <a className="dropdown-item" onClick={() => adminNotificationContainer.switchSlackOption('Incoming Webhooks')}>Slack Incoming Webhooks</a>
@@ -77,7 +77,7 @@ class SlackAppConfiguration extends React.Component {
 
             <div className="row mb-3">
               <div className="offset-3 col-6 text-left">
-                <div className="custom-control custom-switch checkbox-success">
+                <div className="custom-control custom-switch custom-checkbox-success">
                   <input
                     type="checkbox"
                     className="custom-control-input"

+ 4 - 2
src/client/js/components/Admin/Notification/TriggerEventCheckBox.jsx

@@ -6,14 +6,15 @@ const TriggerEventCheckBox = (props) => {
   const { t } = props;
 
   return (
-    <div className="checkbox">
+    <div className={`custom-control custom-checkbox custom-checkbox-${props.checkbox}`}>
       <input
+        className="custom-control-input"
         type="checkbox"
         id={`trigger-event-${props.event}`}
         checked={props.checked}
         onChange={props.onChange}
       />
-      <label htmlFor={`trigger-event-${props.event}`}>
+      <label className="custom-control-label" htmlFor={`trigger-event-${props.event}`}>
         {props.children}{' '}
         {t(`notification_setting.event_${props.event}`)}
       </label>
@@ -25,6 +26,7 @@ const TriggerEventCheckBox = (props) => {
 TriggerEventCheckBox.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
+  checkbox: PropTypes.string.isRequired,
   checked: PropTypes.bool.isRequired,
   onChange: PropTypes.func.isRequired,
   event: PropTypes.string.isRequired,

+ 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-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                id="isBasicEnabled"
+                className="custom-control-input"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isBasicEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsBasicEnabled() }}
+              />
+              <label className="custom-control-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="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        {isBasicEnabled && (
+        <React.Fragment>
+          <div className="row mb-5">
+            <div className="offset-3 col-6">
+              <div className="custom-control custom-switch custom-checkbox-success">
+                <input
+                  id="bindByEmail-basic"
+                  className="custom-control-input"
+                  type="checkbox"
+                  checked={adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                  onChange={() => { adminBasicSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                />
+                <label
+                  className="custom-control-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="offset-4 col-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="offset-3 col-6 text-left">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                id="isGitHubEnabled"
+                className="custom-control-input"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isGitHubEnabled || false}
+                onChange={() => { adminGeneralSecurityContainer.switchIsGitHubOAuthEnabled() }}
+              />
+              <label className="custom-control-label" htmlFor="isGitHubEnabled">
+                {t('security_setting.OAuth.GitHub.enable_github')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('github') && isGitHubEnabled)
+              && <div className="badg badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-3 text-right py-2">{t('security_setting.callback_URL')}</label>
+          <div className="col-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-3 text-right py-2">{t('security_setting.clientID')}</label>
+              <div className="col-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-3 text-right py-2">{t('security_setting.client_secret')}</label>
+              <div className="col-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="offset-3 col-6 text-left">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    id="bindByUserNameGitHub"
+                    className="custom-control-input"
+                    type="checkbox"
+                    checked={adminGitHubSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminGitHubSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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="offset-3 col-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="offset-3 col-6">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                id="isGoogleEnabled"
+                className="custom-control-input"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isGoogleEnabled || false}
+                onChange={() => { adminGeneralSecurityContainer.switchIsGoogleOAuthEnabled() }}
+              />
+              <label className="custom-control-label" htmlFor="isGoogleEnabled">
+                {t('security_setting.OAuth.Google.enable_google')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('google') && isGoogleEnabled)
+              && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-3 text-right py-2">{t('security_setting.callback_URL')}</label>
+          <div className="col-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-3 text-right py-2">{t('security_setting.clientID')}</label>
+              <div className="col-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-3 text-right py-2">{t('security_setting.client_secret')}</label>
+              <div className="col-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="offset-3 col-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    id="bindByUserNameGoogle"
+                    className="custom-control-input"
+                    type="checkbox"
+                    checked={adminGoogleSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminGoogleSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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="offset-3 col-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);

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

@@ -0,0 +1,171 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+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 isOpen={this.props.isOpen} toggle={this.props.onClose}>
+        <ModalHeader tag="h4" className="modal-header" toggle={this.props.onClose}>
+          Test LDAP Account
+        </ModalHeader>
+        <ModalBody>
+          {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-3 text-right">{t('username')}</label>
+            <div className="col-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-3 text-right">{t('Password')}</label>
+            <div className="col-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-12" rows="4" value={this.state.logs} readOnly />
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-light mt-3 offset-5" onClick={this.testLdapCredentials}>Test</button>
+        </ModalFooter>
+      </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);

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

@@ -0,0 +1,455 @@
+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-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                id="isLdapEnabled"
+                className="custom-control-input"
+                type="checkbox"
+                checked={isLdapEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
+              />
+              <label className="custom-control-label" htmlFor="isLdapEnabled">
+                {t('security_setting.ldap.enable_ldap')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isLdapEnabled)
+              && <div className="badge badge-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-3 control-label text-right py-2">
+                Server URL
+              </label>
+              <div className="col-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">
+              <div className="col-3 text-right py-2">
+                <strong>{t('security_setting.ldap.bind_mode')}</strong>
+              </div>
+              <div className="col-6">
+                <div className="dropdown">
+                  <button
+                    className="btn btn-light dropdown-toggle"
+                    type="button"
+                    id="dropdownMenuButton"
+                    data-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="true"
+                  >
+                    {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>}
+                  </button>
+                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                    <a className="dropdown-item" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(true) }}>
+                      {t('security_setting.ldap.bind_user')}
+                    </a>
+                    <a className="dropdown-item" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(false) }}>
+                      {t('security_setting.ldap.bind_manager')}
+                    </a>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <div className="row mb-5">
+              <div className="col-3 text-right py-2">
+                <strong>Bind DN</strong>
+              </div>
+              <div className="col-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">
+              <div htmlFor="bindDNPassword" className="col-3 text-right py-2">
+                <strong>{t('security_setting.ldap.bind_DN_password')}</strong>
+              </div>
+              <div className="col-6">
+                {(adminLdapSecurityContainer.state.isUserBind) ? (
+                  <p className="well card passport-ldap-userbind">
+                    <small>
+                      {t('security_setting.ldap.bind_DN_password_user_detail')}
+                    </small>
+                  </p>
+                )
+                  : (
+                    <>
+                      <p className="well card 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">
+              <div className="col-3 text-right py-2">
+                <strong>{t('security_setting.ldap.search_filter')}</strong>
+              </div>
+              <div className="col-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">
+              <div className="col-3 text-right py-2">
+                <strong htmlFor="attrMapUsername">{t('username')}</strong>
+              </div>
+              <div className="col-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="offset-3 col-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    type="checkbox"
+                    className="custom-control-input"
+                    id="isSameUsernameTreatedAsIdenticalUser"
+                    checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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">
+              <div className="col-3 text-right py-2">
+                <strong htmlFor="attrMapMail">{t('Email')}</strong>
+              </div>
+              <div className="col-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">
+              <div className="col-3 text-right py-2">
+                <strong htmlFor="attrMapName">{t('Name')}</strong>
+              </div>
+              <div className="col-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">
+              <div className="col-3 text-right py-2">
+                <strong htmlFor="groupSearchBase">{t('security_setting.ldap.group_search_base_DN')}</strong>
+              </div>
+              <div className="col-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">
+              <div className="col-3 text-right py-2">
+                <strong htmlFor="groupSearchFilter">{t('security_setting.ldap.group_search_filter')}</strong>
+              </div>
+              <div className="col-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">
+              <div className="col-3 text-right py-2">
+                <strong htmlFor="groupDnProperty">{t('security_setting.ldap.group_search_user_DN_property')}</strong>
+              </div>
+              <div className="col-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="offset-3 col-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-light 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);

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

@@ -0,0 +1,190 @@
+/* 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-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isLocalEnabled"
+                checked={isLocalEnabled}
+                onChange={() => adminGeneralSecurityContainer.switchIsLocalEnabled()}
+                disabled={adminLocalSecurityContainer.state.useOnlyEnvVars}
+              />
+              <label className="custom-control-label" htmlFor="isLocalEnabled">
+                {t('security_setting.Local.enable_local')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && isLocalEnabled)
+              && <div className="badge badge-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">
+              <div className="col-3 text-right py-2">
+                <strong>{t('Register limitation')}</strong>
+              </div>
+              <div className="col-6">
+                <div className="dropdown">
+                  <button
+                    className="btn btn-light dropdown-toggle"
+                    type="button"
+                    id="dropdownMenuButton"
+                    data-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="true"
+                  >
+                    {registrationMode === 'Open' && t('security_setting.registration_mode.open')}
+                    {registrationMode === 'Restricted' && t('security_setting.registration_mode.restricted')}
+                    {registrationMode === 'Closed' && t('security_setting.registration_mode.closed')}
+                  </button>
+                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                    <a className="dropdown-item" onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Open') }}>
+                      {t('security_setting.registration_mode.open')}
+                    </a>
+                    <a className="dropdown-item" onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Restricted') }}>
+                      {t('security_setting.registration_mode.restricted')}
+                    </a>
+                    <a className="dropdown-item" onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Closed') }}>
+                      {t('security_setting.registration_mode.closed')}
+                    </a>
+                  </div>
+                </div>
+
+                <p className="help-block small">
+                  {t('security_setting.Register limitation desc')}
+                </p>
+              </div>
+            </div>
+            <div className="row">
+              <div className="col-3 text-right">
+                <strong dangerouslySetInnerHTML={{ __html: t('The whitelist of registration permission E-mail address') }} />
+              </div>
+              <div className="col-6">
+                <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 className="row my-3">
+              <div className="offset-3 col-6">
+                <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);

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

@@ -0,0 +1,354 @@
+/* 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="offset-3 col-6">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                id="isOidcEnabled"
+                className="custom-control-input"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isOidcEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
+              />
+              <label className="custom-control-label" htmlFor="isOidcEnabled">
+                {t('security_setting.OAuth.enable_oidc')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('oidc') && isOidcEnabled)
+              && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-3 text-right py-2">{t('security_setting.callback_URL')}</label>
+          <div className="col-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-3 text-right py-2">{t('security_setting.providerName')}</label>
+              <div className="col-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-3 text-right py-2">{t('security_setting.issuerHost')}</label>
+              <div className="col-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-3 text-right py-2">{t('security_setting.clientID')}</label>
+              <div className="col-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-3 text-right py-2">{t('security_setting.client_secret')}</label>
+              <div className="col-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-3 text-right py-2">Identifier</label>
+              <div className="col-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-3 text-right py-2">{t('username')}</label>
+              <div className="col-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-3 text-right py-2">{t('Name')}</label>
+              <div className="col-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-3 text-right py-2">{t('Email')}</label>
+              <div className="col-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-3 text-right py-2">{t('security_setting.callback_URL')}</label>
+              <div className="col-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="offset-3 col-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    id="bindByUserName-oidc"
+                    className="custom-control-input"
+                    type="checkbox"
+                    checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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="offset-3 col-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    id="bindByEmail-oidc"
+                    className="custom-control-input"
+                    type="checkbox"
+                    checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                    onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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="offset-3 col-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);

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

@@ -0,0 +1,553 @@
+/* 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-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                id="isSamlEnabled"
+                className="custom-control-input"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isSamlEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsSamlEnabled() }}
+                disabled={adminSamlSecurityContainer.state.useOnlyEnvVars}
+              />
+              <label className="custom-control-label" htmlFor="isSamlEnabled">
+                {t('security_setting.SAML.enable_saml')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isSamlEnabled)
+              && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-3 text-right py-2">{t('security_setting.callback_URL')}</label>
+          <div className="col-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>
+                      <small>
+                        {t('security_setting.SAML.cert_detail')}
+                      </small>
+                    </p>
+                    <div>
+                      <small>
+                        e.g.
+                        <pre className="well card">{`-----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="offset-3 col-6 text-left">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    id="bindByUserName-SAML"
+                    className="custom-control-input"
+                    type="checkbox"
+                    checked={adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminSamlSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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="offset-3 col-6 text-left">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    id="bindByEmail-SAML"
+                    className="custom-control-input"
+                    type="checkbox"
+                    checked={adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                    onChange={() => { adminSamlSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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="offset-3 col-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);

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

@@ -0,0 +1,183 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import {
+  TabContent, TabPane, Nav, NavItem, NavLink,
+} from 'reactstrap';
+
+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() {
+    super();
+
+    this.state = {
+      activeTab: 'passport-local',
+      // Prevent unnecessary rendering
+      activeComponents: new Set(['passport-local']),
+    };
+
+    this.toggleActiveTab = this.toggleActiveTab.bind(this);
+  }
+
+  toggleActiveTab(activeTab) {
+    this.setState({
+      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
+    });
+  }
+
+  render() {
+    const { t } = this.props;
+    const { activeTab, activeComponents } = this.state;
+    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>
+
+        <div className="auth-mechanism-configurations m-t-10">
+          <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
+          <Nav tabs>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-local' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-local') }}
+              >
+                <i className="fa fa-users" /> ID/Pass
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-ldap' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-ldap') }}
+              >
+                <i className="fa fa-sitemap" /> LDAP
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-saml' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-saml') }}
+              >
+                <i className="fa fa-key" /> SAML
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-oidc' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-oidc') }}
+              >
+                <i className="fa fa-openid" /> OIDC
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-basic' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-basic') }}
+              >
+                <i className="fa fa-lock" /> BASIC
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-google' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-google') }}
+              >
+                <i className="fa fa-google" /> Google
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-github' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-github') }}
+              >
+                <i className="fa fa-github" /> GitHub
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-twitter' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-twitter') }}
+              >
+                <i className="fa fa-twitter" /> Twitter
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-facebook' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-facebook') }}
+              >
+                <i className="fa fa-facebook" /> (TBD) Facebook
+              </NavLink>
+            </NavItem>
+          </Nav>
+          <TabContent activeTab={activeTab} className="mt-2">
+            <TabPane tabId="passport-local">
+              {activeComponents.has('passport-local') && <LocalSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-ldap">
+              {activeComponents.has('passport-ldap') && <LdapSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-saml">
+              {activeComponents.has('passport-saml') && <SamlSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-oidc">
+              {activeComponents.has('passport-oidc') && <OidcSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-basic">
+              {activeComponents.has('passport-basic') && <BasicSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-google">
+              {activeComponents.has('passport-google') && <GoogleSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-github">
+              {activeComponents.has('passport-github') && <GitHubSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-twitter">
+              {activeComponents.has('passport-twitter') && <TwitterSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-facebook">
+              {activeComponents.has('passport-facebook') && <FacebookSecuritySetting />}
+            </TabPane>
+          </TabContent>
+        </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);

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

@@ -0,0 +1,200 @@
+/* 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>
+        <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 mb-5">
+          <div className="col-3 text-right py-2">
+            <strong>{t('security_setting.Guest Users Access')}</strong>
+          </div>
+          <div className="col-6">
+            <div className="dropdown">
+              <button
+                className={`btn btn-light dropdown-toggle ${adminGeneralSecurityContainer.isWikiModeForced && 'disabled'}`}
+                type="button"
+                id="dropdownMenuButton"
+                data-toggle="dropdown"
+                aria-haspopup="true"
+                aria-expanded="true"
+              >
+                {currentRestrictGuestMode === 'Deny' && t('security_setting.guest_mode.deny')}
+                {currentRestrictGuestMode === 'Readonly' && t('security_setting.guest_mode.readonly')}
+              </button>
+              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                <a className="dropdown-item" onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Deny') }}>
+                  {t('security_setting.guest_mode.deny')}
+                </a>
+                <a className="dropdown-item" onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Readonly') }}>
+                  {t('security_setting.guest_mode.readonly')}
+                </a>
+              </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-3 text-right" dangerouslySetInnerHTML={{ __html: t('security_setting.page_listing_1') }} />
+          <div className="col-6">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isShowRestrictedByOwner"
+                checked={adminGeneralSecurityContainer.state.isShowRestrictedByOwner}
+                onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByOwner() }}
+              />
+              <label className="custom-control-label" htmlFor="isShowRestrictedByOwner">
+                {t('security_setting.page_listing_1_desc')}
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <strong className="col-3 text-right" dangerouslySetInnerHTML={{ __html: t('security_setting.page_listing_2') }} />
+          <div className="col-6">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isShowRestrictedByGroup"
+                checked={adminGeneralSecurityContainer.state.isShowRestrictedByGroup}
+                onChange={() => { adminGeneralSecurityContainer.switchIsShowRestrictedByGroup() }}
+              />
+              <label className="custom-control-label" htmlFor="isShowRestrictedByGroup">
+                {t('security_setting.page_listing_2_desc')}
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <div className="col-3 text-right">
+            <strong>{t('security_setting.complete_deletion')}</strong>
+          </div>
+          <div className="col-9">
+            <div className="dropdown">
+              <button
+                className="btn btn-light dropdown-toggle"
+                type="button"
+                id="dropdownMenuButton"
+                data-toggle="dropdown"
+                aria-haspopup="true"
+                aria-expanded="true"
+              >
+                {currentPageCompleteDeletionAuthority === 'anyOne' && t('security_setting.anyone')}
+                {currentPageCompleteDeletionAuthority === 'adminOnly' && t('security_setting.admin_only')}
+                {(currentPageCompleteDeletionAuthority === 'adminAndAuthor' || currentPageCompleteDeletionAuthority == null)
+                    && t('security_setting.admin_and_author')}
+              </button>
+              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                <a className="dropdown-item" onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('anyOne') }}>
+                  {t('security_setting.anyone')}
+                </a>
+                <a className="dropdown-item" onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminOnly') }}>
+                  {t('security_setting.admin_only')}
+                </a>
+                <a className="dropdown-item" onClick={() => { adminGeneralSecurityContainer.changePageCompleteDeletionAuthority('adminAndAuthor') }}>
+                  {t('security_setting.admin_and_author')}
+                </a>
+              </div>
+              <p className="help-block small">
+                {t('security_setting.complete_deletion_explain')}
+              </p>
+            </div>
+          </div>
+        </div>
+        <div className="row my-3">
+          <div className="offset-3 col-5">
+            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.putSecuritySetting}>
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+      </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="offset-3 col-6">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                id="isTwitterEnabled"
+                className="custom-control-input"
+                type="checkbox"
+                checked={adminGeneralSecurityContainer.state.isTwitterEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsTwitterOAuthEnabled() }}
+              />
+              <label className="custom-control-label" htmlFor="isTwitterEnabled">
+                {t('security_setting.OAuth.Twitter.enable_twitter')}
+              </label>
+            </div>
+            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('twitter') && isTwitterEnabled)
+              && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-3 text-right py-2">{t('security_setting.callback_URL')}</label>
+          <div className="col-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-3 text-right py-2">{t('security_setting.clientID')}</label>
+              <div className="col-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-3 text-right py-2">{t('security_setting.client_secret')}</label>
+              <div className="col-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="offset-3 col-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    id="bindByUserNameTwitter"
+                    className="custom-control-input"
+                    type="checkbox"
+                    checked={adminTwitterSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                    onChange={() => { adminTwitterSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    className="custom-control-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="offset-3 col-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);

+ 1 - 1
src/client/js/components/Admin/UserGroup/UserGroupDeleteModal.jsx

@@ -164,7 +164,7 @@ class UserGroupDeleteModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isShow} toggle={this.props.onHide}>
-        <ModalHeader toggle={this.props.onHide} className="modal-header bg-danger">
+        <ModalHeader tag="h4" toggle={this.props.onHide} className="modal-header bg-danger">
           <i className="icon icon-fire"></i> {t('admin:user_group_management.delete_modal.header')}
         </ModalHeader>
         <ModalBody>

+ 2 - 2
src/client/js/components/Admin/UserGroupDetail/UserGroupDetailPage.jsx

@@ -23,10 +23,10 @@ class UserGroupDetailPage extends React.Component {
         <div className="m-t-20 form-box">
           <UserGroupEditForm />
         </div>
-        <legend className="m-t-20">{t('admin:user_group_management.user_list')}</legend>
+        <h2 className="admin-setting-header m-t-20">{t('admin:user_group_management.user_list')}</h2>
         <UserGroupUserTable />
         <UserGroupUserModal />
-        <legend className="m-t-20">{t('Page')}</legend>
+        <h2 className="admin-setting-header m-t-20">{t('Page')}</h2>
         <div className="page-list">
           <UserGroupPageList />
         </div>

+ 1 - 1
src/client/js/components/Admin/UserGroupDetail/UserGroupEditForm.jsx

@@ -63,7 +63,7 @@ class UserGroupEditForm extends React.Component {
     return (
       <form className="form-horizontal" onSubmit={this.handleSubmit}>
         <fieldset>
-          <legend>{t('admin:user_group_management.basic_info')}</legend>
+          <h2 className="admin-setting-header">{t('admin:user_group_management.basic_info')}</h2>
           <div className="form-group">
             <label htmlFor="name" className="col-sm-2 control-label">{t('Name')}</label>
             <div className="col-sm-4">

+ 1 - 1
src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -19,7 +19,7 @@ class UserGroupUserModal extends React.Component {
 
     return (
       <Modal isOpen={adminUserGroupDetailContainer.state.isUserGroupUserModalOpen} toggle={adminUserGroupDetailContainer.closeUserGroupUserModal}>
-        <ModalHeader toggle={adminUserGroupDetailContainer.closeUserGroupUserModal}>
+        <ModalHeader tag="h4" toggle={adminUserGroupDetailContainer.closeUserGroupUserModal}>
           {t('admin:user_group_management.add_modal.add_user') }
         </ModalHeader>
         <ModalBody>

+ 1 - 1
src/client/js/components/Admin/Users/ExternalAccountTable.jsx

@@ -85,7 +85,7 @@ class ExternalAccountTable extends React.Component {
                         </span>
                       )
                       : (
-                        <span className="label label-warning">
+                        <span className="badge badge-warning">
                           {t('admin:user_management.unset')}
                         </span>
                       )

+ 1 - 1
src/client/js/components/Admin/Users/PasswordResetModal.jsx

@@ -87,7 +87,7 @@ class PasswordResetModal extends React.Component {
 
     return (
       <Modal isOpen={adminUsersContainer.state.isPasswordResetModalShown} toggle={adminUsersContainer.hidePasswordResetModal}>
-        <ModalHeader toggle={adminUsersContainer.hidePasswordResetModal} className="modal-header">
+        <ModalHeader tag="h4" toggle={adminUsersContainer.hidePasswordResetModal} className="modal-header">
           {t('admin:user_management.reset_password') }
         </ModalHeader>
         <ModalBody>

+ 4 - 4
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -78,9 +78,9 @@ class UserInviteModal extends React.Component {
 
     return (
       <>
-        <div className="checkbox checkbox-success text-left" onChange={this.handleCheckBox} style={{ flex: 0.95 }}>
-          <input type="checkbox" id="sendEmail" className="form-check-input" name="sendEmail" defaultChecked={this.state.sendEmail} />
-          <label htmlFor="sendEmail">
+        <div className="ccustom-control custom-switch custom-checkbox-info text-left" onChange={this.handleCheckBox} style={{ flex: 0.95 }}>
+          <input type="checkbox" id="sendEmail" className="custom-control-input" name="sendEmail" defaultChecked={this.state.sendEmail} />
+          <label className="custom-control-label" htmlFor="sendEmail">
             {t('admin:user_management.invite_modal.invite_thru_email')}
           </label>
         </div>
@@ -189,7 +189,7 @@ class UserInviteModal extends React.Component {
 
     return (
       <Modal isOpen={adminUsersContainer.state.isUserInviteModalShown} toggle={this.onToggleModal}>
-        <ModalHeader toggle={this.onToggleModal} className="modal-header">
+        <ModalHeader tag="h4" toggle={this.onToggleModal} className="modal-header">
           {t('admin:user_management.invite_users') }
         </ModalHeader>
         <ModalBody>

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

+ 98 - 93
src/client/js/components/InstallerForm.jsx

@@ -46,110 +46,115 @@ class InstallerForm extends React.Component {
     const checkedBtn = this.state.checkedBtn;
 
     return (
-      <div className={`login-dialog py-3 col-sm-offset-4 col-sm-4${hasErrorClass}`}>
-        <p className="alert alert-success">
-          <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
-          <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
-        </p>
-
-        <form role="form" action="/installer" method="post" id="register-form">
-          <div className="form-group text-center">
-            <div className="form-check">
+      <div className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
+        <div className="row">
+          <div className="col-md-12">
+            <p className="alert alert-success">
+              <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
+              <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
+            </p>
+          </div>
+        </div>
+        <div className="row">
+          <form role="form" action="/installer" method="post" id="register-form" className="col-md-12">
+            <div className="form-group text-center">
+              <div className="custom-control custom-radio custom-control-inline">
+                <input
+                  type="radio"
+                  className="custom-control-input"
+                  id="register-form-check-en"
+                  name="registerForm[app:globalLang]"
+                  value="en-US"
+                  checked={checkedBtn === 'en-US'}
+                  inline
+                  onChange={(e) => { if (e.target.checked) { this.changeLanguage('en-US') } }}
+                />
+                <label className="custom-control-label" htmlFor="register-form-check-en">
+                  English
+                </label>
+              </div>
+              <div className="custom-control custom-radio custom-control-inline">
+                <input
+                  type="radio"
+                  className="custom-control-input"
+                  id="register-form-check-jp"
+                  name="registerForm[app:globalLang]"
+                  value="ja"
+                  checked={checkedBtn === 'ja'}
+                  inline
+                  onChange={(e) => { if (e.target.checked) { this.changeLanguage('ja') } }}
+                />
+                <label className="custom-control-label" htmlFor="register-form-check-jp">
+                  日本語
+                </label>
+              </div>
+            </div>
+
+            <div className={`input-group${hasErrorClass}`}>
+              <span className="input-group-addon"><i className="icon-user" /></span>
               <input
-                type="radio"
-                className="form-check-input"
-                id="register-form-check-en"
-                name="registerForm[app:globalLang]"
-                value="en-US"
-                checked={checkedBtn === 'en-US'}
-                inline
-                onChange={(e) => { if (e.target.checked) { this.changeLanguage('en-US') } }}
+                type="text"
+                className="form-control"
+                placeholder={this.props.t('User ID')}
+                name="registerForm[username]"
+                defaultValue={this.props.userName}
+                onBlur={this.checkUserName}
+                required
               />
-              <label className="form-check-label" htmlFor="register-form-check-en">
-                English
-              </label>
             </div>
-            <div className="form-check">
+            <p className="form-text">{ unavailableUserId }</p>
+
+            <div className="input-group">
+              <span className="input-group-addon"><i className="icon-tag" /></span>
               <input
-                type="radio"
-                className="form-check-input"
-                id="register-form-check-jp"
-                name="registerForm[app:globalLang]"
-                value="ja"
-                checked={checkedBtn === 'ja'}
-                inline
-                onChange={(e) => { if (e.target.checked) { this.changeLanguage('ja') } }}
+                type="text"
+                className="form-control"
+                placeholder={this.props.t('Name')}
+                name="registerForm[name]"
+                defaultValue={this.props.name}
+                required
               />
-              <label className="form-check-label" htmlFor="register-form-check-jp">
-                日本語
-              </label>
             </div>
-          </div>
 
-          <div className={`input-group${hasErrorClass}`}>
-            <span className="input-group-addon"><i className="icon-user" /></span>
-            <input
-              type="text"
-              className="form-control"
-              placeholder={this.props.t('User ID')}
-              name="registerForm[username]"
-              defaultValue={this.props.userName}
-              onBlur={this.checkUserName}
-              required
-            />
-          </div>
-          <p className="help-block">{ unavailableUserId }</p>
-
-          <div className="input-group">
-            <span className="input-group-addon"><i className="icon-tag" /></span>
-            <input
-              type="text"
-              className="form-control"
-              placeholder={this.props.t('Name')}
-              name="registerForm[name]"
-              defaultValue={this.props.name}
-              required
-            />
-          </div>
-
-          <div className="input-group">
-            <span className="input-group-addon"><i className="icon-envelope" /></span>
-            <input
-              type="email"
-              className="form-control"
-              placeholder={this.props.t('Email')}
-              name="registerForm[email]"
-              defaultValue={this.props.email}
-              required
-            />
-          </div>
+            <div className="input-group">
+              <span className="input-group-addon"><i className="icon-envelope" /></span>
+              <input
+                type="email"
+                className="form-control"
+                placeholder={this.props.t('Email')}
+                name="registerForm[email]"
+                defaultValue={this.props.email}
+                required
+              />
+            </div>
 
-          <div className="input-group">
-            <span className="input-group-addon"><i className="icon-lock" /></span>
-            <input
-              type="password"
-              className="form-control"
-              placeholder={this.props.t('Password')}
-              name="registerForm[password]"
-              required
-            />
-          </div>
+            <div className="input-group">
+              <span className="input-group-addon"><i className="icon-lock" /></span>
+              <input
+                type="password"
+                className="form-control"
+                placeholder={this.props.t('Password')}
+                name="registerForm[password]"
+                required
+              />
+            </div>
 
-          <input type="hidden" name="_csrf" value={this.props.csrf} />
+            <input type="hidden" name="_csrf" value={this.props.csrf} />
 
-          <div className="input-group mt-4 mb-3 d-flex justify-content-center">
-            <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
-              <span className="btn-label"><i className="icon-user-follow" /></span>
-              <span className="btn-label-text">{ this.props.t('Create') }</span>
-            </button>
-          </div>
+            <div className="input-group mt-4 mb-3 d-flex justify-content-center">
+              <button type="submit" className="fcbtn btn btn-success btn-1b btn-register">
+                <span className="btn-label"><i className="icon-user-follow" /></span>
+                <span className="btn-label-text">{ this.props.t('Create') }</span>
+              </button>
+            </div>
 
-          <div className="input-group mt-4 d-flex justify-content-center">
-            <a href="https://growi.org" className="link-growi-org">
-              <span className="growi">GROWI</span>.<span className="org">ORG</span>
-            </a>
-          </div>
-        </form>
+            <div className="input-group mt-4 d-flex justify-content-center">
+              <a href="https://growi.org" className="link-growi-org">
+                <span className="growi">GROWI</span>.<span className="org">ORG</span>
+              </a>
+            </div>
+          </form>
+        </div>
       </div>
     );
   }

+ 84 - 10
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -25,20 +25,94 @@ const PersonalDropdown = (props) => {
     window.location.href = '/logout';
   };
 
+  const followOsCheckboxModifiedHandler = (bool) => {
+    // reset user preference
+    if (bool) {
+      appContainer.setColorSchemePreference(null);
+    }
+    // set preferDarkModeByMediaQuery as users preference
+    else {
+      appContainer.setColorSchemePreference(appContainer.state.preferDarkModeByMediaQuery);
+    }
+  };
+
+  const userPreferenceSwitchModifiedHandler = (bool) => {
+    appContainer.setColorSchemePreference(bool);
+  };
+
+
+  /*
+   * render
+   */
+  const { preferDarkModeByMediaQuery, preferDarkModeByUser } = appContainer.state;
+  const isUserPreferenceExists = preferDarkModeByUser != null;
+  const isDarkMode = () => {
+    if (isUserPreferenceExists) {
+      return preferDarkModeByUser;
+    }
+    return preferDarkModeByMediaQuery;
+  };
+
   return (
     <>
-      <a className="dropdown-toggle waves-effect waves-light" data-toggle="dropdown">
+      {/* Button */}
+      <a className="nav-link 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>
+
+      {/* Menu */}
+      <div className="dropdown-menu dropdown-menu-right">
+
+        <a className="dropdown-item" href={`/user/${user.username}`}><i className="icon-fw icon-user"></i>{ t('User\'s Home') }</a>
+        <a className="dropdown-item" href="/me"><i className="icon-fw icon-wrench"></i>{ t('User Settings') }</a>
+
+        <div className="dropdown-divider"></div>
+
+        <a className="dropdown-item" href={`/user/${user.username}#user-draft-list`}><i className="icon-fw icon-docs"></i>{ t('List Drafts') }</a>
+        <a className="dropdown-item" href="/trash"><i className="icon-fw icon-trash"></i>{ t('Deleted Pages') }</a>
+
+        <div className="dropdown-divider"></div>
+
+        <h6 className="dropdown-header">Color Scheme</h6>
+        <form className="px-4">
+          <div className="form-row align-items-center">
+            <div className="col-auto">
+              <div className="custom-control custom-checkbox">
+                <input
+                  id="cbFollowOs"
+                  className="custom-control-input"
+                  type="checkbox"
+                  checked={!isUserPreferenceExists}
+                  onChange={e => followOsCheckboxModifiedHandler(e.target.checked)}
+                />
+                <label className="custom-control-label" htmlFor="cbFollowOs">Use OS Setting</label>
+              </div>
+            </div>
+          </div>
+          <div className="form-row align-items-center">
+            <div className="col-auto d-flex">
+              <span className={isUserPreferenceExists ? '' : 'text-muted'}>Light</span>
+              <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
+                <input
+                  id="swUserPreference"
+                  className="custom-control-input"
+                  type="checkbox"
+                  checked={isDarkMode()}
+                  disabled={!isUserPreferenceExists}
+                  onChange={e => userPreferenceSwitchModifiedHandler(e.target.checked)}
+                />
+                <label className="custom-control-label" htmlFor="swUserPreference"></label>
+              </div>
+              <span className={isUserPreferenceExists ? '' : 'text-muted'}>Dark</span>
+            </div>
+          </div>
+        </form>
+
+        <div className="dropdown-divider"></div>
+
+        <a className="dropdown-item" type="button" onClick={logoutHandler}><i className="icon-fw icon-power"></i>{ t('Sign out') }</a>
+      </div>
+
     </>
   );
 

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

+ 1 - 1
src/client/js/components/Page/CopyDropdown.jsx

@@ -59,7 +59,7 @@ export default class CopyDropdown extends React.Component {
 
           <DropdownToggle
             caret
-            className="btn-copy"
+            className="d-block text-muted bg-transparent btn-copy"
             style={this.props.buttonStyle}
           >
             <i className="ti-clipboard"></i>

+ 1 - 1
src/client/js/components/Page/RevisionPath.jsx

@@ -159,7 +159,7 @@ class RevisionPath extends React.Component {
 
         <CopyDropdown t={this.props.t} pagePath={this.props.pagePath} pageId={this.props.pageId} buttonStyle={buttonStyle}></CopyDropdown>
 
-        <a href="#edit" className="btn btn-default btn-edit" style={buttonStyle}>
+        <a href="#edit" className="d-block btn btn-default btn-edit text-muted" style={buttonStyle}>
           <i className="icon-note" />
         </a>
       </span>

+ 1 - 1
src/client/js/components/Page/TagEditor.jsx

@@ -47,7 +47,7 @@ export default class TagEditor extends React.Component {
   render() {
     return (
       <Modal isOpen={this.state.isOpenModal} toggle={this.closeModalHandler} id="editTagModal">
-        <ModalHeader closeButton className="bg-primary">
+        <ModalHeader tag="h4" toggle={this.closeModalHandler} className="bg-primary">
           <span className="text-white">Edit Tags</span>
         </ModalHeader>
         <ModalBody>

+ 2 - 2
src/client/js/components/Page/TagLabels.jsx

@@ -110,13 +110,13 @@ class TagLabels extends React.Component {
     return (
       <div className={`tag-viewer ${pageId ? 'existed-page' : 'new-page'}`}>
         {tags.length === 0 && (
-          <a className="btn btn-link btn-edit-tags no-tags p-0" onClick={this.showEditor}>
+          <a className="btn btn-link btn-edit-tags no-tags p-0 text-muted" onClick={this.showEditor}>
             { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
           </a>
         )}
         {tagElements}
         {tags.length > 0 && (
-          <a className="btn btn-link btn-edit-tags p-0" onClick={this.showEditor}>
+          <a className="btn btn-link btn-edit-tags p-0 text-muted" onClick={this.showEditor}>
             <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
           </a>
         )}

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

@@ -73,7 +73,7 @@ export default class DeleteAttachmentModal extends React.Component {
 
     return (
       <Modal {...props} className="attachment-delete-modal" bsSize="large" aria-labelledby="contained-modal-title-lg">
-        <ModalHeader closeButton>
+        <ModalHeader tag="h4" toggle={this.props.toggle}>
           <span id="contained-modal-title-lg">Delete attachment?</span>
         </ModalHeader>
         <ModalBody>

+ 2 - 2
src/client/js/components/PageComment/Comment.jsx

@@ -108,8 +108,8 @@ class Comment extends React.PureComponent {
   }
 
   getRevisionLabelClassName() {
-    return `page-comment-revision label ${
-      this.isCurrentRevision() ? 'label-primary' : 'label-default'}`;
+    return `page-comment-revision badge ${
+      this.isCurrentRevision() ? 'badge-primary' : 'badge-default'}`;
   }
 
   editBtnClickedHandler() {

+ 1 - 1
src/client/js/components/PageComment/DeleteCommentModal.jsx

@@ -37,7 +37,7 @@ export default class DeleteCommentModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isShown} toggle={this.props.cancel} className="page-comment-delete-modal">
-        <ModalHeader toggle={this.props.cancel}>
+        <ModalHeader tag="h4" toggle={this.props.cancel}>
           <span>
             <i className="icon-fw icon-fire text-danger"></i>
             Delete comment?

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

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

@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import i18next from 'i18next';
+
+import {
+  Modal,
+  ModalBody,
+} from 'reactstrap';
+
+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 isOpen={this.state.show} toggle={this.cancel} className="drawio-modal" bsSize="large" keyboard={false}>
+        <ModalBody 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>
+        </ModalBody>
+      </Modal>
+    );
+  }
+
+}
+
+DrawioModal.propTypes = {
+  onSave: PropTypes.func,
+};

+ 1 - 1
src/client/js/components/PageEditor/Editor.jsx

@@ -260,7 +260,7 @@ export default class Editor extends AbstractEditor {
 
     return (
       <Modal isOpen={this.state.isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal modal-gfm-cheatsheet">
-        <ModalHeader closeButton className="bg-primary">
+        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary">
           <span className="text-white"><i className="icon-fw icon-question" />Markdown Help</span>
         </ModalHeader>
         <ModalBody>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 3 - 0
src/client/js/components/PageEditor/EditorIcon.jsx


+ 1 - 1
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -434,7 +434,7 @@ export default class HandsontableModal extends React.PureComponent {
 
     return (
       <Modal isOpen={this.state.show} toggle={this.cancel} size="lg" className={dialogClassName}>
-        <ModalHeader toggle={this.cancel} close={buttons}>Edit Table</ModalHeader>
+        <ModalHeader tag="h4" toggle={this.cancel} close={buttons}>Edit Table</ModalHeader>
         <ModalBody className="p-0 d-flex flex-column">
           <div className="px-4 py-3 modal-navbar bg-light">
             <Button className="mr-4 data-import-button bg-light" onClick={this.toggleDataImportArea}>

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

+ 1 - 1
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -95,7 +95,7 @@ export default class PageRevisionList extends React.Component {
 
     return (
       <React.Fragment>
-        <div className="custom-control custom-switch float-right">
+        <div className="custom-control custom-checkbox custom-checkbox-info float-right">
           <input
             type="checkbox"
             id="cbCompactize"

+ 1 - 1
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -217,7 +217,7 @@ class GrantSelector extends React.Component {
         isOpen={this.state.isSelectGroupModalShown}
         toggle={this.hideSelectGroupModal}
       >
-        <ModalHeader toggle={this.hideSelectGroupModal}>
+        <ModalHeader tag="h4" toggle={this.hideSelectGroupModal}>
           Select a Group
         </ModalHeader>
         <ModalBody>

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

@@ -92,7 +92,7 @@ class SearchPage extends React.Component {
   render() {
     return (
       <div>
-        <div className="search-page-input">
+        <div className="search-page-input sps sps--abv">
           <SearchPageForm
             t={this.props.t}
             onSearchFormChanged={this.search}

+ 1 - 1
src/client/js/components/SearchPage/DeletePageListModal.jsx

@@ -29,7 +29,7 @@ export default class DeletePageListModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isShown} toggle={this.props.cancel} className="page-list-delete-modal">
-        <ModalHeader toggle={this.props.cancel}>
+        <ModalHeader tag="h4" toggle={this.props.cancel}>
           Deleting pages:
         </ModalHeader>
         <ModalBody>

+ 0 - 5
src/client/js/components/SearchPage/SearchResult.jsx

@@ -264,11 +264,6 @@ class SearchResult extends React.Component {
       );
     });
 
-    // TODO あとでなんとかする
-    setTimeout(() => {
-      $('#search-result-list > nav').affix({ offset: { top: 50 } });
-    }, 1200);
-
     /*
     UI あとで考える
     <span className="search-result-meta">Found: {this.props.searchResultMeta.total} pages with "{this.props.searchingKeyword}"</span>

+ 41 - 16
src/client/js/components/TableOfContents.jsx

@@ -1,15 +1,19 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import { withTranslation } from 'react-i18next';
 
 import { debounce } from 'throttle-debounce';
+import StickyEvents from 'sticky-events';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 
 import { createSubscribedElement } from './UnstatedUtils';
 
+const logger = loggerFactory('growi:TableOfContents');
+
 // get these value with
 //   document.querySelector('.revision-toc').getBoundingClientRect().top
 const DEFAULT_REVISION_TOC_TOP_FOR_GROWI_LAYOUT = 190;
@@ -27,37 +31,54 @@ class TableOfContents extends React.Component {
   constructor(props) {
     super(props);
 
+    this.init = this.init.bind(this);
     this.resetScrollbarDebounced = debounce(100, this.resetScrollbar);
+
+    const { layoutType } = this.props.appContainer.config;
+
+    this.defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_GROWI_LAYOUT;
+    if (layoutType === 'kibela') {
+      this.defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_KIBELA_LAYOUT;
+    }
+  }
+
+  componentDidMount() {
+    this.init();
+    this.resetScrollbar();
   }
 
   componentDidUpdate() {
+    this.resetScrollbar();
+  }
+
+  init() {
     const { layoutType } = this.props.appContainer.config;
     if (layoutType === 'crowi') {
       return;
     }
 
-    let defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_GROWI_LAYOUT;
-    if (layoutType === 'kibela') {
-      defaultRevisionTocTop = DEFAULT_REVISION_TOC_TOP_FOR_KIBELA_LAYOUT;
-    }
-
-    // initialize
-    this.resetScrollbar(defaultRevisionTocTop);
-
     /*
      * set event listener
      */
     // resize
     window.addEventListener('resize', (event) => {
-      this.resetScrollbarDebounced(defaultRevisionTocTop);
+      this.resetScrollbarDebounced(this.defaultRevisionTocTop);
+    });
+
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({
+      stickySelector: '#revision-toc',
     });
-    // affix on
-    $('#revision-toc').on('affixed.bs.affix', () => {
-      this.resetScrollbar(this.getCurrentRevisionTocTop());
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.STUCK, (event) => {
+      logger.debug('StickyEvents.STUCK detected');
+      this.resetScrollbar();
     });
-    // affix off
-    $('#revision-toc').on('affixed-top.bs.affix', () => {
-      this.resetScrollbar(defaultRevisionTocTop);
+    elem.addEventListener(StickyEvents.UNSTUCK, (event) => {
+      logger.debug('StickyEvents.UNSTUCK detected');
+      this.resetScrollbar(this.defaultRevisionTocTop);
     });
   }
 
@@ -67,18 +88,22 @@ class TableOfContents extends React.Component {
     return revisionTocElem.getBoundingClientRect().top;
   }
 
-  resetScrollbar(revisionTocTop) {
+  resetScrollbar(defaultRevisionTocTop) {
     const tocContentElem = document.querySelector('.revision-toc .markdownIt-TOC');
 
     if (tocContentElem == null) {
       return;
     }
 
+    const revisionTocTop = defaultRevisionTocTop || this.getCurrentRevisionTocTop();
     // window height - revisionTocTop - .system-version height
     const viewHeight = window.innerHeight - revisionTocTop - 20;
 
     const tocContentHeight = tocContentElem.getBoundingClientRect().height + 15; // add margin
 
+    logger.debug('viewHeight', viewHeight);
+    logger.debug('tocContentHeight', tocContentHeight);
+
     if (viewHeight < tocContentHeight) {
       $('#revision-toc-content').slimScroll({
         railVisible: true,

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

@@ -116,28 +116,6 @@ Crowi.handleKeyCtrlSlashHandler = (event) => {
   event.preventDefault();
 };
 
-Crowi.initAffix = () => {
-  const $affixContent = $('#page-header');
-  if ($affixContent.length > 0) {
-    const $affixContentContainer = $('.row.grw-subnav');
-    const containerHeight = $affixContentContainer.outerHeight(true);
-    $affixContent.affix({
-      offset: {
-        top() {
-          return $('.navbar').outerHeight(true) + containerHeight;
-        },
-      },
-    });
-    $('[data-affix-disable]').on('click', function(e) {
-      const $elm = $($(this).data('affix-disable'));
-      $(window).off('.affix');
-      $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
-      return false;
-    });
-    $affixContentContainer.css({ 'min-height': containerHeight });
-  }
-};
-
 Crowi.initClassesByOS = function() {
   // add classes to cmd-key by OS
   const platform = navigator.platform.toLowerCase();
@@ -665,7 +643,6 @@ window.addEventListener('load', (e) => {
 
   Crowi.highlightSelectedSection(window.location.hash);
   Crowi.modifyScrollTop();
-  Crowi.initAffix();
   Crowi.initClassesByOS();
 });
 

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

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

+ 83 - 0
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';
 
@@ -27,6 +31,8 @@ export default class AppContainer extends Container {
 
     this.state = {
       editorMode: null,
+      preferDarkModeByMediaQuery: false,
+      preferDarkModeByUser: null,
     };
 
     const body = document.querySelector('body');
@@ -52,6 +58,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;
@@ -92,6 +99,33 @@ export default class AppContainer extends Container {
     return 'AppContainer';
   }
 
+  init() {
+    this.initColorScheme();
+    this.initPlugins();
+  }
+
+  async initColorScheme() {
+    const switchStateByMediaQuery = (mql) => {
+      const preferDarkMode = mql.matches;
+      this.setState({ preferDarkModeByMediaQuery: preferDarkMode });
+
+      this.applyColorScheme();
+    };
+
+    const mqlForDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
+    // add event listener
+    mqlForDarkMode.addListener(switchStateByMediaQuery);
+
+    // restore settings from localStorage
+    const { localStorage } = window;
+    if (localStorage.preferDarkModeByUser != null) {
+      await this.setState({ preferDarkModeByUser: localStorage.preferDarkModeByUser === 'true' });
+    }
+
+    // initialize
+    switchStateByMediaQuery(mqlForDarkMode);
+  }
+
   initPlugins() {
     if (this.isPluginEnabled) {
       const growiPlugin = window.growiPlugin;
@@ -299,6 +333,55 @@ export default class AppContainer extends Container {
     targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
   }
 
+  launchDrawioModal(componentKind, beginLineNumber, endLineNumber) {
+    let targetComponent;
+    switch (componentKind) {
+      case 'page':
+        targetComponent = this.getComponentInstance('Page');
+        break;
+    }
+    targetComponent.launchDrawioModal(beginLineNumber, endLineNumber);
+  }
+
+  /**
+   * Set color scheme preference by user
+   * @param {boolean} isDarkMode
+   */
+  async setColorSchemePreference(isDarkMode) {
+    await this.setState({ preferDarkModeByUser: isDarkMode });
+
+    // store settings to localStorage
+    const { localStorage } = window;
+    if (isDarkMode == null) {
+      delete localStorage.removeItem('preferDarkModeByUser');
+    }
+    else {
+      localStorage.preferDarkModeByUser = isDarkMode;
+    }
+
+    this.applyColorScheme();
+  }
+
+  /**
+   * Apply color scheme as 'dark' attribute of <html></html>
+   */
+  applyColorScheme() {
+    const { preferDarkModeByMediaQuery, preferDarkModeByUser } = this.state;
+    let isDarkMode = preferDarkModeByMediaQuery;
+    if (preferDarkModeByUser != null) {
+      isDarkMode = preferDarkModeByUser;
+    }
+
+    // switch to dark mode
+    if (isDarkMode) {
+      document.documentElement.setAttribute('dark', 'true');
+    }
+    // switch to light mode
+    else {
+      document.documentElement.removeAttribute('dark');
+    }
+  }
+
   async apiGet(path, params) {
     return this.apiRequest('get', 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: ':::',
+    });
+  }
+
+}

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

@@ -1,16 +1,19 @@
 .top-left-part {
-  .logo-mark, .logo-text {
+  .logo-mark,
+  .logo-text {
     fill: white;
   }
 }
 
-
 /*
  * Button
  */
 .btn-default {
-  &:hover, &:focus,
-  &.active, &.active:hover, &.active:focus {
+  &:hover,
+  &:focus,
+  &.active,
+  &.active:hover,
+  &.active:focus {
     color: white;
     background-color: lighten($bodycolor, 5%);
   }
@@ -19,12 +22,14 @@
 /*
   * Form
   */
-input.form-control, textarea.form-control {
+input.form-control,
+textarea.form-control {
   color: lighten($bodytext, 30%);
   background-color: darken($bodycolor, 5%);
   border: 1px solid darken($border, 30%);
 }
-.form-control[disabled], .form-control[readonly] {
+.form-control[disabled],
+.form-control[readonly] {
   color: lighten($bodytext, 10%);
   background-color: lighten($bodycolor, 5%);
 }
@@ -61,8 +66,11 @@ input.form-control, textarea.form-control {
  * Panel
  */
 .panel {
-  &, &.panel-white, &.panel-default {
-    .panel-heading, .panel-body {
+  &,
+  &.panel-white,
+  &.panel-default {
+    .panel-heading,
+    .panel-body {
       color: $light;
     }
   }
@@ -71,36 +79,31 @@ input.form-control, textarea.form-control {
 /*
  * Table
  */
- .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th,
- .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td ,
- .table > thead > tr > th, .table-bordered{
-     border-top: 1px solid $border;
- }
-
- .table-bordered > thead > tr > th,
- .table-bordered > tbody > tr > th,
- .table-bordered > tfoot > tr > th,
- .table-bordered > thead > tr > td,
- .table-bordered > tbody > tr > td,
- .table-bordered > tfoot > tr > td {
-   border: 1px solid $border;
- }
- .table > thead > tr > th {
-     border-bottom: 1px solid $border;
- }
-
- .table-bordered {
-     border: 1px solid $border;
- }
+.table > thead > tr > th,
+.table > tbody > tr > th,
+.table > tfoot > tr > th,
+.table > thead > tr > td,
+.table > tbody > tr > td,
+.table > tfoot > tr > td,
+.table > thead > tr > th,
+.table-bordered {
+  border-top: 1px solid $border;
+}
 
+.table-bordered > thead > tr > th,
+.table-bordered > tbody > tr > th,
+.table-bordered > tfoot > tr > th,
+.table-bordered > thead > tr > td,
+.table-bordered > tbody > tr > td,
+.table-bordered > tfoot > tr > td {
+  border: 1px solid $border;
+}
+.table > thead > tr > th {
+  border-bottom: 1px solid $border;
+}
 
-/*
- * GROWI header
- */
-.main-container header.affix {
-  .logo-mark {
-    fill: white;
-  }
+.table-bordered {
+  border: 1px solid $border;
 }
 
 /*
@@ -160,8 +163,8 @@ legend {
 .admin-page {
   #themeOptions {
     .theme-option-container.active a {
-      background-color: darken($themecolor,15%);
-      border-color: darken($themecolor,15%);
+      background-color: darken($themecolor, 15%);
+      border-color: darken($themecolor, 15%);
     }
   }
 }

+ 0 - 29
src/client/styles/agile-admin/inverse/colors/default-dark.scss

@@ -1,29 +0,0 @@
-@import '../variables';
-
-$basecolor: #212731;
-$bgcolor-theme: #f75b36;
-
-$bgcolor-navbar: #151515;
-$bgcolor-global: #101010;
-$bgcolor-global: $basecolor;
-$color-header: lighten($basecolor, 45%);
-$color-global: lighten($basecolor, 35%);
-$linktext: lighten($basecolor, 45%);
-$linktext-hover: lighten($linktext, 10%);
-$sidebar-text: #a6acbc;
-$dark-themecolor: #4f5467;
-
-$primary: $bgcolor-theme;
-$fillcolor-logo-mark: #444;
-$color-link-wiki: saturate($color-global, 20%);
-$color-link-wiki-hover: darken($color-link-wiki, 5%);
-
-$dark: darken($color-global, 5%);
-$border: lighten($basecolor, 15%);
-$navbar-border: lighten($border, 10%);
-$active-navbar-border: darken($border, 3%);
-$btn-default-bgcolor: darken($basecolor, 10%);
-$bgcolor-inline-code: darken($bgcolor-global, 5%);
-
-@import 'apply-colors';
-@import 'apply-colors-dark';

+ 0 - 21
src/client/styles/agile-admin/inverse/colors/default.scss

@@ -1,21 +0,0 @@
-@import '../variables';
-
-$bgcolor-theme: #112744;
-
-$bgcolor-navbar: #334455;
-$bgcolor-global: #fff;
-$bgcolor-global: #fff;
-$color-header: #2b2b2b;
-$color-global: #333333;
-$linktext: lighten($bgcolor-theme, 20%);
-$linktext-hover: lighten($linktext, 20%);
-$sidebar-text: #38495a;
-
-$primary: $bgcolor-theme;
-
-$fillcolor-logo-mark: lighten(desaturate($bgcolor-navbar, 10%), 15%);
-$color-link-wiki: lighten($bgcolor-theme, 20%);
-$color-link-wiki-hover: lighten($color-link-wiki, 20%);
-
-@import 'apply-colors';
-@import 'apply-colors-light';

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

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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio