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

Merge branch 'reactify-admin/security' into call-api-local-security-setting

# Conflicts:
#	src/server/routes/apiv3/security-setting.js
WESEEK Kaito 6 лет назад
Родитель
Сommit
9101f21573
100 измененных файлов с 4918 добавлено и 1344 удалено
  1. 9 1
      .github/workflows/build-rc.yml
  2. 11 3
      .github/workflows/build.yml
  3. 108 33
      .github/workflows/ci.yml
  4. 20 12
      .github/workflows/release.yml
  5. 9 0
      .markdownlint.yml
  6. 1 2
      .stylelintrc.json
  7. 1 0
      .vscode/extensions.json
  8. 15 6
      .vscode/settings.json
  9. 55 13
      CHANGES.md
  10. 28 79
      README.md
  11. 1 1
      app.json
  12. 0 6
      bin/github-actions/bump-version.sh
  13. 6 0
      bin/github-actions/update-readme.sh
  14. 0 9
      bin/wercker/init-git.sh
  15. 0 40
      bin/wercker/trigger-growi-docker.sh
  16. 0 28
      bin/wercker/trigger-growi-docs.sh
  17. 1 1
      config/migrate.js
  18. 4 2
      config/swagger-definition.js
  19. 9 0
      config/webpack.common.js
  20. 0 49
      config/webpack.dev.dll.js
  21. 0 7
      config/webpack.dev.js
  22. 12 11
      docker/Dockerfile
  23. 0 1
      docker/Dockerfile.dockerignore
  24. 16 65
      docker/README.md
  25. 0 3
      docker/bin/remove-except-artifacts.sh
  26. 15 11
      package.json
  27. 88 87
      resource/locales/en-US/translation.json
  28. 83 83
      resource/locales/ja/translation.json
  29. 59 77
      src/client/js/app.jsx
  30. 139 0
      src/client/js/components/Admin/App/AppSetting.jsx
  31. 94 0
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  32. 156 0
      src/client/js/components/Admin/App/AwsSetting.jsx
  33. 117 0
      src/client/js/components/Admin/App/MailSetting.jsx
  34. 80 0
      src/client/js/components/Admin/App/PluginSetting.jsx
  35. 108 0
      src/client/js/components/Admin/App/SiteUrlSetting.jsx
  36. 28 0
      src/client/js/components/Admin/Common/AdminUpdateButtonRow.jsx
  37. 4 5
      src/client/js/components/Admin/CustomCssEditor.jsx
  38. 4 5
      src/client/js/components/Admin/CustomHeaderEditor.jsx
  39. 4 5
      src/client/js/components/Admin/CustomScriptEditor.jsx
  40. 55 21
      src/client/js/components/Admin/Customize/Customize.jsx
  41. 38 0
      src/client/js/components/Admin/Customize/CustomizeBehaviorOption.jsx
  42. 99 0
      src/client/js/components/Admin/Customize/CustomizeBehaviorSetting.jsx
  43. 91 0
      src/client/js/components/Admin/Customize/CustomizeCssSetting.jsx
  44. 38 0
      src/client/js/components/Admin/Customize/CustomizeFunctionOption.jsx
  45. 158 0
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  46. 100 0
      src/client/js/components/Admin/Customize/CustomizeHeaderSetting.jsx
  47. 143 0
      src/client/js/components/Admin/Customize/CustomizeHighlightSetting.jsx
  48. 42 0
      src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx
  49. 81 0
      src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx
  50. 79 0
      src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx
  51. 122 0
      src/client/js/components/Admin/Customize/CustomizeScriptSetting.jsx
  52. 91 0
      src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx
  53. 79 0
      src/client/js/components/Admin/Customize/CustomizeTitle.jsx
  54. 45 0
      src/client/js/components/Admin/Customize/ThemeColorBox.jsx
  55. 10 1
      src/client/js/components/Admin/ExportArchiveDataPage.jsx
  56. 1 1
      src/client/js/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  57. 21 14
      src/client/js/components/Admin/MarkdownSetting/LineBreakForm.jsx
  58. 115 0
      src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx
  59. 22 1
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  60. 12 12
      src/client/js/components/Admin/MarkdownSetting/PresentationForm.jsx
  61. 10 10
      src/client/js/components/Admin/MarkdownSetting/PresentationLineBreakOptions.jsx
  62. 30 47
      src/client/js/components/Admin/MarkdownSetting/WhiteListInput.jsx
  63. 47 21
      src/client/js/components/Admin/MarkdownSetting/XssForm.jsx
  64. 52 0
      src/client/js/components/Admin/Security/BasicSecuritySetting.jsx
  65. 6 0
      src/client/js/components/Admin/Security/GithubSecuritySetting.jsx
  66. 30 24
      src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx
  67. 152 0
      src/client/js/components/Admin/Security/LdapAuthTestModal.jsx
  68. 129 62
      src/client/js/components/Admin/Security/LdapSecuritySetting.jsx
  69. 233 182
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  70. 134 63
      src/client/js/components/Admin/Security/SamlSecuritySetting.jsx
  71. 6 0
      src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx
  72. 37 0
      src/client/js/components/Admin/UserGroupDetail/CheckBoxForSerchUserOption.jsx
  73. 37 0
      src/client/js/components/Admin/UserGroupDetail/RadioButtonForSerchUserOption.jsx
  74. 1 1
      src/client/js/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  75. 105 24
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  76. 48 2
      src/client/js/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  77. 8 0
      src/client/js/components/PageEditor/AbstractEditor.jsx
  78. 23 16
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  79. 1 1
      src/client/js/components/PageEditor/HandsontableModal.jsx
  80. 42 19
      src/client/js/components/PageEditor/MarkdownTableInterceptor.js
  81. 4 12
      src/client/js/components/PageEditor/MarkdownTableUtil.js
  82. 7 0
      src/client/js/components/PageEditor/TextAreaEditor.jsx
  83. 23 7
      src/client/js/components/User/UserPicture.jsx
  84. 9 1
      src/client/js/components/User/Username.jsx
  85. 299 0
      src/client/js/services/AdminAppContainer.js
  86. 27 7
      src/client/js/services/AdminBasicSecurityContainer.js
  87. 312 0
      src/client/js/services/AdminCustomizeContainer.js
  88. 8 3
      src/client/js/services/AdminGithubSecurityConatainer.js
  89. 3 0
      src/client/js/services/AdminGoogleSecurityContainer.js
  90. 106 52
      src/client/js/services/AdminLdapSecurityContainer.js
  91. 49 14
      src/client/js/services/AdminMarkDownContainer.js
  92. 59 8
      src/client/js/services/AdminOidcSecurityContainer.js
  93. 92 48
      src/client/js/services/AdminSamlSecurityContainer.js
  94. 8 3
      src/client/js/services/AdminTwitterSecurityContainer.js
  95. 48 0
      src/client/js/services/UserGroupDetailContainer.js
  96. 4 0
      src/client/styles/scss/_admin.scss
  97. 1 1
      src/client/styles/scss/_on-edit.scss
  98. 0 10
      src/server/form/admin/app.js
  99. 0 11
      src/server/form/admin/aws.js
  100. 1 0
      src/server/form/admin/customfeatures.js

+ 9 - 1
.github/workflows/build-rc.yml

@@ -3,7 +3,7 @@ name: Release Docker Images for RC
 on:
   push:
     branches:
-      - rc/*
+      - rc/**
 
 jobs:
 
@@ -14,6 +14,14 @@ jobs:
     steps:
     - uses: actions/checkout@v1
 
+    - name: Cache/Restore yarn cache
+      uses: actions/cache@v1
+      with:
+        path: /usr/local/share/.cache/yarn
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+
     - name: Set up Docker Buildx
       uses: crazy-max/ghaction-docker-buildx@v1.0.4
 

+ 11 - 3
.github/workflows/build.yml

@@ -3,7 +3,7 @@ name: Release Docker Images
 on:
   push:
     tags:
-      - v3.*
+      - v3.*.*
 
 jobs:
 
@@ -23,6 +23,14 @@ jobs:
         [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
         echo ::set-env name=SUFFIX::$suffix
 
+    - name: Cache/Restore yarn cache
+      uses: actions/cache@v1
+      with:
+        path: /usr/local/share/.cache/yarn
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+
     - name: Set up Docker Buildx
       uses: crazy-max/ghaction-docker-buildx@v1.0.4
 
@@ -34,7 +42,7 @@ jobs:
       run: |
         CACHE_REF=weseek/growi-cache:3${{ env.SUFFIX }}
         docker buildx build \
-          --tag growi \
+          --tag growi${{ env.SUFFIX }} \
           --build-arg flavor=${{ matrix.flavor }} \
           --platform linux/amd64 \
           --load \
@@ -50,7 +58,7 @@ jobs:
     - name: Docker Tags by SemVer
       uses: weseek/ghaction-docker-tags-by-semver@v1.0.5
       with:
-        source: growi
+        source: growi${{ env.SUFFIX }}
         target: weseek/growi
         semver: ${{ env.SEMVER }}
         suffix: ${{ env.SUFFIX }}

+ 108 - 33
.github/workflows/ci.yml

@@ -1,15 +1,19 @@
 name: Node CI
 
-on: [push]
+on:
+  push:
+    branches-ignore:
+      - release/**
+      - tmp/**
 
 jobs:
 
-  resolve-dependencies:
+  lint:
     runs-on: ubuntu-latest
 
     strategy:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x]
 
     steps:
     - uses: actions/checkout@v1
@@ -18,34 +22,52 @@ jobs:
       with:
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
-      id: cache
+      id: cache-dependencies
       uses: actions/cache@v1
       with:
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v1
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
     - name: Install dependencies
-      if: steps.cache.outputs.cache-hit != 'true'
-      run: |
-        yarn
-    - name: Install plugins
-      if: steps.cache.outputs.cache-hit != 'true'
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
-        yarn add -D react-images react-motion
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion
     - name: Print dependencies
       run: |
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         yarn list --depth=0
+    - name: yarn lint
+      run: |
+        yarn lint
+
+    - name: Slack Notification
+      uses: homoluctus/slatify@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*test (${{ matrix.node-version }})*'
+        channel: '#ci'
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
   test:
     runs-on: ubuntu-latest
-    needs: resolve-dependencies
 
     strategy:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x]
 
     steps:
     - uses: actions/checkout@v1
@@ -54,13 +76,32 @@ jobs:
       with:
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
+      id: cache-dependencies
       uses: actions/cache@v1
       with:
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-    - name: yarn lint
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v1
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+    - name: Install dependencies
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
-        yarn lint
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
     - name: Launch MongoDB
       uses: wbari/start-mongoDB@v0.2
       with:
@@ -80,13 +121,13 @@ jobs:
         channel: '#ci'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
+
   build-dev:
     runs-on: ubuntu-latest
-    needs: resolve-dependencies
 
     strategy:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x]
 
     steps:
     - uses: actions/checkout@v1
@@ -95,10 +136,32 @@ jobs:
       with:
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
+      id: cache-dependencies
       uses: actions/cache@v1
       with:
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v1
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+    - name: Install dependencies
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      run: |
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
     - name: yarn build:dev
       run: |
         yarn build:dev
@@ -115,7 +178,6 @@ jobs:
 
   build-prod:
     runs-on: ubuntu-latest
-    needs: resolve-dependencies
 
     strategy:
       matrix:
@@ -127,33 +189,46 @@ jobs:
       uses: actions/setup-node@v1
       with:
         node-version: ${{ matrix.node-version }}
-    - name: Cache/Restore node_modules
+    - name: Get yarn cache dir
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
       uses: actions/cache@v1
       with:
-        path: node_modules
-        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+    - name: Install dependencies
+      run: |
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+        yarn add -D react-images@1.0.0 react-motion
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
+    - name: yarn build:prod
+      run: |
+        yarn build:prod
+    - name: yarn install --production
+      run: |
+        yarn install --production
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --production --depth=0
     - name: Launch MongoDB
       uses: wbari/start-mongoDB@v0.2
       with:
         mongoDBVersion: 3.6
-    - name: yarn build:prod:analyze
-      run: |
-        yarn build:prod:analyze
-    - name: Shrink dependencies for production
-      run: |
-        yarn install --production
     - name: yarn server:prod:ci
       run: |
         yarn server:prod:ci
       env:
         MONGO_URI: mongodb://localhost:27017/growi
 
-    - name: Upload reports
-      uses: actions/upload-artifact@v1
-      if: success()
-      with:
-        name: report
-        path: report
     - name: Slack Notification
       uses: homoluctus/slatify@master
       if: failure()

+ 20 - 12
.github/workflows/release.yml

@@ -3,10 +3,10 @@ name: GitHub Release
 on:
   push:
     branches:
-      - release
+      - release/**
 
 jobs:
-  build:
+  release:
 
     runs-on: ubuntu-latest
 
@@ -19,19 +19,27 @@ jobs:
       run: |
         git config --local user.name "GitHub Action"
         git config --local user.email "info@weseek.co.jp"
+        git remote set-url origin "https://$GITHUB_ACTOR:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY"
 
     - name: Bump version
       run: |
-        npm version patch
-        sh ./bin/github-actions/bump-version.sh
+        npm --no-git-tag-version version patch
+        export RELEASE_VERSION=`npm run version --silent`
+        sh ./bin/github-actions/update-readme.sh
+        echo ::set-env name=RELEASE_VERSION::$RELEASE_VERSION
 
-    - name: Commit
+    - name: Checkout, Commit, Tag and Push
       run: |
-        git commit -am "Release $RELEASE_VERSION"
-
-    - name: Push tag
-      uses: ad-m/github-push-action@master
+        TMP_RELEASE_BRANCH=tmp/release-${{ env.RELEASE_VERSION }}
+        git checkout -B $TMP_RELEASE_BRANCH
+        git commit -am "Release v${{ env.RELEASE_VERSION }}"
+        git tag -a v${{ env.RELEASE_VERSION }} -m "v${{ env.RELEASE_VERSION }}"
+        git push --follow-tags origin $TMP_RELEASE_BRANCH
+        git push --delete origin $TMP_RELEASE_BRANCH
+
+    - name: Upload release notes
+      uses: Roang-zero1/github-create-release-action@master
       with:
-        branch: null
-        github_token: ${{ secrets. GITHUB_TOKEN }}
-
+        created_tag: v${{ env.RELEASE_VERSION }}
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 9 - 0
.markdownlint.yml

@@ -0,0 +1,9 @@
+ul-indent:
+  indent: 4
+ul-style: false
+heading-style: false
+line-length: false
+no-multiple-blanks: false
+no-duplicate-heading: false
+no-inline-html: false
+no-trailing-punctuation: false

+ 1 - 2
.stylelintrc.json

@@ -1,7 +1,6 @@
 {
   "extends": [
-    "stylelint-config-recess-order",
-    "./node_modules/prettier-stylelint/config.js"
+    "stylelint-config-recess-order"
   ],
   "ignoreFiles": [
     "src/client/styles/scss/_override-bootstrap-variables.scss",

+ 1 - 0
.vscode/extensions.json

@@ -12,6 +12,7 @@
     "christian-kohler.npm-intellisense",
     "esbenp.prettier-vscode",
     "shinnn.stylelint",
+    "hex-ci.stylelint-plus",
 	],
 	// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
 	"unwantedRecommendations": [

+ 15 - 6
.vscode/settings.json

@@ -6,15 +6,24 @@
     "javascript": "jsx"
   },
 
+  // use stylelint-plus
+  // see https://qiita.com/y-w/items/bd7f11013fe34b69f0df#vs-code%E3%81%A8%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B
+  "css.validate": false,
+  "scss.validate": false,
+  "[css]": {
+    "editor.formatOnSave": true
+  },
+  "[scss]": {
+    "editor.formatOnSave": true
+  },
+  "stylelint.autoFixOnSave": true,
+
   // for vscode-eslint
-  "eslint.autoFixOnSave": true,
   "[javascript]": {
     "editor.formatOnSave": false
   },
-
-  // for prettier-vecode + prettier-stylelint
-  "prettier.stylelintIntegration": true,
-  "[scss]": {
-    "editor.formatOnSave": true
+  "editor.codeActionsOnSave": {
+    "source.fixAll.eslint": true,
+    "source.fixAll.markdownlint": true
   }
 }

+ 55 - 13
CHANGES.md

@@ -1,25 +1,63 @@
 # CHANGES
 
-## 3.6.0-RC
+## v3.6.4-RC
+
+* Feature: Alert for stale page
+* Improvement: Reactify admin pages (App)
+
+## v3.6.3
+
+* Improvement: Searching users in UserGroup Management
+* Fix: Repair google authentication by migrating to jaredhanson/passport-google-oauth2
+* Fix: Markdown Settings are broken by the button to import recommended settings
+* Support: Upgrade libs
+    * check-node-version
+    * file-loader
+    * mini-css-extract-plugin
+
+## 3.6.2
+
+* Improvement: Reactify admin pages (Customize)
+* Improvement: Ensure not to consider `[text|site](https://example.com]` as a row in the table
+* Improvement: Enter key behavior in markdown table
+* Fix: Pre-installed plugins in official docker image are not detected
+    * Introduced by 3.6.0
+* Fix: Emoji Autocomplete window does not float correctly
+    * Introduced by 3.5.0
+
+## 3.6.1
 
 ### BREAKING CHANGES
 
-* GROWI v3.6.0 no longer support Node.js v8.x
+* GROWI v3.6.x no longer support Node.js v8.x
 * The name of database that is storing migrations meta data has been changed
     * This affects **only when `MONGO_URI` has parameters**
     * v3.5.x or above has a bug ([#1361](https://github.com/weseek/growi/issues/1361))
 
-Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/36x.html
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/36x.html>
 
 ### Updates
 
 * Improvement: Drop unnecessary MongoDB collection indexes
+* Improvement: Accessibility of Antarctic theme
+* Improvement: Reactify admin pages (Markdown Settings)
 * Fix: Appending tag is failed by wrong index of PageTagRelation
     * Introduced by 3.5.20
+* Fix: Pages without heading slash is invalid but creatable
+* Fix: Connect to Elasticsearch with `httpAuth` param
 * Support: Support Node.js v12
+* Support: Optimize build in dev with hard-source-webpack-plugin
 * Support: Upgrade libs
     * growi-commons
 
+## 3.6.0 (Missing number)
+
+## 3.5.25
+
+* Improvement: Disable ESC key to close Handsontable Modal
+* Fix: Exported data of empty collection is broken
+* Fix: Some components crash after when the page with attachment has exported/imported
+
 ## 3.5.24
 
 * Fix: Plugins are not working on Heroku
@@ -225,7 +263,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/36x.html
 * The restriction mode of the root page (`/`) will be set 'Public'
 * The restriction mode of the root page (`/`) can not be changed after v 3.5.1
 
-Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/35x.html
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/35x.html>
 
 ### Updates
 
@@ -342,7 +380,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/35x.html
 
 None.
 
-Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/34x.html>
 
 ### Updates
 
@@ -655,7 +693,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 * Improvement: Post comment with `Ctrl-Enter`
 * Improvement: Place the commented page at the beginning of the list
 * Improvement: Resolve errors on IE11 (Experimental)
-* Support: Migrate to webpack 4 
+* Support: Migrate to webpack 4
 * Support: Upgrade libs
     * eslint
     * react-bootstrap-typeahead
@@ -679,7 +717,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 
 * Feature: Support [blockdiag](http://blockdiag.com)
 * Feature: Add `BLOCKDIAG_URI` environment variable
-* Fix: Select modal for group is not shown 
+* Fix: Select modal for group is not shown
 * Support: Upgrade libs
     * googleapis
     * throttle-debounce
@@ -716,17 +754,21 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 * Improvement: Add 'future' theme
 * Improvement: Modify syntax for Crowi compatible template feature
     * *before*
-        ~~~
+
+        ~~~markdown
         ``` template:/page/name
         page contents
         ```
         ~~~
+
     * *after*
-        ~~~
+
+        ~~~plane
         ::: template:/page/name
         page contents
         :::
         ~~~
+
 * Improvement: Escape iframe tag in block codes
 * Support: Upgrade libs
     * assets-webpack-plugin
@@ -749,7 +791,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 * Improvement: Auto-format markdown table which includes multibyte text
 * Improvement: Show icon when auto-format markdown table is activated
 * Improvement: Enable to switch show/hide border for highlight.js
-* Improvement: BindDN field allows also ActiveDirectory styles 
+* Improvement: BindDN field allows also ActiveDirectory styles
 * Improvement: Show LDAP logs when testing login
 * Fix: Comment body doesn't break long terms
 * Fix: lsx plugin lists up pages that hit by forward match wrongly
@@ -901,7 +943,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 * Support: Upgrade libs
     * uglifycss
     * sinon-chai
-    
+
 ## 2.4.2
 
 * Improvement: Ensure to set absolute url from root when attaching files when `FILE_UPLOAD=local`
@@ -1121,7 +1163,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 
 ## 1.2.13
 
-* Improvement: Enabled to switch whether to push states with History API when tabs changes 
+* Improvement: Enabled to switch whether to push states with History API when tabs changes
 * Fix: Layout of the Not Found page
 
 ## 1.2.12 (Missing number)
@@ -1282,7 +1324,7 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/34x.html
 
 ## 1.0.2
 
-* Improvement: For lsx 
+* Improvement: For lsx
 
 ## 1.0.1
 

+ 28 - 79
README.md

@@ -16,7 +16,7 @@
 </p>
 
 
-GROWI 
+GROWI
 ===========
 
 [![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
@@ -24,6 +24,10 @@ GROWI
 [![devDependencies Status](https://david-dm.org/weseek/growi/dev-status.svg)](https://david-dm.org/weseek/growi?type=dev)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
 
+| demonstration |
+| :-: |
+|![sample image](https://user-images.githubusercontent.com/42988650/70600974-6b29cc80-1c34-11ea-94ef-33c39c6a00dc.gif)|
+
 - [Features](#features)
 - [Quick Start for Production](#quick-start-for-production)
     - [Heroku](#heroku)
@@ -33,24 +37,23 @@ GROWI
 - [Documentation](#documentation)
 - [License](#license)
 
-
 Features
 ========
 
 * **Features**
-  * Create hierarchical pages with markdown -> [HERE](https://docs.growi.org/en/guide/getting-started/five_minutes.html) is 5 minutes tutorial
-  * Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
-      * [GROWI Docs: HackMD(CodiMD) Integration](https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html)
-  * Support Authentication with LDAP / Active Directory, OAuth
-  * SSO(Single Sign On) with SAML
-  * Slack/Mattermost, IFTTT Integration
-  * [GROWI Docs: Features](https://docs.growi.org/en/guide/features/page_layout.html)
+    * Create hierarchical pages with markdown -> [HERE](https://docs.growi.org/en/guide/getting-started/five_minutes.html) is 5 minutes tutorial
+    * Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
+        * [GROWI Docs: HackMD(CodiMD) Integration](https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html)
+    * Support Authentication with LDAP / Active Directory, OAuth
+    * SSO(Single Sign On) with SAML
+    * Slack/Mattermost, IFTTT Integration
+    * [GROWI Docs: Features](https://docs.growi.org/en/guide/features/page_layout.html)
 * **Pluggable**
-  * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/growi-plugin) or [github](https://github.com/search?q=topic%3Agrowi-plugin)!
+    * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/growi-plugin) or [github](https://github.com/search?q=topic%3Agrowi-plugin)!
 * **[Docker Ready][dockerhub]**
 * **[Docker Compose Ready][docker-compose]**
-  * [GROWI Docs: Multiple sites](https://docs.growi.org/en/admin-guide/admin-cookbook/multi-app.html)
-  * [GROWI Docs: HTTPS(with Let's Encrypt) proxy integration](https://docs.growi.org/en/admin-guide/admin-cookbook/lets-encrypt.html)
+    * [GROWI Docs: Multiple sites](https://docs.growi.org/en/admin-guide/admin-cookbook/multi-app.html)
+    * [GROWI Docs: HTTPS(with Let's Encrypt) proxy integration](https://docs.growi.org/en/admin-guide/admin-cookbook/lets-encrypt.html)
 
 Quick Start for Production
 ===========================
@@ -71,6 +74,16 @@ Quick Start for Production
 - [GROWI Docs: Install on CentOS](https://docs.growi.org/en/admin-guide/getting-started/centos.html)
 
 
+Configuration
+============
+
+See [GROWI Docs: Admin Guide](https://docs.growi.org/en/admin-guide/) ([en](https://docs.growi.org/en/admin-guide/)/[ja](https://docs.growi.org/ja/admin-guide/)).
+
+## Environment Variables
+
+- [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/admin-cookbook/env-vars.html)
+
+
 Development
 ==========
 
@@ -87,9 +100,9 @@ See [confirmed versions](https://docs.growi.org/en/dev/startup/dev-env.html#set-
 
 - Redis 3.x
 - ElasticSearch 6.x (needed when using Full-text search)
-  - **CAUTION: Following plugins are required**
-      - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
-      - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)
+    - **CAUTION: Following plugins are required**
+        - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
+        - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)
 
 ## Command details
 
@@ -102,70 +115,6 @@ See [confirmed versions](https://docs.growi.org/en/dev/startup/dev-env.html#set-
 For more info, see [GROWI Docs: List of npm Commands](https://docs.growi.org/en/dev/startup/launch.html#list-of-npm-commands).
 
 
-Environment Variables
-======================
-
-* **Required**
-    * MONGO_URI: URI to connect to MongoDB.
-* **Option**
-    * NODE_ENV: `production` OR `development`.
-    * PORT: Server port. default: `3000`.
-    * NO_CDN: If `true`, system doesn't use CDN, all resources will be downloaded from CDN when build client, and served by the GROWI Express server. default: `false`.
-    * ELASTICSEARCH_URI: URI to connect to Elasticearch.
-    * REDIS_URI: URI to connect to Redis (use it as a session store instead of MongoDB).
-    * PASSWORD_SEED: A password seed used by password hash generator.
-    * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
-    * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
-    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
-    * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
-    * FORCE_WIKI_MODE: Forces wiki mode. default: undefined
-        * `public`  : Forces all pages to become public
-        * `private` : Forces all pages to become private
-        * undefined : Publicity will be configured by the admin security page settings
-    * FORMAT_NODE_LOG: If `false`, Output server log as JSON. defautl: `true` (Enabled only when `NODE_ENV=production`)
-* **Option for file uploading**
-    * FILE_UPLOAD: Attached files storage. default: `aws`
-        * `aws` : Amazon Web Service S3 (needs AWS settings on Admin page)
-        * `gcs` : Google Cloud Storage (needs settings with environment variables)
-        * `mongodb` : MongoDB GridFS (Setting-less)
-        * `local` : Server's Local file system (Setting-less)
-        * `none` : Disable file uploading
-    * FILE_UPLOAD_DISABLED: If `true`, file uploading will be disabled. However, the files can be still viewed. Default: `false`
-    * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
-    * FILE_UPLOAD_TOTAL_LIMIT: Total capacity limit for uploads (bytes). default: `Infinity`
-    * GCS_API_KEY_JSON_PATH: Path of the JSON file that contains [service account key to authenticate to GCP API](https://cloud.google.com/iam/docs/creating-managing-service-account-keys)
-    * GCS_BUCKET: Name of the GCS bucket
-    * GCS_UPLOAD_NAMESPACE: Directory name to create in the bucket
-    * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
-        * MONGO_GRIDFS_TOTAL_LIMIT setting takes precedence over FILE_UPLOAD_TOTAL_LIMIT.
-* **Option to integrate with external systems**
-    * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
-        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html).**
-    * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
-    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
-    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
-* **Option (Overwritable in admin page)**
-    * APP_SITE_URL: Site URL. e.g. `https://example.com`, `https://example.com:8080`
-    * LOCAL_STRATEGY_ENABLED: Enable or disable ID/Pass login
-    * LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some ID/Pass login options
-    * SAML_ENABLED: Enable or disable SAML
-    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some SAML options
-    * SAML_ENTRY_POINT: IdP entry point
-    * SAML_ISSUER: Issuer string to supply to IdP
-    * SAML_ATTR_MAPPING_ID: Attribute map for id
-    * SAML_ATTR_MAPPING_USERNAME: Attribute map for username
-    * SAML_ATTR_MAPPING_MAIL: Attribute map for email
-    * SAML_ATTR_MAPPING_FIRST_NAME: Attribute map for first name
-    * SAML_ATTR_MAPPING_LAST_NAME:  Attribute map for last name
-    * SAML_CERT: PEM-encoded X.509 signing certificate string to validate the response from IdP
-    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
-    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
-    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
-    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
-
-
 Documentation
 ==============
 

+ 1 - 1
app.json

@@ -27,7 +27,7 @@
     },
     "ADDITIONAL_PACKAGES": {
       "description": "Space-separated list of npm package names to install.",
-      "value": "growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images react-motion",
+      "value": "growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images@1.0.0 react-motion",
       "required": false
     }
   },

+ 0 - 6
bin/github-actions/bump-version.sh

@@ -1,6 +0,0 @@
-#!/bin/sh
-
-cd docker
-
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.5\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.5-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/nocdn\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 6 - 0
bin/github-actions/update-readme.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+
+cd docker
+
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.6\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.6-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 0 - 9
bin/wercker/init-git.sh

@@ -1,9 +0,0 @@
-#!/bin/sh
-
-git config --global user.name "wercker"
-git config --global user.email "info@weseek.co.jp"
-
-# reconfigure origin
-GITHUB_ORIGIN=https://yuki-takei:$GITHUB_TOKEN@$WERCKER_GIT_DOMAIN/$WERCKER_GIT_OWNER/$WERCKER_GIT_REPOSITORY.git
-git remote rm origin
-git remote add origin $GITHUB_ORIGIN

+ 0 - 40
bin/wercker/trigger-growi-docker.sh

@@ -1,40 +0,0 @@
-#!/bin/sh
-
-# Trigger a new run
-# see: http://devcenter.wercker.com/docs/api/endpoints/runs#trigger-a-run
-
-# exec curl
-#
-# require
-#   - $WERCKER_TOKEN
-#   - $GROWI_DOCKER_PIPELINE_ID
-#   - $RELEASE_VERSION
-#   - $RELEASE_GIT_COMMIT
-#
-RESPONSE=`curl -X POST \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer $WERCKER_TOKEN" \
-  https://app.wercker.com/api/v3/runs -d '{ \
-    "pipelineId": "'$GROWI_DOCKER_PIPELINE_ID'", \
-    "branch": "master", \
-    "envVars": [ \
-      { \
-        "key": "RELEASE_VERSION", \
-        "value": "'$RELEASE_VERSION'" \
-      }, \
-      { \
-        "key": "GROWI_REPOS_GIT_COMMIT", \
-        "value": "'$RELEASE_GIT_COMMIT'" \
-      } \
-    ] \
-  }' \
-`
-
-echo $RESPONSE | jq .
-
-# get wercker run id
-RUN_ID=`echo $RESPONSE | jq .id`
-# exit with failure status
-if [ "$RUN_ID" = "null" ]; then
-  exit 1
-fi

+ 0 - 28
bin/wercker/trigger-growi-docs.sh

@@ -1,28 +0,0 @@
-#!/bin/sh
-
-# Trigger a new run
-# see: http://devcenter.wercker.com/docs/api/endpoints/runs#trigger-a-run
-
-# exec curl
-#
-# require
-#   - $WERCKER_TOKEN
-#   - $GROWI_DOCS_PIPELINE_ID
-#
-RESPONSE=`curl -X POST \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer $WERCKER_TOKEN" \
-  https://app.wercker.com/api/v3/runs -d '{ \
-    "pipelineId": "'$GROWI_DOCS_PIPELINE_ID'", \
-    "branch": "master"
-  }' \
-`
-
-echo $RESPONSE | jq .
-
-# get wercker run id
-RUN_ID=`echo $RESPONSE | jq .id`
-# exit with failure status
-if [ "$RUN_ID" = "null" ]; then
-  exit 1
-fi

+ 1 - 1
config/migrate.js

@@ -17,7 +17,7 @@ const mongoUri = getMongoUri();
 const url = new URL(mongoUri);
 
 const mongodb = {
-  url: `${url.protocol}//${url.host}`,
+  url: mongoUri,
   databaseName: url.pathname.substring(1), // omit heading slash
   options: {
     useNewUrlParser: true, // removes a deprecation warning when connecting

+ 4 - 2
config/swagger-definition.js

@@ -1,14 +1,16 @@
 const pkg = require('../package.json');
 
+const apiVersion = process.env.API_VERSION || 3;
+
 module.exports = {
   openapi: '3.0.1',
   info: {
-    title: 'GROWI REST API v3',
+    title: `GROWI REST API v${apiVersion}`,
     version: pkg.version,
   },
   servers: [
     {
-      url: 'https://demo.growi.org/_api/v3/',
+      url: 'https://demo.growi.org',
     },
   ],
 };

+ 9 - 0
config/webpack.common.js

@@ -8,6 +8,7 @@ const webpack = require('webpack');
  */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
 const helpers = require('../src/lib/util/helpers');
 
 /*
@@ -127,6 +128,14 @@ module.exports = (options) => {
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
       new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
 
+      new HardSourceWebpackPlugin(),
+      new HardSourceWebpackPlugin.ExcludeModulePlugin([
+        {
+          // see https://github.com/mzgoddard/hard-source-webpack-plugin/blob/master/README.md#excludemoduleplugin
+          test: /mini-css-extract-plugin[\\/]dist[\\/]loader/,
+        },
+      ]),
+
       new LodashModuleReplacementPlugin({
         flattening: true,
       }),

+ 0 - 49
config/webpack.dev.dll.js

@@ -1,49 +0,0 @@
-/**
- * @author: Yuki Takei <yuki@weseek.co.jp>
- */
-const webpack = require('webpack');
-const helpers = require('../src/lib/util/helpers');
-
-
-module.exports = {
-  mode: 'development',
-  entry: {
-    dlls: [
-      // Libraries
-      'axios',
-      'browser-bunyan', 'bunyan-format',
-      'codemirror', 'react-codemirror2',
-      'date-fns',
-      'diff2html',
-      'debug',
-      'entities',
-      'growi-commons',
-      'i18next', 'i18next-browser-languagedetector',
-      'jquery-slimscroll',
-      'lodash', 'pako',
-      'markdown-it', 'csv-to-markdown-table',
-      'react', 'react-dom',
-      'react-bootstrap', 'react-bootstrap-typeahead',
-      'react-i18next', 'react-dropzone', 'react-hotkeys', 'react-copy-to-clipboard', 'react-waypoint',
-      'socket.io-client',
-      'toastr',
-      'unstated',
-      'xss',
-    ],
-  },
-  output: {
-    path: helpers.root('public/dll'),
-    filename: 'dll.js',
-    library: 'growi_dlls',
-  },
-  resolve: {
-    extensions: ['.js', '.json'],
-    modules: [helpers.root('src'), helpers.root('node_modules')],
-  },
-  plugins: [
-    new webpack.DllPlugin({
-      path: helpers.root('public/dll/manifest.json'),
-      name: 'growi_dlls',
-    }),
-  ],
-};

+ 0 - 7
config/webpack.dev.js

@@ -2,8 +2,6 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
 
-const webpack = require('webpack');
-
 /*
  * Webpack Plugins
  */
@@ -59,11 +57,6 @@ module.exports = require('./webpack.common')({
       filename: '[name].bundle.css',
     }),
 
-    new webpack.DllReferencePlugin({
-      context: helpers.root(),
-      manifest: require(helpers.root('public/dll', 'manifest.json')),
-    }),
-
     new BundleAnalyzerPlugin({
       analyzerMode: ANALYZE ? 'server' : 'disabled',
     }),

+ 12 - 11
docker/Dockerfile

@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
   yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
 # install peerDependencies
 RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn add -D react-images react-motion
+  yarn add -D react-images@1.0.0 react-motion
 
 
 
@@ -48,8 +48,15 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi
 
+COPY --from=deps-resolver ${appDir}/node_modules ${appDir}/node_modules
+
+# copy all files except the files listed in Dockerfile.dockerignore
 COPY . ${appDir}
 
+# overwirte package.json and yarn.lock
+COPY --from=deps-resolver ${appDir}/package.json ${appDir}/package.json
+COPY --from=deps-resolver ${appDir}/yarn.lock ${appDir}/yarn.lock
+
 
 
 ##
@@ -62,24 +69,18 @@ COPY docker/nocdn/env.prod.js ${appDir}/config/
 
 
 
-##
-## prebuilder (alias)
-##
-FROM prebuilder-${flavor} AS prebuilder
-
-
-
 ##
 ## builder
 ##
-FROM deps-resolver AS builder
+FROM prebuilder-${flavor} AS builder
 
 ENV appDir /opt/growi
 
-COPY --from=prebuilder ${appDir} ${appDir}
+WORKDIR ${appDir}
 
 # build
-RUN yarn build:prod
+RUN --mount=type=cache,target=./node_modules/.cache \
+  yarn build:prod
 
 # remove except artifacts
 WORKDIR /tmp

+ 0 - 1
docker/Dockerfile.dockerignore

@@ -11,4 +11,3 @@ src/test
 .stylelint*
 app.json
 Procfile
-wercker.yml

+ 16 - 65
docker/README.md

@@ -1,8 +1,8 @@
 
-growi-docker
-===========
+GROWI Official docker image
+========================
 
-[![wercker status](https://app.wercker.com/status/592aa5cebb59c67c4c266ce1f33b6a7e/s/ "wercker status")](https://app.wercker.com/project/byKey/592aa5cebb59c67c4c266ce1f33b6a7e) [![docker-build-automated](https://img.shields.io/docker/automated/jrottenberg/ffmpeg.svg)](https://hub.docker.com/r/weseek/growi/) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
+[![Actions Status](https://github.com/weseek/growi/workflows/Release%20Docker%20Images/badge.svg)](https://github.com/weseek/growi/actions) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
 
 ![GROWI-x-docker](https://user-images.githubusercontent.com/1638767/38307565-105956e2-384f-11e8-8534-b1128522d68d.png)
 
@@ -10,8 +10,10 @@ growi-docker
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`3.5.13`, `3.5`, `3`, `latest` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.13/Dockerfile)
-* [`3.5.13-nocdn`, `3.5-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.13/nocdn/Dockerfile)
+* [`3.6.0`, `3.6`, `3`, `latest`, (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.0/docker/Dockerfile)
+* [`3.6.0-nocdn`, `3.6-nocdn`, `3-nocdn`, `latest-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.0/docker/Dockerfile)
+* [`3.5.25`, `3.5`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.25/Dockerfile)
+* [`3.5.25-nocdn`, `3.5-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.25/nocdn/Dockerfile)
 * [`3.4.7`, `3.4`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/Dockerfile)
 * [`3.4.7-nocdn`, `3.4-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/nocdn/Dockerfile)
 
@@ -42,8 +44,8 @@ Requirements
 ### Optional Dependencies
 
 * ElasticSearch (>= 6.6)
-  * Japanese (kuromoji) Analysis plugin
-  * ICU Analysis Plugin
+    * Japanese (kuromoji) Analysis plugin
+    * ICU Analysis Plugin
 
 
 Usage
@@ -74,65 +76,14 @@ Using docker-compose is the fastest and the most convenient way to boot GROWI.
 see: [weseek/growi-docker-compose](https://github.com/weseek/growi-docker-compose)
 
 
-Environment Variables
--------------------
+Configuration
+-----------
+
+See [GROWI Docs: Admin Guide](https://docs.growi.org/en/admin-guide/) ([en](https://docs.growi.org/en/admin-guide/)/[ja](https://docs.growi.org/ja/admin-guide/)).
+
+### Environment Variables
 
-* **Required**
-    * MONGO_URI: URI to connect to MongoDB.
-* **Option**
-    * NODE_ENV: `production` OR `development`.
-    * PORT: Server port. default: `3000`
-    * ELASTICSEARCH_URI: URI to connect to Elasticearch.
-    * REDIS_URI: URI to connect to Redis (use it as a session store instead of MongoDB).
-    * PASSWORD_SEED: A password seed used by password hash generator.
-    * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
-    * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
-    * FILE_UPLOAD: Attached files storage. default: `aws`
-        * `aws` : AWS S3 (needs AWS settings on Admin page)
-        * `mongodb` : MongoDB GridFS (Setting-less)
-        * `local` : Server's Local file system (Setting-less)
-        * `none` : Disable file uploading
-    * MAX_FILE_SIZE: The maximum file size limit for uploads (bytes). default: `Infinity`
-    * MONGO_GRIDFS_TOTAL_LIMIT: Total capacity limit of MongoDB GridFS (bytes). default: `Infinity`
-    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: If `true`, the system uses only the value of the environment variable as the value of the SAML option that can be set via the environment variable.
-    * PUBLISH_OPEN_API: Publish GROWI OpenAPI resources with [ReDoc](https://github.com/Rebilly/ReDoc). Visit `/api-docs`.
-    * FORCE_WIKI_MODE: Forces wiki mode. default: undefined
-      * `public`  : Forces all pages to become public
-      * `private` : Forces all pages to become private
-      * undefined : Publicity will be configured by the admin security page settings
-    * FORMAT_NODE_LOG: If `false`, Output server log as JSON. defautl: `true` (Enabled only when `NODE_ENV=production`)
-* **Option to integrate with external systems**
-    * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
-        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/management-cookbook/integrate-with-hackmd).**
-    * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
-    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
-    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
-* **Option (Overwritable in admin page)**
-    * APP_SITE_URL: Site URL. e.g. `https://example.com`, `https://example.com:8080`
-    * LOCAL_STRATEGY_ENABLED: Enable or disable ID/Pass login
-    * LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some ID/Pass login options
-    * SAML_ENABLED: Enable or disable SAML
-    * SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: Prioritize env vars than values in DB for some SAML options
-    * SAML_ENTRY_POINT: IdP entry point
-    * SAML_ISSUER: Issuer string to supply to IdP
-    * SAML_ATTR_MAPPING_ID: Attribute map for id
-    * SAML_ATTR_MAPPING_USERNAME: Attribute map for username
-    * SAML_ATTR_MAPPING_MAIL: Attribute map for email
-    * SAML_ATTR_MAPPING_FIRST_NAME: Attribute map for first name
-    * SAML_ATTR_MAPPING_LAST_NAME:  Attribute map for last name
-    * SAML_CERT: PEM-encoded X.509 signing certificate string to validate the response from IdP
-    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
-    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
-    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
-    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_KEY: Twitter consumer key(API key) for OAuth login.
-    * OAUTH_TWITTER_CONSUMER_SECRET: Twitter consumer secret(API secret) for OAuth login.
-
-Other Documentation
---------------------
-
-* [GROWI Github wiki](https://github.com/weseek/growi/wiki)
-  * [Questions and Answers](https://github.com/weseek/growi/wiki/Questions-and-Answers)
+- [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/admin-cookbook/env-vars.html)
 
 
 Issues

+ 0 - 3
docker/bin/remove-except-artifacts.sh

@@ -3,8 +3,5 @@
 set -e
 
 rm -rf \
-  ${appDir}/bin \
   ${appDir}/docker \
   ${appDir}/node_modules \
-  ${appDir}/src/client \
-  ${appDir}/babel.config.js \

+ 15 - 11
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.6.0-RC",
+  "version": "3.6.4-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -20,12 +20,13 @@
     "url": "https://github.com/weseek/growi/issues"
   },
   "scripts": {
-    "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/**/*.js",
+    "build:api:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js \"src/server/**/*.js\"",
+    "build:apiv3:jsdoc": "cross-env API_VERSION=3 npm run build:api:jsdoc",
+    "build:apiv1:jsdoc": "cross-env API_VERSION=1 npm run build:api:jsdoc",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
-    "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
-    "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
-    "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
+    "build:dev:watch": "npm run build:dev:app:watch",
+    "build:dev": "npm run build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
@@ -36,7 +37,7 @@
     "heroku-postbuild": "sh bin/heroku/install-packages.sh && npm run build:prod",
     "lint:js:fix": "eslint \"**/*.{js,jsx}\" --fix",
     "lint:js": "eslint \"**/*.{js,jsx}\"",
-    "lint:styles:fix": "prettier-stylelint --quiet --write src/client/styles/scss/**/*.scss",
+    "lint:styles:fix": "stylelint --fix src/client/styles/scss/**/*.scss",
     "lint:styles": "stylelint src/client/styles/scss/**/*.scss",
     "lint:swagger2openapi": "node node_modules/swagger2openapi/oas-validate tmp/swagger.json",
     "lint": "npm-run-all -p lint:js lint:styles lint:swagger2openapi",
@@ -79,7 +80,7 @@
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
-    "check-node-version": "=3.3.0",
+    "check-node-version": "^4.0.2",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^3.0.0",
     "connect-redis": "^3.3.0",
@@ -107,6 +108,7 @@
     "i18next-express-middleware": "^1.4.1",
     "i18next-node-fs-backend": "^2.1.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
+    "is-iso-date": "^0.0.1",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "migrate-mongo": "^7.0.1",
@@ -124,7 +126,7 @@
     "openid-client": "=2.5.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
-    "passport-google-auth": "^1.0.2",
+    "passport-google-oauth20": "^2.0.0",
     "passport-http": "^0.3.0",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
@@ -176,8 +178,9 @@
     "eslint-plugin-import": "^2.18.0",
     "eslint-plugin-jest": "^23.0.3",
     "eslint-plugin-react": "^7.14.2",
-    "file-loader": "^4.0.0",
+    "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
+    "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
     "jest": "^24.8.0",
@@ -197,7 +200,7 @@
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^3.0.3",
-    "mini-css-extract-plugin": "^0.8.0",
+    "mini-css-extract-plugin": "^0.9.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-sass": "^4.12.0",
@@ -208,7 +211,7 @@
     "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^3.0.0",
-    "prettier-stylelint": "^0.4.2",
+    "prettier": "^1.19.1",
     "react": "^16.8.3",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "^3.4.2",
@@ -226,6 +229,7 @@
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^2.0.3",
     "style-loader": "^1.0.0",
+    "stylelint": "^12.0.1",
     "stylelint-config-recess-order": "^2.0.1",
     "swagger-jsdoc": "^3.4.0",
     "swagger2openapi": "^5.3.1",

+ 88 - 87
resource/locales/en-US/translation.json

@@ -28,13 +28,11 @@
   "Page Path": "Page Path",
   "Category": "Category",
   "User": "User",
-  "status":"Status",
+  "status": "Status",
   "account_id": "Account Id",
-
   "Update": "Update",
   "Update Page": "Update Page",
   "Warning": "Warning",
-
   "Sign in": "Sign in",
   "Sign up is here": "Sign up",
   "Sign in is here": "Sign in",
@@ -44,30 +42,23 @@
   "Sign up with this Google Account": "Sign up with this Google Account",
   "Example": "Example",
   "Taro Yamada": "John Doe",
-
   "List View": "List",
   "Timeline View": "Timeline",
   "History": "History",
   "Presentation Mode": "Presentation",
-
   "username": "Username",
   "Created": "Created",
   "Last updated": "Updated",
   "Last_Login": "Last Login",
-
   "Share": "Share",
   "Share Link": "Share Link",
   "Markdown Link": "Markdown Link",
-
   "Create/Edit Template": "Create/Edit Template Page",
-
   "Unportalize": "Unportalize",
-
   "Go to this version": "View this version",
   "View diff": "View diff",
   "No diff": "No diff",
   "Shrink versions that have no diffs": "Shrink versions that have no diffs",
-
   "User ID": "User ID",
   "Home": "Home",
   "User Settings": "User Settings",
@@ -89,19 +80,15 @@
   "Show": "Show",
   "Hide": "Hide",
   "Disclose E-mail": "Disclose E-mail",
-
   "page exists": "this page already exists",
   "Error occurred": "Error occurred",
-
   "Create today's": "Create today's ...",
   "Memo": "memo",
   "Input page name": "Input page name",
   "Input page name (optional)": "Input page name (optional)",
   "New Page": "New Page",
   "Create under": "Create page under below:",
-
   "Table of Contents": "Table of Contents",
-
   "Management Wiki Home": "Management Wiki Home",
   "App Settings": "App Settings",
   "Site URL settings": "Site URL settings",
@@ -129,30 +116,24 @@
   "Add tags for this page": "Add tags for this page",
   "Edit tags for this page": "Edit tags for this page",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
-
   "Show latest": "Show latest",
   "Load latest": "Load latest",
   "edited this page": "edited this page.",
-
   "List Drafts": "Drafts",
   "Deleted Pages": "Deleted Pages",
   "Sign out": "Logout",
-
   "form_validation": {
     "required": "<code>%s</code> is required"
   },
-
   "installer": {
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
     "initial_account_will_be_administrator_automatically": "The initial account will be administrator automatically.",
     "unavaliable_user_id": "This 'User ID' is unavailable."
   },
-
   "breaking_changes": {
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
   },
-
   "page_register": {
     "notice": {
       "restricted": "Admin approval required.",
@@ -164,7 +145,6 @@
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
   },
-
   "page_me": {
     "form_help": {
       "profile_image1": "Image upload settings not completed.",
@@ -177,10 +157,8 @@
       "update_token1": "You can update to generate a new API Token.",
       "update_token2": "You will need to update the API Token in any existing processes."
     },
-    "form_help": {
-    }
+    "form_help": {}
   },
-
   "Password": "Password",
   "Password Settings": "Password Settings",
   "Set new Password": "Set new Password",
@@ -189,14 +167,11 @@
   "New password": "New password",
   "Re-enter new password": "Re-enter new password",
   "Password is not set": "Password is not set",
-
   "security_settings": "Security Settings",
-
   "API Settings": "API Settings",
   "API Token Settings": "API Token Settings",
   "Current API Token": "Current API Token",
   "Update API Token": "Update API Token",
-
   "header_search_box": {
     "label": {
       "This tree": "This tree"
@@ -205,7 +180,6 @@
       "This tree": "Only children of this tree"
     }
   },
-
   "copy_to_clipboard": {
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",
@@ -213,7 +187,6 @@
     "Page path and parmanent link": "Page path and parmanent link",
     "Markdown link": "Markdown link"
   },
-
   "search_help": {
     "title": "Searching Help",
     "and": {
@@ -243,7 +216,6 @@
   "search": {
     "search page bodies": "Hit [Enter] key to full-text search"
   },
-
   "page_page": {
     "notice": {
       "version": "This is not the current version.",
@@ -251,10 +223,11 @@
       "redirected": "You are redirected from <code>%s</code>",
       "duplicated": "This page was duplicated from <code>%s</code>",
       "unlinked": "Redirect pages to this page have been deleted.",
-      "restricted": "Access to this page is restricted"
+      "restricted": "Access to this page is restricted",
+      "stale": "More than {{count}} year has passed since last update.",
+      "stale_plural": "More than {{count}} years has passed since last update."
     }
   },
-
   "page_edit": {
     "Show active line": "Show active line",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
@@ -262,14 +235,12 @@
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
     }
   },
-
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "New page is already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete completely"
   },
-
   "modal_rename": {
     "label": {
       "Move/Rename page": "Move/Rename page",
@@ -285,10 +256,8 @@
       "recursive": "Move/Rename children of under <code>%s</code> recursively"
     }
   },
-
   "Put Back": "Put Back",
   "Delete Completely": "Delete Completely",
-
   "modal_delete": {
     "delete_page": "Delete Page",
     "deleting_page": "Deleting Page",
@@ -298,7 +267,6 @@
     "recursively": "Delete children of under <code>%s</code> recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
-
   "modal_duplicate": {
     "label": {
       "Duplicate page": "Duplicate page",
@@ -306,7 +274,6 @@
       "Current page name": "Current page name"
     }
   },
-
   "modal_putback": {
     "label": {
       "Put Back Page": "Put Back Page",
@@ -316,7 +283,6 @@
       "recursively": "Put Back children of under <code>%s</code> recursively"
     }
   },
-
   "modal_shortcuts": {
     "global": {
       "title": "Global shortcuts",
@@ -339,7 +305,6 @@
       "Post": "Post"
     }
   },
-
   "template": {
     "modal_label": {
       "Create/Edit Template Page": "Create/Edit Template Page",
@@ -358,7 +323,6 @@
       "desc": "Applies to all decendant pages"
     }
   },
-
   "sandbox": {
     "header": "Header",
     "header_x": "Header {{index}}",
@@ -390,7 +354,6 @@
     "insert_image": "inserts an image",
     "open_sandbox": "Open Sandbox"
   },
-
   "admin_top": {
     "Management Wiki": "Management Wiki",
     "System Information": "System Information",
@@ -401,7 +364,6 @@
     "Specified version": "Specified version",
     "Installed version": "Installed version"
   },
-
   "app_setting": {
     "Site Name": "Site name",
     "sitename_change": "You can change Site Name which is used for header and HTML title.",
@@ -411,7 +373,7 @@
     "siteurl_help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
     "Confidential name": "Confidential name",
     "Default Language for new users": "Default Language for new users",
-    "ex): internal use only":"ex): internal use only",
+    "ex) internal use only": "ex): internal use only",
     "File Uploading": "File Uploading",
     "enable_files_except_image": "Enable file upload other than image files.",
     "attach_enable": "You can attach files other than image files if you enable this option.",
@@ -421,7 +383,7 @@
     "SMTP_but_AWS": "If you do not have SMTP settings but AWS settings,  e-mails will be sent by SES.",
     "neihter_of": "If you do not of neither of these, e-mails will not be sent.",
     "From e-mail address": "From e-mail address",
-    "SMTP settings": "SMTP settings"  ,
+    "SMTP settings": "SMTP settings",
     "Host": "Host",
     "Port": "Port",
     "User": "User",
@@ -438,21 +400,23 @@
     "Load plugins": "Load plugins",
     "Enable": "Enable",
     "Disable": "Disable",
-    "Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</code> is used."
+    "Use env var if empty": "If the value in the database is empty, the value of the environment variable <cod>{{variable}}</code> is used.",
+    "updated_app_setting": "Succeeded to update app setting",
+    "updated_site_url": "Succeeded to update site URL",
+    "updated_plugin_setting": "Succeeded to update plugin setting"
   },
-
   "security_setting": {
-		"Security settings": "Security settings",
+    "Security settings": "Security settings",
     "Guest Users Access": "Guest Users Access",
     "Fixed by env var": "This is fixed by the env var <code>%s=%s</code>.",
     "Register limitation": "Register limitation",
     "Register limitation desc": "Restricts ways to register new user.",
-		"The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
-		"users_without_account": "Users without account is not accessible",
+    "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
+    "users_without_account": "Users without account is not accessible",
     "example": "Example",
     "restrict_emails": "You can restrict registerable e-mail address.",
-		"for_instance": " For instance, if you use growi within a company, you can write ",
-		"only_those": " Only those whose e-mail address including the company address can register.",
+    "for_instance": " For instance, if you use growi within a company, you can write ",
+    "only_those": " Only those whose e-mail address including the company address can register.",
     "insert_single": "Please insert single e-mail address per line.",
     "page_listing_1": "Page listing/searching<br>restricted by 'Just Me'",
     "page_listing_1_desc": "Show pages that are restricted by 'Just Me' option when listing/searching",
@@ -463,11 +427,10 @@
     "admin_only": "Admin Only",
     "admin_and_author": "Admin and Author",
     "anyone": "Anyone",
-
-		"Authentication mechanism settings": "Authentication Mechanism Settings",
+    "Authentication mechanism settings": "Authentication Mechanism Settings",
     "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",
+    "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",
@@ -476,6 +439,7 @@
     "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)"
@@ -528,30 +492,33 @@
       "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",
+      "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 {{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>{{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> ."
+      "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> .",
+      "updated_saml": "Succeeded to update SAML setting"
     },
     "Basic": {
-      "enable_basic":"enable 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",
+        "enable_google": "enable Google OAuth",
         "name": "Google OAuth",
         "register_1": "Access {{link}}",
         "register_2": "Create Project if no projects exist",
@@ -574,7 +541,7 @@
         "updated_twitter": "Succeeded to update Twitter OAuth setting"
       },
       "GitHub": {
-        "enable_github":"enable GitHub OAuth",
+        "enable_github": "enable GitHub OAuth",
         "name": "GitHub OAuth",
         "register_1": "Access {{link}}",
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>{{url}}</code>",
@@ -589,7 +556,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?",
@@ -608,8 +576,7 @@
       "attrMapFirstName": "First Name",
       "attrMapLastName": "Last Name"
     }
-	},
-
+  },
   "markdown_setting": {
     "line_break_setting": "Line Break Setting",
     "line_break_setting_desc": "You can change line break settings.",
@@ -635,14 +602,13 @@
     "Ignore all tags desc": "Stripe all HTML tags and attributes",
     "Recommended setting": "Recommended Setting",
     "Custom Whitelist": "Custom Whitelist",
-    "Tag names":"Tag names",
-    "Tag attributes":"Tag attributes",
-    "import_recommended": "Import recommended %s",
+    "Tag names": "Tag names",
+    "Tag attributes": "Tag attributes",
+    "import_recommended": "Import recommended {{target}}",
     "updated_lineBreak": "Succeeded to update line braek setting",
     "updated_presentation": "Succeeded to update presentation setting",
     "updated_xss": "Succeeded to update XSS setting"
   },
-
   "notification_setting": {
     "notification_list": "List of Notification Settings",
     "add_notification": "Add New",
@@ -662,8 +628,8 @@
       "ifttt_link": "Create a new IFTTT applet with Email trigger"
     }
   },
-
   "customize_page": {
+    "recommended": "Recommended",
     "Behavior": "Behavior",
     "Layout": "Layout",
     "Function": "Function",
@@ -681,7 +647,7 @@
     "custom_title": "Custom Title",
     "custom_title_detail": "You can customize <code>%s</code> tag.<br><code>%s</code> will be automatically replaced with the app name, and <code>%s</code> will be replaced with the page name/path.",
     "custom_header": "Custom HTML Header",
-    "custom_header_detail": "You can customize HTML header that applies all pages. Your custom script will be inserted in <code>%s</code> but above other <code>%s</code> tags.<br>Relaod page to see changes.",
+    "custom_header_detail": "You can customize HTML header that applies all pages. Your custom script will be inserted in <code>&lt;header&gt;</code> but above other <code>&lt;script&gt;</code> tags.<br>Relaod page to see changes.",
     "Custom CSS": "Custom CSS",
     "write_CSS": "You can write CSS that is applied to whole system.",
     "reflect_change": "You need to reload the page to reflect the change.",
@@ -691,9 +657,42 @@
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
     "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
-    "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page"
+    "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
+    "stale_notification": "Display Notification on Stale Pages",
+    "stale_notification_desc": "Displays the notification to pages more than 1 year since the last update.",
+    "update_layout_success": "Succeeded to update layout",
+    "update_behavior_success": "Succeeded to update behavior",
+    "update_function_success": "Succeeded to update function",
+    "update_highlight_success": "Succeeded to update code highlight",
+    "update_customTitle_success": "Succeeded to update customize title",
+    "update_customHeader_success": "Succeeded to update customize html header",
+    "update_customCss_success": "Succeeded to update customize css",
+    "update_script_success": "Succeeded to update custom script",
+    "layout_description": {
+      "growi_title": "Simple and Clear",
+      "growi_text1": "Full screen layout and thin margins/paddings",
+      "growi_text2": "Show and post comments at the bottom of the page",
+      "growi_text3": "Affix Table-of-contents",
+      "kibela_title": "Easy Viewing Structure",
+      "kibela_text1": "Center aligned contents",
+      "kibela_text2": "Show and post comments at the bottom of the page",
+      "kibela_text3": "Affix Table-of-contents",
+      "crowi_title": "Separated Functions",
+      "crowi_text1": "Collapsible Sidebar",
+      "crowi_text2": "Show and post comments in Sidebar",
+      "crowi_text3": "Collapsible Table-of-contents"
+    },
+    "behavior_description": {
+      "growi_text1": "Both of <code>/page</code> and <code>/page/</code> shows the same page。",
+      "growi_text2": "<code>/nonexistent_page</code> shows editing form",
+      "growi_text3": "All pages shows the list of sub pages <b>if using GROWI Enhanced Layout</b>",
+      "crowi_text1": "<code>/page</code> shows the page",
+      "crowi_text2": "<code>/page/</code> shows the list of sub pages",
+      "crowi_text3": "If portal is applied to <code>/page/</code> , the portal and the list of sub pages are shown",
+      "crowi_text4": "<code>/nonexistent_page</code> shows editing form<",
+      "crowi_text5": "<code>/nonexistent_page/</code> the list of sub pages"
+    }
   },
-
   "user_management": {
     "target_user": "Target User",
     "new_password": "New Password",
@@ -722,10 +721,10 @@
     "reset_password": "Reset Password",
     "related_username": "Related user's ",
     "accept": "Accept",
-    "deactivate_account":"Deactivate Account",
-    "your_own":"You cannot deactivate your own account",
-    "administrator_menu":"Administrator Menu",
-    "cannot_remove":"You cannot remove yourself from administrator",
+    "deactivate_account": "Deactivate Account",
+    "your_own": "You cannot deactivate your own account",
+    "administrator_menu": "Administrator Menu",
+    "cannot_remove": "You cannot remove yourself from administrator",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
     "current_users": "Current users:",
     "valid_email": "Valid email address is required",
@@ -737,8 +736,12 @@
     "remove_user_success": "Succeeded to removing {{username}} ",
     "remove_external_user_success": "Succeeded to remove {{accountId}} "
   },
-
   "user_group_management": {
+    "search_option": "Search Option",
+    "enable_option": "Enable {{option}}",
+    "forward_match": "forword match",
+    "partial_match": "partial match",
+    "backward_match": "backward match",
     "group_list": "Group List",
     "back_to_list": "Go Back to Group List",
     "basic_info": "Basic Info",
@@ -760,7 +763,6 @@
     "no_pages": "There are no pages the group has view permission",
     "remove_from_group": "Remove this user"
   },
-
   "importer_management": {
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
@@ -819,13 +821,12 @@
     "page_skip": "Pages with a name that already exists on GROWI are not imported",
     "Directory_hierarchy_tag": "Directory Hierarchy Tag"
   },
-
-  "full_text_search_management":{
-    "elasticsearch_management":"Elasticsearch Management",
-    "build_button":"Rebuild Index",
-    "rebuild_description_1":"Force rebuild index.",
-    "rebuild_description_2":"Click 'Build Now' to delete and create mapping file and add all pages.",
-    "rebuild_description_3":"This may take a while."
+  "full_text_search_management": {
+    "elasticsearch_management": "Elasticsearch Management",
+    "build_button": "Rebuild Index",
+    "rebuild_description_1": "Force rebuild index.",
+    "rebuild_description_2": "Click 'Build Now' to delete and create mapping file and add all pages.",
+    "rebuild_description_3": "This may take a while."
   },
   "export_management": {
     "exporting_collection_list": "Exporting Collection List",

+ 83 - 83
resource/locales/ja/translation.json

@@ -30,11 +30,9 @@
   "User": "ユーザー",
   "status": "ステータス",
   "account_id": "アカウントID",
-
   "Update": "更新",
   "Update Page": "ページを更新",
   "Warning": "注意",
-
   "Sign in": "ログイン",
   "Sign up is here": "新規登録はこちら",
   "Sign in is here": "ログインはこちら",
@@ -44,30 +42,23 @@
   "Sign up with this Google Account": "この Google アカウントで登録します",
   "Example": "例",
   "Taro Yamada": "山田 太郎",
-
   "List View": "リスト表示",
   "Timeline View": "タイムライン表示",
   "History": "更新履歴",
   "Presentation Mode": "プレゼンテーション",
-
   "username": "ユーザー名",
   "Created": "作成日",
   "Last updated": "最終更新",
   "Last_Login": "最終ログイン",
-
   "Share": "共有",
   "Share Link": "共有用リンク",
   "Markdown Link": "Markdown形式のリンク",
-
   "Create/Edit Template": "テンプレートページの作成/編集",
-
   "Unportalize": "ポータル解除",
-
   "Go to this version": "このバージョンを見る",
   "View diff": "差分を表示",
   "No diff": "差分なし",
   "Shrink versions that have no diffs": "差分のないバージョンをコンパクトに表示する",
-
   "User ID": "ユーザーID",
   "Home": "ホーム",
   "User Settings": "ユーザー設定",
@@ -89,19 +80,15 @@
   "Show": "公開",
   "Hide": "非公開",
   "Disclose E-mail": "メールアドレスの公開",
-
   "page exists": "このページはすでに存在しています",
-  "Error occurred":"エラーが発生しました",
-
+  "Error occurred": "エラーが発生しました",
   "Create today's": "今日の◯◯を作成",
   "Memo": "メモ",
   "Input page name": "ページ名を入力",
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",
   "Create under": "ページを以下に作成",
-
   "Table of Contents": "目次",
-
   "Management Wiki Home": "Wiki管理トップ",
   "App Settings": "アプリ設定",
   "Site URL settings": "サイトURL設定",
@@ -128,34 +115,28 @@
   "Add tags for this page": "タグを付ける",
   "Edit tags for this page": "タグを編集する",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
-
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
   "edited this page": "さんがこのページを編集しました。",
-
   "List Drafts": "下書き一覧",
   "Deleted Pages": "削除済みページ",
   "Sign out": "ログアウト",
-
   "form_validation": {
     "required": "<code>%s</code> に値を入力してください"
   },
-
   "installer": {
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
     "initial_account_will_be_administrator_automatically": "初めに作成するアカウントは、自動的に管理者権限が付与されます",
     "unavaliable_user_id": "このユーザーIDは利用できません。"
   },
-
   "breaking_changes": {
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
   },
-
   "page_register": {
     "notice": {
-       "restricted": "この Wiki への新規登録は制限されています。",
-       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
+      "restricted": "この Wiki への新規登録は制限されています。",
+      "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
     },
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
@@ -163,7 +144,6 @@
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
     }
   },
-
   "page_me": {
     "form_help": {
       "profile_image1": "画像をアップロードをするための設定がされていません。",
@@ -176,10 +156,8 @@
       "update_token1": "API Token を更新すると、自動的に新しい Token が生成されます。",
       "update_token2": "現在の Token を利用している処理は動かなくなります。"
     },
-    "form_help": {
-    }
+    "form_help": {}
   },
-
   "Password": "パスワード",
   "Password Settings": "パスワード設定",
   "Set new Password": "パスワードを新規に設定",
@@ -188,14 +166,11 @@
   "New password": "新しいパスワード",
   "Re-enter new password": "(確認用)",
   "Password is not set": "パスワードが設定されていません",
-
   "security_settings": "セキュリティ設定",
-
   "API Settings": "API設定",
   "API Token Settings": "API Token設定",
   "Current API Token": "現在のAPI Token",
   "Update API Token": "API Tokenを更新",
-
   "header_search_box": {
     "label": {
       "This tree": "この階層"
@@ -204,7 +179,6 @@
       "This tree": "この階層下の子ページのみ"
     }
   },
-
   "copy_to_clipboard": {
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",
@@ -212,7 +186,6 @@
     "Page path and parmanent link": "ページ名とパーマリンク",
     "Markdown link": "マークダウン形式のリンク"
   },
-
   "search_help": {
     "title": "検索のヘルプ",
     "and": {
@@ -242,7 +215,6 @@
   "search": {
     "search page bodies": "[Enter] キー押下で全文検索"
   },
-
   "page_page": {
     "notice": {
       "version": "これは現在の版ではありません。",
@@ -250,10 +222,10 @@
       "redirected": "リダイレクト元 >> <code>%s</code>",
       "duplicated": "このページは <code>%s</code> から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
-      "restricted": "このページの閲覧は制限されています"
+      "restricted": "このページの閲覧は制限されています",
+      "stale": "このページは最終更新日から{{count}}年以上が経過しています。"
     }
   },
-
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
@@ -261,14 +233,12 @@
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
     }
   },
-
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "新しいページが既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが完全削除できます"
   },
-
   "modal_rename": {
     "label": {
       "Move/Rename page": "ページを移動/名前変更する",
@@ -284,10 +254,8 @@
       "recursive": "<code>%s</code> 配下のページも移動/名前変更します"
     }
   },
-
   "Put Back": "元に戻す",
   "Delete Completely": "完全削除",
-
   "modal_delete": {
     "delete_page": "ページを削除する",
     "deleting_page": "ページパス",
@@ -297,7 +265,6 @@
     "recursively": "<code>%s</code> 配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
-
   "modal_duplicate": {
     "label": {
       "Duplicate page": "ページを複製する",
@@ -305,7 +272,6 @@
       "Current page name": "現在のページ名"
     }
   },
-
   "modal_putback": {
     "label": {
       "Put Back Page": "ページを元に戻す",
@@ -315,7 +281,6 @@
       "recursively": "<code>%s</code> 配下のページも元に戻します"
     }
   },
-
   "modal_shortcuts": {
     "global": {
       "title": "グローバルショートカット",
@@ -338,7 +303,6 @@
       "Post": "投稿"
     }
   },
-
   "template": {
     "modal_label": {
       "Create/Edit Template Page": "テンプレートページの作成/編集",
@@ -357,7 +321,6 @@
       "desc": "テンプレートページが存在する下位層のすべてのページに適用されます"
     }
   },
-
   "sandbox": {
     "header": "見出し",
     "header_x": "見出し {{index}}",
@@ -389,7 +352,6 @@
     "insert_image": "で画像を挿入できます",
     "open_sandbox": "Sandbox を開く"
   },
-
   "admin_top": {
     "Management Wiki": "Wiki管理",
     "System Information": "システム情報",
@@ -400,7 +362,6 @@
     "Specified version": "指定バージョン",
     "Installed version": "インストールされているバージョン"
   },
-
   "app_setting": {
     "Site Name": "サイト名",
     "sitename_change": "ヘッダーや HTML タイトルに使用されるサイト名を変更できます。",
@@ -410,7 +371,7 @@
     "siteurl_help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL",
     "Confidential name": "コンフィデンシャル表示",
     "Default Language for new users": "新規ユーザーのデフォルト設定言語",
-    "ex): internal use only": "例: 社外秘",
+    "ex) internal use only": "例: 社外秘",
     "File Uploading": "ファイルアップロード",
     "enable_files_except_image": "画像以外のファイルアップロードを許可",
     "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
@@ -420,7 +381,7 @@
     "SMTP_but_AWS": "SMTP設定がなく、AWSの設定がある場合、SESでの送信を試みます。",
     "neihter_of": "どちらの設定もない場合、メールは送信されません。",
     "From e-mail address": "Fromアドレス",
-    "SMTP settings": "SMTP設定"   ,
+    "SMTP settings": "SMTP設定",
     "Host": "ホスト",
     "Port": "ポート",
     "User": "ユーザー",
@@ -437,9 +398,11 @@
     "Load plugins": "プラグインを読み込む",
     "Enable": "有効",
     "Disable": "無効",
-    "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します"
-   },
-
+    "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{variable}}</code> の値を利用します",
+    "updated_app_setting": "アプリ設定を更新しました",
+    "updated_site_url": "サイトURLを更新しました",
+    "updated_plugin_setting": "プラグイン設定を更新しました"
+  },
   "security_setting": {
     "Guest Users Access": "ゲストユーザーのアクセス",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
@@ -449,9 +412,9 @@
     "users_without_account": "アカウントを持たないユーザーはアクセス不可",
     "example": "例",
     "restrict_emails": "登録可能なメールアドレスを制限することができます。",
-    "for_instance":"例えば、",
-    "only_those":"と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
-    "insert_single":"1行に1メールアドレス入力してください。",
+    "for_instance": "例えば、",
+    "only_those": "と記載することで、そのドメインのメールアドレスを持っている人のみ登録可能になります。",
+    "insert_single": "1行に1メールアドレス入力してください。",
     "page_listing_1": "ページのリスト表示と検索<br>'自分のみ'に閲覧制限しているページ",
     "page_listing_1_desc": "ページのリスト表示や検索結果において、'自分のみ'に閲覧制限をしているページをアクセス権のないユーザーにも表示します。",
     "page_listing_2": "ページのリスト表示と検索<br>特定グループに閲覧制限しているページ",
@@ -461,16 +424,16 @@
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
-
-    "Authentication mechanism settings":"認証機構設定",
+    "Authentication mechanism settings": "認証機構設定",
     "alert_siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
-    "xss_prevent_setting":"XSS(Cross Site Scripting)対策設定",
-    "xss_prevent_setting_link":"マークダウン設定ページに移動",
+    "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
+    "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
     "updated_general_security_setting": "セキュリティ設定を更新しました。",
+    "setup_not_completed_yet": "まだセットアップは完了していません。",
     "guest_mode": {
       "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
       "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
@@ -523,7 +486,8 @@
       "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",
@@ -533,20 +497,22 @@
       "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
       "cert_detail": "IdP からのレスポンスの validation を行うためのPEMエンコードされた X.509 証明書",
       "Use env var if empty": "データベース側の値が空の場合、環境変数 <code>{{env}}</code> の値を利用します",
-      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
+      "note for the only env option": "現在SAML認証のON/OFFの設定値及びハイライトされている設定値は環境変数の値のみを使用するようになっています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
+      "updated_saml": "SAML設定 を更新しました"
     },
     "Basic": {
-      "enable_basic":"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 を有効にする",
+        "enable_google": "Google OAuth を有効にする",
         "name": "Google OAuth",
         "register_1": "{{link}}へアクセス",
         "register_2": "プロジェクトがない場合はプロジェクトを作成",
@@ -569,7 +535,7 @@
         "updated_twitter": "Twitter OAuth を更新しました"
       },
       "GitHub": {
-        "enable_github":"GitHub OAuth を有効にする",
+        "enable_github": "GitHub OAuth を有効にする",
         "name": "GitHub OAuth",
         "register_1": "{{link}} へアクセス",
         "register_2": "\"Authorization callback URL\"を<code>{{url}}</code>としてGrowiを登録",
@@ -581,7 +547,8 @@
         "id_detail": "OIDC claims で一意に識別可能な値を格納している属性",
         "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
-        "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性"
+        "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
+        "updated_oidc": "OpenID Connect を更新しました"
       },
       "how_to": {
         "google": "Google OAuth の設定方法",
@@ -600,7 +567,6 @@
       "attrMapLastName": "名"
     }
   },
-
   "markdown_setting": {
     "line_break_setting": "Line Break設定",
     "line_break_setting_desc": "Line Breakの設定を変更できます。",
@@ -626,14 +592,13 @@
     "Ignore all tags desc": "すべてのHTMLタグと属性を使用不可にします",
     "Recommended setting": "おすすめ設定",
     "Custom Whitelist": "カスタムホワイトリスト",
-    "Tag names": "タグ名のホワイトリスト",
-    "Tag attributes": "タグ属性のホワイトリスト",
-    "import_recommended": "おすすめをインポート",
+    "Tag names": "タグ名",
+    "Tag attributes": "タグ属性",
+    "import_recommended": "{{target}} のおすすめをインポート",
     "updated_lineBreak": "改行設定を更新しました",
     "updated_presentation": "プレゼンテーション設定を更新しました",
     "updated_xss": "XSS設定を更新しました"
   },
-
   "notification_setting": {
     "notification_list": "通知設定の一覧",
     "add_notification": "通知設定の追加",
@@ -653,9 +618,9 @@
       "ifttt_link": "IFTTT でメールトリガの新しいアプレットを作る"
     }
   },
-
   "customize_page": {
-    "Behavior": "挙動",
+    "recommended": "おすすめ",
+    "Behavior": "動作",
     "Layout": "レイアウト",
     "Function": "機能",
     "function_choose": "機能の有効/無効を選択できます。",
@@ -672,7 +637,7 @@
     "custom_title": "カスタム Title",
     "custom_title_detail": "<code>%s</code>タグのコンテンツをカスタマイズできます。<br><code>%s</code>がサイト名、<code>%s</code>がページ名またはページパスに置換されます。",
     "custom_header": "カスタム HTML Header",
-    "custom_header_detail": "システム全体に適用される HTML を記述できます。<code>%s</code> タグ内の他の <code>%s</code> タグ読み込み前に展開されます。<br>変更の反映はページの更新が必要です。",
+    "custom_header_detail": "システム全体に適用される HTML を記述できます。<code>&lt;header&gt;</code> タグ内の他の <code>&lt;script&gt;</code> タグ読み込み前に展開されます。<br>変更の反映はページの更新が必要です。",
     "Custom CSS": "カスタム CSS",
     "write_CSS": " システム全体に適用されるCSSを記述できます。",
     "reflect_change": "変更の反映はページの更新が必要です。",
@@ -682,9 +647,42 @@
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
     "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
-    "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。"
+    "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
+    "stale_notification": "更新されていないページに通知を表示",
+    "stale_notification_desc": "最終更新から1年以上が経過しているページに通知を表示します。",
+    "update_layout_success": "レイアウトを更新しました",
+    "update_behavior_success": "動作を更新しました",
+    "update_function_success": "機能を更新しました",
+    "update_highlight_success": "コードハイライトを更新しました",
+    "update_customTitle_success": "カスタムタイトルを更新しました",
+    "update_customHeader_success": "カスタムHTMLヘッダーを更新しました",
+    "update_customCss_success": "カスタムCSSを更新しました",
+    "update_script_success": "カスタムスクリプトを更新しました",
+    "layout_description": {
+      "growi_title": "シンプル・明瞭",
+      "growi_text1": "全画面レイアウトで、余白は少なくなります。",
+      "growi_text2": "コメントはページの下部に表示されます。",
+      "growi_text3": "ページ情報は下部に表示されます。",
+      "kibela_title": "閲覧重視の構造",
+      "kibela_text1": "コンテンツが中心に表示されます。",
+      "kibela_text2": "コメントはページの下部に表示されます。",
+      "kibela_text3": "ページ情報は下部に表示されます。",
+      "crowi_title": "ビュー・コントロールの分離",
+      "crowi_text1": "サイドバーを開くと情報が表示されます。",
+      "crowi_text2": "コメントはサイドバーに表示されます。",
+      "crowi_text3": "ページ情報はサイドバーに表示されます。"
+    },
+    "behavior_description": {
+      "growi_text1": "<code>/page</code>と<code>/page/</code>どちらのパスも同じページを表示します。",
+      "growi_text2": "<code>/nonexistent_page</code> では編集フォームを表示します",
+      "growi_text3": "<b>GROWI Enhanced Layout</b>では全てのページが配下のページリストを表示します",
+      "crowi_text1": "<code>/page</code> ではページを表示します。",
+      "crowi_text2": "<code>/page/</code> では配下のページを表示します。",
+      "crowi_text3": "<code>/page/</code>がポータルに適応している場合、ポータルページと配下のページリストを表示します。",
+      "crowi_text4": "<code>/nonexistent_page</code> では編集フォームを表示します",
+      "crowi_text5": "<code>/nonexistent_page</code> では配下のページリストを表示します。"
+    }
   },
-
   "user_management": {
     "target_user": "対象ユーザー",
     "new_password": "新しいパスワード",
@@ -728,8 +726,12 @@
     "remove_user_success": "{{username}}を削除しました",
     "remove_external_user_success": "{{accountId}}を削除しました "
   },
-
   "user_group_management": {
+    "search_option": "検索オプション",
+    "enable_option": "{{option}}を有効にする",
+    "forward_match": "前方一致",
+    "partial_match": "部分一致",
+    "backward_match": "後方一致",
     "group_list": "グループ一覧",
     "back_to_list": "グループ一覧に戻る",
     "basic_info": "基本情報",
@@ -752,7 +754,6 @@
     "no_pages": "グループが閲覧権限を保有するページはありません",
     "remove_from_group": "グループから外す"
   },
-
   "importer_management": {
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
@@ -811,13 +812,12 @@
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
-
-  "full_text_search_management":{
-    "elasticsearch_management":"Elasticsearch 管理",
-    "build_button":"インデックスのリビルド",
-    "rebuild_description_1":"Build Now ボタンを押すと全てのページのインデックスを削除し、作り直します。",
-    "rebuild_description_2":"この作業には数秒かかります。",
-    "rebuild_description_3":""
+  "full_text_search_management": {
+    "elasticsearch_management": "Elasticsearch 管理",
+    "build_button": "インデックスのリビルド",
+    "rebuild_description_1": "Build Now ボタンを押すと全てのページのインデックスを削除し、作り直します。",
+    "rebuild_description_2": "この作業には数秒かかります。",
+    "rebuild_description_3": ""
   },
   "export_management": {
     "exporting_collection_list": "エクスポート中のコレクション",

+ 59 - 77
src/client/js/app.jsx

@@ -35,12 +35,10 @@ import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
-import CustomCssEditor from './components/Admin/CustomCssEditor';
-import CustomScriptEditor from './components/Admin/CustomScriptEditor';
-import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
 import SecurityManagement from './components/Admin/Security/SecurityManagement';
+import AppSettingsPage from './components/Admin/App/AppSettingsPage';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import UserGroupPage from './components/Admin/UserGroup/UserGroupPage';
 import Customize from './components/Admin/Customize/Customize';
@@ -53,12 +51,14 @@ import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
+import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
 import AdminGeneralSecurityContainer from './services/AdminGeneralSecurityContainer';
 import AdminLdapSecurityContainer from './services/AdminLdapSecurityContainer';
+import AdminAppContainer from './services/AdminAppContainer';
 import WebsocketContainer from './services/WebsocketContainer';
-import MarkDownSettingContainer from './services/MarkDownSettingContainer';
+import AdminMarkDownContainer from './services/AdminMarkDownContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
 import AdminSamlSecurityContainer from './services/AdminSamlSecurityContainer';
 import AdminOidcSecurityContainer from './services/AdminOidcSecurityContainer';
@@ -119,7 +119,6 @@ let componentMappings = {
   'user-draft-list': <MyDraftList />,
 
   'admin-full-text-search-management': <FullTextSearchManagement />,
-  'admin-customize': <Customize />,
 
   'staff-credit': <StaffCredit />,
   'admin-importer': <ImportDataPage />,
@@ -130,17 +129,17 @@ if (pageContainer.state.pageId != null) {
   componentMappings = Object.assign({
     'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
-    'page-attachment':  <PageAttachment />,
-    'page-timeline':  <PageTimeline />,
-    'page-comment-write':  <CommentEditorLazyRenderer />,
+    'page-attachment': <PageAttachment />,
+    'page-timeline': <PageTimeline />,
+    'page-comment-write': <CommentEditorLazyRenderer />,
     'revision-toc': <TableOfContents />,
     'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
     'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
     'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
-    'bookmark-button':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
-    'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
-    'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
-    'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+    'bookmark-button': <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
+    'bookmark-button-lg': <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
+    'rename-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+    'duplicate-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
   }, componentMappings);
 }
 if (pageContainer.state.path != null) {
@@ -148,7 +147,7 @@ if (pageContainer.state.path != null) {
     // eslint-disable-next-line quote-props
     'page': <Page />,
     'revision-path': <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} />,
-    'tag-label':  <TagLabels />,
+    'tag-label': <TagLabels />,
   }, componentMappings);
 }
 
@@ -166,32 +165,60 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
+// create unstated container instance for admin
+const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
+const adminUsersContainer = new AdminUsersContainer(appContainer);
+const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
+const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
+const adminContainers = {
+  'admin-customize': adminCustomizeContainer,
+  'admin-user-page': adminUsersContainer,
+  'admin-external-account-setting': adminExternalAccountsContainer,
+  'admin-markdown-setting': adminMarkDownContainer,
+  'admin-export-page': websocketContainer,
+};
+
 // render for admin
-const adminUsersElem = document.getElementById('admin-user-page');
-if (adminUsersElem != null) {
-  const adminUsersContainer = new AdminUsersContainer(appContainer);
+const adminAppElem = document.getElementById('admin-app');
+if (adminAppElem != null) {
+  const adminAppContainer = new AdminAppContainer(appContainer);
   ReactDOM.render(
-    <Provider inject={[injectableContainers, adminUsersContainer]}>
+    <Provider inject={[injectableContainers, adminAppContainer]}>
       <I18nextProvider i18n={i18n}>
-        <UserManagement />
+        <AppSettingsPage />
       </I18nextProvider>
     </Provider>,
-    adminUsersElem,
+    adminAppElem,
   );
 }
 
-const adminExternalAccountsElem = document.getElementById('admin-external-account-setting');
-if (adminExternalAccountsElem != null) {
-  const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
-  ReactDOM.render(
-    <Provider inject={[injectableContainers, adminExternalAccountsContainer]}>
-      <I18nextProvider i18n={i18n}>
-        <ManageExternalAccount />
-      </I18nextProvider>
-    </Provider>,
-    adminExternalAccountsElem,
-  );
-}
+/**
+ * define components
+ *  key: id of element
+ *  value: React Element
+ */
+const adminComponentMappings = {
+  'admin-customize': <Customize />,
+  'admin-user-page': <UserManagement />,
+  'admin-external-account-setting': <ManageExternalAccount />,
+  'admin-markdown-setting': <MarkdownSetting />,
+  'admin-export-page': <ExportArchiveDataPage crowi={appContainer} />,
+};
+
+
+Object.keys(adminComponentMappings).forEach((key) => {
+  const adminElem = document.getElementById(key);
+  if (adminElem) {
+    ReactDOM.render(
+      <Provider inject={[injectableContainers, adminContainers[key]]}>
+        <I18nextProvider i18n={i18n}>
+          {adminComponentMappings[key]}
+        </I18nextProvider>
+      </Provider>,
+      adminElem,
+    );
+  }
+});
 
 const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 if (adminUserGroupDetailElem != null) {
@@ -208,7 +235,7 @@ if (adminUserGroupDetailElem != null) {
 
 const adminMarkDownSettingElem = document.getElementById('admin-markdown-setting');
 if (adminMarkDownSettingElem != null) {
-  const markDownSettingContainer = new MarkDownSettingContainer(appContainer);
+  const markDownSettingContainer = new AdminMarkDownContainer(appContainer);
   ReactDOM.render(
     <Provider inject={[injectableContainers, markDownSettingContainer]}>
       <I18nextProvider i18n={i18n}>
@@ -243,37 +270,6 @@ if (adminSecuritySettingElem != null) {
   );
 }
 
-const customCssEditorElem = document.getElementById('custom-css-editor');
-if (customCssEditorElem != null) {
-  // get input[type=hidden] element
-  const customCssInputElem = document.getElementById('inputCustomCss');
-
-  ReactDOM.render(
-    <CustomCssEditor inputElem={customCssInputElem} />,
-    customCssEditorElem,
-  );
-}
-const customScriptEditorElem = document.getElementById('custom-script-editor');
-if (customScriptEditorElem != null) {
-  // get input[type=hidden] element
-  const customScriptInputElem = document.getElementById('inputCustomScript');
-
-  ReactDOM.render(
-    <CustomScriptEditor inputElem={customScriptInputElem} />,
-    customScriptEditorElem,
-  );
-}
-const customHeaderEditorElem = document.getElementById('custom-header-editor');
-if (customHeaderEditorElem != null) {
-  // get input[type=hidden] element
-  const customHeaderInputElem = document.getElementById('inputCustomHeader');
-
-  ReactDOM.render(
-    <CustomHeaderEditor inputElem={customHeaderInputElem} />,
-    customHeaderEditorElem,
-  );
-}
-
 const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
 if (adminUserGroupPageElem != null) {
   const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
@@ -291,20 +287,6 @@ if (adminUserGroupPageElem != null) {
   );
 }
 
-const adminExportPageElem = document.getElementById('admin-export-page');
-if (adminExportPageElem != null) {
-  ReactDOM.render(
-    <Provider inject={[appContainer, websocketContainer]}>
-      <I18nextProvider i18n={i18n}>
-        <ExportArchiveDataPage
-          crowi={appContainer}
-        />
-      </I18nextProvider>
-    </Provider>,
-    adminExportPageElem,
-  );
-}
-
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(

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

@@ -0,0 +1,139 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class AppSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateAppSettingHandler();
+      toastSuccess(t('app_setting.updated_app_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.Site Name')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.title}
+              onChange={(e) => { adminAppContainer.changeTitle(e.target.value) }}
+              placeholder="GROWI"
+            />
+            <p className="help-block">{t('app_setting.sitename_change')}</p>
+          </div>
+        </div>
+
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.Confidential name')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.confidential}
+              onChange={(e) => { adminAppContainer.changeConfidential(e.target.value) }}
+              placeholder={t('app_setting.ex) internal use only')}
+            />
+            <p className="help-block">{t('app_setting.header_content')}</p>
+          </div>
+        </div>
+
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.Default Language for new users')}</label>
+          <div className="col-xs-6">
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioLangEn"
+                name="globalLang"
+                value="en-US"
+                checked={adminAppContainer.state.globalLang === 'en-US'}
+                onClick={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
+              />
+              <label htmlFor="radioLangEn">{t('English')}</label>
+            </div>
+            <div className="radio radio-primary radio-inline">
+              <input
+                type="radio"
+                id="radioLangJa"
+                name="globalLang"
+                value="ja"
+                checked={adminAppContainer.state.globalLang === 'ja'}
+                onClick={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
+              />
+              <label htmlFor="radioLangJa">{t('Japanese')}</label>
+            </div>
+          </div>
+        </div>
+
+        <div className="row md-5">
+          <label className="col-xs-3 control-label">{t('app_setting.File Uploading')}</label>
+          <div className="col-xs-6">
+            <div className="checkbox checkbox-info">
+              <input
+                type="checkbox"
+                id="cbFileUpload"
+                name="fileUpload"
+                checked={adminAppContainer.state.fileUpload}
+                onChange={(e) => { adminAppContainer.changeFileUpload(e.target.checked) }}
+              />
+              <label htmlFor="cbFileUpload">{t('app_setting.enable_files_except_image')}</label>
+            </div>
+
+            <p className="help-block">
+              {t('app_setting.enable_files_except_image')}
+              <br />
+              {t('app_setting.attach_enable')}
+            </p>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingWrapper = (props) => {
+  return createSubscribedElement(AppSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+AppSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(AppSettingWrapper);

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

@@ -0,0 +1,94 @@
+import React, { Fragment } from 'react';
+import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+
+import AppSetting from './AppSetting';
+import SiteUrlSetting from './SiteUrlSetting';
+import MailSetting from './MailSetting';
+import AwsSetting from './AwsSetting';
+import PluginSetting from './PluginSetting';
+
+const logger = loggerFactory('growi:appSettings');
+
+class AppSettingsPage extends React.Component {
+
+  async componentDidMount() {
+    const { adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.retrieveAppSettingsData();
+    }
+    catch (err) {
+      toastError(err);
+      adminAppContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Fragment>
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('App Settings')}</h2>
+            <AppSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('Site URL settings')}</h2>
+            <SiteUrlSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('app_setting.Mail settings')}</h2>
+            <MailSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('app_setting.AWS settings')}</h2>
+            <AwsSetting />
+          </div>
+        </div>
+
+        <div className="row">
+          <div className="col-md-12">
+            <h2>{t('app_setting.Plugin settings')}</h2>
+            <PluginSetting />
+          </div>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+AppSettingsPage.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageWrapper = (props) => {
+  return createSubscribedElement(AppSettingsPage, props, [AppContainer, AdminAppContainer]);
+};
+
+
+export default withTranslation()(AppSettingsPageWrapper);

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

@@ -0,0 +1,156 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class AwsSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateAwsSettingHandler();
+      toastSuccess(t('app_setting.updated_app_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">
+          {t('app_setting.AWS_access')}
+          <br />
+          {t('app_setting.No_SMTP_setting')}
+          <br />
+          <br />
+          <span className="text-danger">
+            <i className="ti-unlink"></i>
+            {t('app_setting.change_setting')}
+          </span>
+        </p>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            {t('app_setting.region')}
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              placeholder={`${t('eg')} ap-northeast-1`}
+              defaultValue={adminAppContainer.state.region}
+              onChange={(e) => {
+                adminAppContainer.changeRegion(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            {t('app_setting.custom endpoint')}
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={`${t('eg')} http://localhost:9000`}
+              defaultValue={adminAppContainer.state.customEndpoint}
+              onChange={(e) => {
+                adminAppContainer.changeCustomEndpoint(e.target.value);
+              }}
+            />
+            <p className="help-block">{t('app_setting.custom_endpoint_change')}</p>
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            {t('app_setting.bucket name')}
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={`${t('eg')} crowi`}
+              defaultValue={adminAppContainer.state.bucket}
+              onChange={(e) => {
+                adminAppContainer.changeBucket(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            Access Key ID
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.accessKeyId}
+              onChange={(e) => {
+                adminAppContainer.changeAccessKeyId(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">
+            Secret Access Key
+          </label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.secretKey}
+              onChange={(e) => {
+                adminAppContainer.changeSecretKey(e.target.value);
+              }}
+            />
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const AwsSettingWrapper = (props) => {
+  return createSubscribedElement(AwsSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+AwsSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(AwsSettingWrapper);

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

@@ -0,0 +1,117 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class MailSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateMailSettingHandler();
+      toastSuccess(t('app_setting.updated_app_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">{t('app_setting.SMTP_used')} {t('app_setting.SMTP_but_AWS')}<br />{t('app_setting.neihter_of')}</p>
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">{t('app_setting.From e-mail address')}</label>
+          <div className="col-xs-6">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={`${t('eg')} mail@growi.org`}
+              defaultValue={adminAppContainer.state.fromAddress}
+              onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <label className="col-xs-3 control-label">{ t('app_setting.SMTP settings') }</label>
+          <div className="col-xs-4">
+            <label>{ t('app_setting.Host') }</label>
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.smtpHost}
+              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+            />
+          </div>
+          <div className="col-xs-2">
+            <label>{ t('app_setting.Port') }</label>
+            <input
+              className="form-control"
+              defaultValue={adminAppContainer.state.smtpPort}
+              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <div className="row mb-5">
+          <div className="col-xs-3 col-xs-offset-3">
+            <label>{ t('app_setting.User') }</label>
+            <input
+              className="form-control"
+              type="text"
+              defaultValue={adminAppContainer.state.SmtpUser}
+              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+            />
+          </div>
+          <div className="col-xs-3">
+            <label>{ t('Password') }</label>
+            <input
+              className="form-control"
+              type="password"
+              defaultValue={adminAppContainer.state.smtpPassword}
+              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+            />
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const MailSettingWrapper = (props) => {
+  return createSubscribedElement(MailSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+MailSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(MailSettingWrapper);

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

@@ -0,0 +1,80 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:app:pluginSetting');
+
+class PluginSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updatePluginSettingHandler();
+      toastSuccess(t('app_setting.updated_plugin_setting'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">{t('app_setting.Enable plugin loading')}</p>
+
+        <div className="row mb-5">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                id="isEnabledPlugins"
+                type="checkbox"
+                checked={adminAppContainer.state.isEnabledPlugins}
+                onChange={(e) => {
+                  adminAppContainer.changeIsEnabledPlugins(e.target.checked);
+                }}
+              />
+              <label htmlFor="isEnabledPlugins">{t('app_setting.Load plugins')}</label>
+            </div>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const PluginSettingWrapper = (props) => {
+  return createSubscribedElement(PluginSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+PluginSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(PluginSettingWrapper);

+ 108 - 0
src/client/js/components/Admin/App/SiteUrlSetting.jsx

@@ -0,0 +1,108 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminAppContainer from '../../../services/AdminAppContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+class SiteUrlSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.submitHandler = this.submitHandler.bind(this);
+  }
+
+  async submitHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateSiteUrlSettingHandler();
+      toastSuccess(t('app_setting.updated_site_url'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminAppContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <p className="well">{t('app_setting.Site URL desc')}</p>
+        {!adminAppContainer.state.isSetSiteUrl && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('app_setting.Site URL warn')}</p>)}
+
+        <div className="row">
+          <div className="col-md-12">
+            <div className="col-xs-offset-3">
+              <table className="table settings-table">
+                <colgroup>
+                  <col className="from-db" />
+                  <col className="from-env-vars" />
+                </colgroup>
+                <thead>
+                  <tr>
+                    <th>Database</th>
+                    <th>Environment variables</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td>
+                      <input
+                        className="form-control"
+                        type="text"
+                        name="settingForm[app:siteUrl]"
+                        defaultValue={adminAppContainer.state.siteUrl}
+                        onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
+                        placeholder="e.g. https://my.growi.org"
+                      />
+                      <p className="help-block">
+                        {/* eslint-disable-next-line react/no-danger */}
+                        <div dangerouslySetInnerHTML={{ __html: t('app_setting.siteurl_help') }} />
+                      </p>
+                    </td>
+                    <td>
+                      <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl} readOnly />
+                      <p className="help-block">
+                        {/* eslint-disable-next-line react/no-danger */}
+                        <div dangerouslySetInnerHTML={{ __html: t('app_setting.Use env var if empty', { variable: 'APP_SITE_URL' }) }} />
+                      </p>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const SiteUrlSettingWrapper = (props) => {
+  return createSubscribedElement(SiteUrlSetting, props, [AppContainer, AdminAppContainer]);
+};
+
+SiteUrlSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
+};
+
+export default withTranslation()(SiteUrlSettingWrapper);

+ 28 - 0
src/client/js/components/Admin/Common/AdminUpdateButtonRow.jsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class AdminUpdateButtonRow extends React.PureComponent {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="row my-3">
+        <div className="col-xs-offset-4 col-xs-5">
+          <button type="button" className="btn btn-primary" onClick={this.props.onClick} disabled={this.props.disabled}>{ t('Update') }</button>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+AdminUpdateButtonRow.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  onClick: PropTypes.func.isRequired,
+  disabled: PropTypes.bool.isRequired,
+};
+
+export default withTranslation()(AdminUpdateButtonRow);

+ 4 - 5
src/client/js/components/Admin/CustomCssEditor.jsx

@@ -16,12 +16,10 @@ require('jquery-ui/ui/widgets/resizable');
 export default class CustomCssEditor extends React.Component {
 
   render() {
-    // get initial value from inputElem
-    const value = this.props.inputElem.value;
 
     return (
       <CodeMirror
-        value={value}
+        value={this.props.value}
         autoFocus
         options={{
           mode: 'css',
@@ -43,7 +41,7 @@ export default class CustomCssEditor extends React.Component {
           });
         }}
         onChange={(editor, data, value) => {
-          this.props.inputElem.value = value;
+          this.props.onChange(value);
         }}
       />
     );
@@ -52,5 +50,6 @@ export default class CustomCssEditor extends React.Component {
 }
 
 CustomCssEditor.propTypes = {
-  inputElem: PropTypes.object.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
 };

+ 4 - 5
src/client/js/components/Admin/CustomHeaderEditor.jsx

@@ -14,12 +14,10 @@ require('jquery-ui/ui/widgets/resizable');
 export default class CustomHeaderEditor extends React.Component {
 
   render() {
-    // get initial value from inputElem
-    const value = this.props.inputElem.value;
 
     return (
       <CodeMirror
-        value={value}
+        value={this.props.value}
         autoFocus
         options={{
           mode: 'htmlmixed',
@@ -41,7 +39,7 @@ export default class CustomHeaderEditor extends React.Component {
           });
         }}
         onChange={(editor, data, value) => {
-          this.props.inputElem.value = value;
+          this.props.onChange(value);
         }}
       />
     );
@@ -50,5 +48,6 @@ export default class CustomHeaderEditor extends React.Component {
 }
 
 CustomHeaderEditor.propTypes = {
-  inputElem: PropTypes.object.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
 };

+ 4 - 5
src/client/js/components/Admin/CustomScriptEditor.jsx

@@ -16,12 +16,10 @@ require('jquery-ui/ui/widgets/resizable');
 export default class CustomScriptEditor extends React.Component {
 
   render() {
-    // get initial value from inputElem
-    const value = this.props.inputElem.value;
 
     return (
       <CodeMirror
-        value={value}
+        value={this.props.value}
         autoFocus
         options={{
           mode: 'javascript',
@@ -43,7 +41,7 @@ export default class CustomScriptEditor extends React.Component {
           });
         }}
         onChange={(editor, data, value) => {
-          this.props.inputElem.value = value;
+          this.props.onChange(value);
         }}
       />
     );
@@ -52,5 +50,6 @@ export default class CustomScriptEditor extends React.Component {
 }
 
 CustomScriptEditor.propTypes = {
-  inputElem: PropTypes.object.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
 };

+ 55 - 21
src/client/js/components/Admin/Customize/Customize.jsx

@@ -3,36 +3,69 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import loggerFactory from '@alias/logger';
+
 import AppContainer from '../../../services/AppContainer';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastError } from '../../../util/apiNotification';
+
+import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeBehaviorSetting from './CustomizeBehaviorSetting';
+import CustomizeFunctionSetting from './CustomizeFunctionSetting';
+import CustomizeHighlightSetting from './CustomizeHighlightSetting';
+import CustomizeCssSetting from './CustomizeCssSetting';
+import CustomizeScriptSetting from './CustomizeScriptSetting';
+import CustomizeHeaderSetting from './CustomizeHeaderSetting';
+import CustomizeTitle from './CustomizeTitle';
+
+const logger = loggerFactory('growi:Customize');
 
 class Customize extends React.Component {
 
+  async componentDidMount() {
+    const { adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.retrieveCustomizeData();
+    }
+    catch (err) {
+      toastError(err);
+      adminCustomizeContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
+
+  }
+
   render() {
-    const { t } = this.props;
 
     return (
       <Fragment>
-        {/* fieldset + legend ではなく、row + header + フォームコンポーネントに書き換える(GC244着手時に対応) */}
-        <fieldset>
-          <legend>{t('customize_page.Layout')}</legend>
-          {/* レイアウトフォームの react componentをここで呼ぶ(GW-244) */}
-          <legend>{t('customize_page.Theme')}</legend>
-          {/* テーマフォームの react componentをここで呼ぶ(GW-245) */}
-          <legend>{t('customize_page.Behavior')}</legend>
-          {/* 挙動フォームの react componentをここで呼ぶ(GW-246) */}
-          <legend>{t('customize_page.Function')}</legend>
-          {/* 機能フォームの react componentをここで呼ぶ(GW-276) */}
-          <legend>{t('customize_page.Code Highlight')}</legend>
-          {/* コードハイライトフォームの react componentをここで呼ぶ(GW-277) */}
-          <legend>{t('customize_page.custom_title')}</legend>
-          {/* カスタムタイトルフォームの react componentをここで呼ぶ(GW-278) */}
-          <legend>{t('customize_page.Custom CSS')}</legend>
-          {/* カスタムCSSフォームの react componentをここで呼ぶ(GW-279) */}
-          <legend>{t('customize_page.Custom script')}</legend>
-          {/* カスタムスクリプトフォームの react componentをここで呼ぶ(GW-280) */}
-        </fieldset>
+        <div className="mb-5">
+          <CustomizeLayoutSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeBehaviorSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeFunctionSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeHighlightSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeTitle />
+        </div>
+        <div className="mb-5">
+          <CustomizeHeaderSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeCssSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeScriptSetting />
+        </div>
       </Fragment>
     );
   }
@@ -40,12 +73,13 @@ class Customize extends React.Component {
 }
 
 const CustomizeWrapper = (props) => {
-  return createSubscribedElement(Customize, props, [AppContainer]);
+  return createSubscribedElement(Customize, props, [AppContainer, AdminCustomizeContainer]);
 };
 
 Customize.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 
 export default withTranslation()(CustomizeWrapper);

+ 38 - 0
src/client/js/components/Admin/Customize/CustomizeBehaviorOption.jsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CustomizeBehaviorOption extends React.PureComponent {
+
+  render() {
+
+    return (
+      <React.Fragment>
+        <h4>
+          <div className="radio radio-primary">
+            <input type="radio" id={`radioBehavior${this.props.behaviorType}`} checked={this.props.isSelected} onChange={this.props.onSelected} />
+            <label htmlFor={`radioBehavior${this.props.behaviorType}`}>
+              {/* eslint-disable-next-line react/no-danger */}
+              <span dangerouslySetInnerHTML={{ __html: this.props.labelHtml }} />
+            </label>
+          </div>
+        </h4>
+        {/* render layout description */}
+        {this.props.children}
+      </React.Fragment>
+    );
+  }
+
+}
+
+CustomizeBehaviorOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  behaviorType: PropTypes.string.isRequired,
+  labelHtml: PropTypes.string.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  onSelected: PropTypes.func.isRequired,
+  children: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorOption);

+ 99 - 0
src/client/js/components/Admin/Customize/CustomizeBehaviorSetting.jsx

@@ -0,0 +1,99 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import CustomizeBehaviorOption from './CustomizeBehaviorOption';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeBehaviorSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeBehavior();
+      toastSuccess(t('customize_page.update_behavior_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Behavior')}</h2>
+        <div className="row">
+          <div className="col-xs-6">
+            <CustomizeBehaviorOption
+              behaviorType="growi"
+              isSelected={adminCustomizeContainer.state.currentBehavior === 'growi'}
+              onSelected={() => adminCustomizeContainer.switchBehaviorType('growi')}
+              labelHtml={`GROWI Simplified Behavior <small class="text-success">${t('customize_page.recommended')}</small>`}
+            >
+              <ul>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.growi_text1') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.growi_text2') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.growi_text3') }} /></li>
+              </ul>
+            </CustomizeBehaviorOption>
+          </div>
+
+          <div className="col-xs-6">
+            <CustomizeBehaviorOption
+              behaviorType="crowi-plus"
+              isSelected={adminCustomizeContainer.state.currentBehavior === 'crowi-plus'}
+              onSelected={() => adminCustomizeContainer.switchBehaviorType('crowi-plus')}
+              labelHtml="Crowi Classic Behavior"
+            >
+              <ul>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text1') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text2') }} /></li>
+                <ul>
+                  <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text3') }} /></li>
+                </ul>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text4') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text5') }} /></li>
+              </ul>
+            </CustomizeBehaviorOption>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeBehaviorSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeBehaviorSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeBehaviorSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorSettingWrapper);

+ 91 - 0
src/client/js/components/Admin/Customize/CustomizeCssSetting.jsx

@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomCssEditor from '../CustomCssEditor';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeCssSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      editorInputValue: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  componentDidMount() {
+    const { customizeCss } = this.props.appContainer.getConfig();
+    this.setState({ editorInputValue: customizeCss || '' });
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeCss();
+      toastSuccess(t('customize_page.update_customCss_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Custom CSS')}</h2>
+        <p className="well">
+          { t('customize_page.write_CSS') }<br />
+          { t('customize_page.reflect_change') }
+        </p>
+        <div className="form-group">
+          <div className="col-xs-12">
+            <CustomCssEditor
+              // The value passed must be immutable
+              value={this.state.editorInputValue}
+              onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeCss(inputValue) }}
+            />
+          </div>
+          <div className="col-xs-12">
+            <p className="help-block text-right">
+              <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
+              { t('customize_page.ctrl_space') }
+            </p>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeCssSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeCssSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeCssSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeCssSettingWrapper);

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

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CustomizeFunctionOption extends React.PureComponent {
+
+  render() {
+    return (
+      <React.Fragment>
+        <div className="checkbox checkbox-success">
+          <input
+            type="checkbox"
+            id={this.props.optionId}
+            checked={this.props.isChecked}
+            onChange={this.props.onChecked}
+          />
+          <label htmlFor={this.props.optionId}>
+            <strong>{this.props.label}</strong>
+          </label>
+        </div>
+        {this.props.children}
+      </React.Fragment>
+    );
+  }
+
+}
+
+CustomizeFunctionOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  optionId: PropTypes.string.isRequired,
+  label: PropTypes.string.isRequired,
+  isChecked: PropTypes.bool.isRequired,
+  onChecked: PropTypes.func.isRequired,
+  children: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(CustomizeFunctionOption);

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

@@ -0,0 +1,158 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomizeFunctionOption from './CustomizeFunctionOption';
+
+const logger = loggerFactory('growi:importer');
+
+class CustomizeBehaviorSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeFunction();
+      toastSuccess(t('customize_page.update_function_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Function')}</h2>
+        <p className="well">{ t('customize_page.function_choose') }</p>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isEnabledTimeline"
+              label={t('customize_page.Timeline function')}
+              isChecked={adminCustomizeContainer.state.isEnabledTimeline}
+              onChecked={() => { adminCustomizeContainer.switchEnableTimeline() }}
+            >
+              <p className="help-block">
+                { t('customize_page.subpage_display') }<br />
+                { t('customize_page.performance_decrease') }<br />
+                { t('customize_page.list_page_display') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isSavedStatesOfTabChanges"
+              label={t('customize_page.tab_switch')}
+              isChecked={adminCustomizeContainer.state.isSavedStatesOfTabChanges}
+              onChecked={() => { adminCustomizeContainer.switchSavedStatesOfTabChanges() }}
+            >
+              <p className="help-block">
+                { t('customize_page.save_edit') }<br />
+                { t('customize_page.by_invalidating') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isEnabledAttachTitleHeader"
+              label={t('customize_page.attach_title_header')}
+              isChecked={adminCustomizeContainer.state.isEnabledAttachTitleHeader}
+              onChecked={() => { adminCustomizeContainer.switchEnabledAttachTitleHeader() }}
+            >
+              <p className="help-block">
+                { t('customize_page.attach_title_header_desc') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="my-0 btn-group">
+              <label>{t('customize_page.recent_created__n_draft_num_desc')}</label>
+              <div className="dropdown">
+                <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                  <span className="pull-left">{adminCustomizeContainer.state.currentRecentCreatedLimit}</span>
+                  <span className="bs-caret pull-right">
+                    <span className="caret" />
+                  </span>
+                </button>
+                {/* TODO adjust dropdown after BS4 */}
+                <ul className="dropdown-menu" role="menu">
+                  <li key={10} role="presentation" type="button" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(10) }}>
+                    <a role="menuitem">10</a>
+                  </li>
+                  <li key={30} role="presentation" type="button" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(30) }}>
+                    <a role="menuitem">30</a>
+                  </li>
+                  <li key={50} role="presentation" type="button" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(50) }}>
+                    <a role="menuitem">50</a>
+                  </li>
+                </ul>
+              </div>
+              <p className="help-block">
+                { t('customize_page.recently_created_n_draft_num_desc') }
+              </p>
+            </div>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isEnabledStaleNotification"
+              label={t('customize_page.stale_notification')}
+              isChecked={adminCustomizeContainer.state.isEnabledStaleNotification}
+              onChecked={() => { adminCustomizeContainer.switchEnableStaleNotification() }}
+            >
+              <p className="help-block">
+                { t('customize_page.stale_notification_desc') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeBehaviorSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeBehaviorSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeBehaviorSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorSettingWrapper);

+ 100 - 0
src/client/js/components/Admin/Customize/CustomizeHeaderSetting.jsx

@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomHeaderEditor from '../CustomHeaderEditor';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeHeaderSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      editorInputValue: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  componentDidMount() {
+    const { customizeHeader } = this.props.appContainer.getConfig();
+    this.setState({ editorInputValue: customizeHeader || '' });
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeHeader();
+      toastSuccess(t('customize_page.update_customHeader_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.custom_header')}</h2>
+
+        <p
+          className="well"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('customize_page.custom_header_detail') }}
+        />
+
+        <div className="help-block">
+          { t('Example') }:
+          <pre className="hljs">
+            {/* eslint-disable-next-line react/no-unescaped-entities */}
+            <code>&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js" defer&gt;&lt;/script&gt;</code>
+          </pre>
+        </div>
+
+        <div className="col-xs-12">
+          <CustomHeaderEditor
+            // The value passed must be immutable
+            value={this.state.editorInputValue}
+            onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeHeader(inputValue) }}
+          />
+        </div>
+        <div className="col-xs-12">
+          <p className="help-block text-right">
+            <i className="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
+            { t('customize_page.ctrl_space') }
+          </p>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeHeaderSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeHeaderSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeHeaderSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeHeaderSettingWrapper);

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

@@ -0,0 +1,143 @@
+/* eslint-disable no-useless-escape */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:customizeHighlight');
+
+class CustomizeHighlightSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateHighlightJsStyle();
+      toastSuccess(t('customize_page.update_highlight_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  getDemoFunction() {
+    return `function $initHighlight(block, cls) {
+    try {
+
+      if (cls.search(/\bno\-highlight\b/) !== -1) {
+        return \`\${process(block, true, 0x0F)} class="\${cls}"\`;
+      }
+    }
+    catch (e) {
+      /* handle exception */
+    }
+    for (let i = 0 / 2; i < classes.length; i++) {
+      if (checkCondition(classes[i]) === undefined) { console.log('undefined') }
+    }
+  };`;
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+    const options = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
+    const menuItem = [];
+
+    Object.entries(options).forEach((option) => {
+      const styleId = option[0];
+      const styleName = option[1].name;
+      const isBorderEnable = option[1].border;
+
+      menuItem.push(
+        <li key={styleId} role="presentation" type="button" onClick={() => adminCustomizeContainer.switchHighlightJsStyle(styleId, styleName, isBorderEnable)}>
+          <a role="menuitem">{styleName}</a>
+        </li>,
+      );
+    });
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Code Highlight')}</h2>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="my-0 btn-group">
+              <label>{t('customize_page.Theme')}</label>
+              <div className="dropdown">
+                <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                  <span className="pull-left">{adminCustomizeContainer.state.currentHighlightJsStyleName}</span>
+                  <span className="bs-caret pull-right">
+                    <span className="caret" />
+                  </span>
+                </button>
+                {/* TODO adjust dropdown after BS4 */}
+                <ul className="dropdown-menu" role="menu">
+                  {menuItem}
+                </ul>
+              </div>
+              {/* eslint-disable-next-line react/no-danger */}
+              <p className="help-block text-warning"><span dangerouslySetInnerHTML={{ __html:  t('customize_page.nocdn_desc') }} /></p>
+            </div>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                type="checkbox"
+                id="highlightBorder"
+                checked={adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled}
+                onChange={() => { adminCustomizeContainer.switchHighlightJsStyleBorder() }}
+              />
+              <label htmlFor="highlightBorder">
+                <strong>Border</strong>
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <div className="help-block">
+          <label>Examples:</label>
+          <div className="wiki">
+            <pre className={`hljs ${!adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled && 'hljs-no-border'}`}>
+              <code className="highlightjs-demo">
+                {this.getDemoFunction()}
+              </code>
+            </pre>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeHighlightSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeHighlightSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeHighlightSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeHighlightSettingWrapper);

+ 42 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx

@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CustomizeLayoutOption extends React.Component {
+
+  render() {
+    const { layoutType } = this.props;
+
+    return (
+      <React.Fragment>
+        <h4>
+          <div className="radio radio-primary">
+            <input type="radio" id={`radio-layout-${layoutType}`} checked={this.props.isSelected} onChange={this.props.onSelected} />
+            <label htmlFor={`radio-layout-${layoutType}`}>
+              {/* eslint-disable-next-line react/no-danger */}
+              <span dangerouslySetInnerHTML={{ __html: this.props.labelHtml }} />
+            </label>
+          </div>
+        </h4>
+        <a href={`/images/admin/customize/layout-${layoutType}.gif`} className="ss-container">
+          <img src={`/images/admin/customize/layout-${layoutType}-thumb.gif`} width="240px" />
+        </a>
+        {/* render layout description */}
+        {this.props.children}
+      </React.Fragment>
+    );
+  }
+
+}
+
+CustomizeLayoutOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  layoutType: PropTypes.string.isRequired,
+  labelHtml: PropTypes.string.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  onSelected: PropTypes.func.isRequired,
+  children: PropTypes.array.isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutOption);

+ 81 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx

@@ -0,0 +1,81 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AppContainer from '../../../services/AppContainer';
+
+import CustomizeLayoutOption from './CustomizeLayoutOption';
+
+class CustomizeLayoutOptions extends React.Component {
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <div className="row">
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="crowi-plus"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'growi'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('growi')}
+            labelHtml={`GROWI Enhanced Layout <small class="text-success">${t('customize_page.recommended')}</small>`}
+          >
+            <h4>{t('customize_page.layout_description.growi_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.growi_text1')}</li>
+              <li>{t('customize_page.layout_description.growi_text2')}</li>
+              <li>{t('customize_page.layout_description.growi_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
+
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="kibela"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'kibela'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('kibela')}
+            labelHtml="Kibela Like Layout"
+          >
+            <h4>{t('customize_page.layout_description.kibela_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.kibela_text1')}</li>
+              <li>{t('customize_page.layout_description.kibela_text2')}</li>
+              <li>{t('customize_page.layout_description.kibela_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
+
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="classic"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'crowi'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('crowi')}
+            labelHtml="Crowi Classic Layout"
+          >
+            <h4>{t('customize_page.layout_description.crowi_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.crowi_text1')}</li>
+              <li>{t('customize_page.layout_description.crowi_text2')}</li>
+              <li>{t('customize_page.layout_description.crowi_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+const CustomizeLayoutOptionsWrapper = (props) => {
+  return createSubscribedElement(CustomizeLayoutOptions, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeLayoutOptions.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutOptionsWrapper);

+ 79 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import CustomizeLayoutOptions from './CustomizeLayoutOptions';
+import CustomizeThemeOptions from './CustomizeThemeOptions';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:importer');
+
+
+class CustomizeLayoutSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeLayoutAndTheme();
+      toastSuccess(t('customize_page.update_layout_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  renderDevAlert() {
+    if (process.env.NODE_ENV === 'development') {
+      return (
+        <div className="alert alert-warning">
+          <strong>DEBUG MESSAGE:</strong> development build では、リアルタイムプレビューが無効になります
+        </div>
+      );
+    }
+  }
+
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Layout')}</h2>
+        <CustomizeLayoutOptions />
+        <h2 className="admin-setting-header">{t('customize_page.Theme')}</h2>
+        {this.renderDevAlert()}
+        <CustomizeThemeOptions />
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeLayoutSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeLayoutSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeLayoutSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutSettingWrapper);

+ 122 - 0
src/client/js/components/Admin/Customize/CustomizeScriptSetting.jsx

@@ -0,0 +1,122 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomScriptEditor from '../CustomScriptEditor';
+
+const logger = loggerFactory('growi:customizeScript');
+
+class CustomizeScriptSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      editorInputValue: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  componentDidMount() {
+    const { customizeScript } = this.props.appContainer.getConfig();
+    this.setState({ editorInputValue: customizeScript || '' });
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeScript();
+      toastSuccess(t('customize_page.update_script_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  getExampleCode() {
+    return `console.log($('.main-container'));
+    window.addEventListener('load', (event) => {
+      console.log('config: ', appContainer.config);
+    });
+    `;
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Custom script')}</h2>
+        <p className="well">
+          { t('customize_page.write_java') }<br />
+          { t('customize_page.reflect_change') }
+        </p>
+
+        <div className="help-block">
+          Placeholders:<br />
+          (Available after <code>load</code> event)
+          <dl className="dl-horizontal">
+            <dt><code>$</code></dt>
+            <dd>jQuery instance</dd>
+            <dt><code>appContainer</code></dt>
+            <dd>GROWI App <a href="https://github.com/jamiebuilds/unstated">Unstated Container</a></dd>
+            <dt><code>growiRenderer</code></dt>
+            <dd>GROWI Renderer origin instance</dd>
+            <dt><code>growiPlugin</code></dt>
+            <dd>GROWI Plugin Manager instance</dd>
+            <dt><code>Crowi</code></dt>
+            <dd>Crowi legacy instance (jQuery based)</dd>
+          </dl>
+        </div>
+
+        <div className="help-block">
+          Examples:
+          <pre className="hljs"><code>{this.getExampleCode()}</code></pre>
+        </div>
+
+        <div className="form-group">
+          <div className="col-xs-12">
+            <CustomScriptEditor
+              // The value passed must be immutable
+              value={this.state.editorInputValue}
+              onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeScript(inputValue) }}
+            />
+          </div>
+          <div className="col-xs-12">
+            <p className="help-block text-right">
+              <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
+              { t('customize_page.ctrl_space') }
+            </p>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeScriptSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeScriptSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeScriptSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeScriptSettingWrapper);

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

@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import ThemeColorBox from './ThemeColorBox';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+
+class CustomizeThemeOptions extends React.Component {
+
+  render() {
+    const { adminCustomizeContainer } = this.props;
+    const { currentLayout, currentTheme } = adminCustomizeContainer.state;
+
+    const lightTheme = [{
+      name: 'default', bg: '#ffffff', topbar: '#334455', theme: '#112744',
+    }, {
+      name: 'nature', bg: '#f9fff3', topbar: '#118050', theme: '#460039',
+    }, {
+      name: 'mono-blue', bg: '#F7FBFD', topbar: '#00587A', theme: '#00587A',
+    }, {
+      name: 'wood', bg: '#fffefb', topbar: '#aaa45f', theme: '#dddebf',
+    }, {
+      name: 'island', bg: '#8ecac0', topbar: '#0c2a44', theme: '#cef2ef',
+    }, {
+      name: 'christmas', bg: '#fffefb', topbar: '#b3000c', theme: '#017e20',
+    }, {
+      name: 'antarctic', bg: '#ffffff', topbar: '#000080', theme: '#99cccc',
+    }];
+
+    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',
+    }, {
+      name: 'halloween', bg: '#030003', topbar: '#cc5d1f', theme: '#e9af2b',
+    }];
+
+    return (
+      <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
+        {/* Light Themes  */}
+        <div className="d-flex">
+          {lightTheme.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={currentTheme === theme.name}
+                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                name={theme.name}
+                bg={theme.bg}
+                topbar={theme.topbar}
+                theme={theme.theme}
+              />
+            );
+          })}
+        </div>
+        {/* Dark Themes  */}
+        <div className="d-flex mt-3">
+          {darkTheme.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={currentTheme === theme.name}
+                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                name={theme.name}
+                bg={theme.bg}
+                topbar={theme.topbar}
+                theme={theme.theme}
+              />
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+const CustomizeThemeOptionsWrapper = (props) => {
+  return createSubscribedElement(CustomizeThemeOptions, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeThemeOptions.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default CustomizeThemeOptionsWrapper;

+ 79 - 0
src/client/js/components/Admin/Customize/CustomizeTitle.jsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeTitle extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeTitle();
+      toastSuccess(t('customize_page.update_customTitle_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+    const { currentCustomizeTitle } = adminCustomizeContainer.state;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.custom_title')}</h2>
+        <p
+          className="well"
+          // eslint-disable-next-line react/no-danger, max-len
+          dangerouslySetInnerHTML={{ __html: '<code>&lt;title&gt;</code>タグのコンテンツをカスタマイズできます。<br><code>&#123;&#123;sitename&#125;&#125;</code>がサイト名、<code>&#123;&#123;page&#125;&#125;</code>がページ名またはページパスに置換されます。' }}
+        />
+        {/* TODO i18n */}
+        <div className="help-block">
+          Default Value: <code>&#123;&#123;page&#125;&#125; - &#123;&#123;sitename&#125;&#125;</code>
+          <br />
+          Default Output: <pre><code className="xml">&lt;title&gt;/Sandbox - {'GROWI'}&lt;&#047;title&gt;</code></pre>
+        </div>
+        <div className="form-group">
+          <input
+            className="form-control"
+            defaultValue={currentCustomizeTitle}
+            onChange={(e) => { adminCustomizeContainer.changeCustomizeTitle(e.target.value) }}
+          />
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeTitleWrapper = (props) => {
+  return createSubscribedElement(CustomizeTitle, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeTitle.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeTitleWrapper);

+ 45 - 0
src/client/js/components/Admin/Customize/ThemeColorBox.jsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+class ThemeColorBox extends React.PureComponent {
+
+  render() {
+    const { name } = this.props;
+
+    return (
+      <div
+        id={`theme-option-${name}`}
+        className={`theme-option-container d-flex flex-column align-items-center ${this.props.isSelected && 'active'}`}
+        onClick={this.props.onSelected}
+      >
+        <a
+          className={`m-0 ${name} theme-button`}
+          id={name}
+        >
+          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+            <g>
+              <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={this.props.bg}></path>
+              <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill={this.props.topbar}></path>
+              <path d="M 44 15 L65 15 L65 65 L44 65 L44 15 Z" fill={this.props.theme}></path>
+            </g>
+          </svg>
+        </a>
+        <span className="theme-option-name"><b>{ name }</b></span>
+      </div>
+    );
+  }
+
+}
+
+
+ThemeColorBox.propTypes = {
+  isSelected: PropTypes.bool.isRequired,
+  onSelected: PropTypes.func.isRequired,
+  name: PropTypes.string.isRequired,
+  bg: PropTypes.string.isRequired,
+  topbar: PropTypes.string.isRequired,
+  theme: PropTypes.string.isRequired,
+};
+
+export default ThemeColorBox;

+ 10 - 1
src/client/js/components/Admin/ExportArchiveDataPage.jsx

@@ -15,6 +15,10 @@ import ProgressBar from './Common/ProgressBar';
 import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
 
+const IGNORED_COLLECTION_NAMES = [
+  'sessions',
+];
+
 class ExportArchiveDataPage extends React.Component {
 
   constructor(props) {
@@ -46,9 +50,14 @@ class ExportArchiveDataPage extends React.Component {
     ]);
     // TODO: toastSuccess, toastError
 
+    // filter only not ignored collection names
+    const filteredCollections = collections.filter((collectionName) => {
+      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+    });
+
     const { zipFileStats, isExporting, progressList } = status;
     this.setState({
-      collections,
+      collections: filteredCollections,
       zipFileStats,
       isExporting,
       progressList,

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

@@ -57,7 +57,7 @@ class UploadForm extends React.Component {
                 type="file"
                 name="file"
                 className="form-control-file"
-                accept=".growi.zip"
+                accept=".zip"
                 ref={this.inputRef}
                 onChange={this.changeFileName}
               />

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

@@ -8,7 +8,7 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
-import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
 
 const logger = loggerFactory('growi:importer');
 
@@ -25,7 +25,7 @@ class LineBreakForm extends React.Component {
     const { t } = this.props;
 
     try {
-      await this.props.markDownSettingContainer.updateLineBreakSetting();
+      await this.props.adminMarkDownContainer.updateLineBreakSetting();
       toastSuccess(t('markdown_setting.updated_lineBreak'));
     }
     catch (err) {
@@ -35,8 +35,8 @@ class LineBreakForm extends React.Component {
   }
 
   renderLineBreakOption() {
-    const { t, markDownSettingContainer } = this.props;
-    const { isEnabledLinebreaks } = markDownSettingContainer.state;
+    const { t, adminMarkDownContainer } = this.props;
+    const { isEnabledLinebreaks } = adminMarkDownContainer.state;
 
     const helpLineBreak = { __html: t('markdown_setting.Enable Line Break desc') };
 
@@ -48,10 +48,10 @@ class LineBreakForm extends React.Component {
               type="checkbox"
               id="isEnabledLinebreaks"
               checked={isEnabledLinebreaks}
-              onChange={() => { markDownSettingContainer.setState({ isEnabledLinebreaks: !isEnabledLinebreaks }) }}
+              onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaks: !isEnabledLinebreaks }) }}
             />
             <label htmlFor="isEnabledLinebreaks">
-              { t('markdown_setting.Enable Line Break') }
+              {t('markdown_setting.Enable Line Break')}
             </label>
           </div>
           <p className="help-block" dangerouslySetInnerHTML={helpLineBreak} />
@@ -61,8 +61,8 @@ class LineBreakForm extends React.Component {
   }
 
   renderLineBreakInCommentOption() {
-    const { t, markDownSettingContainer } = this.props;
-    const { isEnabledLinebreaksInComments } = markDownSettingContainer.state;
+    const { t, adminMarkDownContainer } = this.props;
+    const { isEnabledLinebreaksInComments } = adminMarkDownContainer.state;
 
     const helpLineBreakInComment = { __html: t('markdown_setting.Enable Line Break for comment desc') };
 
@@ -74,10 +74,10 @@ class LineBreakForm extends React.Component {
               type="checkbox"
               id="isEnabledLinebreaksInComments"
               checked={isEnabledLinebreaksInComments}
-              onChange={() => { markDownSettingContainer.setState({ isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments }) }}
+              onChange={() => { adminMarkDownContainer.setState({ isEnabledLinebreaksInComments: !isEnabledLinebreaksInComments }) }}
             />
             <label htmlFor="isEnabledLinebreaksInComments">
-              { t('markdown_setting.Enable Line Break for comment') }
+              {t('markdown_setting.Enable Line Break for comment')}
             </label>
           </div>
           <p className="help-block" dangerouslySetInnerHTML={helpLineBreakInComment} />
@@ -87,7 +87,7 @@ class LineBreakForm extends React.Component {
   }
 
   render() {
-    const { t } = this.props;
+    const { t, adminMarkDownContainer } = this.props;
 
     return (
       <React.Fragment>
@@ -97,7 +97,14 @@ class LineBreakForm extends React.Component {
         </fieldset>
         <div className="form-group my-3">
           <div className="col-xs-offset-4 col-xs-5">
-            <button type="submit" className="btn btn-primary" onClick={this.onClickSubmit}>{ t('Update') }</button>
+            <button
+              type="submit"
+              className="btn btn-primary"
+              onClick={this.onClickSubmit}
+              disabled={adminMarkDownContainer.state.retrieveError != null}
+            >
+              {t('Update')}
+            </button>
           </div>
         </div>
       </React.Fragment>
@@ -110,13 +117,13 @@ class LineBreakForm extends React.Component {
  * Wrapper component for using unstated
  */
 const LineBreakFormWrapper = (props) => {
-  return createSubscribedElement(LineBreakForm, props, [AppContainer, MarkDownSettingContainer]);
+  return createSubscribedElement(LineBreakForm, props, [AppContainer, AdminMarkDownContainer]);
 };
 
 LineBreakForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 };
 
 export default withTranslation()(LineBreakFormWrapper);

+ 115 - 0
src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx

@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+const logger = loggerFactory('growi:LineBreak');
+
+class LineBreakSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { appContainer } = this.props;
+
+    this.state = {
+      isEnabledLinebreaks: appContainer.config.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: appContainer.config.isEnabledLinebreaksInComments,
+    };
+    this.onChangeEnableLineBreaks = this.onChangeEnableLineBreaks.bind(this);
+    this.onChangeEnableLineBreaksInComments = this.onChangeEnableLineBreaksInComments.bind(this);
+    this.changeLineBreakSettings = this.changeLineBreakSettings.bind(this);
+  }
+
+
+  onChangeEnableLineBreaks() {
+    this.setState({ isEnabledLinebreaks: !this.state.isEnabledLinebreaks });
+  }
+
+  onChangeEnableLineBreaksInComments() {
+    this.setState({ isEnabledLinebreaksInComments: !this.state.isEnabledLinebreaksInComments });
+  }
+
+  async changeLineBreakSettings() {
+    const { appContainer } = this.props;
+    const params = {
+      isEnabledLinebreaks: this.state.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
+    };
+    try {
+      await appContainer.apiPost('/admin/markdown/lineBreaksSetting', { params });
+      toastSuccess('Success change line braek setting');
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <React.Fragment>
+        <div className="row my-3">
+          <div className="form-group">
+            <legend>{ t('markdown_setting.line_break_setting') }</legend>
+            <p className="well">{ t('markdown_setting.line_break_setting_desc') }</p>
+            <fieldset className="row">
+              <div className="form-group">
+                <div className="col-xs-4 text-right">
+                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaks}>
+                    <input type="checkbox" name="isEnabledLinebreaks" checked={this.state.isEnabledLinebreaks} />
+                    <label>
+                      { t('markdown_setting.Enable Line Break') }
+                    </label>
+                    <p className="help-block">{ t('markdown_setting.Enable Line Break desc') }</p>
+                  </div>
+                </div>
+              </div>
+            </fieldset>
+            <fieldset className="row">
+              <div className="form-group my-3">
+                <div className="col-xs-4 text-right">
+                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaksInComments}>
+                    <input type="checkbox" name="isEnabledLinebreaksInComments" checked={this.state.isEnabledLinebreaksInComments} />
+                    <label>
+                      { t('markdown_setting.Enable Line Break for comment') }
+                    </label>
+                    <p className="help-block">{ t('markdown_setting.Enable Line Break for comment desc') }</p>
+                  </div>
+                </div>
+              </div>
+            </fieldset>
+          </div>
+          <div className="form-group my-3">
+            <div className="col-xs-offset-4 col-xs-5">
+              <button type="submit" className="btn btn-primary" onClick={this.changeLineBreakSettings}>{ t('Update') }</button>
+            </div>
+          </div>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const LineBreakSettingWrapper = (props) => {
+  return createSubscribedElement(LineBreakSetting, props, [AppContainer]);
+};
+
+LineBreakSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(LineBreakSettingWrapper);

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

@@ -2,15 +2,35 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import loggerFactory from '@alias/logger';
+
 import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
 import LineBreakForm from './LineBreakForm';
 import PresentationForm from './PresentationForm';
 import XssForm from './XssForm';
+import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
+
+const logger = loggerFactory('growi:MarkDown');
 
 class MarkdownSetting extends React.Component {
 
+  async componentDidMount() {
+    const { adminMarkDownContainer } = this.props;
+
+    try {
+      await adminMarkDownContainer.retrieveMarkdownData();
+    }
+    catch (err) {
+      toastError(err);
+      adminMarkDownContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
+
+  }
+
   render() {
     const { t } = this.props;
 
@@ -43,12 +63,13 @@ class MarkdownSetting extends React.Component {
 }
 
 const MarkdownSettingWrapper = (props) => {
-  return createSubscribedElement(MarkdownSetting, props, [AppContainer]);
+  return createSubscribedElement(MarkdownSetting, props, [AppContainer, AdminMarkDownContainer]);
 };
 
 MarkdownSetting.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 
 };
 

+ 12 - 12
src/client/js/components/Admin/MarkdownSetting/PresentationForm.jsx

@@ -7,7 +7,7 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
 import AppContainer from '../../../services/AppContainer';
-import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
 
 const logger = loggerFactory('growi:markdown:presentation');
 
@@ -23,7 +23,7 @@ class PresentationForm extends React.Component {
     const { t } = this.props;
 
     try {
-      await this.props.markDownSettingContainer.updatePresentationSetting();
+      await this.props.adminMarkDownContainer.updatePresentationSetting();
       toastSuccess(t('markdown_setting.updated_presentation'));
     }
     catch (err) {
@@ -34,8 +34,8 @@ class PresentationForm extends React.Component {
 
 
   render() {
-    const { t, markDownSettingContainer } = this.props;
-    const { pageBreakSeparator, pageBreakCustomSeparator } = markDownSettingContainer.state;
+    const { t, adminMarkDownContainer } = this.props;
+    const { pageBreakSeparator, pageBreakCustomSeparator } = adminMarkDownContainer.state;
 
     return (
       <fieldset className="form-group row my-2">
@@ -49,7 +49,7 @@ class PresentationForm extends React.Component {
             type="radio"
             id="pageBreakOption1"
             checked={pageBreakSeparator === 1}
-            onChange={() => markDownSettingContainer.switchPageBreakSeparator(1)}
+            onChange={() => adminMarkDownContainer.switchPageBreakSeparator(1)}
           />
           <label htmlFor="pageBreakOption1">
             <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
@@ -65,7 +65,7 @@ class PresentationForm extends React.Component {
             type="radio"
             id="pageBreakOption2"
             checked={pageBreakSeparator === 2}
-            onChange={() => markDownSettingContainer.switchPageBreakSeparator(2)}
+            onChange={() => adminMarkDownContainer.switchPageBreakSeparator(2)}
           />
           <label htmlFor="pageBreakOption2">
             <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
@@ -81,7 +81,7 @@ class PresentationForm extends React.Component {
             type="radio"
             id="pageBreakOption3"
             checked={pageBreakSeparator === 3}
-            onChange={() => markDownSettingContainer.switchPageBreakSeparator(3)}
+            onChange={() => adminMarkDownContainer.switchPageBreakSeparator(3)}
           />
           <label htmlFor="pageBreakOption3">
             <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
@@ -89,8 +89,8 @@ class PresentationForm extends React.Component {
               { t('markdown_setting.Custom separator desc') }
               <input
                 className="form-control"
-                value={pageBreakCustomSeparator}
-                onChange={(e) => { markDownSettingContainer.setPageBreakCustomSeparator(e.target.value) }}
+                defaultValue={pageBreakCustomSeparator}
+                onChange={(e) => { adminMarkDownContainer.setPageBreakCustomSeparator(e.target.value) }}
               />
             </div>
           </label>
@@ -98,7 +98,7 @@ class PresentationForm extends React.Component {
 
         <div className="form-group my-3">
           <div className="col-xs-offset-4 col-xs-5">
-            <div className="btn btn-primary" onClick={this.onClickSubmit}>{ t('Update') }</div>
+            <div className="btn btn-primary" onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null}>{ t('Update') }</div>
           </div>
         </div>
 
@@ -109,13 +109,13 @@ class PresentationForm extends React.Component {
 }
 
 const PresentationFormWrapper = (props) => {
-  return createSubscribedElement(PresentationForm, props, [AppContainer, MarkDownSettingContainer]);
+  return createSubscribedElement(PresentationForm, props, [AppContainer, AdminMarkDownContainer]);
 };
 
 PresentationForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 
 };
 

+ 10 - 10
src/client/js/components/Admin/MarkdownSetting/PresentationLineBreakOptions.jsx

@@ -5,13 +5,13 @@ import { withTranslation } from 'react-i18next';
 import { createSubscribedElement } from '../../UnstatedUtils';
 
 import AppContainer from '../../../services/AppContainer';
-import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
 
 class PresentationLineBreakOptions extends React.Component {
 
   render() {
-    const { t, markDownSettingContainer } = this.props;
-    const { pageBreakOption, customRegularExpression } = markDownSettingContainer.state;
+    const { t, adminMarkDownContainer } = this.props;
+    const { pageBreakOption, customRegularExpression } = adminMarkDownContainer.state;
 
     return (
       <Fragment>
@@ -20,7 +20,7 @@ class PresentationLineBreakOptions extends React.Component {
             type="radio"
             id="pageBreakOption1"
             checked={pageBreakOption === 1}
-            onChange={() => { markDownSettingContainer.setState({ pageBreakOption: 1 }) }}
+            onChange={() => { adminMarkDownContainer.setState({ pageBreakOption: 1 }) }}
           />
           <label htmlFor="pageBreakOption1">
             <p className="font-weight-bold">{ t('markdown_setting.Preset one separator') }</p>
@@ -36,7 +36,7 @@ class PresentationLineBreakOptions extends React.Component {
             type="radio"
             id="pageBreakOption2"
             checked={pageBreakOption === 2}
-            onChange={() => { markDownSettingContainer.setState({ pageBreakOption: 2 }) }}
+            onChange={() => { adminMarkDownContainer.setState({ pageBreakOption: 2 }) }}
           />
           <label htmlFor="pageBreakOption2">
             <p className="font-weight-bold">{ t('markdown_setting.Preset two separator') }</p>
@@ -52,7 +52,7 @@ class PresentationLineBreakOptions extends React.Component {
             type="radio"
             id="pageBreakOption3"
             checked={pageBreakOption === 3}
-            onChange={() => { markDownSettingContainer.setState({ pageBreakOption: 3 }) }}
+            onChange={() => { adminMarkDownContainer.setState({ pageBreakOption: 3 }) }}
           />
           <label htmlFor="pageBreakOption3">
             <p className="font-weight-bold">{ t('markdown_setting.Custom separator') }</p>
@@ -60,8 +60,8 @@ class PresentationLineBreakOptions extends React.Component {
               { t('markdown_setting.Custom separator desc') }
               <input
                 className="form-control"
-                value={customRegularExpression}
-                onChange={(e) => { markDownSettingContainer.setState({ customRegularExpression: e.target.value }) }}
+                defaultValue={customRegularExpression}
+                onChange={(e) => { adminMarkDownContainer.setState({ customRegularExpression: e.target.value }) }}
               />
             </div>
           </label>
@@ -73,13 +73,13 @@ class PresentationLineBreakOptions extends React.Component {
 }
 
 const PresentationLineBreakOptionsWrapper = (props) => {
-  return createSubscribedElement(PresentationLineBreakOptions, props, [AppContainer, MarkDownSettingContainer]);
+  return createSubscribedElement(PresentationLineBreakOptions, props, [AppContainer, AdminMarkDownContainer]);
 };
 
 PresentationLineBreakOptions.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 
 };
 

+ 30 - 47
src/client/js/components/Admin/MarkdownSetting/WhiteListInput.jsx

@@ -6,83 +6,67 @@ import { createSubscribedElement } from '../../UnstatedUtils';
 import { tags, attrs } from '../../../../../lib/service/xss/recommended-whitelist';
 
 import AppContainer from '../../../services/AppContainer';
-import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
 
 class WhiteListInput extends React.Component {
 
-  renderRecommendTagBtn() {
-    const { t, markDownSettingContainer } = this.props;
+  constructor(props) {
+    super(props);
 
-    return (
-      <p id="btn-import-tags" className="btn btn-xs btn-primary" onClick={() => { markDownSettingContainer.setState({ tagWhiteList: tags }) }}>
-        { t('markdown_setting.import_recommended', 'tags') }
-      </p>
-    );
-  }
+    this.tagWhiteList = React.createRef();
+    this.attrWhiteList = React.createRef();
 
-  renderRecommendAttrBtn() {
-    const { t, markDownSettingContainer } = this.props;
-
-    return (
-      <p id="btn-import-tags" className="btn btn-xs btn-primary" onClick={() => { markDownSettingContainer.setState({ attrWhiteList: attrs }) }}>
-        { t('markdown_setting.import_recommended', 'Attrs') }
-      </p>
-    );
+    this.onClickRecommendTagButton = this.onClickRecommendTagButton.bind(this);
+    this.onClickRecommendAttrButton = this.onClickRecommendAttrButton.bind(this);
   }
 
-  renderTagValue() {
-    const { customizable, markDownSettingContainer } = this.props;
-
-    if (customizable) {
-      return markDownSettingContainer.state.tagWhiteList;
-    }
-
-    return tags;
+  onClickRecommendTagButton() {
+    this.tagWhiteList.current.value = tags;
+    this.props.adminMarkDownContainer.setState({ tagWhiteList: tags });
   }
 
-  renderAttrValue() {
-    const { customizable, markDownSettingContainer } = this.props;
-
-    if (customizable) {
-      return markDownSettingContainer.state.attrWhiteList;
-    }
-
-    return attrs;
+  onClickRecommendAttrButton() {
+    this.attrWhiteList.current.value = attrs;
+    this.props.adminMarkDownContainer.setState({ attrWhiteList: attrs });
   }
 
   render() {
-    const { t, customizable, markDownSettingContainer } = this.props;
+    const { t, adminMarkDownContainer } = this.props;
 
     return (
       <>
         <div className="m-t-15">
           <div className="d-flex justify-content-between">
-            { t('markdown_setting.Tag names') }
-            {customizable && this.renderRecommendTagBtn()}
+            {t('markdown_setting.Tag names')}
+            <p id="btn-import-tags" className="btn btn-xs btn-primary" onClick={this.onClickRecommendTagButton}>
+              {t('markdown_setting.import_recommended', { target: 'Tags' })}
+            </p>
           </div>
           <textarea
             className="form-control xss-list"
             name="recommendedTags"
             rows="6"
             cols="40"
-            readOnly={!customizable}
-            value={this.renderTagValue()}
-            onChange={(e) => { markDownSettingContainer.setState({ tagWhiteList: e.target.value }) }}
+            ref={this.tagWhiteList}
+            defaultValue={adminMarkDownContainer.state.tagWhiteList}
+            onChange={(e) => { adminMarkDownContainer.setState({ tagWhiteList: e.target.value }) }}
           />
         </div>
         <div className="m-t-15">
           <div className="d-flex justify-content-between">
-            { t('markdown_setting.Tag attributes') }
-            {customizable && this.renderRecommendAttrBtn()}
+            {t('markdown_setting.Tag attributes')}
+            <p id="btn-import-tags" className="btn btn-xs btn-primary" onClick={this.onClickRecommendAttrButton}>
+              {t('markdown_setting.import_recommended', { target: 'Attrs' })}
+            </p>
           </div>
           <textarea
             className="form-control xss-list"
             name="recommendedAttrs"
             rows="6"
             cols="40"
-            readOnly={!customizable}
-            value={this.renderAttrValue()}
-            onChange={(e) => { markDownSettingContainer.setState({ attrWhiteList: e.target.value }) }}
+            ref={this.attrWhiteList}
+            defaultValue={adminMarkDownContainer.state.attrWhiteList}
+            onChange={(e) => { adminMarkDownContainer.setState({ attrWhiteList: e.target.value }) }}
           />
         </div>
       </>
@@ -92,15 +76,14 @@ class WhiteListInput extends React.Component {
 }
 
 const WhiteListWrapper = (props) => {
-  return createSubscribedElement(WhiteListInput, props, [AppContainer, MarkDownSettingContainer]);
+  return createSubscribedElement(WhiteListInput, props, [AppContainer, AdminMarkDownContainer]);
 };
 
 WhiteListInput.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 
-  customizable: PropTypes.bool.isRequired,
 };
 
 export default withTranslation()(WhiteListWrapper);

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

@@ -5,9 +5,10 @@ import loggerFactory from '@alias/logger';
 
 import { createSubscribedElement } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { tags, attrs } from '../../../../../lib/service/xss/recommended-whitelist';
 
 import AppContainer from '../../../services/AppContainer';
-import MarkDownSettingContainer from '../../../services/MarkDownSettingContainer';
+import AdminMarkDownContainer from '../../../services/AdminMarkDownContainer';
 
 import WhiteListInput from './WhiteListInput';
 
@@ -25,7 +26,7 @@ class XssForm extends React.Component {
     const { t } = this.props;
 
     try {
-      await this.props.markDownSettingContainer.updateXssSetting();
+      await this.props.adminMarkDownContainer.updateXssSetting();
       toastSuccess(t('markdown_setting.updated_xss'));
     }
     catch (err) {
@@ -35,23 +36,23 @@ class XssForm extends React.Component {
   }
 
   xssOptions() {
-    const { t, markDownSettingContainer } = this.props;
-    const { xssOption } = markDownSettingContainer.state;
+    const { t, adminMarkDownContainer } = this.props;
+    const { xssOption } = adminMarkDownContainer.state;
 
     return (
-      <fieldset className="form-group col-xs-12 my-3">
+      <fieldset className="row col-xs-12 my-3">
         <div className="col-xs-4 radio radio-primary">
           <input
             type="radio"
             id="xssOption1"
             name="XssOption"
             checked={xssOption === 1}
-            onChange={() => { markDownSettingContainer.setState({ xssOption: 1 }) }}
+            onChange={() => { adminMarkDownContainer.setState({ xssOption: 1 }) }}
           />
           <label htmlFor="xssOption1">
-            <p className="font-weight-bold">{ t('markdown_setting.Ignore all tags') }</p>
+            <p className="font-weight-bold">{t('markdown_setting.Ignore all tags')}</p>
             <div className="m-t-15">
-              { t('markdown_setting.Ignore all tags desc') }
+              {t('markdown_setting.Ignore all tags desc')}
             </div>
           </label>
         </div>
@@ -62,11 +63,36 @@ class XssForm extends React.Component {
             id="xssOption2"
             name="XssOption"
             checked={xssOption === 2}
-            onChange={() => { markDownSettingContainer.setState({ xssOption: 2 }) }}
+            onChange={() => { adminMarkDownContainer.setState({ xssOption: 2 }) }}
           />
           <label htmlFor="xssOption2">
-            <p className="font-weight-bold">{ t('markdown_setting.Recommended setting') }</p>
-            <WhiteListInput customizable={false} />
+            <p className="font-weight-bold">{t('markdown_setting.Recommended setting')}</p>
+            <div className="m-t-15">
+              <div className="d-flex justify-content-between">
+                {t('markdown_setting.Tag names')}
+              </div>
+              <textarea
+                className="form-control xss-list"
+                name="recommendedTags"
+                rows="6"
+                cols="40"
+                readOnly
+                defaultValue={tags}
+              />
+            </div>
+            <div className="m-t-15">
+              <div className="d-flex justify-content-between">
+                {t('markdown_setting.Tag attributes')}
+              </div>
+              <textarea
+                className="form-control xss-list"
+                name="recommendedAttrs"
+                rows="6"
+                cols="40"
+                readOnly
+                defaultValue={attrs}
+              />
+            </div>
           </label>
         </div>
 
@@ -76,11 +102,11 @@ class XssForm extends React.Component {
             id="xssOption3"
             name="XssOption"
             checked={xssOption === 3}
-            onChange={() => { markDownSettingContainer.setState({ xssOption: 3 }) }}
+            onChange={() => { adminMarkDownContainer.setState({ xssOption: 3 }) }}
           />
           <label htmlFor="xssOption3">
-            <p className="font-weight-bold">{ t('markdown_setting.Custom Whitelist') }</p>
-            <WhiteListInput customizable />
+            <p className="font-weight-bold">{t('markdown_setting.Custom Whitelist')}</p>
+            <WhiteListInput />
           </label>
         </div>
       </fieldset>
@@ -88,8 +114,8 @@ class XssForm extends React.Component {
   }
 
   render() {
-    const { t, markDownSettingContainer } = this.props;
-    const { isEnabledXss } = markDownSettingContainer.state;
+    const { t, adminMarkDownContainer } = this.props;
+    const { isEnabledXss } = adminMarkDownContainer.state;
 
     return (
       <React.Fragment>
@@ -103,10 +129,10 @@ class XssForm extends React.Component {
                   className="form-check-input"
                   name="isEnabledXss"
                   checked={isEnabledXss}
-                  onChange={markDownSettingContainer.switchEnableXss}
+                  onChange={adminMarkDownContainer.switchEnableXss}
                 />
                 <label htmlFor="XssEnable">
-                  { t('markdown_setting.Enable XSS prevention') }
+                  {t('markdown_setting.Enable XSS prevention')}
                 </label>
               </div>
             </div>
@@ -114,7 +140,7 @@ class XssForm extends React.Component {
           </div>
           <div className="form-group my-3">
             <div className="col-xs-offset-4 col-xs-5">
-              <div className="btn btn-primary" onClick={this.onClickSubmit}>{ t('Update') }</div>
+              <div className="btn btn-primary" onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null}> {t('Update')}</div>
             </div>
           </div>
         </form>
@@ -125,13 +151,13 @@ class XssForm extends React.Component {
 }
 
 const XssFormWrapper = (props) => {
-  return createSubscribedElement(XssForm, props, [AppContainer, MarkDownSettingContainer]);
+  return createSubscribedElement(XssForm, props, [AppContainer, AdminMarkDownContainer]);
 };
 
 XssForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  markDownSettingContainer: PropTypes.instanceOf(MarkDownSettingContainer).isRequired,
+  adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 };
 
 export default withTranslation()(XssFormWrapper);

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

@@ -2,15 +2,55 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
 
 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';
 
+const logger = loggerFactory('growi:security:AdminTwitterSecurityContainer');
+
 class BasicSecurityManagement extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      retrieveError: null,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminBasicSecurityContainer } = this.props;
+
+    try {
+      await adminBasicSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  async onClickSubmit() {
+    const { t, adminBasicSecurityContainer } = this.props;
+
+    try {
+      await adminBasicSecurityContainer.updateBasicSetting();
+      toastSuccess(t('security_setting.Basic.updated_basic'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
   render() {
     const { t, adminGeneralSecurityContainer, adminBasicSecurityContainer } = this.props;
 
@@ -21,6 +61,12 @@ class BasicSecurityManagement extends React.Component {
           { t('security_setting.Basic.name') } { t('security_setting.configuration') }
         </h2>
 
+        {this.state.retrieveError != null && (
+        <div className="alert alert-danger">
+          <p>{t('Error occurred')} : {this.state.err}</p>
+        </div>
+        )}
+
         <div className="row mb-5">
           <strong className="col-xs-3 text-right">{ t('security_setting.Basic.name') }</strong>
           <div className="col-xs-6 text-left">
@@ -68,6 +114,12 @@ class BasicSecurityManagement extends React.Component {
         </React.Fragment>
         )}
 
+        <div className="row my-3">
+          <div className="col-xs-offset-4 col-xs-5">
+            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.onClickSubmit}>{ t('Update') }</button>
+          </div>
+        </div>
+
       </React.Fragment>
     );
   }

+ 6 - 0
src/client/js/components/Admin/Security/GithubSecuritySetting.jsx

@@ -110,6 +110,12 @@ class GithubSecurityManagement extends React.Component {
         {adminGeneralSecurityContainer.state.isGithubOAuthEnabled && (
           <React.Fragment>
 
+            {!adminGithubSecurityContainer.state.isGitHubStrategySetup && (
+              <div className="alert alert-warning">
+                <p>{t('security_setting.setup_not_completed_yet')}</p>
+              </div>
+            )}
+
             <div className="row mb-5">
               <label htmlFor="githubClientId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
               <div className="col-xs-6">

+ 30 - 24
src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -58,17 +58,17 @@ class GoogleSecurityManagement extends React.Component {
       <React.Fragment>
 
         <h2 className="alert-anchor border-bottom">
-          { t('security_setting.OAuth.Google.name') } { t('security_setting.configuration') }
+          {t('security_setting.OAuth.Google.name')} {t('security_setting.configuration')}
         </h2>
 
         {this.state.retrieveError != null && (
-        <div className="alert alert-danger">
-          <p>{t('Error occurred')} : {this.state.err}</p>
-        </div>
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {this.state.err}</p>
+          </div>
         )}
 
         <div className="row mb-5">
-          <strong className="col-xs-3 text-right">{ t('security_setting.OAuth.Google.name') }</strong>
+          <strong className="col-xs-3 text-right">{t('security_setting.OAuth.Google.name')}</strong>
           <div className="col-xs-6 text-left">
             <div className="checkbox checkbox-success">
               <input
@@ -78,14 +78,14 @@ class GoogleSecurityManagement extends React.Component {
                 onChange={() => { adminGeneralSecurityContainer.switchIsGoogleOAuthEnabled() }}
               />
               <label htmlFor="isGoogleEnabled">
-                { t('security_setting.OAuth.Google.enable_google') }
+                {t('security_setting.OAuth.Google.enable_google')}
               </label>
             </div>
           </div>
         </div>
 
         <div className="row mb-5">
-          <label className="col-xs-3 text-right">{ t('security_setting.callback_URL') }</label>
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
           <div className="col-xs-6">
             <input
               className="form-control"
@@ -93,15 +93,15 @@ class GoogleSecurityManagement extends React.Component {
               value={adminGoogleSecurityContainer.state.callbackUrl}
               readOnly
             />
-            <p className="help-block small">{ t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' }) }</p>
+            <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 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>
@@ -110,8 +110,14 @@ class GoogleSecurityManagement extends React.Component {
         {adminGeneralSecurityContainer.state.isGoogleOAuthEnabled && (
           <React.Fragment>
 
+            {!adminGoogleSecurityContainer.state.isGoogleStrategySetup && (
+              <div className="alert alert-warning">
+                <p>{t('security_setting.setup_not_completed_yet')}</p>
+              </div>
+            )}
+
             <div className="row mb-5">
-              <label htmlFor="googleClientId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
+              <label htmlFor="googleClientId" className="col-xs-3 text-right">{t('security_setting.clientID')}</label>
               <div className="col-xs-6">
                 <input
                   className="form-control"
@@ -127,7 +133,7 @@ class GoogleSecurityManagement extends React.Component {
             </div>
 
             <div className="row mb-5">
-              <label htmlFor="googleClientSecret" className="col-xs-3 text-right">{ t('security_setting.client_secret') }</label>
+              <label htmlFor="googleClientSecret" className="col-xs-3 text-right">{t('security_setting.client_secret')}</label>
               <div className="col-xs-6">
                 <input
                   className="form-control"
@@ -167,7 +173,7 @@ class GoogleSecurityManagement extends React.Component {
 
         <div className="row my-3">
           <div className="col-xs-offset-3 col-xs-5">
-            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.onClickSubmit}>{ t('Update') }</button>
+            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.onClickSubmit}>{t('Update')}</button>
           </div>
         </div>
 
@@ -176,15 +182,15 @@ class GoogleSecurityManagement extends React.Component {
         <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>
+            <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') }} />
+            <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>
 

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

@@ -0,0 +1,152 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import Modal from 'react-bootstrap/es/Modal';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { 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: '',
+    };
+
+    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) {
+        this.addLogs(response.err);
+      }
+      if (response.ldapConfiguration) {
+        const prettified = JSON.stringify(response.ldapConfiguration.server, undefined, 4);
+        this.addLogs(`LDAP Configuration : ${prettified}`);
+      }
+      if (response.ldapAccountInfo) {
+        const prettified = JSON.stringify(response.ldapAccountInfo, undefined, 4);
+        this.addLogs(`Retrieved LDAP Account : ${prettified}`);
+      }
+
+    }
+    // Catch server communication error
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <Modal show={this.props.isOpen} onHide={this.props.onClose}>
+        <Modal.Header className="modal-header" closeButton>
+          <Modal.Title>
+            Test LDAP Account
+          </Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <div className="row p-3">
+            <label htmlFor="username" className="col-xs-3 text-right">{t('username')}</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                name="username"
+                value={this.state.username}
+                onChange={(e) => { this.onChangeUsername(e.target.value) }}
+              />
+            </div>
+          </div>
+          <div className="row p-3">
+            <label htmlFor="password" className="col-xs-3 text-right">{t('Password')}</label>
+            <div className="col-xs-6">
+              <input
+                className="form-control"
+                type="password"
+                name="password"
+                value={this.state.password}
+                onChange={(e) => { this.onChangePassword(e.target.value) }}
+              />
+            </div>
+          </div>
+          <div>
+            <h5>Logs</h5>
+            <textarea id="taLogs" className="col-xs-12" rows="4" value={this.state.logs} readOnly />
+          </div>
+        </Modal.Body>
+        <Modal.Footer>
+          <button type="button" className="btn btn-default mt-3 col-xs-offset-5 col-xs-2" onClick={this.testLdapCredentials}>Test</button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+
+LdapAuthTestModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func.isRequired,
+};
+
+const LdapAuthTestModalWrapper = (props) => {
+  return createSubscribedElement(LdapAuthTestModal, props, [AppContainer, AdminLdapSecurityContainer]);
+};
+
+export default withTranslation()(LdapAuthTestModalWrapper);

+ 129 - 62
src/client/js/components/Admin/Security/LdapSecuritySetting.jsx

@@ -1,15 +1,67 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
 
 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';
+
+const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
 
 class LdapSecuritySetting extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      retrieveError: null,
+      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({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  async onClickSubmit() {
+    const { t, adminLdapSecurityContainer } = this.props;
+
+    try {
+      await adminLdapSecurityContainer.updateLdapSetting();
+      toastSuccess(t('security_setting.ldap.updated_ldap'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  openLdapAuthTestModal() {
+    this.setState({ isLdapAuthTestModalShown: true });
+  }
+
+  closeLdapAuthTestModal() {
+    this.setState({ isLdapAuthTestModalShown: false });
+  }
+
   render() {
     const { t, adminGeneralSecurityContainer, adminLdapSecurityContainer } = this.props;
     const { isLdapEnabled } = adminGeneralSecurityContainer.state;
@@ -18,7 +70,7 @@ class LdapSecuritySetting extends React.Component {
       <React.Fragment>
 
         <h2 className="alert-anchor border-bottom">
-          LDAP { t('security_setting.configuration') }
+          LDAP {t('security_setting.configuration')}
         </h2>
 
         <div className="row mb-5">
@@ -32,7 +84,7 @@ class LdapSecuritySetting extends React.Component {
                 onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
               />
               <label htmlFor="isLdapEnabled">
-                { t('security_setting.ldap.enable_ldap') }
+                {t('security_setting.ldap.enable_ldap')}
               </label>
             </div>
           </div>
@@ -48,7 +100,7 @@ class LdapSecuritySetting extends React.Component {
                   className="form-control"
                   type="text"
                   name="serverUrl"
-                  value={adminLdapSecurityContainer.state.serverUrl}
+                  defaultValue={adminLdapSecurityContainer.state.serverUrl}
                   onChange={e => adminLdapSecurityContainer.changeServerUrl(e.target.value)}
                 />
                 <small>
@@ -57,29 +109,31 @@ class LdapSecuritySetting extends React.Component {
                     // 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>
+                  {t('security_setting.example')}: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
                 </small>
               </div>
             </div>
 
             <div className="row mb-5">
-              <strong className="col-xs-3 text-right">{ t('security_setting.ldap.bind_mode') }</strong>
+              <strong className="col-xs-3 text-right">{t('security_setting.ldap.bind_mode')}</strong>
               <div className="col-xs-6 text-left">
                 <div className="my-0 btn-group">
                   <div className="dropdown">
                     <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                      <span className="pull-left">{t(`security_setting.ldap.bind_${adminLdapSecurityContainer.state.bindMode}`)}</span>
+                      {adminLdapSecurityContainer.state.isUserBind
+                        ? <span className="pull-left">{t('security_setting.ldap.bind_user')}</span>
+                        : <span className="pull-left">{t('security_setting.ldap.bind_manager')}</span>}
                       <span className="bs-caret pull-right">
                         <span className="caret" />
                       </span>
                     </button>
                     {/* TODO adjust dropdown after BS4 */}
                     <ul className="dropdown-menu" role="menu">
-                      <li key="manager" role="presentation" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode('manager') }}>
-                        <a role="menuitem">{ t('security_setting.ldap.bind_manager') }</a>
+                      <li key="user" role="presentation" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(true) }}>
+                        <a role="menuitem">{t('security_setting.ldap.bind_user')}</a>
                       </li>
-                      <li key="user" role="presentation" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode('user') }}>
-                        <a role="menuitem">{ t('security_setting.ldap.bind_user') }</a>
+                      <li key="manager" role="presentation" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(false) }}>
+                        <a role="menuitem">{t('security_setting.ldap.bind_manager')}</a>
                       </li>
                     </ul>
                   </div>
@@ -94,70 +148,72 @@ class LdapSecuritySetting extends React.Component {
                   className="form-control"
                   type="text"
                   name="bindDN"
-                  value={adminLdapSecurityContainer.state.bindDN}
+                  defaultValue={adminLdapSecurityContainer.state.ldapBindDN}
                   onChange={e => adminLdapSecurityContainer.changeBindDN(e.target.value)}
                 />
-                {(adminLdapSecurityContainer.state.bindMode === 'manager') ? (
+                {(adminLdapSecurityContainer.state.isUserBind === false) ? (
                   <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>
+                      {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>
-                ) : (
-                  <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-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>
+                  )}
               </div>
             </div>
 
             <div className="row mb-5">
-              <label htmlFor="bindDNPassword" className="col-xs-3 text-right">{ t('security_setting.ldap.bind_DN_password') }</label>
+              <label htmlFor="bindDNPassword" className="col-xs-3 text-right">{t('security_setting.ldap.bind_DN_password')}</label>
               <div className="col-xs-6">
                 <input
                   className="form-control passport-ldap-managerbind"
                   type="password"
                   name="bindDNPassword"
-                  value={adminLdapSecurityContainer.state.bindDNPassword}
+                  defaultValue={adminLdapSecurityContainer.state.ldapBindDNPassword}
                   onChange={e => adminLdapSecurityContainer.changeBindDNPassword(e.target.value)}
                 />
                 {(adminLdapSecurityContainer.state.bindMode === 'manager') ? (
                   <p className="help-block passport-ldap-managerbind">
                     <small>
-                      { t('security_setting.ldap.bind_DN_password_manager_detail') }
+                      {t('security_setting.ldap.bind_DN_password_manager_detail')}
                     </small>
                   </p>
-                ) : (
-                  <p className="help-block passport-ldap-userbind">
-                    <small>
-                      { t('security_setting.ldap.bind_DN_password_user_detail') }
-                    </small>
-                  </p>
-                )}
+                )
+                  : (
+                    <p className="help-block passport-ldap-userbind">
+                      <small>
+                        {t('security_setting.ldap.bind_DN_password_user_detail')}
+                      </small>
+                    </p>
+                  )}
               </div>
             </div>
 
             <div className="row mb-5">
-              <strong className="col-xs-3 text-right">{ t('security_setting.ldap.search_filter') }</strong>
+              <strong className="col-xs-3 text-right">{t('security_setting.ldap.search_filter')}</strong>
               <div className="col-xs-6">
                 <input
                   className="form-control"
                   type="text"
                   name="searchFilter"
-                  value={adminLdapSecurityContainer.state.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 />
+                    {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 */}
@@ -166,9 +222,9 @@ class LdapSecuritySetting extends React.Component {
                 </p>
                 <p className="help-block">
                   <small>
-                    { t('security_setting.example') }1 - { t('security_setting.ldap.search_filter_example1') }:
+                    {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') }:
+                    {t('security_setting.example')}2 - {t('security_setting.ldap.search_filter_example2')}:
                     <code>(sAMAccountName={'{{ username }}'})</code>
                   </small>
                 </p>
@@ -176,7 +232,7 @@ class LdapSecuritySetting extends React.Component {
             </div>
 
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({ t('security_setting.optional') })
+              Attribute Mapping ({t('security_setting.optional')})
             </h3>
 
             <div className="row mb-5">
@@ -187,7 +243,7 @@ class LdapSecuritySetting extends React.Component {
                   type="text"
                   placeholder="Default: uid"
                   name="attrMapUsername"
-                  value={adminLdapSecurityContainer.state.attrMapUsername}
+                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapUsername}
                   onChange={e => adminLdapSecurityContainer.changeAttrMapUsername(e.target.value)}
                 />
                 <p className="help-block">
@@ -201,13 +257,13 @@ class LdapSecuritySetting extends React.Component {
               <div className="col-xs-offset-3 col-xs-6 text-left">
                 <div className="checkbox checkbox-success">
                   <input
-                    id="cbSameUsernameTreatedAsIdenticalUser"
+                    id="isSameUsernameTreatedAsIdenticalUser"
                     type="checkbox"
-                    checked={adminLdapSecurityContainer.state.cbSameUsernameTreatedAsIdenticalUser}
-                    onChange={() => { adminLdapSecurityContainer.switchCbSameUsernameTreatedAsIdenticalUser() }}
+                    checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
                   />
                   <label
-                    htmlFor="cbSameUsernameTreatedAsIdenticalUser"
+                    htmlFor="isSameUsernameTreatedAsIdenticalUser"
                     // eslint-disable-next-line react/no-danger
                     dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
                   />
@@ -220,37 +276,37 @@ class LdapSecuritySetting extends React.Component {
             </div>
 
             <div className="row mb-5">
-              <strong htmlFor="attrMapMail" className="col-xs-3 text-right">{ t('Email') }</strong>
+              <strong htmlFor="attrMapMail" className="col-xs-3 text-right">{t('Email')}</strong>
               <div className="col-xs-6">
                 <input
                   className="form-control"
                   type="text"
                   placeholder="Default: mail"
                   name="attrMapMail"
-                  value={adminLdapSecurityContainer.state.attrMapMail}
+                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapMail}
                   onChange={e => adminLdapSecurityContainer.changeAttrMapMail(e.target.value)}
                 />
                 <p className="help-block">
                   <small>
-                    { t('security_setting.ldap.mail_detail') }
+                    {t('security_setting.ldap.mail_detail')}
                   </small>
                 </p>
               </div>
             </div>
 
             <div className="row mb-5">
-              <strong htmlFor="attrMapName" className="col-xs-3 text-right">{ t('Name') }</strong>
+              <strong htmlFor="attrMapName" className="col-xs-3 text-right">{t('Name')}</strong>
               <div className="col-xs-6">
                 <input
                   className="form-control"
                   type="text"
                   name="attrMapName"
-                  value={adminLdapSecurityContainer.state.attrMapName}
+                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapName}
                   onChange={e => adminLdapSecurityContainer.changeAttrMapName(e.target.value)}
                 />
                 <p className="help-block">
                   <small>
-                    { t('security_setting.ldap.name_detail') }
+                    {t('security_setting.ldap.name_detail')}
                   </small>
                 </p>
               </div>
@@ -258,37 +314,37 @@ class LdapSecuritySetting extends React.Component {
 
 
             <h3 className="alert-anchor border-bottom">
-              { t('security_setting.ldap.group_search_filter') } ({ t('security_setting.optional') })
+              {t('security_setting.ldap.group_search_filter')} ({t('security_setting.optional')})
             </h3>
 
             <div className="row mb-5">
-              <strong htmlFor="groupSearchBase" className="col-xs-3 text-right">{ t('security_setting.ldap.group_search_base_DN') }</strong>
+              <strong htmlFor="groupSearchBase" className="col-xs-3 text-right">{t('security_setting.ldap.group_search_base_DN')}</strong>
               <div className="col-xs-6">
                 <input
                   className="form-control"
                   type="text"
                   name="groupSearchBase"
-                  value={adminLdapSecurityContainer.state.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>
+                    {t('security_setting.example')}: <code>ou=groups,dc=domain,dc=com</code>
                   </small>
                 </p>
               </div>
             </div>
 
             <div className="row mb-5">
-              <strong htmlFor="groupSearchFilter" className="col-xs-3 text-right">{ t('security_setting.ldap.group_search_filter') }</strong>
+              <strong htmlFor="groupSearchFilter" className="col-xs-3 text-right">{t('security_setting.ldap.group_search_filter')}</strong>
               <div className="col-xs-6">
                 <input
                   className="form-control"
                   type="text"
                   name="groupSearchFilter"
-                  value={adminLdapSecurityContainer.state.groupSearchFilter}
+                  defaultValue={adminLdapSecurityContainer.state.ldapGroupSearchFilter}
                   onChange={e => adminLdapSecurityContainer.changeGroupSearchFilter(e.target.value)}
                 />
                 <p className="help-block">
@@ -302,7 +358,7 @@ class LdapSecuritySetting extends React.Component {
                 </p>
                 <p className="help-block">
                   <small>
-                    { t('security_setting.example') }:
+                    {t('security_setting.example')}:
                     {/* eslint-disable-next-line react/no-danger */}
                     <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail4') }} />
                   </small>
@@ -311,14 +367,14 @@ class LdapSecuritySetting extends React.Component {
             </div>
 
             <div className="row mb-5">
-              <label htmlFor="groupDnProperty" className="col-xs-3 text-right">{ t('security_setting.ldap.group_search_user_DN_property') }</label>
+              <label htmlFor="groupDnProperty" className="col-xs-3 text-right">{t('security_setting.ldap.group_search_user_DN_property')}</label>
               <div className="col-xs-6">
                 <input
                   className="form-control"
                   type="text"
                   placeholder="Default: uid"
                   name="groupDnProperty"
-                  value={adminLdapSecurityContainer.state.groupDnProperty}
+                  defaultValue={adminLdapSecurityContainer.state.ldapGroupDnProperty}
                   onChange={e => adminLdapSecurityContainer.changeGroupDnProperty(e.target.value)}
                 />
                 <p className="help-block">
@@ -331,6 +387,17 @@ class LdapSecuritySetting extends React.Component {
           </React.Fragment>
         )}
 
+        <div className="row my-3">
+          <div className="col-xs-offset-3 col-xs-5">
+            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.onClickSubmit}>{t('Update')}</button>
+            {adminGeneralSecurityContainer.state.isLdapEnabled
+              && <button type="button" className="btn btn-default ml-2" onClick={this.openLdapAuthTestModal}>{t('security_setting.ldap.test_config')}</button>
+            }
+          </div>
+        </div>
+
+        <LdapAuthTestModal isOpen={this.state.isLdapAuthTestModalShown} onClose={this.closeLdapAuthTestModal} />
+
       </React.Fragment>
     );
   }

+ 233 - 182
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -2,16 +2,55 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
 
 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';
 
+const logger = loggerFactory('growi:security:AdminGoogleSecurityContainer');
 
 class OidcSecurityManagement extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      retrieveError: null,
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async componentDidMount() {
+    const { adminOidcSecurityContainer } = this.props;
+
+    try {
+      await adminOidcSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  async onClickSubmit() {
+    const { t, adminOidcSecurityContainer } = this.props;
+
+    try {
+      await adminOidcSecurityContainer.updateOidcSetting();
+      toastSuccess(t('security_setting.OAuth.OIDC.updated_oidc'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
   render() {
     const { t, adminGeneralSecurityContainer, adminOidcSecurityContainer } = this.props;
 
@@ -20,11 +59,17 @@ class OidcSecurityManagement extends React.Component {
       <React.Fragment>
 
         <h2 className="alert-anchor border-bottom">
-          { t('security_setting.OAuth.OIDC.name') } { t('security_setting.configuration') }
+          {t('security_setting.OAuth.OIDC.name')} {t('security_setting.configuration')}
         </h2>
 
+        {this.state.retrieveError != null && (
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {this.state.err}</p>
+          </div>
+        )}
+
         <div className="row mb-5">
-          <strong className="col-xs-3 text-right">{ t('security_setting.OAuth.OIDC.name') }</strong>
+          <strong className="col-xs-3 text-right">{t('security_setting.OAuth.OIDC.name')}</strong>
           <div className="col-xs-6 text-left">
             <div className="checkbox checkbox-success">
               <input
@@ -34,14 +79,14 @@ class OidcSecurityManagement extends React.Component {
                 onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
               />
               <label htmlFor="isOidcEnabled">
-                { t('security_setting.OAuth.enable_oidc') }
+                {t('security_setting.OAuth.enable_oidc')}
               </label>
             </div>
           </div>
         </div>
 
         <div className="row mb-5">
-          <label className="col-xs-3 text-right">{ t('security_setting.callback_URL') }</label>
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
           <div className="col-xs-6">
             <input
               className="form-control"
@@ -49,227 +94,233 @@ class OidcSecurityManagement extends React.Component {
               value={adminOidcSecurityContainer.state.callbackUrl}
               readOnly
             />
-            <p className="help-block small">{ t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' }) }</p>
+            <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 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>
 
         {adminGeneralSecurityContainer.state.isOidcEnabled && (
-        <React.Fragment>
+          <React.Fragment>
 
-          <div className="row mb-5">
-            <label htmlFor="oidcProviderName" className="col-xs-3 text-right">{ t('security_setting.providerName') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcProviderName"
-                value={adminOidcSecurityContainer.state.oidcProviderName}
-                onChange={e => adminOidcSecurityContainer.changeOidcProviderName(e.target.value)}
-              />
-            </div>
-          </div>
-
-          <div className="row mb-5">
-            <label htmlFor="oidcIssuerHost" className="col-xs-3 text-right">{ t('security_setting.issuerHost') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcIssuerHost"
-                value={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 className="row mb-5">
+              <label htmlFor="oidcProviderName" className="col-xs-3 text-right">{t('security_setting.providerName')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcProviderName"
+                  value={adminOidcSecurityContainer.state.oidcProviderName}
+                  onChange={e => adminOidcSecurityContainer.changeOidcProviderName(e.target.value)}
+                />
+              </div>
             </div>
-          </div>
 
-          <div className="row mb-5">
-            <label htmlFor="oidcClientId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcClientId"
-                value={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 className="row mb-5">
+              <label htmlFor="oidcIssuerHost" className="col-xs-3 text-right">{t('security_setting.issuerHost')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcIssuerHost"
+                  value={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>
 
-          <div className="row mb-5">
-            <label htmlFor="oidcClientSecret" className="col-xs-3 text-right">{ t('security_setting.client_secret') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcClientSecret"
-                value={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 className="row mb-5">
+              <label htmlFor="oidcClientId" className="col-xs-3 text-right">{t('security_setting.clientID')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcClientId"
+                  value={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>
-
-          <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({ t('security_setting.optional') })
-          </h3>
 
-          <div className="row mb-5">
-            <label htmlFor="oidcAttrMapId" className="col-xs-3 text-right">Identifier</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcAttrMapId"
-                value={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 className="row mb-5">
+              <label htmlFor="oidcClientSecret" className="col-xs-3 text-right">{t('security_setting.client_secret')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcClientSecret"
+                  value={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>
-          </div>
 
-          <div className="row mb-5">
-            <label htmlFor="oidcAttrMapUserName" className="col-xs-3 text-right">{ t('username') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcAttrMapUserName"
-                value={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>
+            <h3 className="alert-anchor border-bottom">
+              Attribute Mapping ({t('security_setting.optional')})
+            </h3>
 
-          <div className="row mb-5">
-            <label htmlFor="oidcAttrMapName" className="col-xs-3 text-right">{ t('Name') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcAttrMapName"
-                value={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 className="row mb-5">
+              <label htmlFor="oidcAttrMapId" className="col-xs-3 text-right">Identifier</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapId"
+                  value={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>
 
-          <div className="row mb-5">
-            <label htmlFor="oidcAttrMapEmail" className="col-xs-3 text-right">{ t('Email') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                name="oidcAttrMapEmail"
-                value={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 className="row mb-5">
+              <label htmlFor="oidcAttrMapUserName" className="col-xs-3 text-right">{t('username')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapUserName"
+                  value={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>
 
-          <div className="row mb-5">
-            <label className="col-xs-3 text-right">{ t('security_setting.callback_URL') }</label>
-            <div className="col-xs-6">
-              <input
-                className="form-control"
-                type="text"
-                value={adminOidcSecurityContainer.state.callbackUrl}
-                readOnly
-              />
-              <p className="help-block small">{ t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' }) }</p>
-              {!adminGeneralSecurityContainer.state.appSiteUrl && (
-              <div className="alert alert-danger">
-                <i
-                  className="icon-exclamation"
-                // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App settings')}<i class="icon-login"></i></a>` }) }}
+            <div className="row mb-5">
+              <label htmlFor="oidcAttrMapName" className="col-xs-3 text-right">{t('Name')}</label>
+              <div className="col-xs-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapName"
+                  value={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>
 
-          <div className="row mb-3">
-            <div className="col-xs-offset-3 col-xs-6 text-left">
-              <div className="checkbox checkbox-success">
+            <div className="row mb-5">
+              <label htmlFor="oidcAttrMapEmail" className="col-xs-3 text-right">{t('Email')}</label>
+              <div className="col-xs-6">
                 <input
-                  id="bindByUserName-oidc"
-                  type="checkbox"
-                  checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                  onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                />
-                <label
-                  htmlFor="bindByUserName-oidc"
-                  dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
+                  className="form-control"
+                  type="text"
+                  name="oidcAttrMapEmail"
+                  value={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>
-              <p className="help-block">
-                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
-              </p>
             </div>
-          </div>
 
-          <div className="row mb-5">
-            <div className="col-xs-offset-3 col-xs-6 text-left">
-              <div className="checkbox checkbox-success">
+            <div className="row mb-5">
+              <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
+              <div className="col-xs-6">
                 <input
-                  id="bindByEmail-oidc"
-                  type="checkbox"
-                  checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser}
-                  onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
-                />
-                <label
-                  htmlFor="bindByEmail-oidc"
-                  dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  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>
+
+            <div className="row mb-3">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByUserName-oidc"
+                    type="checkbox"
+                    checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                    onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByUserName-oidc"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
+                </p>
               </div>
-              <p className="help-block">
-                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
-              </p>
             </div>
-          </div>
 
-        </React.Fragment>
+            <div className="row mb-5">
+              <div className="col-xs-offset-3 col-xs-6 text-left">
+                <div className="checkbox checkbox-success">
+                  <input
+                    id="bindByEmail-oidc"
+                    type="checkbox"
+                    checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser}
+                    onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                  />
+                  <label
+                    htmlFor="bindByEmail-oidc"
+                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
+                  />
+                </div>
+                <p className="help-block">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
+                </p>
+              </div>
+            </div>
+
+          </React.Fragment>
         )}
 
+        <div className="row my-3">
+          <div className="col-xs-offset-3 col-xs-5">
+            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.onClickSubmit}>{t('Update')}</button>
+          </div>
+        </div>
+
         <hr />
 
         <div style={{ minHeight: '300px' }}>
           <h4>
             <i className="icon-question" aria-hidden="true"></i>
-            <a href="#collapseHelpForOidcOauth" data-toggle="collapse">{ t('security_setting.OAuth.how_to.oidc') }</a>
+            <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>
+            <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>
 

+ 134 - 63
src/client/js/components/Admin/Security/SamlSecuritySetting.jsx

@@ -2,16 +2,73 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
 
 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';
 
+const logger = loggerFactory('growi:security:AdminSamlSecurityContainer');
 
 class SamlSecurityManagement extends React.Component {
 
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      retrieveError: null,
+      envEntryPoint: '',
+      envIssuer: '',
+      envCert: '',
+      envAttrMapId: '',
+      envAttrMapUserName: '',
+      envAttrMapMail: '',
+      envAttrMapFirstName: '',
+      envAttrMapLastName: '',
+    };
+
+    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 || '',
+      });
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ retrieveError: err });
+      logger.error(err);
+    }
+  }
+
+  async onClickSubmit() {
+    const { t, adminSamlSecurityContainer } = this.props;
+
+    try {
+      await adminSamlSecurityContainer.updateSamlSetting();
+      toastSuccess(t('security_setting.SAML.updated_saml'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
   render() {
     const { t, adminGeneralSecurityContainer, adminSamlSecurityContainer } = this.props;
     const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
@@ -20,18 +77,24 @@ class SamlSecurityManagement extends React.Component {
       <React.Fragment>
 
         <h2 className="alert-anchor border-bottom">
-          { t('security_setting.SAML.name') } { t('security_setting.configuration') }
+          {t('security_setting.SAML.name')} {t('security_setting.configuration')}
         </h2>
 
+        {this.state.retrieveError != null && (
+          <div className="alert alert-danger">
+            <p>{t('Error occurred')} : {this.state.err}</p>
+          </div>
+        )}
+
         {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' }) }}
-        />
+          <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">
-          <strong className="col-xs-3 text-right">{ t('security_setting.SAML.name') }</strong>
+          <strong className="col-xs-3 text-right">{t('security_setting.SAML.name')}</strong>
           <div className="col-xs-6 text-left">
             <div className="checkbox checkbox-success">
               <input
@@ -41,30 +104,30 @@ class SamlSecurityManagement extends React.Component {
                 onChange={() => { adminGeneralSecurityContainer.switchIsSamlEnabled() }}
               />
               <label htmlFor="isSamlEnabled">
-                { t('security_setting.SAML.enable_saml') }
+                {t('security_setting.SAML.enable_saml')}
               </label>
             </div>
           </div>
         </div>
 
         <div className="row mb-5">
-          <label className="col-xs-3 text-right">{ t('security_setting.callback_URL') }</label>
+          <label className="col-xs-3 text-right">{t('security_setting.callback_URL')}</label>
           <div className="col-xs-6">
             <input
               className="form-control"
               type="text"
-              value={adminSamlSecurityContainer.state.callbackUrl}
+              defaultValue={adminSamlSecurityContainer.state.callbackUrl}
               readOnly
             />
-            <p className="help-block small">{ t('security_setting.desc_of_callback_URL', { AuthName: 'SAML Identity' }) }</p>
-            {!adminSamlSecurityContainer.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>
+            <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>
@@ -73,14 +136,16 @@ class SamlSecurityManagement extends React.Component {
           <React.Fragment>
 
             {(adminSamlSecurityContainer.state.missingMandatoryConfigKeys.length !== 0) && (
-            <div className="alert alert-danger">
-              { t('security_setting.missing mandatory configs') }
-              <ul>
-                {/* TODO GW-583 show li after fetch data */}
-                {/* <li>{ t('security_setting.form_item_name.key') }</li> */}
-              </ul>
-            </div>
-          )}
+              <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">
@@ -98,22 +163,22 @@ class SamlSecurityManagement extends React.Component {
               </thead>
               <tbody>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.entryPoint') }</th>
+                  <th>{t('security_setting.form_item_name.entryPoint')}</th>
                   <td>
                     <input
                       className="form-control"
                       type="text"
-                      name="samlDbEntryPoint"
+                      name="samlEntryPoint"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbEntryPoint}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbEntryPoint(e.target.value)}
+                      defaultValue={adminSamlSecurityContainer.state.samlEntryPoint}
+                      onChange={e => adminSamlSecurityContainer.changeSamlEntryPoint(e.target.value)}
                     />
                   </td>
                   <td>
                     <input
                       className="form-control"
                       type="text"
-                      value={adminSamlSecurityContainer.state.samlEnvVarEntryPoint}
+                      value={this.state.envEntryPoint}
                       readOnly
                     />
                     <p className="help-block">
@@ -122,22 +187,22 @@ class SamlSecurityManagement extends React.Component {
                   </td>
                 </tr>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.issuer') }</th>
+                  <th>{t('security_setting.form_item_name.issuer')}</th>
                   <td>
                     <input
                       className="form-control"
                       type="text"
                       name="samlEnvVarissuer"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbIssuer}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbIssuer(e.target.value)}
+                      defaultValue={adminSamlSecurityContainer.state.samlIssuer}
+                      onChange={e => adminSamlSecurityContainer.changeSamlIssuer(e.target.value)}
                     />
                   </td>
                   <td>
                     <input
                       className="form-control"
                       type="text"
-                      value={adminSamlSecurityContainer.state.samlEnvVarIssuer}
+                      value={this.state.envIssuer}
                       readOnly
                     />
                     <p className="help-block">
@@ -146,25 +211,25 @@ class SamlSecurityManagement extends React.Component {
                   </td>
                 </tr>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.cert') }</th>
+                  <th>{t('security_setting.form_item_name.cert')}</th>
                   <td>
                     <textarea
                       className="form-control input-sm"
                       type="text"
                       rows="5"
-                      name="samlDbCert"
+                      name="samlCert"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbcert}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbCert(e.target.value)}
+                      defaultValue={adminSamlSecurityContainer.state.samlcert}
+                      onChange={e => adminSamlSecurityContainer.changeSamlCert(e.target.value)}
                     />
                     <p className="help-block">
                       <small>
-                        { t('security_setting.SAML.cert_detail') }
+                        {t('security_setting.SAML.cert_detail')}
                       </small>
                     </p>
                     <div>
                       <small>
-                      e.g.
+                        e.g.
                         <pre>{`-----BEGIN CERTIFICATE-----
 MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
 UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
@@ -183,7 +248,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                       type="text"
                       rows="5"
                       readOnly
-                      value={adminSamlSecurityContainer.state.samlEnvVarCert}
+                      value={this.state.envCert}
                     />
                     <p className="help-block">
                       <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.Use env var if empty', { env: 'SAML_CERT' }) }} />
@@ -208,18 +273,18 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
               </thead>
               <tbody>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.attrMapId') }</th>
+                  <th>{t('security_setting.form_item_name.attrMapId')}</th>
                   <td>
                     <input
                       className="form-control"
                       type="text"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbAttrMapId}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapId(e.target.value)}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapId}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapId(e.target.value)}
                     />
                     <p className="help-block">
                       <small>
-                        { t('security_setting.SAML.id_detail') }
+                        {t('security_setting.SAML.id_detail')}
                       </small>
                     </p>
                   </td>
@@ -227,7 +292,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapId}
+                      value={this.state.envAttrMapId}
                       readOnly
                     />
                     <p className="help-block">
@@ -236,14 +301,14 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                   </td>
                 </tr>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.attrMapUsername') }</th>
+                  <th>{t('security_setting.form_item_name.attrMapUsername')}</th>
                   <td>
                     <input
                       className="form-control"
                       type="text"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbAttrMapUserName}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapUserName(e.target.value)}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapUserName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapUserName(e.target.value)}
                     />
                     <p className="help-block">
                       <small dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.username_detail') }} />
@@ -253,7 +318,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapUserName}
+                      value={this.state.envAttrMapUserName}
                       readOnly
                     />
                     <p className="help-block">
@@ -262,14 +327,14 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                   </td>
                 </tr>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.attrMapMail') }</th>
+                  <th>{t('security_setting.form_item_name.attrMapMail')}</th>
                   <td>
                     <input
                       className="form-control"
                       type="text"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbAttrMapMail}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapMail(e.target.value)}
+                      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' }) }} />
@@ -279,7 +344,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapMail}
+                      value={this.state.envAttrMapMail}
                       readOnly
                     />
                     <p className="help-block">
@@ -288,14 +353,14 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                   </td>
                 </tr>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.attrMapFirstName') }</th>
+                  <th>{t('security_setting.form_item_name.attrMapFirstName')}</th>
                   <td>
                     <input
                       className="form-control"
                       type="text"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbAttrMapFirstName}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapFirstName(e.target.value)}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapFirstName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapFirstName(e.target.value)}
                     />
                     <p className="help-block">
                       {/* eslint-disable-next-line max-len */}
@@ -306,7 +371,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapFirstName}
+                      value={this.state.envAttrMapFirstName}
                       readOnly
                     />
                     <p className="help-block">
@@ -319,14 +384,14 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                   </td>
                 </tr>
                 <tr>
-                  <th>{ t('security_setting.form_item_name.attrMapLastName') }</th>
+                  <th>{t('security_setting.form_item_name.attrMapLastName')}</th>
                   <td>
                     <input
                       className="form-control"
                       type="text"
                       readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlDbAttrMapLastName}
-                      onChange={e => adminSamlSecurityContainer.changeSamlDbAttrMapLastName(e.target.value)}
+                      defaultValue={adminSamlSecurityContainer.state.samlAttrMapLastName}
+                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapLastName(e.target.value)}
                     />
                     <p className="help-block">
                       {/* eslint-disable-next-line max-len */}
@@ -337,7 +402,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     <input
                       className="form-control"
                       type="text"
-                      value={adminSamlSecurityContainer.state.samlEnvVarAttrMapLastName}
+                      value={this.state.envAttrMapLastName}
                       readOnly
                     />
                     <p className="help-block">
@@ -400,6 +465,12 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
 
         )}
 
+        <div className="row my-3">
+          <div className="col-xs-offset-3 col-xs-5">
+            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.onClickSubmit}>{t('Update')}</button>
+          </div>
+        </div>
+
 
       </React.Fragment>
     );

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

@@ -110,6 +110,12 @@ class TwitterSecurityManagement extends React.Component {
         {adminGeneralSecurityContainer.state.isTwitterOAuthEnabled && (
           <React.Fragment>
 
+            {!adminTwitterSecurityContainer.state.isTwitterStrategySetup && (
+              <div className="alert alert-warning">
+                <p>{t('security_setting.setup_not_completed_yet')}</p>
+              </div>
+            )}
+
             <div className="row mb-5">
               <label htmlFor="TwitterConsumerId" className="col-xs-3 text-right">{ t('security_setting.clientID') }</label>
               <div className="col-xs-6">

+ 37 - 0
src/client/js/components/Admin/UserGroupDetail/CheckBoxForSerchUserOption.jsx

@@ -0,0 +1,37 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CheckBoxForSerchUserOption extends React.Component {
+
+  render() {
+    const { t, option } = this.props;
+    return (
+      <div className="checkbox checkbox-info" key={`isAlso${option}Searched`}>
+        <input
+          type="checkbox"
+          id={`isAlso${option}Searched`}
+          className="form-check-input"
+          checked={this.props.checked}
+          onChange={this.props.onChange}
+        />
+        <label className="text-capitalize form-check-label ml-3" htmlFor={`isAlso${option}Searched`}>
+          {t('user_group_management.enable_option', { option })}
+        </label>
+      </div>
+    );
+  }
+
+}
+
+
+CheckBoxForSerchUserOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  option: PropTypes.string.isRequired,
+  checked: PropTypes.bool.isRequired,
+  onChange: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(CheckBoxForSerchUserOption);

+ 37 - 0
src/client/js/components/Admin/UserGroupDetail/RadioButtonForSerchUserOption.jsx

@@ -0,0 +1,37 @@
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class RadioButtonForSerchUserOption extends React.Component {
+
+  render() {
+    const { t, searchType } = this.props;
+    return (
+      <div className="radio" key={`${searchType}Match`}>
+        <input
+          type="radio"
+          id={`${searchType}Match`}
+          className="form-check-radio"
+          checked={this.props.checked}
+          onChange={this.props.onChange}
+        />
+        <label className="text-capitalize form-check-label ml-3" htmlFor={`${searchType}Match`}>
+          {t(`user_group_management.${searchType}_match`)}
+        </label>
+      </div>
+    );
+  }
+
+}
+
+
+RadioButtonForSerchUserOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  searchType: PropTypes.string.isRequired,
+  checked: PropTypes.bool.isRequired,
+  onChange: PropTypes.func.isRequired,
+};
+
+export default withTranslation()(RadioButtonForSerchUserOption);

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

@@ -40,7 +40,7 @@ class UserGroupPageList extends React.Component {
       const { total, pages } = res.data;
 
       this.setState({
-        total,
+        total: total || 0,
         activePage: pageNum,
         currentPages: pages,
       });

+ 105 - 24
src/client/js/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -2,10 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+import { debounce } from 'throttle-debounce';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
+import UserPicture from '../../User/UserPicture';
 
 class UserGroupUserFormByInput extends React.Component {
 
@@ -13,55 +16,133 @@ class UserGroupUserFormByInput extends React.Component {
     super(props);
 
     this.state = {
-      username: '',
+      keyword: '',
+      inputUser: '',
+      applicableUsers: [],
+      isLoading: false,
+      searchError: null,
     };
 
     this.xss = window.xss;
 
-    this.changeUsername = this.changeUsername.bind(this);
     this.addUserBySubmit = this.addUserBySubmit.bind(this);
     this.validateForm = this.validateForm.bind(this);
-  }
+    this.handleChange = this.handleChange.bind(this);
+    this.handleSearch = this.handleSearch.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+    this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
 
-  changeUsername(e) {
-    this.setState({ username: e.target.value });
+    this.searhApplicableUsersDebounce = debounce(1000, this.searhApplicableUsers);
   }
 
-  async addUserBySubmit(e) {
-    e.preventDefault();
-    const { username } = this.state;
+  async addUserBySubmit() {
+    if (this.state.inputUser.length === 0) { return }
+    const userName = this.state.inputUser[0].username;
 
     try {
-      await this.props.userGroupDetailContainer.addUserByUsername(username);
-      toastSuccess(`Added "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
-      this.setState({ username: '' });
+      await this.props.userGroupDetailContainer.addUserByUsername(userName);
+      toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`);
+      this.setState({ inputUser: '' });
     }
     catch (err) {
-      toastError(new Error(`Unable to add "${this.xss.process(username)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
+      toastError(new Error(`Unable to add "${this.xss.process(userName)}" to "${this.xss.process(this.props.userGroupDetailContainer.state.userGroup.name)}"`));
     }
   }
 
   validateForm() {
-    return this.state.username !== '';
+    return this.state.inputUser !== '';
+  }
+
+  async searhApplicableUsers() {
+    try {
+      const users = await this.props.userGroupDetailContainer.fetchApplicableUsers(this.state.keyword);
+      this.setState({ applicableUsers: users, isLoading: false });
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  /**
+   * Reflect when forecast is clicked
+   * @param {object} inputUser
+   */
+  handleChange(inputUser) {
+    this.setState({ inputUser });
+  }
+
+  handleSearch(keyword) {
+
+    if (keyword === '') {
+      return;
+    }
+
+    this.setState({ keyword, isLoading: true });
+    this.searhApplicableUsersDebounce();
+  }
+
+  onKeyDown(event) {
+    // 13 is Enter key
+    if (event.keyCode === 13) {
+      this.addUserBySubmit();
+    }
+  }
+
+  renderMenuItemChildren(option) {
+    const { userGroupDetailContainer } = this.props;
+    const user = option;
+    return (
+      <React.Fragment>
+        <UserPicture user={user} size="sm" withoutLink />
+        <strong className="ml-2">{user.username}</strong>
+        {userGroupDetailContainer.state.isAlsoNameSearched && <span className="ml-2">{user.name}</span>}
+        {userGroupDetailContainer.state.isAlsoMailSearched && <span className="ml-2">{user.email}</span>}
+      </React.Fragment>
+    );
+  }
+
+  getEmptyLabel() {
+    return (this.state.searchError !== null) && 'Error on searching.';
   }
 
   render() {
     const { t } = this.props;
 
+    const inputProps = { autoComplete: 'off' };
+
     return (
-      <form className="form-inline" onSubmit={this.addUserBySubmit}>
-        <div className="form-group">
-          <input
-            type="text"
-            name="username"
-            className="form-control input-sm"
-            placeholder={t('username')}
-            value={this.state.username}
-            onChange={this.changeUsername}
+      <div className="row">
+        <div className="col-xs-8 pr-0">
+          <AsyncTypeahead
+            {...this.props}
+            id="name-typeahead-asynctypeahead"
+            ref={(c) => { this.typeahead = c }}
+            inputProps={inputProps}
+            isLoading={this.state.isLoading}
+            labelKey={user => `${user.username} ${user.name} ${user.email}`}
+            minLength={0}
+            options={this.state.applicableUsers} // Search result
+            searchText={(this.state.isLoading ? 'Searching...' : this.getEmptyLabel())}
+            renderMenuItemChildren={this.renderMenuItemChildren}
+            align="left"
+            onChange={this.handleChange}
+            onSearch={this.handleSearch}
+            onKeyDown={this.onKeyDown}
+            caseSensitive={false}
+            clearButton
           />
         </div>
-        <button type="submit" className="btn btn-sm btn-success" disabled={!this.validateForm()}>{ t('add') }</button>
-      </form>
+        <div className="col-xs-2 pl-0">
+          <button
+            type="button"
+            className="btn btn-sm btn-success"
+            disabled={!this.validateForm()}
+            onClick={this.addUserBySubmit}
+          >
+            {t('add')}
+          </button>
+        </div>
+      </div>
     );
   }
 

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

@@ -7,6 +7,8 @@ import UserGroupUserFormByInput from './UserGroupUserFormByInput';
 import { createSubscribedElement } from '../../UnstatedUtils';
 import AppContainer from '../../../services/AppContainer';
 import UserGroupDetailContainer from '../../../services/UserGroupDetailContainer';
+import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
+import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
 
 class UserGroupUserModal extends React.Component {
 
@@ -16,10 +18,54 @@ class UserGroupUserModal extends React.Component {
     return (
       <Modal show={userGroupDetailContainer.state.isUserGroupUserModalOpen} onHide={userGroupDetailContainer.closeUserGroupUserModal}>
         <Modal.Header closeButton>
-          <Modal.Title>{ t('user_group_management.add_user') }</Modal.Title>
+          <Modal.Title>{t('user_group_management.add_user')}</Modal.Title>
         </Modal.Header>
         <Modal.Body>
-          <UserGroupUserFormByInput />
+          <div className="p-3">
+            <UserGroupUserFormByInput />
+          </div>
+          <h2 className="border-bottom">{t('user_group_management.search_option')}</h2>
+          <div className="row mt-4">
+            <div className="col-xs-6">
+              <div className="mb-5">
+                <CheckBoxForSerchUserOption
+                  option="Mail"
+                  checked={userGroupDetailContainer.state.isAlsoMailSearched}
+                  onChange={userGroupDetailContainer.switchIsAlsoMailSearched}
+                />
+              </div>
+              <div className="mb-5">
+                <CheckBoxForSerchUserOption
+                  option="Name"
+                  checked={userGroupDetailContainer.state.isAlsoNameSearched}
+                  onChange={userGroupDetailContainer.switchIsAlsoNameSearched}
+                />
+              </div>
+            </div>
+            <div className="col-xs-6">
+              <div className="mb-5">
+                <RadioButtonForSerchUserOption
+                  searchType="forward"
+                  checked={userGroupDetailContainer.state.searchType === 'forward'}
+                  onChange={() => { userGroupDetailContainer.switchSearchType('forward') }}
+                />
+              </div>
+              <div className="mb-5">
+                <RadioButtonForSerchUserOption
+                  searchType="partial"
+                  checked={userGroupDetailContainer.state.searchType === 'partial'}
+                  onChange={() => { userGroupDetailContainer.switchSearchType('partial') }}
+                />
+              </div>
+              <div className="mb-5">
+                <RadioButtonForSerchUserOption
+                  searchType="backward"
+                  checked={userGroupDetailContainer.state.searchType === 'backword'}
+                  onChange={() => { userGroupDetailContainer.switchSearchType('backword') }}
+                />
+              </div>
+            </div>
+          </div>
         </Modal.Body>
       </Modal>
     );

+ 8 - 0
src/client/js/components/PageEditor/AbstractEditor.jsx

@@ -79,6 +79,14 @@ export default class AbstractEditor extends React.Component {
     throw new Error('this method should be impelemented in subclass');
   }
 
+  /**
+   * replace the current line with param 'text'
+   * @param {string} text
+   */
+  replaceLine(text) {
+    throw new Error('this method should be impelemented in subclass');
+  }
+
   /**
    * insert text
    * @param {string} text

+ 23 - 16
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -49,6 +49,9 @@ require('codemirror/addon/display/placeholder');
 require('codemirror/mode/gfm/gfm');
 require('../../util/codemirror/autorefresh.ext');
 
+
+const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
+
 export default class CodeMirrorEditor extends AbstractEditor {
 
   constructor(props) {
@@ -247,6 +250,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
     editor.getDoc().replaceRange(text, this.getBol(), pos);
   }
 
+  /**
+   * @inheritDoc
+   */
+  replaceLine(text) {
+    const editor = this.getCodeMirror();
+    editor.getDoc().replaceRange(text, this.getBol(), this.getEol());
+  }
+
   /**
    * @inheritDoc
    */
@@ -442,23 +453,19 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   cursorHandler(editor, event) {
-    const strFromBol = this.getStrFromBol();
-
-    const autoformatTableClass = 'autoformat-markdown-table-activated';
-    const additionalClassSet = this.state.additionalClassSet;
-    const hasCustomClass = additionalClassSet.has(autoformatTableClass);
-    if (mtu.isEndOfLine(editor) && mtu.linePartOfTableRE.test(strFromBol)) {
-      if (!hasCustomClass) {
-        additionalClassSet.add(autoformatTableClass);
-        this.setState({ additionalClassSet });
-      }
+    const { additionalClassSet } = this.state;
+    const hasCustomClass = additionalClassSet.has(MARKDOWN_TABLE_ACTIVATED_CLASS);
+
+    const isInTable = mtu.isInTable(editor);
+
+    if (!hasCustomClass && isInTable) {
+      additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
     }
-    else {
-      // eslint-disable-next-line no-lonely-if
-      if (hasCustomClass) {
-        additionalClassSet.delete(autoformatTableClass);
-        this.setState({ additionalClassSet });
-      }
+
+    if (hasCustomClass && !isInTable) {
+      additionalClassSet.delete(MARKDOWN_TABLE_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
     }
   }
 

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

@@ -412,7 +412,7 @@ export default class HandsontableModal extends React.PureComponent {
     const dialogClassName = dialogClassNames.join(' ');
 
     return (
-      <Modal show={this.state.show} onHide={this.cancel} bsSize="large" dialogClassName={dialogClassName}>
+      <Modal show={this.state.show} onHide={this.cancel} bsSize="large" dialogClassName={dialogClassName} keyboard={false}>
         <Modal.Header closeButton>
           { this.renderExpandOrContractButton() }
           <Modal.Title>Edit Table</Modal.Title>

+ 42 - 19
src/client/js/components/PageEditor/MarkdownTableInterceptor.js

@@ -24,44 +24,67 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
     return false;
   }
 
+  addRow(cm) {
+    // get lines all of table from current position to beginning of table
+    const strFromBot = mtu.getStrFromBot(cm);
+    let table = MarkdownTable.fromMarkdownString(strFromBot);
+
+    mtu.addRowToMarkdownTable(table);
+
+    const strToEot = mtu.getStrToEot(cm);
+    const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
+    if (tableBottom.table.length > 0) {
+      table = mtu.mergeMarkdownTable([table, tableBottom]);
+    }
+
+    mtu.replaceMarkdownTableWithReformed(cm, table);
+  }
+
+  reformTable(cm) {
+    const tableStr = mtu.getStrFromBot(cm) + mtu.getStrToEot(cm);
+    const table = MarkdownTable.fromMarkdownString(tableStr);
+    mtu.replaceMarkdownTableWithReformed(cm, table);
+  }
+
+  removeRow(editor) {
+    editor.replaceLine('\n');
+  }
+
   /**
    * @inheritdoc
    */
-  process(contextName, ...args) {
+  async process(contextName, ...args) {
     const context = Object.assign(args[0]); // clone
     const editor = context.editor; // AbstractEditor instance
 
     // do nothing if editor is not a CodeMirrorEditor
     if (editor == null || editor.getCodeMirror() == null) {
-      return Promise.resolve(context);
+      return context;
     }
 
     const cm = editor.getCodeMirror();
 
-    // get strings from BOL(beginning of line) to current position
-    const strFromBol = editor.getStrFromBol();
-
-    if (mtu.isEndOfLine(cm) && mtu.linePartOfTableRE.test(strFromBol)) {
-      // get lines all of table from current position to beginning of table
-      const strFromBot = mtu.getStrFromBot(cm);
-      let table = MarkdownTable.fromMarkdownString(strFromBot);
-
-      mtu.addRowToMarkdownTable(table);
+    const isInTable = mtu.isInTable(cm);
+    const isLastRow = mtu.getStrToEot(cm) === editor.getStrToEol();
 
-      const strToEot = mtu.getStrToEot(cm);
-      const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
-      if (tableBottom.table.length > 0) {
-        table = mtu.mergeMarkdownTable([table, tableBottom]);
+    if (isInTable) {
+      // at EOL in the table
+      if (mtu.isEndOfLine(cm)) {
+        this.addRow(cm);
+      }
+      // last empty row
+      else if (isLastRow && mtu.emptyLineOfTableRE.test(editor.getStrFromBol() + editor.getStrToEol())) {
+        this.removeRow(editor);
+      }
+      else {
+        this.reformTable(cm);
       }
-
-      mtu.replaceMarkdownTableWithReformed(cm, table);
 
       // report to manager that handling was done
       context.handlers.push(this.className);
+      return context;
     }
 
-    // resolve
-    return Promise.resolve(context);
   }
 
 }

+ 4 - 12
src/client/js/components/PageEditor/MarkdownTableUtil.js

@@ -7,14 +7,14 @@ class MarkdownTableUtil {
 
   constructor() {
     // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
-    // https://regex101.com/r/7BN2fR/7
     this.tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
     this.tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
-    this.linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
+    // https://regex101.com/r/7BN2fR/10
+    this.linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
+    // https://regex101.com/r/1UuWBJ/3
+    this.emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
 
-    this.getBot = this.getBot.bind(this);
     this.getEot = this.getEot.bind(this);
-    this.getBol = this.getBol.bind(this);
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
     this.isInTable = this.isInTable.bind(this);
@@ -67,14 +67,6 @@ class MarkdownTableUtil {
     return { line: eotLine, ch: lineLength };
   }
 
-  /**
-   * return the postion of the BOL(beginning of line)
-   */
-  getBol(editor) {
-    const curPos = editor.getCursor();
-    return { line: curPos.line, ch: 0 };
-  }
-
   /**
    * return strings from BOT(beginning of table) to the cursor position
    */

+ 7 - 0
src/client/js/components/PageEditor/TextAreaEditor.jsx

@@ -149,6 +149,13 @@ export default class TextAreaEditor extends AbstractEditor {
     this.replaceValue(text, this.getBolPos(), lowerPos);
   }
 
+  /**
+   * @inheritDoc
+   */
+  replaceLine(text) {
+    this.replaceValue(text, this.getBolPos(), this.getEolPos());
+  }
+
   getBolPos() {
     const currentPos = this.textarea.selectionStart;
     return this.textarea.value.lastIndexOf('\n', currentPos - 1) + 1;

+ 23 - 7
src/client/js/components/User/UserPicture.jsx

@@ -2,24 +2,27 @@ import React from 'react';
 import md5 from 'md5';
 import PropTypes from 'prop-types';
 
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
 // TODO UserComponent?
 export default class UserPicture extends React.Component {
 
   getUserPicture(user) {
+    let pictPath;
+
     // gravatar
     if (user.isGravatarEnabled === true) {
-      return this.generateGravatarSrc(user);
+      pictPath = this.generateGravatarSrc(user);
     }
     // uploaded image
     if (user.image != null) {
-      return user.image;
+      pictPath = user.image;
     }
     if (user.imageAttachment != null) {
       return user.imageAttachment.filePathProxied;
     }
 
-    return '/images/icons/user.svg';
-
+    return pictPath || DEFAULT_IMAGE;
   }
 
   generateGravatarSrc(user) {
@@ -38,9 +41,22 @@ export default class UserPicture extends React.Component {
     return className.join(' ');
   }
 
+  renderForNull() {
+    return (
+      <img
+        src={DEFAULT_IMAGE}
+        alt="someone"
+        className={this.getClassName()}
+      />
+    );
+  }
+
   render() {
     const user = this.props.user;
-    const href = `/user/${user.username}`;
+
+    if (user == null) {
+      return this.renderForNull();
+    }
 
     const imgElem = (
       <img
@@ -53,14 +69,14 @@ export default class UserPicture extends React.Component {
     return (
       (this.props.withoutLink)
         ? <span>{imgElem}</span>
-        : <a href={href}>{imgElem}</a>
+        : <a href={`/user/${user.username}`}>{imgElem}</a>
     );
   }
 
 }
 
 UserPicture.propTypes = {
-  user: PropTypes.object.isRequired,
+  user: PropTypes.object,
   size: PropTypes.string,
   withoutLink: PropTypes.bool,
 };

+ 9 - 1
src/client/js/components/User/Username.jsx

@@ -3,9 +3,17 @@ import PropTypes from 'prop-types';
 
 export default class Username extends React.Component {
 
+  renderForNull() {
+    return <span>anyone</span>;
+  }
+
   render() {
     const { user } = this.props;
 
+    if (user == null) {
+      return this.renderForNull();
+    }
+
     const name = user.name || '(no name)';
     const username = user.username;
     const href = `/user/${user.username}`;
@@ -18,5 +26,5 @@ export default class Username extends React.Component {
 }
 
 Username.propTypes = {
-  user: PropTypes.object.isRequired,
+  user: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), // Possibility of receiving a string of 'null'
 };

+ 299 - 0
src/client/js/services/AdminAppContainer.js

@@ -0,0 +1,299 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+const logger = loggerFactory('growi:appSettings');
+
+/**
+ * Service container for admin app setting page (AppSettings.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminAppContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      title: '',
+      confidential: '',
+      globalLang: '',
+      fileUpload: '',
+      siteUrl: '',
+      envSiteUrl: '',
+      isSetSiteUrl: true,
+      fromAddress: '',
+      smtpHost: '',
+      smtpPort: '',
+      smtpUser: '',
+      smtpPassword: '',
+      region: '',
+      customEndpoint: '',
+      bucket: '',
+      accessKeyId: '',
+      secretKey: '',
+      isEnabledPlugins: true,
+    };
+
+    this.changeTitle = this.changeTitle.bind(this);
+    this.changeConfidential = this.changeConfidential.bind(this);
+    this.changeGlobalLang = this.changeGlobalLang.bind(this);
+    this.changeFileUpload = this.changeFileUpload.bind(this);
+    this.changeSiteUrl = this.changeSiteUrl.bind(this);
+    this.changeFromAddress = this.changeFromAddress.bind(this);
+    this.changeSmtpHost = this.changeSmtpHost.bind(this);
+    this.changeSmtpPort = this.changeSmtpPort.bind(this);
+    this.changeSmtpUser = this.changeSmtpUser.bind(this);
+    this.changeSmtpPassword = this.changeSmtpPassword.bind(this);
+    this.changeRegion = this.changeRegion.bind(this);
+    this.changeCustomEndpoint = this.changeCustomEndpoint.bind(this);
+    this.changeBucket = this.changeBucket.bind(this);
+    this.changeAccessKeyId = this.changeAccessKeyId.bind(this);
+    this.changeSecretKey = this.changeSecretKey.bind(this);
+    this.changeIsEnabledPlugins = this.changeIsEnabledPlugins.bind(this);
+    this.updateAppSettingHandler = this.updateAppSettingHandler.bind(this);
+    this.updateSiteUrlSettingHandler = this.updateSiteUrlSettingHandler.bind(this);
+    this.updateMailSettingHandler = this.updateMailSettingHandler.bind(this);
+    this.updateAwsSettingHandler = this.updateAwsSettingHandler.bind(this);
+    this.updatePluginSettingHandler = this.updatePluginSettingHandler.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminAppContainer';
+  }
+
+  /**
+   * retrieve app sttings data
+   */
+  async retrieveAppSettingsData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/app-settings/');
+      const { appSettingsParams } = response.data;
+
+      this.setState({
+        title: appSettingsParams.title,
+        confidential: appSettingsParams.confidential,
+        globalLang: appSettingsParams.globalLang,
+        fileUpload: appSettingsParams.fileUpload,
+        siteUrl: appSettingsParams.siteUrl,
+        envSiteUrl: appSettingsParams.envSiteUrl,
+        isSetSiteUrl: !!appSettingsParams.siteUrl,
+        fromAddress: appSettingsParams.fromAddress,
+        smtpHost: appSettingsParams.smtpHost,
+        smtpPort: appSettingsParams.smtpPort,
+        smtpUser: appSettingsParams.smtpUser,
+        smtpPassword: appSettingsParams.smtpPassword,
+        region: appSettingsParams.region,
+        customEndpoint: appSettingsParams.customEndpoint,
+        bucket: appSettingsParams.bucket,
+        accessKeyId: appSettingsParams.accessKeyId,
+        secretKey: appSettingsParams.secretKey,
+        isEnabledPlugins: appSettingsParams.isEnabledPlugins,
+      });
+
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+  /**
+   * Change title
+   */
+  changeTitle(title) {
+    this.setState({ title });
+  }
+
+  /**
+   * Change confidential
+   */
+  changeConfidential(confidential) {
+    this.setState({ confidential });
+  }
+
+  /**
+   * Change globalLang
+   */
+  changeGlobalLang(globalLang) {
+    this.setState({ globalLang });
+  }
+
+  /**
+   * Change fileUpload
+   */
+  changeFileUpload(fileUpload) {
+    this.setState({ fileUpload });
+  }
+
+  /**
+   * Change site url
+   */
+  changeSiteUrl(siteUrl) {
+    this.setState({ siteUrl });
+  }
+
+
+  /**
+   * Change from address
+   */
+  changeFromAddress(fromAddress) {
+    this.setState({ fromAddress });
+  }
+
+  /**
+   * Change smtp host
+   */
+  changeSmtpHost(smtpHost) {
+    this.setState({ smtpHost });
+  }
+
+  /**
+   * Change smtp port
+   */
+  changeSmtpPort(smtpPort) {
+    this.setState({ smtpPort });
+  }
+
+  /**
+   * Change smtp user
+   */
+  changeSmtpUser(smtpUser) {
+    this.setState({ smtpUser });
+  }
+
+  /**
+   * Change smtp password
+   */
+  changeSmtpPassword(smtpPassword) {
+    this.setState({ smtpPassword });
+  }
+
+  /**
+   * Change region
+   */
+  changeRegion(region) {
+    this.setState({ region });
+  }
+
+  /**
+   * Change custom endpoint
+   */
+  changeCustomEndpoint(customEndpoint) {
+    this.setState({ customEndpoint });
+  }
+
+  /**
+   * Change bucket name
+   */
+  changeBucket(bucket) {
+    this.setState({ bucket });
+  }
+
+  /**
+   * Change access key id
+   */
+  changeAccessKeyId(accessKeyId) {
+    this.setState({ accessKeyId });
+  }
+
+  /**
+   * Change secret key
+   */
+  changeSecretKey(secretKey) {
+    this.setState({ secretKey });
+  }
+
+  /**
+   * Change secret key
+   */
+  changeIsEnabledPlugins(isEnabledPlugins) {
+    this.setState({ isEnabledPlugins });
+  }
+
+  /**
+   * Update app setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateAppSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/app-setting', {
+      title: this.state.title,
+      confidential: this.state.confidential,
+      globalLang: this.state.globalLang,
+      fileUpload: this.state.fileUpload,
+    });
+    const { appSettingParams } = response.data;
+    return appSettingParams;
+  }
+
+
+  /**
+   * Update site url setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateSiteUrlSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/site-url-setting', {
+      siteUrl: this.state.siteUrl,
+    });
+    const { siteUrlSettingParams } = response.data;
+    return siteUrlSettingParams;
+  }
+
+  /**
+   * Update mail setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateMailSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/mail-setting', {
+      fromAddress: this.state.fromAddress,
+      smtpHost: this.state.smtpHost,
+      smtpPort: this.state.smtpPort,
+      smtpUser: this.state.smtpUser,
+      smtpPassword: this.state.smtpPassword,
+    });
+    const { mailSettingParams } = response.data;
+    return mailSettingParams;
+  }
+
+  /**
+   * Update AWS setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateAwsSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/aws-setting', {
+      region: this.state.region,
+      customEndpoint: this.state.customEndpoint,
+      bucket: this.state.bucket,
+      accessKeyId: this.state.accessKeyId,
+      secretKey: this.state.secretKey,
+    });
+    const { awsSettingParams } = response.data;
+    return awsSettingParams;
+  }
+
+  /**
+   * Update plugin setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updatePluginSettingHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/plugin-setting', {
+      isEnabledPlugins: this.state.isEnabledPlugins,
+    });
+    const { pluginSettingParams } = response.data;
+    return pluginSettingParams;
+  }
+
+
+}

+ 27 - 7
src/client/js/services/AdminBasicSecurityContainer.js

@@ -17,19 +17,22 @@ export default class AdminBasicSecurityContainer extends Container {
     this.appContainer = appContainer;
 
     this.state = {
-      // TODO GW-583 set value
-      isSameUsernameTreatedAsIdenticalUser: 'hoge',
+      isSameUsernameTreatedAsIdenticalUser: false,
     };
 
-    this.init();
-
   }
 
-  init() {
-    // TODO GW-583 fetch config value with api
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    const response = await this.appContainer.apiv3.get('/security-setting/');
+    const { basicAuth } = response.data.securityParams;
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser: basicAuth.isSameUsernameTreatedAsIdenticalUser || false,
+    });
   }
 
-
   /**
    * Workaround for the mangling in production build to break constructor.name
    */
@@ -44,4 +47,21 @@ export default class AdminBasicSecurityContainer extends Container {
     this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
   }
 
+  /**
+   * Update basicSetting
+   */
+  async updateBasicSetting() {
+
+    const response = await this.appContainer.apiv3.put('/security-setting/basic', {
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+    });
+
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
+    });
+    return response;
+  }
+
 }

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

@@ -0,0 +1,312 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:AdminCustomizeContainer');
+
+/**
+ * Service container for admin customize setting page (Customize.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminCustomizeContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      currentTheme: '',
+      currentLayout: '',
+      currentBehavior: '',
+      isEnabledTimeline: false,
+      isSavedStatesOfTabChanges: false,
+      isEnabledAttachTitleHeader: false,
+      currentRecentCreatedLimit: 10,
+      isEnabledStaleNotification: false,
+      currentHighlightJsStyleId: '',
+      isHighlightJsStyleBorderEnabled: false,
+      currentCustomizeTitle: '',
+      currentCustomizeHeader: '',
+      currentCustomizeCss: '',
+      currentCustomizeScript: '',
+      /* eslint-disable quote-props, no-multi-spaces */
+      highlightJsCssSelectorOptions: {
+        'github':           { name: '[Light] GitHub',         border: false },
+        'github-gist':      { name: '[Light] GitHub Gist',    border: true },
+        'atom-one-light':   { name: '[Light] Atom One Light', border: true },
+        'xcode':            { name: '[Light] Xcode',          border: true },
+        'vs':               { name: '[Light] Vs',             border: true },
+        'atom-one-dark':    { name: '[Dark] Atom One Dark',   border: false },
+        'hybrid':           { name: '[Dark] Hybrid',          border: false },
+        'monokai':          { name: '[Dark] Monokai',         border: false },
+        'tomorrow-night':   { name: '[Dark] Tomorrow Night',  border: false },
+        'vs2015':           { name: '[Dark] Vs 2015',         border: false },
+      },
+      /* eslint-enable quote-props, no-multi-spaces */
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminCustomizeContainer';
+  }
+
+  /**
+   * retrieve customize data
+   */
+  async retrieveCustomizeData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/customize-setting/');
+      const { customizeParams } = response.data;
+
+      this.setState({
+        currentTheme: customizeParams.themeType,
+        currentLayout: customizeParams.layoutType,
+        currentBehavior: customizeParams.behaviorType,
+        isEnabledTimeline: customizeParams.isEnabledTimeline,
+        isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
+        isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
+        currentRecentCreatedLimit: customizeParams.recentCreatedLimit,
+        isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
+        currentHighlightJsStyleId: customizeParams.styleName,
+        isHighlightJsStyleBorderEnabled: customizeParams.styleBorder,
+        currentCustomizeTitle: customizeParams.customizeTitle,
+        currentCustomizeHeader: customizeParams.customizeHeader,
+        currentCustomizeCss: customizeParams.customizeCss,
+        currentCustomizeScript: customizeParams.customizeScript,
+      });
+
+      // search style name from object for display
+      this.setState({ currentHighlightJsStyleName: this.state.highlightJsCssSelectorOptions[customizeParams.styleName].name });
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+  /**
+   * Switch layoutType
+   */
+  switchLayoutType(lauoutName) {
+    this.setState({ currentLayout: lauoutName });
+  }
+
+  /**
+   * Switch themeType
+   */
+  switchThemeType(themeName) {
+    // can't choose theme when kibela
+    if (this.state.currentLayout === 'kibela') {
+      return;
+    }
+    this.setState({ currentTheme: themeName });
+  }
+
+  /**
+   * Switch behaviorType
+   */
+  switchBehaviorType(behaviorName) {
+    this.setState({ currentBehavior: behaviorName });
+  }
+
+  /**
+   * Switch enabledTimeLine
+   */
+  switchEnableTimeline() {
+    this.setState({ isEnabledTimeline:  !this.state.isEnabledTimeline });
+  }
+
+  /**
+   * Switch savedStatesOfTabChanges
+   */
+  switchSavedStatesOfTabChanges() {
+    this.setState({ isSavedStatesOfTabChanges:  !this.state.isSavedStatesOfTabChanges });
+  }
+
+  /**
+   * Switch enabledAttachTitleHeader
+   */
+  switchEnabledAttachTitleHeader() {
+    this.setState({ isEnabledAttachTitleHeader:  !this.state.isEnabledAttachTitleHeader });
+  }
+
+  /**
+   * Switch recentCreatedLimit
+   */
+  switchRecentCreatedLimit(value) {
+    this.setState({ currentRecentCreatedLimit: value });
+  }
+
+  /**
+   * Switch enabledStaleNotification
+   */
+  switchEnableStaleNotification() {
+    this.setState({ isEnabledStaleNotification:  !this.state.isEnabledStaleNotification });
+  }
+
+  /**
+   * Switch highlightJsStyle
+   */
+  switchHighlightJsStyle(styleId, styleName, isBorderEnable) {
+    this.setState({ currentHighlightJsStyleId: styleId });
+    this.setState({ currentHighlightJsStyleName: styleName });
+    // recommended settings are applied
+    this.setState({ isHighlightJsStyleBorderEnabled: isBorderEnable });
+  }
+
+  /**
+   * Switch highlightJsStyleBorder
+   */
+  switchHighlightJsStyleBorder() {
+    this.setState({ isHighlightJsStyleBorderEnabled: !this.state.isHighlightJsStyleBorderEnabled });
+  }
+
+  /**
+   * Change customize Title
+   */
+  changeCustomizeTitle(inputValue) {
+    this.setState({ currentCustomizeTitle: inputValue });
+  }
+
+  /**
+   * Change customize Html header
+   */
+  changeCustomizeHeader(inputValue) {
+    this.setState({ currentCustomizeHeader: inputValue });
+  }
+
+  /**
+   * Change customize css
+   */
+  changeCustomizeCss(inputValue) {
+    this.setState({ currentCustomizeCss: inputValue });
+  }
+
+  /**
+   * Change customize script
+   */
+  changeCustomizeScript(inpuValue) {
+    this.setState({ currentCustomizeScript: inpuValue });
+  }
+
+
+  /**
+   * Update layout
+   * @memberOf AdminCustomizeContainer
+   * @return {Array} Appearance
+   */
+  async updateCustomizeLayoutAndTheme() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/layoutTheme', {
+      layoutType: this.state.currentLayout,
+      themeType: this.state.currentTheme,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update behavior
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Behavior
+   */
+  async updateCustomizeBehavior() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/behavior', {
+      behaviorType: this.state.currentBehavior,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update function
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Functions
+   */
+  async updateCustomizeFunction() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/function', {
+      isEnabledTimeline: this.state.isEnabledTimeline,
+      isSavedStatesOfTabChanges: this.state.isSavedStatesOfTabChanges,
+      isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
+      recentCreatedLimit: this.state.currentRecentCreatedLimit,
+      isEnabledStaleNotification: this.state.isEnabledStaleNotification,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update code highlight
+   * @memberOf AdminCustomizeContainer
+   * @return {Array} Code highlight
+   */
+  async updateHighlightJsStyle() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/highlight', {
+      highlightJsStyle: this.state.currentHighlightJsStyleId,
+      highlightJsStyleBorder: this.state.isHighlightJsStyleBorderEnabled,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customTitle
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize title
+   */
+  async updateCustomizeTitle() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-title', {
+      customizeTitle: this.state.currentCustomizeTitle,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customHeader
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize html header
+   */
+  async updateCustomizeHeader() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-header', {
+      customizeHeader: this.state.currentCustomizeHeader,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customCss
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize css
+   */
+  async updateCustomizeCss() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-css', {
+      customizeCss: this.state.currentCustomizeCss,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customize script
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize scripts
+   */
+  async updateCustomizeScript() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-script', {
+      customizeScript: this.state.currentCustomizeScript,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+}

+ 8 - 3
src/client/js/services/AdminGithubSecurityConatainer.js

@@ -21,6 +21,7 @@ export default class AdminGithubSecurityContainer extends Container {
 
     this.state = {
       appSiteUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/github/callback'),
+      isGitHubStrategySetup: false,
       githubClientId: '',
       githubClientSecret: '',
       isSameUsernameTreatedAsIdenticalUser: true,
@@ -35,6 +36,7 @@ export default class AdminGithubSecurityContainer extends Container {
     const response = await this.appContainer.apiv3.get('/security-setting/');
     const { githubOAuth } = response.data.securityParams;
     this.setState({
+      isGitHubStrategySetup: githubOAuth.isGitHubStrategySetup,
       githubClientId: githubOAuth.githubClientId || '',
       githubClientSecret: githubOAuth.githubClientSecret || '',
       isSameUsernameTreatedAsIdenticalUser: githubOAuth.isSameUsernameTreatedAsIdenticalUser || false,
@@ -80,10 +82,13 @@ export default class AdminGithubSecurityContainer extends Container {
       isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
     });
 
+    const { securitySettingParams } = response.data;
+
     this.setState({
-      githubClientId: this.state.githubClientId,
-      githubClientSecret: this.state.githubClientSecret,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      isGitHubStrategySetup: securitySettingParams.isGitHubStrategySetup,
+      githubClientId: securitySettingParams.githubClientId,
+      githubClientSecret: securitySettingParams.githubClientSecret,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
     });
     return response;
   }

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

@@ -21,6 +21,7 @@ export default class AdminGoogleSecurityContainer extends Container {
 
     this.state = {
       callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/google/callback'),
+      isGoogleStrategySetup: false,
       googleClientId: '',
       googleClientSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
@@ -36,6 +37,7 @@ export default class AdminGoogleSecurityContainer extends Container {
     const response = await this.appContainer.apiv3.get('/security-setting/');
     const { googleOAuth } = response.data.securityParams;
     this.setState({
+      isGoogleStrategySetup: googleOAuth.isGoogleStrategySetup,
       googleClientId: googleOAuth.googleClientId || '',
       googleClientSecret: googleOAuth.googleClientSecret || '',
       isSameUsernameTreatedAsIdenticalUser: googleOAuth.isSameUsernameTreatedAsIdenticalUser || false,
@@ -84,6 +86,7 @@ export default class AdminGoogleSecurityContainer extends Container {
     const { securitySettingParams } = response.data;
 
     this.setState({
+      isGoogleStrategySetup: securitySettingParams.isGoogleStrategySetup,
       googleClientId: securitySettingParams.googleClientId,
       googleClientSecret: securitySettingParams.googleClientSecret,
       isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,

+ 106 - 52
src/client/js/services/AdminLdapSecurityContainer.js

@@ -17,27 +17,42 @@ export default class AdminLdapSecurityContainer extends Container {
     this.appContainer = appContainer;
 
     this.state = {
-      // TODO GW-583 set value
       serverUrl: '',
-      bindMode: 'manager',
-      bindDN: '',
-      bindDNPassword: '',
-      searchFilter: '',
-      attrMapUsername: '',
-      cbSameUsernameTreatedAsIdenticalUser: true,
-      attrMapMail: '',
-      attrMapName: '',
-      groupSearchBase: '',
-      groupSearchFilter: '',
-      groupDnProperty: '',
+      isUserBind: false,
+      ldapBindDN: '',
+      ldapBindDNPassword: '',
+      ldapSearchFilter: '',
+      ldapAttrMapUsername: '',
+      isSameUsernameTreatedAsIdenticalUser: false,
+      ldapAttrMapMail: '',
+      ldapAttrMapName: '',
+      ldapGroupSearchBase: '',
+      ldapGroupSearchFilter: '',
+      ldapGroupDnProperty: '',
     };
 
-    this.init();
-
   }
 
-  init() {
-    // TODO GW-583 fetch config value with api
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    const response = await this.appContainer.apiv3.get('/security-setting/');
+    const { ldapAuth } = response.data.securityParams;
+    this.setState({
+      serverUrl: ldapAuth.serverUrl || '',
+      isUserBind: ldapAuth.isUserBind || false,
+      ldapBindDN: ldapAuth.ldapBindDN || '',
+      ldapBindDNPassword: ldapAuth.ldapBindDNPassword || '',
+      ldapSearchFilter: ldapAuth.ldapSearchFilter || '',
+      ldapAttrMapUsername: ldapAuth.ldapAttrMapUsername || '',
+      isSameUsernameTreatedAsIdenticalUser: ldapAuth.isSameUsernameTreatedAsIdenticalUser || false,
+      ldapAttrMapMail: ldapAuth.ldapAttrMapMail || '',
+      ldapAttrMapName: ldapAuth.ldapAttrMapName || '',
+      ldapGroupSearchBase: ldapAuth.ldapGroupSearchBase || '',
+      ldapGroupSearchFilter: ldapAuth.ldapGroupSearchFilter || '',
+      ldapGroupDnProperty: ldapAuth.ldapGroupDnProperty || '',
+    });
   }
 
 
@@ -49,87 +64,126 @@ export default class AdminLdapSecurityContainer extends Container {
   }
 
   /**
-   * Change server url
+   * Change serverUrl
+   */
+  changeServerUrl(serverUrl) {
+    this.setState({ serverUrl });
+  }
+
+  /**
+   * Change ldapBindMode
    */
-  changeServerUrl(inputValue) {
-    this.setState({ serverUrl: inputValue });
+  changeLdapBindMode() {
+    this.setState({ isUserBind: !this.state.isUserBind });
   }
 
   /**
-   * Change ldap bind mode
+   * Change bindDN
    */
-  changeLdapBindMode(mode) {
-    this.setState({ bindMode: mode });
+  changeBindDN(ldapBindDN) {
+    this.setState({ ldapBindDN });
   }
 
   /**
-   * Change bind DN
+   * Change bindDNPassword
    */
-  changeBindDN(inputValue) {
-    this.setState({ bindDN: inputValue });
+  changeBindDNPassword(ldapBindDNPassword) {
+    this.setState({ ldapBindDNPassword });
   }
 
   /**
-   * Change bind DN password
+   * Change ldapSearchFilter
    */
-  changeBindDNPassword(inputValue) {
-    this.setState({ bindDNPassword: inputValue });
+  changeSearchFilter(ldapSearchFilter) {
+    this.setState({ ldapSearchFilter });
   }
 
   /**
-   * Change search filter
+   * Change ldapAttrMapUsername
    */
-  changeSearchFilter(inputValue) {
-    this.setState({ searchFilter: inputValue });
+  changeAttrMapUsername(ldapAttrMapUsername) {
+    this.setState({ ldapAttrMapUsername });
   }
 
   /**
-   * Change attr map username
+   * Switch is same username treated as identical user
    */
-  changeAttrMapUsername(inputValue) {
-    this.setState({ attrMapUsername: inputValue });
+  switchIsSameUsernameTreatedAsIdenticalUser() {
+    this.setState({ isSameUsernameTreatedAsIdenticalUser: !this.state.isSameUsernameTreatedAsIdenticalUser });
   }
 
   /**
-   * Switch cb same username treated as identical user
+   * Change ldapAttrMapMail
    */
-  switchCbSameUsernameTreatedAsIdenticalUser() {
-    this.setState({ cbSameUsernameTreatedAsIdenticalUser: !this.state.cbSameUsernameTreatedAsIdenticalUser });
+  changeAttrMapMail(ldapAttrMapMail) {
+    this.setState({ ldapAttrMapMail });
   }
 
   /**
-   * Change attr map email
+   * Change ldapAttrMapName
    */
-  changeAttrMapMail(inputValue) {
-    this.setState({ attrMapMail: inputValue });
+  changeAttrMapName(ldapAttrMapName) {
+    this.setState({ ldapAttrMapName });
   }
 
   /**
-   * Change attr map name
+   * Change ldapGroupSearchBase
    */
-  changeAttrMapName(inputValue) {
-    this.setState({ attrMapName: inputValue });
+  changeGroupSearchBase(ldapGroupSearchBase) {
+    this.setState({ ldapGroupSearchBase });
   }
 
   /**
-   * Change group search base
+   * Change ldapGroupSearchFilter
    */
-  changeGroupSearchBase(inputValue) {
-    this.setState({ groupSearchBase: inputValue });
+  changeGroupSearchFilter(ldapGroupSearchFilter) {
+    this.setState({ ldapGroupSearchFilter });
   }
 
   /**
-   * Change group search filter
+   * Change ldapGroupDnProperty
    */
-  changeGroupSearchFilter(inputValue) {
-    this.setState({ groupSearchFilter: inputValue });
+  changeGroupDnProperty(ldapGroupDnProperty) {
+    this.setState({ ldapGroupDnProperty });
   }
 
   /**
-   * Change group dn property
+   * Update ldap option
    */
-  changeGroupDnProperty(inputValue) {
-    this.setState({ groupDnProperty: inputValue });
+  async updateLdapSetting() {
+
+    const response = await this.appContainer.apiv3.put('/security-setting/ldap', {
+      serverUrl: this.state.serverUrl,
+      isUserBind: this.state.isUserBind,
+      ldapBindDN: this.state.ldapBindDN,
+      ldapBindDNPassword: this.state.ldapBindDNPassword,
+      ldapSearchFilter: this.state.ldapSearchFilter,
+      ldapAttrMapUsername: this.state.ldapAttrMapUsername,
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      ldapAttrMapMail: this.state.ldapAttrMapMail,
+      ldapAttrMapName: this.state.ldapAttrMapName,
+      ldapGroupSearchBase: this.state.ldapGroupSearchBase,
+      ldapGroupSearchFilter: this.state.ldapGroupSearchFilter,
+      ldapGroupDnProperty: this.state.ldapGroupDnProperty,
+    });
+
+    const { securitySettingParams } = response.data;
+
+    this.setState({
+      serverUrl: securitySettingParams.serverUrl || '',
+      isUserBind: securitySettingParams.isUserBind || false,
+      ldapBindDN: securitySettingParams.ldapBindDN || '',
+      ldapBindDNPassword: securitySettingParams.ldapBindDNPassword || '',
+      ldapSearchFilter: securitySettingParams.ldapSearchFilter || '',
+      ldapAttrMapUsername: securitySettingParams.ldapAttrMapUsername || '',
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser || false,
+      ldapAttrMapMail: securitySettingParams.ldapAttrMapMail || '',
+      ldapAttrMapName: securitySettingParams.ldapAttrMapName || '',
+      ldapGroupSearchBase: securitySettingParams.ldapGroupSearchBase || '',
+      ldapGroupSearchFilter: securitySettingParams.ldapGroupSearchFilter || '',
+      ldapGroupDnProperty: securitySettingParams.ldapGroupDnProperty || '',
+    });
+    return response;
   }
 
 }

+ 49 - 14
src/client/js/services/MarkDownSettingContainer.js → src/client/js/services/AdminMarkDownContainer.js

@@ -1,10 +1,16 @@
 import { Container } from 'unstated';
 
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+const logger = loggerFactory('growi:services:AdminMarkdownContainer');
+
 /**
  * Service container for admin markdown setting page (MarkDownSetting.jsx)
  * @extends {Container} unstated Container
  */
-export default class MarkDownSettingContainer extends Container {
+export default class AdminMarkDownContainer extends Container {
 
   constructor(appContainer) {
     super();
@@ -12,16 +18,15 @@ export default class MarkDownSettingContainer extends Container {
     this.appContainer = appContainer;
 
     this.state = {
-      isEnabledLinebreaks: appContainer.config.isEnabledLinebreaks,
-      isEnabledLinebreaksInComments: appContainer.config.isEnabledLinebreaksInComments,
-      pageBreakSeparator: appContainer.config.pageBreakSeparator,
-      pageBreakCustomSeparator: appContainer.config.pageBreakCustomSeparator || '',
-      // pageBreakOption: appContainer.config.pageBreakOption,
-      customRegularExpression: appContainer.config.customRegularExpression || '',
-      isEnabledXss: (appContainer.config.xssOption != null),
-      xssOption: appContainer.config.xssOption,
-      tagWhiteList: appContainer.config.tagWhiteList || '',
-      attrWhiteList: appContainer.config.attrWhiteList || '',
+      retrieveError: null,
+      isEnabledLinebreaks: false,
+      isEnabledLinebreaksInComments: false,
+      pageBreakSeparator: 1,
+      pageBreakCustomSeparator: '',
+      isEnabledXss: false,
+      xssOption: 1,
+      tagWhiteList: '',
+      attrWhiteList: '',
     };
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
@@ -31,7 +36,33 @@ export default class MarkDownSettingContainer extends Container {
    * Workaround for the mangling in production build to break constructor.name
    */
   static getClassName() {
-    return 'MarkDownSettingContainer';
+    return 'AdminMarkDownContainer';
+  }
+
+  /**
+   * retrieve markdown data
+   */
+  async retrieveMarkdownData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/markdown-setting/');
+      const { markdownParams } = response.data;
+
+      this.setState({
+        isEnabledLinebreaks: markdownParams.isEnabledLinebreaks,
+        isEnabledLinebreaksInComments: markdownParams.isEnabledLinebreaksInComments,
+        pageBreakSeparator: markdownParams.pageBreakSeparator,
+        pageBreakCustomSeparator: markdownParams.pageBreakCustomSeparator || '',
+        isEnabledXss: markdownParams.isEnabledXss,
+        xssOption: markdownParams.xssOption,
+        tagWhiteList: markdownParams.tagWhiteList || '',
+        attrWhiteList: markdownParams.attrWhiteList || '',
+      });
+
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
   }
 
   /**
@@ -75,12 +106,16 @@ export default class MarkDownSettingContainer extends Container {
    * Update Xss Setting
    */
   async updateXssSetting() {
+    let { tagWhiteList, attrWhiteList } = this.state;
+
+    tagWhiteList = Array.isArray(tagWhiteList) ? tagWhiteList : tagWhiteList.split(',');
+    attrWhiteList = Array.isArray(attrWhiteList) ? attrWhiteList : attrWhiteList.split(',');
 
     const response = await this.appContainer.apiv3.put('/markdown-setting/xss', {
       isEnabledXss: this.state.isEnabledXss,
       xssOption: this.state.xssOption,
-      tagWhiteList: this.state.tagWhiteList,
-      attrWhiteList: this.state.attrWhiteList,
+      tagWhiteList,
+      attrWhiteList,
     });
 
     return response;

+ 59 - 8
src/client/js/services/AdminOidcSecurityContainer.js

@@ -1,6 +1,9 @@
 import { Container } from 'unstated';
 
 import loggerFactory from '@alias/logger';
+import { pathUtils } from 'growi-commons';
+
+import urljoin from 'url-join';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:security:AdminOidcSecurityContainer');
@@ -17,8 +20,7 @@ export default class AdminOidcSecurityContainer extends Container {
     this.appContainer = appContainer;
 
     this.state = {
-      // TODO GW-583 set value
-      callbackUrl: '',
+      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/oidc/callback'),
       oidcProviderName: '',
       oidcIssuerHost: '',
       oidcClientId: '',
@@ -27,16 +29,30 @@ export default class AdminOidcSecurityContainer extends Container {
       oidcAttrMapUserName: '',
       oidcAttrMapName: '',
       oidcAttrMapEmail: '',
-      isSameUsernameTreatedAsIdenticalUser: true,
-      isSameEmailTreatedAsIdenticalUser: true,
+      isSameUsernameTreatedAsIdenticalUser: false,
+      isSameEmailTreatedAsIdenticalUser: false,
     };
 
-    this.init();
-
   }
 
-  init() {
-    // TODO GW-583 fetch config value with api
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    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 || false,
+      isSameEmailTreatedAsIdenticalUser: oidcAuth.isSameEmailTreatedAsIdenticalUser || false,
+    });
   }
 
   /**
@@ -116,4 +132,39 @@ export default class AdminOidcSecurityContainer extends Container {
     this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
   }
 
+  /**
+   * Update OpenID Connect
+   */
+  async updateOidcSetting() {
+
+    const response = await this.appContainer.apiv3.put('/security-setting/oidc', {
+      oidcProviderName: this.state.oidcProviderName,
+      oidcIssuerHost: this.state.oidcIssuerHost,
+      oidcClientId: this.state.oidcClientId,
+      oidcClientSecret: this.state.oidcClientSecret,
+      oidcAttrMapId: this.state.oidcAttrMapId,
+      oidcAttrMapUserName: this.state.oidcAttrMapUserName,
+      oidcAttrMapName: this.state.oidcAttrMapName,
+      oidcAttrMapEmail: this.state.oidcAttrMapEmail,
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
+    });
+
+    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 || false,
+      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser || false,
+    });
+    return response;
+  }
+
 }

+ 92 - 48
src/client/js/services/AdminSamlSecurityContainer.js

@@ -1,6 +1,9 @@
 import { Container } from 'unstated';
 
 import loggerFactory from '@alias/logger';
+import { pathUtils } from 'growi-commons';
+
+import urljoin from 'url-join';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:security:AdminSamlSecurityContainer');
@@ -17,40 +20,45 @@ export default class AdminSamlSecurityContainer extends Container {
     this.appContainer = appContainer;
 
     this.state = {
-      // TODO GW-583 set value
       useOnlyEnvVars: false,
-      appSiteUrl: false,
-      callbackUrl: 'hoge.com',
+      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/saml/callback'),
       missingMandatoryConfigKeys: [],
-      samlDbEntryPoint: '',
-      samlEnvVarEntryPoint: '',
-      samlDbIssuer: '',
-      samlEnvVarIssuer: '',
-      samlDbCert: '',
-      samlEnvVarCert: '',
-      samlDbAttrMapId: '',
-      samlEnvVarAttrMapId: '',
-      samlDbAttrMapUserName: '',
-      samlEnvVarAttrMapUserName: '',
-      samlDbAttrMapMail: '',
-      samlEnvVarAttrMapMail: '',
-      samlDbAttrMapFirstName: '',
-      samlEnvVarAttrMapFirstName: '',
-      samlDbAttrMapLastName: '',
-      samlEnvVarAttrMapLastName: '',
+      samlEntryPoint: '',
+      samlIssuer: '',
+      samlCert: '',
+      samlAttrMapId: '',
+      samlAttrMapUserName: '',
+      samlAttrMapMail: '',
+      samlAttrMapFirstName: '',
+      samlAttrMapLastName: '',
       isSameUsernameTreatedAsIdenticalUser: false,
       isSameEmailTreatedAsIdenticalUser: false,
     };
 
-    this.init();
-
   }
 
-  init() {
-    // TODO GW-583 fetch config value with api
+  /**
+   * retrieve security data
+   */
+  async retrieveSecurityData() {
+    const response = await this.appContainer.apiv3.get('/security-setting/');
+    const { samlAuth } = response.data.securityParams;
+    this.setState({
+      missingMandatoryConfigKeys: samlAuth.missingMandatoryConfigKeys,
+      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 || false,
+      isSameEmailTreatedAsIdenticalUser: samlAuth.isSameEmailTreatedAsIdenticalUser || false,
+    });
+    return samlAuth;
   }
 
-
   /**
    * Workaround for the mangling in production build to break constructor.name
    */
@@ -59,59 +67,59 @@ export default class AdminSamlSecurityContainer extends Container {
   }
 
   /**
-   * Change samlDbEntryPoint
+   * Change samlEntryPoint
    */
-  changeSamlDbEntryPoint(inputValue) {
-    this.setState({ samlDbEntryPoint: inputValue });
+  changeSamlEntryPoint(inputValue) {
+    this.setState({ samlEntryPoint: inputValue });
   }
 
   /**
-   * Change samlDbIssuer
+   * Change samlIssuer
    */
-  changeSamlDbIssuer(inputValue) {
-    this.setState({ samlDbIssuer: inputValue });
+  changeSamlIssuer(inputValue) {
+    this.setState({ samlIssuer: inputValue });
   }
 
   /**
-   * Change samlDbCert
+   * Change samlCert
    */
-  changeSamlDbCert(inputValue) {
-    this.setState({ samlDbCert: inputValue });
+  changeSamlCert(inputValue) {
+    this.setState({ samlCert: inputValue });
   }
 
   /**
-   * Change samlDbAttrMapId
+   * Change samlAttrMapId
    */
-  changeSamlDbAttrMapId(inputValue) {
-    this.setState({ samlDbAttrMapId: inputValue });
+  changeSamlAttrMapId(inputValue) {
+    this.setState({ samlAttrMapId: inputValue });
   }
 
   /**
-   * Change samlDbAttrMapUserName
+   * Change samlAttrMapUserName
    */
-  changeSamlDbAttrMapUserName(inputValue) {
-    this.setState({ samlDbAttrMapUserName: inputValue });
+  changeSamlAttrMapUserName(inputValue) {
+    this.setState({ samlAttrMapUserName: inputValue });
   }
 
   /**
-   * Change samlDbAttrMapMail
+   * Change samlAttrMapMail
    */
-  changeSamlDbAttrMapMail(inputValue) {
-    this.setState({ samlDbAttrMapMail: inputValue });
+  changeSamlAttrMapMail(inputValue) {
+    this.setState({ samlAttrMapMail: inputValue });
   }
 
   /**
-   * Change samlDbAttrMapFirstName
+   * Change samlAttrMapFirstName
    */
-  changeSamlDbAttrMapFirstName(inputValue) {
-    this.setState({ samlDbAttrMapFirstName: inputValue });
+  changeSamlAttrMapFirstName(inputValue) {
+    this.setState({ samlAttrMapFirstName: inputValue });
   }
 
   /**
-   * Change samlDbAttrMapLastName
+   * Change samlAttrMapLastName
    */
-  changeSamlDbAttrMapLastName(inputValue) {
-    this.setState({ samlDbAttrMapLastName: inputValue });
+  changeSamlAttrMapLastName(inputValue) {
+    this.setState({ samlAttrMapLastName: inputValue });
   }
 
   /**
@@ -128,4 +136,40 @@ export default class AdminSamlSecurityContainer extends Container {
     this.setState({ isSameEmailTreatedAsIdenticalUser: !this.state.isSameEmailTreatedAsIdenticalUser });
   }
 
+  /**
+   * Update saml option
+   */
+  async updateSamlSetting() {
+
+    const response = await this.appContainer.apiv3.put('/security-setting/saml', {
+      samlEntryPoint: this.state.samlEntryPoint,
+      samlIssuer: this.state.samlIssuer,
+      samlCert: this.state.samlCert,
+      samlAttrMapId: this.state.samlAttrMapId,
+      samlAttrMapUserName: this.state.samlAttrMapUserName,
+      samlAttrMapMail: this.state.samlAttrMapMail,
+      samlAttrMapFirstName: this.state.samlAttrMapFirstName,
+      samlAttrMapLastName: this.state.samlAttrMapLastName,
+      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      isSameEmailTreatedAsIdenticalUser: this.state.isSameEmailTreatedAsIdenticalUser,
+    });
+
+    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 || false,
+      isSameEmailTreatedAsIdenticalUser: securitySettingParams.isSameEmailTreatedAsIdenticalUser || false,
+    });
+    return response;
+  }
+
 }

+ 8 - 3
src/client/js/services/AdminTwitterSecurityContainer.js

@@ -21,6 +21,7 @@ export default class AdminTwitterSecurityContainer extends Container {
 
     this.state = {
       callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/twitter/callback'),
+      isTwitterStrategySetup: false,
       twitterConsumerKey: '',
       twitterConsumerSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
@@ -35,6 +36,7 @@ export default class AdminTwitterSecurityContainer extends Container {
     const response = await this.appContainer.apiv3.get('/security-setting/');
     const { twitterOAuth } = response.data.securityParams;
     this.setState({
+      isTwitterStrategySetup: twitterOAuth.isTwitterStrategySetup,
       twitterConsumerKey: twitterOAuth.twitterConsumerKey || '',
       twitterConsumerSecret: twitterOAuth.twitterConsumerSecret || '',
       isSameUsernameTreatedAsIdenticalUser: twitterOAuth.isSameUsernameTreatedAsIdenticalUser || false,
@@ -80,10 +82,13 @@ export default class AdminTwitterSecurityContainer extends Container {
       isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
     });
 
+    const { securitySettingParams } = response.data;
+
     this.setState({
-      twitterConsumerKey: this.state.twitterConsumerKey,
-      twitterConsumerSecret: this.state.twitterConsumerSecret,
-      isSameUsernameTreatedAsIdenticalUser: this.state.isSameUsernameTreatedAsIdenticalUser,
+      isTwitterStrategySetup: securitySettingParams.isTwitterStrategySetup,
+      twitterConsumerKey: securitySettingParams.twitterConsumerKey,
+      twitterConsumerSecret: securitySettingParams.twitterConsumerSecret,
+      isSameUsernameTreatedAsIdenticalUser: securitySettingParams.isSameUsernameTreatedAsIdenticalUser,
     });
     return response;
   }

+ 48 - 0
src/client/js/services/UserGroupDetailContainer.js

@@ -24,10 +24,15 @@ export default class UserGroupDetailContainer extends Container {
       userGroupRelations: [],
       relatedPages: [],
       isUserGroupUserModalOpen: false,
+      searchType: 'partial',
+      isAlsoMailSearched: false,
+      isAlsoNameSearched: false,
     };
 
     this.init();
 
+    this.switchIsAlsoMailSearched = this.switchIsAlsoMailSearched.bind(this);
+    this.switchIsAlsoNameSearched = this.switchIsAlsoNameSearched.bind(this);
     this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
     this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
     this.addUserByUsername = this.addUserByUsername.bind(this);
@@ -65,6 +70,27 @@ export default class UserGroupDetailContainer extends Container {
     }
   }
 
+  /**
+   * switch isAlsoMailSearched
+   */
+  switchIsAlsoMailSearched() {
+    this.setState({ isAlsoMailSearched: !this.state.isAlsoMailSearched });
+  }
+
+  /**
+   * switch isAlsoNameSearched
+   */
+  switchIsAlsoNameSearched() {
+    this.setState({ isAlsoNameSearched: !this.state.isAlsoNameSearched });
+  }
+
+  /**
+   * switch searchType
+   */
+  switchSearchType(searchType) {
+    this.setState({ searchType });
+  }
+
   /**
    * update user group
    *
@@ -99,6 +125,24 @@ export default class UserGroupDetailContainer extends Container {
     await this.setState({ isUserGroupUserModalOpen: false });
   }
 
+  /**
+   * search user for invitation
+   * @param {string} username username of the user to be searched
+   */
+  async fetchApplicableUsers(searchWord) {
+    const res = await this.appContainer.apiv3.get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
+      searchWord,
+      searchType: this.state.searchType,
+      isAlsoMailSearched: this.state.isAlsoMailSearched,
+      isAlsoNameSearched: this.state.isAlsoNameSearched,
+    });
+
+    const { users } = res.data;
+
+    return users;
+  }
+
+
   /**
    * update user group
    *
@@ -107,6 +151,10 @@ export default class UserGroupDetailContainer extends Container {
    */
   async addUserByUsername(username) {
     const res = await this.appContainer.apiv3.post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
+
+    // do not add users for ducaplicate
+    if (res.data.userGroupRelation == null) { return }
+
     const { userGroupRelation } = res.data;
 
     this.setState((prevState) => {

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

@@ -29,6 +29,10 @@
     }
   }
 
+  .admin-setting-header {
+    border-bottom: 1px solid #dee2e6;
+  }
+
   .admin-security {
     .passport-logo {
       height: 32px;

+ 1 - 1
src/client/styles/scss/_on-edit.scss

@@ -181,7 +181,7 @@ body.on-edit {
       }
 
       // add icon on cursor
-      .autoformat-markdown-table-activated .CodeMirror-cursor {
+      .markdown-table-activated .CodeMirror-cursor {
         &:after {
           position: relative;
           top: -1.1em;

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

@@ -1,10 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[app:title]').trim(),
-  field('settingForm[app:confidential]'),
-  field('settingForm[app:globalLang]'),
-  field('settingForm[app:fileUpload]').trim().toBooleanStrict(),
-);

+ 0 - 11
src/server/form/admin/aws.js

@@ -1,11 +0,0 @@
-const form = require('express-form');
-
-const field = form.field;
-
-module.exports = form(
-  field('settingForm[aws:region]', 'リージョン').trim().is(/^[a-z]+-[a-z]+-\d+$/, 'リージョンには、AWSリージョン名を入力してください。 例: ap-northeast-1'),
-  field('settingForm[aws:customEndpoint]', 'カスタムエンドポイント').trim().is(/^(https?:\/\/[^/]+|)$/, 'カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。'),
-  field('settingForm[aws:bucket]', 'バケット名').trim(),
-  field('settingForm[aws:accessKeyId]', 'Access Key Id').trim().is(/^[\da-zA-Z]+$/),
-  field('settingForm[aws:secretAccessKey]', 'Secret Access Key').trim(),
-);

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

@@ -8,4 +8,5 @@ module.exports = form(
   field('settingForm[customize:isSavedStatesOfTabChanges]').trim().toBooleanStrict(),
   field('settingForm[customize:isEnabledAttachTitleHeader]').trim().toBooleanStrict(),
   field('settingForm[customize:showRecentCreatedNumber]').trim().toInt(),
+  field('settingForm[customize:isEnabledStaleNotification]').trim().toBooleanStrict(),
 );

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