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

Merge branch 'master' into imprv/options-selector-restyle

mos 5 лет назад
Родитель
Сommit
3d3063c8ac
100 измененных файлов с 4790 добавлено и 3642 удалено
  1. 57 0
      .github/workflows/list-unhealthy-branches.yml
  2. 35 0
      .github/workflows/prerelease.yml
  3. 1 9
      .github/workflows/release-rc.yml
  4. 13 12
      .github/workflows/release.yml
  5. 25 2
      CHANGES.md
  6. 154 0
      bin/github-actions/list-branches.js
  7. 1 9
      config/webpack.common.js
  8. 10 0
      config/webpack.dev.js
  9. 1 0
      config/webpack.prod.js
  10. 9 15
      docker/Dockerfile
  11. 4 4
      package.json
  12. 1 1
      resource/locales/en_US/admin/admin.json
  13. 14 19
      resource/locales/en_US/translation.json
  14. 1 1
      resource/locales/ja_JP/admin/admin.json
  15. 13 18
      resource/locales/ja_JP/translation.json
  16. 5 5
      resource/locales/zh_CN/admin/admin.json
  17. 10 20
      resource/locales/zh_CN/translation.json
  18. 3 0
      src/client/js/admin.jsx
  19. 6 2
      src/client/js/app.jsx
  20. 1 1
      src/client/js/base.jsx
  21. 20 23
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  22. 94 56
      src/client/js/components/Admin/App/MailSetting.jsx
  23. 51 58
      src/client/js/components/Admin/Customize/Customize.jsx
  24. 1 1
      src/client/js/components/Admin/ImportData/GrowiArchive/ErrorViewer.jsx
  25. 245 0
      src/client/js/components/Admin/ImportData/ImportDataPageContents.jsx
  26. 28 321
      src/client/js/components/Admin/ImportDataPage.jsx
  27. 25 52
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  28. 47 0
      src/client/js/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  29. 24 92
      src/client/js/components/Admin/Notification/NotificationSetting.jsx
  30. 98 0
      src/client/js/components/Admin/Notification/NotificationSettingContents.jsx
  31. 32 127
      src/client/js/components/Admin/Security/BasicSecuritySetting.jsx
  32. 127 0
      src/client/js/components/Admin/Security/BasicSecuritySettingContents.jsx
  33. 27 197
      src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx
  34. 198 0
      src/client/js/components/Admin/Security/GitHubSecuritySettingContents.jsx
  35. 27 207
      src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx
  36. 208 0
      src/client/js/components/Admin/Security/GoogleSecuritySettingContents.jsx
  37. 27 440
      src/client/js/components/Admin/Security/LdapSecuritySetting.jsx
  38. 446 0
      src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx
  39. 27 169
      src/client/js/components/Admin/Security/LocalSecuritySetting.jsx
  40. 193 0
      src/client/js/components/Admin/Security/LocalSecuritySettingContents.jsx
  41. 27 473
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  42. 476 0
      src/client/js/components/Admin/Security/OidcSecuritySettingContents.jsx
  43. 28 533
      src/client/js/components/Admin/Security/SamlSecuritySetting.jsx
  44. 516 0
      src/client/js/components/Admin/Security/SamlSecuritySettingContents.jsx
  45. 32 184
      src/client/js/components/Admin/Security/SecurityManagement.jsx
  46. 196 0
      src/client/js/components/Admin/Security/SecurityManagementContents.jsx
  47. 3 18
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  48. 29 206
      src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx
  49. 206 0
      src/client/js/components/Admin/Security/TwitterSecuritySettingContents.jsx
  50. 4 3
      src/client/js/components/Fab.jsx
  51. 4 2
      src/client/js/components/Hotkeys/HotkeysDetector.jsx
  52. 26 0
      src/client/js/components/Icons/CreatePageIcon.jsx
  53. 20 0
      src/client/js/components/Icons/MoonIcon.jsx
  54. 20 0
      src/client/js/components/Icons/ReturnTopIcon.jsx
  55. 20 0
      src/client/js/components/Icons/SidebarDockIcon.jsx
  56. 25 0
      src/client/js/components/Icons/SidebarDrawerIcon.jsx
  57. 28 0
      src/client/js/components/Icons/SunIcon.jsx
  58. 25 6
      src/client/js/components/Me/PasswordSettings.jsx
  59. 1 1
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  60. 33 15
      src/client/js/components/Navbar/PersonalDropdown.jsx
  61. 11 2
      src/client/js/components/Page/CopyDropdown.jsx
  62. 13 0
      src/client/js/components/Page/PageShareManagement.jsx
  63. 5 7
      src/client/js/components/Page/RevisionLoader.jsx
  64. 4 0
      src/client/js/components/PageDuplicateModal.jsx
  65. 6 2
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  66. 38 25
      src/client/js/components/PageEditor/EditorIcon.jsx
  67. 1 1
      src/client/js/components/PageEditor/LinkEditModal.jsx
  68. 55 144
      src/client/js/components/PageHistory.jsx
  69. 6 3
      src/client/js/components/PageHistory/PageRevisionList.jsx
  70. 2 0
      src/client/js/components/PageRenameModal.jsx
  71. 3 1
      src/client/js/components/ShareLinkForm.jsx
  72. 3 0
      src/client/js/components/ShareLinkList.jsx
  73. 10 8
      src/client/js/components/Sidebar.jsx
  74. 33 23
      src/client/js/components/SlackNotification.jsx
  75. 7 2
      src/client/js/components/StickyStretchableScroller.jsx
  76. 21 0
      src/client/js/components/SuspenseUtils.jsx
  77. 38 36
      src/client/js/services/AdminAppContainer.js
  78. 4 1
      src/client/js/services/AdminBasicSecurityContainer.js
  79. 4 1
      src/client/js/services/AdminCustomizeContainer.js
  80. 5 1
      src/client/js/services/AdminGeneralSecurityContainer.js
  81. 5 2
      src/client/js/services/AdminGitHubSecurityContainer.js
  82. 4 1
      src/client/js/services/AdminGoogleSecurityContainer.js
  83. 160 0
      src/client/js/services/AdminImportContainer.js
  84. 4 1
      src/client/js/services/AdminLdapSecurityContainer.js
  85. 4 1
      src/client/js/services/AdminLocalSecurityContainer.js
  86. 16 26
      src/client/js/services/AdminMarkDownContainer.js
  87. 15 26
      src/client/js/services/AdminNotificationContainer.js
  88. 4 1
      src/client/js/services/AdminOidcSecurityContainer.js
  89. 22 2
      src/client/js/services/AdminSamlSecurityContainer.js
  90. 4 1
      src/client/js/services/AdminTwitterSecurityContainer.js
  91. 162 0
      src/client/js/services/PageHistoryContainer.js
  92. 0 2
      src/client/js/services/PersonalContainer.js
  93. 1 1
      src/client/styles/scss/_editor-navbar.scss
  94. 7 4
      src/client/styles/scss/_layout.scss
  95. 10 2
      src/client/styles/scss/_navbar.scss
  96. 0 11
      src/client/styles/scss/_notification.scss
  97. 20 0
      src/client/styles/scss/_on-edit.scss
  98. 36 1
      src/client/styles/scss/_override-bootstrap-variables.scss
  99. 0 1
      src/client/styles/scss/style-app.scss
  100. 19 0
      src/client/styles/scss/theme/_apply-colors.scss

+ 57 - 0
.github/workflows/list-unhealthy-branches.yml

@@ -0,0 +1,57 @@
+name: List Unhealthy Branches
+
+on:
+  schedule:
+    - cron: '0 6 * * wed'
+
+jobs:
+  list:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+      with:
+        fetch-depth: 0
+
+    - uses: actions/setup-node@v2-beta
+      with:
+        node-version: '14'
+
+    - name: List branches
+      id: list-branches
+      run: |
+        export SLACK_ATTACHMENTS_ILLEGAL=`node bin/github-actions/list-branches --illegal`
+        export SLACK_ATTACHMENTS_INACTIVE=`node bin/github-actions/list-branches --inactive`
+        echo ::set-output name=SLACK_ATTACHMENTS_ILLEGAL::$SLACK_ATTACHMENTS_ILLEGAL
+        echo ::set-output name=SLACK_ATTACHMENTS_INACTIVE::$SLACK_ATTACHMENTS_INACTIVE
+        echo ::set-output name=SLACK_ATTACHMENTS_LENGTH_ILLEGAL::$(echo $SLACK_ATTACHMENTS_ILLEGAL | jq '. | length')
+        echo ::set-output name=SLACK_ATTACHMENTS_LENGTH_INACTIVE::$(echo $SLACK_ATTACHMENTS_INACTIVE | jq '. | length')
+
+    - name: Slack Notification for illegal named branches
+      if: steps.list-branches.outputs.SLACK_ATTACHMENTS_LENGTH_ILLEGAL > 0
+      uses: sonots/slack-notice-action@v3
+      env:
+        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+      with:
+        status: custom
+        payload: |
+          {
+            text: '<!channel> There is some *illegal named branches* on GitHub.',
+            channel: '#ci',
+            attachments: ${{ steps.list-branches.outputs.SLACK_ATTACHMENTS_ILLEGAL }}
+          }
+
+    - name: Slack Notification for inactive branches
+      if: steps.list-branches.outputs.SLACK_ATTACHMENTS_LENGTH_INACTIVE > 0
+      uses: sonots/slack-notice-action@v3
+      env:
+        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+      with:
+        status: custom
+        payload: |
+          {
+            text: '<!channel> There is some *illegal named branches* on GitHub.',
+            channel: '#ci',
+            attachments: ${{ steps.list-branches.outputs.SLACK_ATTACHMENTS_INACTIVE }}
+          }

+ 35 - 0
.github/workflows/prerelease.yml

@@ -0,0 +1,35 @@
+name: Pre Release
+
+on:
+  push:
+    branches:
+      - master
+
+jobs:
+  build-image-for-cache:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Set up Docker Buildx
+      uses: crazy-max/ghaction-docker-buildx@v3
+
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      id: cache
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-default
+        restore-keys: |
+          ${{ runner.os }}-buildx-
+
+    - name: Build Docker Image
+      run: |
+        docker buildx build \
+          --cache-from "type=local,src=/tmp/.buildx-cache" \
+          --cache-to "type=local,dest=/tmp/.buildx-cache" \
+          --platform linux/amd64 \
+          --load \
+          --file ./docker/Dockerfile .

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

@@ -14,16 +14,8 @@ jobs:
     steps:
     - uses: actions/checkout@v2
 
-    - 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
+      uses: crazy-max/ghaction-docker-buildx@v3
 
     - name: Login to docker.io registry
       run: |

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

@@ -71,31 +71,32 @@ 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
+      uses: crazy-max/ghaction-docker-buildx@v3
 
     - name: Login to docker.io registry
       run: |
         echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
+    - name: Cache Docker layers
+      uses: actions/cache@v2
+      id: cache
+      with:
+        path: /tmp/.buildx-cache
+        key: ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-${{ matrix.flavor }}
+        restore-keys: |
+          ${{ runner.OS }}-buildx-${{ hashFiles('**/yarn.lock') }}-
+          ${{ runner.OS }}-buildx-
+
     - name: Build Docker Image
       run: |
-        CACHE_REF=weseek/growi-cache:4${{ env.SUFFIX }}
         docker buildx build \
+          --cache-from "type=local,src=/tmp/.buildx-cache" \
+          --cache-to "type=local,dest=/tmp/.buildx-cache" \
           --tag growi${{ env.SUFFIX }} \
           --build-arg flavor=${{ matrix.flavor }} \
           --platform linux/amd64 \
           --load \
-          --cache-from=type=registry,ref=$CACHE_REF \
-          --cache-to=type=registry,ref=$CACHE_REF,mode=max \
           --file ./docker/Dockerfile .
 
     - name: Docker Tags by SemVer

+ 25 - 2
CHANGES.md

@@ -1,14 +1,37 @@
 # CHANGES
 
-## v4.1.1-RC
+## v4.1.3-RC
 
-* Feature: External share link
 * Feature: Create/edit linker with GUI
+* Improvement: Paging page histories
+* Improvement: Avoid using `cursor.snapshot()` in preparation for MongoDB version upgrade
+* Improvement: Allow to save "From e-mail address" only in App Settings
+* Improvement: Allow to empty "From e-mail address" in App Settings
+* Improvement: Export/Import archive data serially so as not to waste memory
+* Fix: To be able to delete attachment metadata even when the actual data does not exist
+* Fix: Limit the attrubutes of user data for `/_api/v3/users`
+* Fix: Prevent XSS with SVG
+* Upgrade libs
+    * optimize-css-assets-webpack-plugin
+    * terser-webpack-plugin
+
+## v4.1.2
+
+* Fix: Uploaded images do not displayed
+    * Introduced by v4.1.1
+
+## v4.1.1
+
+* Feature: External share link
 * Improvement: Optimize some features that operate revision data
     * Page history
     * Renaming pages
     * Deleting pages
+* Fix: Cmd+c/v/... does not work on Mac
+    * Introduced by v4.1.0
 * Fix: "Append params" switch of CopyDropdown does not work when multiple CopyDropdown instance exists
+* Fix: "Append params" switch of CopyDropdown escapes spaces
+* Fix: Blockdiag does not be rendered
 * Fix: Access token parser
 
 ## v4.1.0

+ 154 - 0
bin/github-actions/list-branches.js

@@ -0,0 +1,154 @@
+/* eslint-disable no-console */
+
+/*
+ * USAGE:
+ *  node list-branches [OPTION]
+ *
+ * OPTIONS:
+ *  --inactive : Return inactive branches (default)
+ *  --illegal : Return illegal named branches
+ */
+
+const { execSync } = require('child_process');
+const url = require('url');
+
+const EXCLUDE_TERM_DAYS = 14;
+const EXCLUDE_PATTERNS = [
+  /^feat\/custom-sidebar-2$/,
+  // https://regex101.com/r/Lnx7Pz/3
+  /^dev\/[\d.x]*$/,
+];
+const LEGAL_PATTERNS = [
+  /^master$/,
+  // https://regex101.com/r/p9xswM/4
+  /^(dev|feat|imprv|support|fix|rc|release|tmp)\/.+$/,
+];
+const GITHUB_REPOS_URI = 'https://github.com/weseek/growi/';
+
+class BranchSummary {
+
+  constructor(line) {
+    const splitted = line.split('\t'); // split with '%09'
+
+    this.authorDate = new Date(splitted[0].trim());
+    this.authorName = splitted[1].trim();
+    this.branchName = splitted[2].trim().replace(/^origin\//, '');
+    this.subject = splitted[3].trim();
+  }
+
+}
+
+function getExcludeTermDate() {
+  const date = new Date();
+  date.setDate(date.getDate() - EXCLUDE_TERM_DAYS);
+  return date;
+}
+
+function getBranchSummaries() {
+  // exec git for-each-ref
+  const out = execSync(`\
+    git for-each-ref refs/remotes \
+      --sort=-committerdate \
+      --format='%(authordate:iso) %09 %(authorname) %09 %(refname:short) %09 %(subject)'
+  `).toString();
+
+  // parse
+  const summaries = out
+    .split('\n')
+    .filter(v => v !== '') // trim empty string
+    .map(line => new BranchSummary(line))
+    .filter((summary) => { // exclude branches that matches to patterns
+      return !EXCLUDE_PATTERNS.some(pattern => pattern.test(summary.branchName));
+    });
+
+  return summaries;
+}
+
+function getGitHubCommitsUrl(branchName) {
+  return url.resolve(GITHUB_REPOS_URI, `commits/${branchName}`);
+}
+
+function getGitHubComparingLink(branchName) {
+  const label = `master &lt;- ${branchName}`;
+  const link = url.resolve(GITHUB_REPOS_URI, `compare/${branchName}`);
+  return `<${link}|${label}>`;
+}
+
+/**
+ * @see https://api.slack.com/messaging/composing/layouts#building-attachments
+ * @see https://github.com/marketplace/actions/slack-incoming-webhook
+ *
+ * @param {string} mode
+ * @param {BranchSummary} summaries
+ */
+function printSlackAttachments(mode, summaries) {
+  const color = (mode === 'illegal') ? 'warning' : '#999999';
+
+  const attachments = summaries.map((summary) => {
+    const {
+      authorName, authorDate, branchName, subject,
+    } = summary;
+
+    return {
+      color,
+      title: branchName,
+      title_link: getGitHubCommitsUrl(branchName),
+      fields: [
+        {
+          title: 'Author Date',
+          value: authorDate,
+          short: true,
+        },
+        {
+          title: 'Author',
+          value: authorName,
+          short: true,
+        },
+        {
+          title: 'Last Commit Subject',
+          value: subject,
+        },
+        {
+          title: 'Comparing Link',
+          value: getGitHubComparingLink(branchName),
+        },
+      ],
+    };
+  });
+
+  console.log(JSON.stringify(attachments));
+}
+
+async function main(mode) {
+  const summaries = getBranchSummaries();
+
+  let filteredSummaries;
+
+  switch (mode) {
+    case 'illegal':
+      filteredSummaries = summaries
+        .filter((summary) => { // exclude branches that matches to patterns
+          return !LEGAL_PATTERNS.some(pattern => pattern.test(summary.branchName));
+        });
+      break;
+    default: {
+      const excludeTermDate = getExcludeTermDate();
+      filteredSummaries = summaries
+        .filter((summary) => {
+          return summary.authorDate < excludeTermDate;
+        });
+      break;
+    }
+  }
+
+  printSlackAttachments(mode, filteredSummaries);
+}
+
+const args = process.argv.slice(2);
+
+let mode = 'inactive';
+if (args.length > 0 && args[0] === '--illegal') {
+  mode = 'illegal';
+}
+
+main(mode);

+ 1 - 9
config/webpack.common.js

@@ -8,7 +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');
 
 /*
@@ -123,14 +123,6 @@ 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,
       }),

+ 10 - 0
config/webpack.dev.js

@@ -7,6 +7,8 @@
  */
 const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
+const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
+
 const helpers = require('../src/lib/util/helpers');
 
 /**
@@ -61,6 +63,14 @@ module.exports = require('./webpack.common')({
       analyzerMode: ANALYZE ? 'server' : 'disabled',
     }),
 
+    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/,
+      },
+    ]),
+
   ],
   optimization: {},
   performance: {

+ 1 - 0
config/webpack.prod.js

@@ -67,6 +67,7 @@ module.exports = require('./webpack.common')({
 
   ],
   optimization: {
+    minimize: true,
     minimizer: [
       new TerserPlugin({}),
       new OptimizeCSSAssetsPlugin({}),

+ 9 - 15
docker/Dockerfile

@@ -12,20 +12,17 @@ LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi
 
-COPY ./package.json ${appDir}/
-COPY ./yarn.lock ${appDir}/
 WORKDIR ${appDir}
+COPY ./package.json ./
+COPY ./yarn.lock ./
 
 # setup
 RUN yarn config set network-timeout 300000
-RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn
+RUN yarn
 # install official plugins
-RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+RUN 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@1.0.0 react-motion
+RUN yarn add -D react-images@1.0.0 react-motion
 
 
 
@@ -35,8 +32,7 @@ RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
 FROM deps-resolver AS deps-resolver-prod
 
 # shrink dependencies for production
-RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
-  yarn install --production
+RUN yarn install --production
 
 
 
@@ -79,8 +75,7 @@ ENV appDir /opt/growi
 WORKDIR ${appDir}
 
 # build
-RUN --mount=type=cache,target=./node_modules/.cache \
-  yarn build:prod
+RUN yarn build:prod
 
 # remove except artifacts
 WORKDIR /tmp
@@ -92,14 +87,13 @@ WORKDIR ${appDir}
 ##
 ## release
 ##
-FROM node:12-alpine
+FROM node:14-alpine
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi
 
 # install tini
-RUN --mount=type=cache,target=/var/cache/apk \
-  apk add tini su-exec
+RUN apk add tini su-exec
 
 COPY docker/docker-entrypoint.sh /
 RUN chmod 700 /docker-entrypoint.sh

+ 4 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.1.1-RC",
+  "version": "4.1.3-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -204,7 +204,7 @@
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.1.1",
-    "markdown-it-drawio-viewer": "^1.3.0",
+    "markdown-it-drawio-viewer": "^1.3.1",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",
@@ -220,7 +220,7 @@
     "normalize-path": "^3.0.0",
     "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
-    "optimize-css-assets-webpack-plugin": "^5.0.0",
+    "optimize-css-assets-webpack-plugin": "^5.0.3",
     "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^3.0.0",
@@ -249,7 +249,7 @@
     "stylelint-config-recess-order": "^2.0.1",
     "swagger-jsdoc": "^3.4.0",
     "swagger2openapi": "^5.3.1",
-    "terser-webpack-plugin": "^2.0.1",
+    "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
     "unstated": "^2.1.1",

+ 1 - 1
resource/locales/en_US/admin/admin.json

@@ -210,7 +210,7 @@
     "export_collections": "Export Collections",
     "check_all": "Check All",
     "uncheck_all": "Uncheck All",
-    "desc_password_seed": "DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.<br><br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.",
+    "desc_password_seed": "<p>DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.<br><br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.</p>",
     "create_new_archive_data": "Create New Archive Data",
     "export": "Export",
     "cancel": "Cancel",

+ 14 - 19
resource/locales/en_US/translation.json

@@ -139,7 +139,7 @@
     "settings": "Settings",
     "color_mode": "Color mode",
     "sidebar_mode": "Sidebar mode",
-    "sidebar_mode_editor": "Sidebar mode on Editor",
+    "sidebar_mode_editor": "Sidebar mode on editor",
     "use_os_settings": "Use OS settings"
   },
   "form_validation": {
@@ -440,6 +440,10 @@
     "someone_editing": "Someone editing this page on HackMD",
     "this_page_has_draft": "This page has a draft on HackMD"
   },
+  "slack_notification": {
+    "popover_title": "Slack Notification",
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+  },
   "search_result": {
     "result_meta": "Found \"{{keyword}}\" in {{total}}.",
     "deletion_mode_btn_lavel": "Select and delete page",
@@ -707,29 +711,16 @@
     "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
     "rebuild_description_2": "This may take a while."
   },
-  "export_management": {
-    "exporting_collection_list": "Exporting Collection List",
-    "exported_data_list": "Exported Archive Data List",
-    "export_collections": "Export Collections",
-    "check_all": "Check All",
-    "uncheck_all": "Uncheck All",
-    "desc_password_seed": "<p>DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.</p><strong>HINT:</strong><p>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.</p>",
-    "create_new_archive_data": "Create New Archive Data",
-    "export": "Export",
-    "cancel": "Cancel",
-    "file": "File",
-    "growi_version": "Growi Version",
-    "collections": "Collections",
-    "exported_at": "Exported At",
-    "export_menu": "Export Menu",
-    "download": "Download",
-    "delete": "Delete"
-  },
   "login": {
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",
     "Setup": "Setup"
   },
+  "export_bulk": {
+    "failed_to_export": "Failed to export",
+    "export_page_markdown": "Export page as Markdown",
+    "export_page_pdf": "Export page as PDF"
+  },
   "message": {
     "successfully_connected": "Successfully Connected!",
     "fail_to_save_access_token": "Failed to save access_token. Please try again.",
@@ -753,5 +744,9 @@
     "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
+  },
+  "validation":{
+    "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
+    "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required."
   }
 }

+ 1 - 1
resource/locales/ja_JP/admin/admin.json

@@ -147,7 +147,7 @@
     "export_collections": "コレクションのエクスポート",
     "check_all": "全てにチェックを付ける",
     "uncheck_all": "全てからチェックを外す",
-    "desc_password_seed": "ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。",
+    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
     "create_new_archive_data": "アーカイブデータの新規作成",
     "export": "エクスポート",
     "cancel": "キャンセル",

+ 13 - 18
resource/locales/ja_JP/translation.json

@@ -442,6 +442,10 @@
     "someone_editing": "このページは、HackMD で編集されています。",
     "this_page_has_draft": "このページは、HackMD のドラフトがあります。"
   },
+  "slack_notification": {
+    "popover_title": "Slack 通知",
+    "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
+  },
   "search_result": {
     "result_meta": "{{total}}件のページが見つかりました。検索ワード: \"{{keyword}}\"",
     "deletion_mode_btn_lavel": "ページを指定して削除",
@@ -700,29 +704,16 @@
     "rebuild_description_1": "全てのページのインデックスを削除し、作り直します。",
     "rebuild_description_2": "この作業には数秒かかります。"
   },
-  "export_management": {
-    "exporting_collection_list": "エクスポート中のコレクション",
-    "exported_data_list": "エクスポートされたアーカイブリスト",
-    "export_collections": "コレクションのエクスポート",
-    "check_all": "全てにチェックを付ける",
-    "uncheck_all": "全てからチェックを外す",
-    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。</p><strong>ヒント:</strong><p>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
-    "create_new_archive_data": "アーカイブデータの新規作成",
-    "export": "エクスポート",
-    "cancel": "キャンセル",
-    "file": "ファイル名",
-    "growi_version": "Growi バージョン",
-    "collections": "コレクション",
-    "exported_at": "エクスポートされた時間",
-    "export_menu": "エクスポートメニュー",
-    "download": "ダウンロード",
-    "delete": "削除"
-  },
   "login": {
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
   },
+  "export_bulk": {
+    "failed_to_export": "ページのエクスポートに失敗しました",
+    "export_page_markdown": "マークダウン形式でページをエクスポート",
+    "export_page_pdf": "PDF形式でページをエクスポート"
+  },
   "message": {
     "successfully_connected": "接続に成功しました!",
     "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
@@ -746,5 +737,9 @@
     "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
+  },
+  "validation":{
+    "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
+    "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。"
   }
 }

+ 5 - 5
resource/locales/zh_CN/admin/admin.json

@@ -35,9 +35,9 @@
 		"host": "服务器",
 		"port": "端口号",
 		"user": "用户名",
-    "initialize_mail_settings": "初始化邮件设置",
-    "initialize_mail_modal_header": "初始化邮件设置",
-    "confirm_to_initialize_mail_settings": "You can't restore to the current settings. Are you sure you want to initialize e-mail settings?",
+    "initialize_mail_settings": "重置邮件设置",
+    "initialize_mail_modal_header": "重置邮件设置",
+    "confirm_to_initialize_mail_settings": "当前设置将被清空且不可恢复。确认重置?",
 		"aws_settings": "AWS设置",
 		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
 		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
@@ -225,7 +225,7 @@
 		"export_collections": "导出集合",
 		"check_all": "全部检查",
 		"uncheck_all": "全部取消选中",
-		"desc_password_seed": "DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.<br><br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.",
+		"desc_password_seed": "<p>还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。</p>",
 		"create_new_archive_data": "创建新的存档数据",
 		"export": "导出",
 		"cancel": "取消",
@@ -236,7 +236,7 @@
 		"export_menu": "导出菜单",
 		"download": "下载",
 		"delete": "删除"
-	},
+  },
 	"user_management": {
 		"invite_users": "邀请新用户",
 		"click_twice_same_checkbox": "您应该至少选中一个复选框。",

+ 10 - 20
resource/locales/zh_CN/translation.json

@@ -410,7 +410,11 @@
 		"not_initialized": "HackmdEditor component has not initialized",
 		"someone_editing": "Someone editing this page on HackMD",
 		"this_page_has_draft": "This page has a draft on HackMD"
-	},
+  },
+  "slack_notification": {
+    "popover_title": "Slack Notification",
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+  },
   "security_settings": "安全设置",
   "share_links": {
     "Shere this page link to public": "Shere this page link to public",
@@ -689,24 +693,6 @@
 		"rebuild_description_1": "单击按钮以重新生成索引并添加所有页面数据。",
 		"rebuild_description_2": "这可能需要一段时间。"
 	},
-	"export_management": {
-		"exporting_collection_list": "正在导出集合列表",
-		"exported_data_list": "导出的存档数据列表",
-		"export_collections": "导出集合",
-		"check_all": "全部选中",
-		"uncheck_all": "全部取消选中",
-		"desc_password_seed": "还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。",
-		"create_new_archive_data": "创建新的存档数据",
-		"export": "导出",
-		"cancel": "取消",
-		"file": "文件",
-		"growi_version": "Growi 版本号",
-		"collections": "收藏",
-		"exported_at": "导出在",
-		"export_menu": "导出菜单",
-		"download": "下载",
-		"delete": "删除"
-	},
 	"personal_dropdown": {
 		"home": "家",
 		"settings": "设置",
@@ -752,5 +738,9 @@
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
-	}
+  },
+  "validation":{
+    "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",
+    "aws_custom_endpoint": "关于自定义端点,请指定以http(s)://开头的URL,链接末尾不需要添加“/”"
+  }
 }

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

@@ -31,6 +31,7 @@ import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import AdminUserGroupDetailContainer from './services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
 import AdminAppContainer from './services/AdminAppContainer';
+import AdminImportContainer from './services/AdminImportContainer';
 import AdminMarkDownContainer from './services/AdminMarkDownContainer';
 import AdminExternalAccountsContainer from './services/AdminExternalAccountsContainer';
 import AdminGeneralSecurityContainer from './services/AdminGeneralSecurityContainer';
@@ -55,6 +56,7 @@ const { i18n } = appContainer;
 // create unstated container instance
 const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
+const adminImportContainer = new AdminImportContainer(appContainer);
 const adminSocketIoContainer = new AdminSocketIoContainer(appContainer);
 const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
@@ -67,6 +69,7 @@ const injectableContainers = [
   appContainer,
   navigationContainer,
   adminAppContainer,
+  adminImportContainer,
   adminSocketIoContainer,
   adminHomeContainer,
   adminCustomizeContainer,

+ 6 - 2
src/client/js/app.jsx

@@ -32,6 +32,7 @@ import TableOfContents from './components/TableOfContents';
 import PersonalSettings from './components/Me/PersonalSettings';
 import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
+import PageHistoryContainer from './services/PageHistoryContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
@@ -51,12 +52,13 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 // create unstated container instance
 const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
+const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, navigationContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
+  appContainer, socketIoContainer, navigationContainer, pageContainer, pageHistoryContainer, commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -141,7 +143,9 @@ $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
       <ErrorBoundary>
-        <PageHistory shareLinkId={pageContainer.state.shareLinkId} pageId={pageContainer.state.pageId} crowi={appContainer} />
+        <Provider inject={injectableContainers}>
+          <PageHistory />
+        </Provider>
       </ErrorBoundary>
     </I18nextProvider>, document.getElementById('revision-history'),
   );

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

@@ -46,10 +46,10 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
-  'share-link-alert': <ShareLinkAlert />,
   'grw-fab-container': <Fab />,
   'grw-hotkeys-manager': <HotkeysManager />,
 
+  'share-link-alert': <ShareLinkAlert />,
 };
 
 export { appContainer, componentMappings };

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

@@ -1,9 +1,11 @@
-import React, { Suspense } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import AdminAppContainer from '../../../services/AdminAppContainer';
 
@@ -11,44 +13,39 @@ import AppSettingsPageContents from './AppSettingsPageContents';
 
 const logger = loggerFactory('growi:appSettings');
 
+let retrieveErrors = null;
 function AppSettingsPage(props) {
-  return (
-    <Suspense
-      fallback={(
-        <div className="row">
-          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
-        </div>
-)}
-    >
-      <RenderAppSettingsPageWrapper />
-    </Suspense>
-  );
-}
-
-function RenderAppSettingsPage(props) {
   if (props.adminAppContainer.state.title === props.adminAppContainer.dummyTitle) {
-    throw new Promise(async() => {
+    throw (async() => {
       try {
         await props.adminAppContainer.retrieveAppSettingsData();
       }
       catch (err) {
-        toastError(err);
-        props.adminAppContainer.setState({ retrieveError: err.message });
-        logger.error(err);
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        logger.error(errs);
+        props.adminAppContainer.setState({
+          title: props.adminAppContainer.dummyTitleForError,
+        });
+        retrieveErrors = errs;
       }
-    });
+    })();
+  }
+
+  if (props.adminAppContainer.state.title === props.adminAppContainer.dummyTitleForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
   return <AppSettingsPageContents />;
 }
 
-RenderAppSettingsPage.propTypes = {
+AppSettingsPage.propTypes = {
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const RenderAppSettingsPageWrapper = withUnstatedContainers(RenderAppSettingsPage, [AdminAppContainer]);
+const AppSettingsPageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(AppSettingsPage), [AdminAppContainer]);
 
-export default AppSettingsPage;
+export default AppSettingsPageWithUnstatedContainer;

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

@@ -21,8 +21,15 @@ class MailSetting extends React.Component {
       isInitializeValueModalOpen: false,
     };
 
+    this.emailInput = React.createRef();
+    this.hostInput = React.createRef();
+    this.portInput = React.createRef();
+    this.userInput = React.createRef();
+    this.passwordInput = React.createRef();
+
     this.openInitializeValueModal = this.openInitializeValueModal.bind(this);
     this.closeInitializeValueModal = this.closeInitializeValueModal.bind(this);
+    this.submitFromAdressHandler = this.submitFromAdressHandler.bind(this);
     this.submitHandler = this.submitHandler.bind(this);
     this.initialize = this.initialize.bind(this);
   }
@@ -48,12 +55,30 @@ class MailSetting extends React.Component {
     }
   }
 
+  async submitFromAdressHandler() {
+    const { t, adminAppContainer } = this.props;
+
+    try {
+      await adminAppContainer.updateFromAdressHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.mail_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
   async initialize() {
     const { t, adminAppContainer } = this.props;
 
     try {
-      await adminAppContainer.initializeMailSettingHandler();
-      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.mail_settings') }));
+      const mailSettingParams = await adminAppContainer.initializeMailSettingHandler();
+      toastSuccess(t('toaster.initialize_successed', { target: t('admin:app_setting.smtp_settings') }));
+      // convert values to '' if value is null for overwriting values of inputs with refs
+      this.hostInput.current.value = mailSettingParams.smtpHost || '';
+      this.portInput.current.value = mailSettingParams.smtpPort || '';
+      this.userInput.current.value = mailSettingParams.smtpUser || '';
+      this.passwordInput.current.value = mailSettingParams.smtpPassword || '';
       this.closeInitializeValueModal();
     }
     catch (err) {
@@ -74,72 +99,85 @@ class MailSetting extends React.Component {
             <input
               className="form-control"
               type="text"
+              ref={this.emailInput}
               placeholder={`${t('eg')} mail@growi.org`}
               defaultValue={adminAppContainer.state.fromAddress || ''}
               onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
             />
           </div>
         </div>
-
-        <div className="row form-group mb-5">
-          <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.smtp_settings')}</label>
-          <div className="col-md-4">
-            <label>{t('admin: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-md-2">
-            <label>{t('admin:app_setting.port')}</label>
-            <input
-              className="form-control"
-              defaultValue={adminAppContainer.state.smtpPort || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
-            />
+        <div className="row my-3">
+          <div className="mx-auto">
+            <button type="button" className="btn btn-primary" onClick={this.submitFromAdressHandler}>{ t('Update') }</button>
           </div>
         </div>
-
-        <div className="row form-group mb-5">
-          <div className="col-md-3 offset-md-3">
-            <label>{t('admin: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-md-3">
-            <label>{t('Password')}</label>
-            <input
-              className="form-control"
-              type="password"
-              defaultValue={adminAppContainer.state.smtpPassword || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
-            />
+        <div id="mail-smtp" className="tab-pane active mt-5">
+          <div className="row form-group mb-5">
+            <label className="col-md-3 col-form-label text-left">{t('admin:app_setting.smtp_settings')}</label>
+            <div className="col-md-4">
+              <label>{t('admin:app_setting.host')}</label>
+              <input
+                className="form-control"
+                type="text"
+                ref={this.hostInput}
+                defaultValue={adminAppContainer.state.smtpHost || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+              />
+            </div>
+            <div className="col-md-2">
+              <label>{t('admin:app_setting.port')}</label>
+              <input
+                className="form-control"
+                ref={this.portInput}
+                defaultValue={adminAppContainer.state.smtpPort || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+              />
+            </div>
           </div>
-        </div>
 
-        <div className="row my-3">
-          <div className="offset-5">
-            <button type="button" className="btn btn-primary" onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
-              { t('Update') }
-            </button>
+          <div className="row form-group mb-5">
+            <div className="col-md-3 offset-md-3">
+              <label>{t('admin:app_setting.user')}</label>
+              <input
+                className="form-control"
+                type="text"
+                ref={this.userInput}
+                defaultValue={adminAppContainer.state.smtpUser || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+              />
+            </div>
+            <div className="col-md-3">
+              <label>{t('Password')}</label>
+              <input
+                className="form-control"
+                type="password"
+                ref={this.passwordInput}
+                defaultValue={adminAppContainer.state.smtpPassword || ''}
+                onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+              />
+            </div>
           </div>
-          <div className="offset-1">
-            <button
-              type="button"
-              className="btn btn-secondary"
-              onClick={this.openInitializeValueModal}
-              disabled={adminAppContainer.state.retrieveError != null}
-            >
-              {t('admin:app_setting.initialize_mail_settings')}
-            </button>
+
+          <div className="row my-3">
+            <div className="offset-5">
+              <button type="button" className="btn btn-primary" onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+                { t('Update') }
+              </button>
+            </div>
+            <div className="offset-1">
+              <button
+                type="button"
+                className="btn btn-secondary"
+                onClick={this.openInitializeValueModal}
+                disabled={adminAppContainer.state.retrieveError != null}
+              >
+                {t('admin:app_setting.initialize_mail_settings')}
+              </button>
+            </div>
           </div>
         </div>
+
+
         <Modal isOpen={this.state.isInitializeValueModalOpen} toggle={this.closeInitializeValueModal} className="initialize-mail-settings">
           <ModalHeader tag="h4" toggle={this.closeInitializeValueModal} className="bg-danger text-light">
             {t('admin:app_setting.initialize_mail_modal_header')}
@@ -153,7 +191,7 @@ class MailSetting extends React.Component {
                 {t('Cancel')}
               </button>
               <button type="button" className="btn btn-danger" onClick={this.initialize}>
-                {t('Initialize')}
+                {t('Reset')}
               </button>
             </div>
           </ModalBody>

+ 51 - 58
src/client/js/components/Admin/Customize/Customize.jsx

@@ -1,13 +1,14 @@
 
 import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
-import AppContainer from '../../../services/AppContainer';
+import loggerFactory from '@alias/logger';
 import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
 import CustomizeLayoutSetting from './CustomizeLayoutSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
@@ -17,70 +18,62 @@ import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeTitle from './CustomizeTitle';
 
-class Customize extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRetrieving: true,
-    };
-
-  }
-
-  async componentDidMount() {
-    const { adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.retrieveCustomizeData();
-      this.setState({ isRetrieving: false });
-    }
-    catch (err) {
-      toastError(err);
-    }
-
+const logger = loggerFactory('growi:services:AdminCustomizePage');
+
+let retrieveErrors = null;
+function Customize(props) {
+  const { adminCustomizeContainer } = props;
+
+  if (adminCustomizeContainer.state.currentTheme === adminCustomizeContainer.dummyCurrentTheme) {
+    throw (async() => {
+      try {
+        await adminCustomizeContainer.retrieveCustomizeData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        logger.error(errs);
+        retrieveErrors = errs;
+        adminCustomizeContainer.setState({ currentTheme: adminCustomizeContainer.dummyCurrentThemeForError });
+      }
+    })();
   }
 
-  render() {
-    if (this.state.isRetrieving) {
-      return null;
-    }
-
-    return (
-      <Fragment>
-        <div className="mb-5">
-          <CustomizeLayoutSetting />
-        </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>
-    );
+  if (adminCustomizeContainer.state.currentTheme === adminCustomizeContainer.dummyCurrentThemeForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return (
+    <Fragment>
+      <div className="mb-5">
+        <CustomizeLayoutSetting />
+      </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>
+  );
 }
 
-const CustomizeWrapper = withUnstatedContainers(Customize, [AppContainer, AdminCustomizeContainer]);
+const CustomizePageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(Customize), [AdminCustomizeContainer]);
 
 Customize.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 
-export default withTranslation()(CustomizeWrapper);
+export default CustomizePageWithUnstatedContainer;

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

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

+ 245 - 0
src/client/js/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -0,0 +1,245 @@
+import React, { Fragment } from 'react';
+import { withTranslation } from 'react-i18next';
+import PropTypes from 'prop-types';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import GrowiArchiveSection from './GrowiArchiveSection';
+
+import AdminImportContainer from '../../../services/AdminImportContainer';
+
+class ImportDataPageContents extends React.Component {
+
+  render() {
+    const { t, adminImportContainer } = this.props;
+
+    return (
+      <Fragment>
+        <GrowiArchiveSection />
+
+        <form
+          className="mt-5"
+          id="importerSettingFormEsa"
+          role="form"
+        >
+          <fieldset>
+            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'esa.io' })}</h2>
+            <table className="table table-bordered table-mapping">
+              <thead>
+                <tr>
+                  <th width="45%">esa.io</th>
+                  <th width="10%"></th>
+                  <th>GROWI</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <th>{t('Article')}</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{t('Page')}</th>
+                </tr>
+                <tr>
+                  <th>{t('Category')}</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{t('Page Path')}</th>
+                </tr>
+                <tr>
+                  <th>{t('User')}</th>
+                  <th></th>
+                  <th>(TBD)</th>
+                </tr>
+              </tbody>
+            </table>
+
+            <div className="card well mb-0 small">
+              <ul>
+                <li>{t('admin:importer_management.page_skip')}</li>
+              </ul>
+            </div>
+
+            <div className="form-group row">
+              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+            </div>
+
+            <div className="form-group row">
+              <label htmlFor="settingForm[importer:esa:team_name]" className="text-left text-md-right col-md-3 col-form-label">
+                {t('admin:importer_management.esa_settings.team_name')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="esaTeamName"
+                  value={adminImportContainer.state.esaTeamName}
+                  onChange={adminImportContainer.handleInputValue}
+                />
+              </div>
+
+            </div>
+
+            <div className="form-group row">
+              <label htmlFor="settingForm[importer:esa:access_token]" className="text-left text-md-right col-md-3 col-form-label">
+                {t('admin:importer_management.esa_settings.access_token')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="password"
+                  name="esaAccessToken"
+                  value={adminImportContainer.state.esaAccessToken}
+                  onChange={adminImportContainer.handleInputValue}
+                />
+              </div>
+            </div>
+
+            <div className="form-group row">
+              <div className="offset-md-3 col-md-6">
+                <input
+                  id="testConnectionToEsa"
+                  type="button"
+                  className="btn btn-primary btn-esa"
+                  name="Esa"
+                  onClick={adminImportContainer.esaHandleSubmit}
+                  value={t('admin:importer_management.import')}
+                />
+                <input type="button" className="btn btn-secondary" onClick={adminImportContainer.esaHandleSubmitUpdate} value={t('Update')} />
+                <span className="offset-0 offset-sm-1">
+                  <input
+                    id="importFromEsa"
+                    type="button"
+                    name="Esa"
+                    className="btn btn-secondary btn-esa"
+                    onClick={adminImportContainer.esaHandleSubmitTest}
+                    value={t('admin:importer_management.esa_settings.test_connection')}
+                  />
+                </span>
+
+              </div>
+            </div>
+          </fieldset>
+        </form>
+
+        <form
+          className="mt-5"
+          id="importerSettingFormQiita"
+          role="form"
+        >
+          <fieldset>
+            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'Qiita:Team' })}</h2>
+            <table className="table table-bordered table-mapping">
+              <thead>
+                <tr>
+                  <th width="45%">Qiita:Team</th>
+                  <th width="10%"></th>
+                  <th>GROWI</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <th>{t('Article')}</th>
+                  <th><i className="icon-arrow-right-circle text-success"></i></th>
+                  <th>{t('Page')}</th>
+                </tr>
+                <tr>
+                  <th>{t('Tag')}</th>
+                  <th></th>
+                  <th>-</th>
+                </tr>
+                <tr>
+                  <th>{t('admin:importer_management.Directory_hierarchy_tag')}</th>
+                  <th></th>
+                  <th>(TBD)</th>
+                </tr>
+                <tr>
+                  <th>{t('User')}</th>
+                  <th></th>
+                  <th>(TBD)</th>
+                </tr>
+              </tbody>
+            </table>
+            <div className="card well mb-0 small">
+              <ul>
+                <li>{t('admin:importer_management.page_skip')}</li>
+              </ul>
+            </div>
+
+            <div className="form-group row">
+              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+            </div>
+            <div className="form-group row">
+              <label htmlFor="settingForm[importer:qiita:team_name]" className="text-left text-md-right col-md-3 col-form-label">
+                {t('admin:importer_management.qiita_settings.team_name')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="qiitaTeamName"
+                  value={adminImportContainer.state.qiitaTeamName}
+                  onChange={adminImportContainer.handleInputValue}
+                />
+              </div>
+            </div>
+
+            <div className="form-group row">
+              <label htmlFor="settingForm[importer:qiita:access_token]" className="text-left text-md-right col-md-3 col-form-label">
+                {t('admin:importer_management.qiita_settings.access_token')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="password"
+                  name="qiitaAccessToken"
+                  value={adminImportContainer.state.qiitaAccessToken}
+                  onChange={adminImportContainer.handleInputValue}
+                />
+              </div>
+            </div>
+
+
+            <div className="form-group row">
+              <div className="offset-md-3 col-md-6">
+                <input
+                  id="testConnectionToQiita"
+                  type="button"
+                  className="btn btn-primary btn-qiita"
+                  name="Qiita"
+                  onClick={adminImportContainer.qiitaHandleSubmit}
+                  value={t('admin:importer_management.import')}
+                />
+                <input type="button" className="btn btn-secondary" onClick={adminImportContainer.qiitaHandleSubmitUpdate} value={t('Update')} />
+                <span className="offset-0 offset-sm-1">
+                  <input
+                    name="Qiita"
+                    type="button"
+                    id="importFromQiita"
+                    className="btn btn-secondary btn-qiita"
+                    onClick={adminImportContainer.qiitaHandleSubmitTest}
+                    value={t('admin:importer_management.qiita_settings.test_connection')}
+                  />
+                </span>
+
+              </div>
+            </div>
+
+
+          </fieldset>
+
+
+        </form>
+      </Fragment>
+    );
+  }
+
+}
+
+ImportDataPageContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired,
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContents, [AdminImportContainer]);
+
+export default withTranslation()(ImportDataPageContentsWrapper);

+ 28 - 321
src/client/js/components/Admin/ImportDataPage.jsx

@@ -1,345 +1,52 @@
-import React, { Fragment } from 'react';
-import { withTranslation } from 'react-i18next';
+import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import { toastSuccess, toastError } from '../../util/apiNotification';
+import toArrayIfNot from '../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../SuspenseUtils';
 
-import AppContainer from '../../services/AppContainer';
+import AdminImportContainer from '../../services/AdminImportContainer';
+import { toastError } from '../../util/apiNotification';
 
-import GrowiArchiveSection from './ImportData/GrowiArchiveSection';
+import ImportDataPageContents from './ImportData/ImportDataPageContents';
 
 const logger = loggerFactory('growi:importer');
 
-class ImportDataPage extends React.Component {
+let retrieveErrors = null;
+function ImportDataPage(props) {
+  const { adminImportContainer } = props;
 
-  constructor(props) {
-    super(props);
-    this.state = {
-      esaTeamName: '',
-      esaAccessToken: '',
-      qiitaTeamName: '',
-      qiitaAccessToken: '',
-    };
-
-    this.esaHandleSubmit = this.esaHandleSubmit.bind(this);
-    this.esaHandleSubmitTest = this.esaHandleSubmitTest.bind(this);
-    this.esaHandleSubmitUpdate = this.esaHandleSubmitUpdate.bind(this);
-    this.qiitaHandleSubmit = this.qiitaHandleSubmit.bind(this);
-    this.qiitaHandleSubmitTest = this.qiitaHandleSubmitTest.bind(this);
-    this.qiitaHandleSubmitUpdate = this.qiitaHandleSubmitUpdate.bind(this);
-    this.handleInputValue = this.handleInputValue.bind(this);
-  }
-
-  handleInputValue(event) {
-    this.setState({
-      [event.target.name]: event.target.value,
-    });
-  }
-
-  async esaHandleSubmit() {
-    try {
-      const params = {
-        'importer:esa:team_name': this.state.esaTeamName,
-        'importer:esa:access_token': this.state.esaAccessToken,
-      };
-      await this.props.appContainer.apiPost('/admin/import/esa', params);
-      toastSuccess('Import posts from esa success.');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err, 'Error occurred in importing pages from esa.io');
-    }
-  }
-
-  async esaHandleSubmitTest() {
-    try {
-      const params = {
-        'importer:esa:team_name': this.state.esaTeamName,
-        'importer:esa:access_token': this.state.esaAccessToken,
-      };
-      await this.props.appContainer.apiPost('/admin/import/testEsaAPI', params);
-      toastSuccess('Test connection to esa success.');
-    }
-    catch (error) {
-      toastError(error, 'Test connection to esa failed.');
-    }
-  }
-
-  async esaHandleSubmitUpdate() {
-    const params = {
-      'importer:esa:team_name': this.state.esaTeamName,
-      'importer:esa:access_token': this.state.esaAccessToken,
-    };
-    try {
-      await this.props.appContainer.apiPost('/admin/settings/importerEsa', params);
-      toastSuccess('Updated');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err, 'Errors');
-    }
+  if (adminImportContainer.state.esaTeamName === adminImportContainer.dummyEsaTeamName) {
+    throw (async() => {
+      try {
+        await adminImportContainer.retrieveImportSettingsData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        logger.error(errs);
+        retrieveErrors = errs;
+        adminImportContainer.setState({ esaTeamName: adminImportContainer.dummyEsaTeamNameForError });
+      }
+    })();
   }
 
-  async qiitaHandleSubmit() {
-    try {
-      const params = {
-        'importer:qiita:team_name': this.state.qiitaTeamName,
-        'importer:qiita:access_token': this.state.qiitaAccessToken,
-      };
-      await this.props.appContainer.apiPost('/admin/import/qiita', params);
-      toastSuccess('Import posts from qiita:team success.');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err, 'Error occurred in importing pages from qiita:team');
-    }
-  }
-
-
-  async qiitaHandleSubmitTest() {
-    try {
-      const params = {
-        'importer:qiita:team_name': this.state.qiitaTeamName,
-        'importer:qiita:access_token': this.state.qiitaAccessToken,
-      };
-      await this.props.appContainer.apiPost('/admin/import/testQiitaAPI', params);
-      toastSuccess('Test connection to qiita:team success.');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err, 'Test connection to qiita:team failed.');
-    }
-  }
-
-  async qiitaHandleSubmitUpdate() {
-    const params = {
-      'importer:qiita:team_name': this.state.qiitaTeamName,
-      'importer:qiita:access_token': this.state.qiitaAccessToken,
-    };
-    try {
-      await this.props.appContainer.apiPost('/admin/settings/importerQiita', params);
-      toastSuccess('Updated');
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(err, 'Errors');
-    }
-  }
-
-  render() {
-    const {
-      esaTeamName, esaAccessToken, qiitaTeamName, qiitaAccessToken,
-    } = this.state;
-    const { t } = this.props;
-    return (
-      <Fragment>
-        <GrowiArchiveSection />
-
-        <form
-          className="mt-5"
-          id="importerSettingFormEsa"
-          role="form"
-        >
-          <fieldset>
-            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'esa.io' })}</h2>
-            <table className="table table-bordered table-mapping">
-              <thead>
-                <tr>
-                  <th width="45%">esa.io</th>
-                  <th width="10%"></th>
-                  <th>GROWI</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('Article')}</th>
-                  <th><i className="icon-arrow-right-circle text-success"></i></th>
-                  <th>{t('Page')}</th>
-                </tr>
-                <tr>
-                  <th>{t('Category')}</th>
-                  <th><i className="icon-arrow-right-circle text-success"></i></th>
-                  <th>{t('Page Path')}</th>
-                </tr>
-                <tr>
-                  <th>{t('User')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-              </tbody>
-            </table>
-
-            <div className="card well mb-0 small">
-              <ul>
-                <li>{t('admin:importer_management.page_skip')}</li>
-              </ul>
-            </div>
-
-            <div className="form-group row">
-              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-            </div>
-
-            <div className="form-group row">
-              <label htmlFor="settingForm[importer:esa:team_name]" className="text-left text-md-right col-md-3 col-form-label">
-                { t('admin:importer_management.esa_settings.team_name') }
-              </label>
-              <div className="col-md-6">
-                <input className="form-control" type="text" name="esaTeamName" value={esaTeamName} onChange={this.handleInputValue} />
-              </div>
-
-            </div>
-
-            <div className="form-group row">
-              <label htmlFor="settingForm[importer:esa:access_token]" className="text-left text-md-right col-md-3 col-form-label">
-                { t('admin:importer_management.esa_settings.access_token') }
-              </label>
-              <div className="col-md-6">
-                <input className="form-control" type="password" name="esaAccessToken" value={esaAccessToken} onChange={this.handleInputValue} />
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6">
-                <input
-                  id="testConnectionToEsa"
-                  type="button"
-                  className="btn btn-primary btn-esa"
-                  name="Esa"
-                  onClick={this.esaHandleSubmit}
-                  value={t('admin:importer_management.import')}
-                />
-                <input type="button" className="btn btn-secondary" onClick={this.esaHandleSubmitUpdate} value={t('Update')} />
-                <span className="offset-0 offset-sm-1">
-                  <input
-                    id="importFromEsa"
-                    type="button"
-                    name="Esa"
-                    className="btn btn-secondary btn-esa"
-                    onClick={this.esaHandleSubmitTest}
-                    value={t('admin:importer_management.esa_settings.test_connection')}
-                  />
-                </span>
-
-              </div>
-            </div>
-          </fieldset>
-        </form>
-
-        <form
-          className="mt-5"
-          id="importerSettingFormQiita"
-          role="form"
-        >
-          <fieldset>
-            <h2 className="admin-setting-header">{t('admin:importer_management.import_from', { from: 'Qiita:Team' })}</h2>
-            <table className="table table-bordered table-mapping">
-              <thead>
-                <tr>
-                  <th width="45%">Qiita:Team</th>
-                  <th width="10%"></th>
-                  <th>GROWI</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('Article')}</th>
-                  <th><i className="icon-arrow-right-circle text-success"></i></th>
-                  <th>{t('Page')}</th>
-                </tr>
-                <tr>
-                  <th>{t('Tag')}</th>
-                  <th></th>
-                  <th>-</th>
-                </tr>
-                <tr>
-                  <th>{t('admin:importer_management.Directory_hierarchy_tag')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-                <tr>
-                  <th>{t('User')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-              </tbody>
-            </table>
-            <div className="card well mb-0 small">
-              <ul>
-                <li>{t('admin:importer_management.page_skip')}</li>
-              </ul>
-            </div>
-
-            <div className="form-group row">
-              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-            </div>
-            <div className="form-group row">
-              <label htmlFor="settingForm[importer:qiita:team_name]" className="text-left text-md-right col-md-3 col-form-label">
-                { t('admin:importer_management.qiita_settings.team_name') }
-              </label>
-              <div className="col-md-6">
-                <input className="form-control" type="text" name="qiitaTeamName" value={qiitaTeamName} onChange={this.handleInputValue} />
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label htmlFor="settingForm[importer:qiita:access_token]" className="text-left text-md-right col-md-3 col-form-label">
-                { t('admin:importer_management.qiita_settings.access_token') }
-              </label>
-              <div className="col-md-6">
-                <input className="form-control" type="password" name="qiitaAccessToken" value={qiitaAccessToken} onChange={this.handleInputValue} />
-              </div>
-            </div>
-
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6">
-                <input
-                  id="testConnectionToQiita"
-                  type="button"
-                  className="btn btn-primary btn-qiita"
-                  name="Qiita"
-                  onClick={this.qiitaHandleSubmit}
-                  value={t('admin:importer_management.import')}
-                />
-                <input type="button" className="btn btn-secondary" onClick={this.qiitaHandleSubmitUpdate} value={t('Update')} />
-                <span className="offset-0 offset-sm-1">
-                  <input
-                    name="Qiita"
-                    type="button"
-                    id="importFromQiita"
-                    className="btn btn-secondary btn-qiita"
-                    onClick={this.qiitaHandleSubmitTest}
-                    value={t('admin:importer_management.qiita_settings.test_connection')}
-                  />
-                </span>
-
-              </div>
-            </div>
-
-
-          </fieldset>
-
-
-        </form>
-      </Fragment>
-
-    );
+  if (adminImportContainer.state.esaTeamName === adminImportContainer.dummyEsaTeamNameForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <ImportDataPageContents />;
 }
 
 ImportDataPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired,
 };
 
 
 /**
  * Wrapper component for using unstated
  */
-const ImportDataPageWrapper = withUnstatedContainers(ImportDataPage, [AppContainer]);
-
+const ImportDataPageWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(ImportDataPage), [AdminImportContainer]);
 
-export default withTranslation()(ImportDataPageWrapper);
+export default ImportDataPageWithUnstatedContainer;

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

@@ -1,75 +1,48 @@
 import React from 'react';
-import { Card, CardBody } from 'reactstrap';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import AppContainer from '../../../services/AppContainer';
-import LineBreakForm from './LineBreakForm';
-import PresentationForm from './PresentationForm';
-import XssForm from './XssForm';
+import MarkDownSettingContents from './MarkDownSettingContents';
 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.message });
-      logger.error(err);
-    }
-
+let retrieveErrors = null;
+function MarkdownSetting(props) {
+  const { adminMarkDownContainer } = props;
+
+  if (adminMarkDownContainer.state.isEnabledLinebreaks === adminMarkDownContainer.dummyIsEnabledLinebreaks) {
+    throw (async() => {
+      try {
+        await adminMarkDownContainer.retrieveMarkdownData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        logger.error(errs);
+        retrieveErrors = errs;
+        adminMarkDownContainer.setState({ isEnabledLinebreaks: adminMarkDownContainer.dummyIsEnabledLinebreaksForError });
+      }
+    })();
   }
 
-  render() {
-    const { t } = this.props;
-
-    return (
-      <React.Fragment>
-        {/* Line Break Setting */}
-        <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
-        <Card className="card well my-3">
-          <CardBody className="px-0 py-2">{ t('admin:markdown_setting.lineBreak_desc') }</CardBody>
-        </Card>
-        <LineBreakForm />
-
-        {/* Presentation Setting */}
-        <h2 className="admin-setting-header">{ t('admin:markdown_setting.presentation_header') }</h2>
-        <Card className="card well my-3">
-          <CardBody className="px-0 py-2">{ t('admin:markdown_setting.presentation_desc') }</CardBody>
-        </Card>
-        <PresentationForm />
-
-        {/* XSS Setting */}
-        <h2 className="admin-setting-header">{ t('admin:markdown_setting.xss_header') }</h2>
-        <Card className="card well my-3">
-          <CardBody className="px-0 py-2">{ t('admin:markdown_setting.xss_desc') }</CardBody>
-        </Card>
-        <XssForm />
-      </React.Fragment>
-    );
+  if (adminMarkDownContainer.state.isEnabledLinebreaks === adminMarkDownContainer.dummyIsEnabledLinebreaksForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <MarkDownSettingContents />;
 }
 
-const MarkdownSettingWrapper = withUnstatedContainers(MarkdownSetting, [AppContainer, AdminMarkDownContainer]);
+const MarkdownSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(MarkdownSetting), [AdminMarkDownContainer]);
 
 MarkdownSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
-
 };
 
-export default withTranslation()(MarkdownSettingWrapper);
+export default MarkdownSettingWithUnstatedContainer;

+ 47 - 0
src/client/js/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx

@@ -0,0 +1,47 @@
+import React from 'react';
+import { Card, CardBody } from 'reactstrap';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import LineBreakForm from './LineBreakForm';
+import PresentationForm from './PresentationForm';
+import XssForm from './XssForm';
+
+
+class MarkDownSettingContents extends React.Component {
+
+  render() {
+    const { t } = this.props;
+    return (
+      <React.Fragment>
+        {/* Line Break Setting */}
+        <h2 className="admin-setting-header">{t('admin:markdown_setting.lineBreak_header')}</h2>
+        <Card className="card well my-3">
+          <CardBody className="px-0 py-2">{ t('admin:markdown_setting.lineBreak_desc') }</CardBody>
+        </Card>
+        <LineBreakForm />
+
+        {/* Presentation Setting */}
+        <h2 className="admin-setting-header">{ t('admin:markdown_setting.presentation_header') }</h2>
+        <Card className="card well my-3">
+          <CardBody className="px-0 py-2">{ t('admin:markdown_setting.presentation_desc') }</CardBody>
+        </Card>
+        <PresentationForm />
+
+        {/* XSS Setting */}
+        <h2 className="admin-setting-header">{ t('admin:markdown_setting.xss_header') }</h2>
+        <Card className="card well my-3">
+          <CardBody className="px-0 py-2">{ t('admin:markdown_setting.xss_desc') }</CardBody>
+        </Card>
+        <XssForm />
+      </React.Fragment>
+    );
+  }
+
+}
+
+MarkDownSettingContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(MarkDownSettingContents);

+ 24 - 92
src/client/js/components/Admin/Notification/NotificationSetting.jsx

@@ -1,116 +1,48 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  TabContent, TabPane, Nav, NavItem, NavLink,
-} from 'reactstrap';
 
 import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import AppContainer from '../../../services/AppContainer';
 import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
 
-import SlackAppConfiguration from './SlackAppConfiguration';
-import UserTriggerNotification from './UserTriggerNotification';
-import GlobalNotification from './GlobalNotification';
+import NotificationSettingContents from './NotificationSettingContents';
 
 const logger = loggerFactory('growi:NotificationSetting');
 
-class NotificationSetting extends React.Component {
-
-  constructor() {
-    super();
-
-    this.state = {
-      activeTab: 'slack-configuration',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['slack-configuration']),
-    };
-
-    this.toggleActiveTab = this.toggleActiveTab.bind(this);
+let retrieveErrors = null;
+function NotificationSetting(props) {
+  const { adminNotificationContainer } = props;
+  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrl) {
+    throw (async() => {
+      try {
+        await adminNotificationContainer.retrieveNotificationData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        logger.error(errs);
+        retrieveErrors = errs;
+        adminNotificationContainer.setState({ webhookUrl: adminNotificationContainer.dummyWebhookUrlForError });
+      }
+    })();
   }
 
-  async componentDidMount() {
-    const { adminNotificationContainer } = this.props;
-
-    try {
-      await adminNotificationContainer.retrieveNotificationData();
-    }
-    catch (err) {
-      toastError(err);
-      adminNotificationContainer.setState({ retrieveError: err });
-      logger.error(err);
-    }
-
-  }
-
-  toggleActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-  render() {
-    const { activeTab, activeComponents } = this.state;
-
-    return (
-      <React.Fragment>
-        <Nav tabs>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'slack-configuration' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('slack-configuration') }}
-              href="#slack-configuration"
-            >
-              <i className="icon-settings"></i> Slack configuration
-            </NavLink>
-          </NavItem>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'user-trigger-notification' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('user-trigger-notification') }}
-              href="#user-trigger-notification"
-            >
-              <i className="icon-settings"></i> User trigger notification
-            </NavLink>
-          </NavItem>
-          <NavItem>
-            <NavLink
-              className={`${activeTab === 'global-notification' && 'active'} `}
-              onClick={() => { this.toggleActiveTab('global-notification') }}
-              href="#global-notification"
-            >
-              <i className="icon-settings"></i> Global notification
-            </NavLink>
-          </NavItem>
-        </Nav>
-        <TabContent activeTab={activeTab}>
-          <TabPane tabId="slack-configuration">
-            {activeComponents.has('slack-configuration') && <SlackAppConfiguration />}
-          </TabPane>
-          <TabPane tabId="user-trigger-notification">
-            {activeComponents.has('user-trigger-notification') && <UserTriggerNotification />}
-          </TabPane>
-          <TabPane tabId="global-notification">
-            {activeComponents.has('global-notification') && <GlobalNotification />}
-          </TabPane>
-        </TabContent>
-      </React.Fragment>
-    );
+  if (adminNotificationContainer.state.webhookUrl === adminNotificationContainer.dummyWebhookUrlForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <NotificationSettingContents />;
 }
 
-const NotificationSettingWrapper = withUnstatedContainers(NotificationSetting, [AppContainer, AdminNotificationContainer]);
+const NotificationSettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(NotificationSetting), [AdminNotificationContainer]);
 
 NotificationSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-
 };
 
-export default withTranslation()(NotificationSettingWrapper);
+export default NotificationSettingWithUnstatedContainer;

+ 98 - 0
src/client/js/components/Admin/Notification/NotificationSettingContents.jsx

@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import {
+  TabContent, TabPane, Nav, NavItem, NavLink,
+} from 'reactstrap';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminNotificationContainer from '../../../services/AdminNotificationContainer';
+
+import SlackAppConfiguration from './SlackAppConfiguration';
+import UserTriggerNotification from './UserTriggerNotification';
+import GlobalNotification from './GlobalNotification';
+
+
+class NotificationSettingContents extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      activeTab: 'slack-configuration',
+      // Prevent unnecessary rendering
+      activeComponents: new Set(['slack-configuration']),
+    };
+
+    this.toggleActiveTab = this.toggleActiveTab.bind(this);
+  }
+
+  toggleActiveTab(activeTab) {
+    this.setState({
+      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
+    });
+  }
+
+  render() {
+    const { activeTab, activeComponents } = this.state;
+
+    return (
+      <React.Fragment>
+        <Nav tabs>
+          <NavItem>
+            <NavLink
+              className={`${activeTab === 'slack-configuration' && 'active'} `}
+              onClick={() => { this.toggleActiveTab('slack-configuration') }}
+              href="#slack-configuration"
+            >
+              <i className="icon-settings"></i> Slack configuration
+            </NavLink>
+          </NavItem>
+          <NavItem>
+            <NavLink
+              className={`${activeTab === 'user-trigger-notification' && 'active'} `}
+              onClick={() => { this.toggleActiveTab('user-trigger-notification') }}
+              href="#user-trigger-notification"
+            >
+              <i className="icon-settings"></i> User trigger notification
+            </NavLink>
+          </NavItem>
+          <NavItem>
+            <NavLink
+              className={`${activeTab === 'global-notification' && 'active'} `}
+              onClick={() => { this.toggleActiveTab('global-notification') }}
+              href="#global-notification"
+            >
+              <i className="icon-settings"></i> Global notification
+            </NavLink>
+          </NavItem>
+        </Nav>
+        <TabContent activeTab={activeTab}>
+          <TabPane tabId="slack-configuration">
+            {activeComponents.has('slack-configuration') && <SlackAppConfiguration />}
+          </TabPane>
+          <TabPane tabId="user-trigger-notification">
+            {activeComponents.has('user-trigger-notification') && <UserTriggerNotification />}
+          </TabPane>
+          <TabPane tabId="global-notification">
+            {activeComponents.has('global-notification') && <GlobalNotification />}
+          </TabPane>
+        </TabContent>
+      </React.Fragment>
+    );
+  }
+
+}
+
+const NotificationSettingContentsWrapper = withUnstatedContainers(NotificationSettingContents, [AppContainer, AdminNotificationContainer]);
+
+NotificationSettingContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
+
+};
+
+export default withTranslation()(NotificationSettingContentsWrapper);

+ 32 - 127
src/client/js/components/Admin/Security/BasicSecuritySetting.jsx

@@ -1,146 +1,51 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 import AdminBasicSecurityContainer from '../../../services/AdminBasicSecurityContainer';
 
-class BasicSecurityManagement extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRetrieving: true,
-    };
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async componentDidMount() {
-    const { adminBasicSecurityContainer } = this.props;
-
-    try {
-      await adminBasicSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.setState({ isRetrieving: false });
-  }
-
-  async onClickSubmit() {
-    const { t, adminBasicSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminBasicSecurityContainer.updateBasicSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.Basic.updated_basic'));
-    }
-    catch (err) {
-      toastError(err);
-    }
+import BasicSecurityManagementContents from './BasicSecuritySettingContents';
+
+let retrieveErrors = null;
+function BasicSecurityManagement(props) {
+  const { adminBasicSecurityContainer } = props;
+  if (adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser === adminBasicSecurityContainer.dummyIsSameUsernameTreatedAsIdenticalUser) {
+    throw (async() => {
+      try {
+        await adminBasicSecurityContainer.retrieveSecurityData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        retrieveErrors = errs;
+        adminBasicSecurityContainer.setState({
+          isSameUsernameTreatedAsIdenticalUser: adminBasicSecurityContainer.dummyIsSameUsernameTreatedAsIdenticalUser,
+        });
+
+      }
+    })();
   }
 
-  render() {
-    const { t, adminGeneralSecurityContainer, adminBasicSecurityContainer } = this.props;
-    const { isBasicEnabled } = adminGeneralSecurityContainer.state;
-
-    if (this.state.isRetrieving) {
-      return null;
-    }
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          { t('security_setting.Basic.name') }
-        </h2>
-
-        {this.state.retrieveError != null && (
-        <div className="alert alert-danger">
-          <p>{t('Error occurred')} : {this.state.err}</p>
-        </div>
-        )}
-
-        <div className="form-group row">
-          <div className="col-6 offset-3">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                id="isBasicEnabled"
-                className="custom-control-input"
-                type="checkbox"
-                checked={adminGeneralSecurityContainer.state.isBasicEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsBasicEnabled() }}
-              />
-              <label className="custom-control-label" htmlFor="isBasicEnabled">
-                { t('security_setting.Basic.enable_basic') }
-              </label>
-            </div>
-            <p className="form-text text-muted">
-              <small>
-                <span dangerouslySetInnerHTML={{ __html: t('security_setting.Basic.desc_1') }} /><br />
-                { t('security_setting.Basic.desc_2')}
-              </small>
-            </p>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('basic') && isBasicEnabled)
-            && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-        {isBasicEnabled && (
-        <React.Fragment>
-          <div className="row mb-5">
-            <div className="offset-md-3 col-md-6">
-              <div className="custom-control custom-checkbox custom-checkbox-success">
-                <input
-                  id="bindByEmail-basic"
-                  className="custom-control-input"
-                  type="checkbox"
-                  checked={adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
-                  onChange={() => { adminBasicSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                />
-                <label
-                  className="custom-control-label"
-                  htmlFor="bindByEmail-basic"
-                  dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical', 'username') }}
-                />
-              </div>
-              <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn', 'username') }} />
-              </p>
-            </div>
-          </div>
-
-          <div className="row my-3">
-            <div className="offset-4 col-5">
-              <button type="button" className="btn btn-primary" disabled={adminBasicSecurityContainer.state.retrieveError != null} onClick={this.onClickSubmit}>
-                {t('Update')}
-              </button>
-            </div>
-          </div>
-
-        </React.Fragment>
-        )}
-
-      </React.Fragment>
-    );
+  if (
+    adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser === adminBasicSecurityContainer.dummyIsSameUsernameTreatedAsIdenticalUserForError
+  ) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <BasicSecurityManagementContents />;
 }
 
 BasicSecurityManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,
 };
 
-const OidcSecurityManagementWrapper = withUnstatedContainers(
-  BasicSecurityManagement,
-  [AdminGeneralSecurityContainer, AdminBasicSecurityContainer],
-);
+const BasicSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(BasicSecurityManagement), [
+  AdminBasicSecurityContainer,
+]);
 
-export default withTranslation()(OidcSecurityManagementWrapper);
+export default BasicSecurityManagementWithUnstatedContainer;

+ 127 - 0
src/client/js/components/Admin/Security/BasicSecuritySettingContents.jsx

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

+ 27 - 197
src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx

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

+ 198 - 0
src/client/js/components/Admin/Security/GitHubSecuritySettingContents.jsx

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

+ 27 - 207
src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx

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

+ 208 - 0
src/client/js/components/Admin/Security/GoogleSecuritySettingContents.jsx

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

+ 27 - 440
src/client/js/components/Admin/Security/LdapSecuritySetting.jsx

@@ -1,458 +1,45 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import AppContainer from '../../../services/AppContainer';
-import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
-import LdapAuthTestModal from './LdapAuthTestModal';
 
-
-class LdapSecuritySetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRetrieving: true,
-      isLdapAuthTestModalShown: false,
-    };
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-    this.openLdapAuthTestModal = this.openLdapAuthTestModal.bind(this);
-    this.closeLdapAuthTestModal = this.closeLdapAuthTestModal.bind(this);
-  }
-
-  async componentDidMount() {
-    const { adminLdapSecurityContainer } = this.props;
-
-    try {
-      await adminLdapSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.setState({ isRetrieving: false });
-  }
-
-  async onClickSubmit() {
-    const { t, adminLdapSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminLdapSecurityContainer.updateLdapSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.ldap.updated_ldap'));
-    }
-    catch (err) {
-      toastError(err);
-    }
+import LdapSecuritySettingContents from './LdapSecuritySettingContents';
+
+let retrieveErrors = null;
+function LdapSecuritySetting(props) {
+  const { adminLdapSecurityContainer } = props;
+  if (adminLdapSecurityContainer.state.serverUrl === adminLdapSecurityContainer.dummyServerUrl) {
+    throw (async() => {
+      try {
+        await adminLdapSecurityContainer.retrieveSecurityData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        retrieveErrors = errs;
+        adminLdapSecurityContainer.setState({ serverUrl: adminLdapSecurityContainer.dummyServerUrlForError });
+      }
+    })();
   }
 
-  openLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: true });
-  }
-
-  closeLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: false });
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminLdapSecurityContainer } = this.props;
-    const { isLdapEnabled } = adminGeneralSecurityContainer.state;
-
-    if (this.state.isRetrieving) {
-      return null;
-    }
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          LDAP
-        </h2>
-
-        <div className="form-group row">
-          <div className="col-6 offset-3">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                id="isLdapEnabled"
-                className="custom-control-input"
-                type="checkbox"
-                checked={isLdapEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
-              />
-              <label className="custom-control-label" htmlFor="isLdapEnabled">
-                {t('security_setting.ldap.enable_ldap')}
-              </label>
-            </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isLdapEnabled)
-              && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-
-        {isLdapEnabled && (
-          <React.Fragment>
-
-            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
-
-            <div className="form-group row">
-              <label htmlFor="serverUrl" className="text-left text-md-right col-md-3 col-form-label">
-                Server URL
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="serverUrl"
-                  defaultValue={adminLdapSecurityContainer.state.serverUrl || ''}
-                  onChange={e => adminLdapSecurityContainer.changeServerUrl(e.target.value)}
-                />
-                <small>
-                  <p
-                    className="form-text text-muted"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.server_url_detail') }}
-                  />
-                  {t('security_setting.example')}: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
-                </small>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong>{t('security_setting.ldap.bind_mode')}</strong>
-              </label>
-              <div className="col-md-6">
-                <div className="dropdown">
-                  <button
-                    className="btn btn-outline-secondary dropdown-toggle"
-                    type="button"
-                    id="dropdownMenuButton"
-                    data-toggle="dropdown"
-                    aria-haspopup="true"
-                    aria-expanded="true"
-                  >
-                    {adminLdapSecurityContainer.state.isUserBind
-                        ? <span className="pull-left">{t('security_setting.ldap.bind_user')}</span>
-                        : <span className="pull-left">{t('security_setting.ldap.bind_manager')}</span>}
-                  </button>
-                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                    <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(true) }}>
-                      {t('security_setting.ldap.bind_user')}
-                    </button>
-                    <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(false) }}>
-                      {t('security_setting.ldap.bind_manager')}
-                    </button>
-                  </div>
-                </div>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong>Bind DN</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="bindDN"
-                  defaultValue={adminLdapSecurityContainer.state.ldapBindDN || ''}
-                  onChange={e => adminLdapSecurityContainer.changeBindDN(e.target.value)}
-                />
-                {(adminLdapSecurityContainer.state.isUserBind === true) ? (
-                  <p className="form-text text-muted 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="form-text text-muted passport-ldap-managerbind">
-                      <small>
-                        {t('security_setting.ldap.bind_DN_manager_detail')}<br />
-                        {t('security_setting.example')}1: <code>uid=admin,dc=domain,dc=com</code><br />
-                        {t('security_setting.example')}2: <code>admin@domain.com</code>
-                      </small>
-                    </p>
-                  )}
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <div htmlFor="bindDNPassword" className="text-left text-md-right col-md-3 col-form-label">
-                <strong>{t('security_setting.ldap.bind_DN_password')}</strong>
-              </div>
-              <div className="col-md-6">
-                {(adminLdapSecurityContainer.state.isUserBind) ? (
-                  <p className="well card passport-ldap-userbind">
-                    <small>
-                      {t('security_setting.ldap.bind_DN_password_user_detail')}
-                    </small>
-                  </p>
-                )
-                  : (
-                    <>
-                      <p className="well card passport-ldap-managerbind">
-                        <small>
-                          {t('security_setting.ldap.bind_DN_password_manager_detail')}
-                        </small>
-                      </p>
-                      <input
-                        className="form-control passport-ldap-managerbind"
-                        type="password"
-                        name="bindDNPassword"
-                        defaultValue={adminLdapSecurityContainer.state.ldapBindDNPassword || ''}
-                        onChange={e => adminLdapSecurityContainer.changeBindDNPassword(e.target.value)}
-                      />
-                    </>
-                  )}
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong>{t('security_setting.ldap.search_filter')}</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="searchFilter"
-                  defaultValue={adminLdapSecurityContainer.state.ldapSearchFilter || ''}
-                  onChange={e => adminLdapSecurityContainer.changeSearchFilter(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_setting.ldap.search_filter_detail1')}<br />
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.search_filter_detail2') }} /><br />
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.search_filter_detail3') }} />
-                  </small>
-                </p>
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_setting.example')}1 - {t('security_setting.ldap.search_filter_example1')}:
-                    <code>(|(uid={'{{ username }}'})(mail={'{{ username }}'}))</code><br />
-                    {t('security_setting.example')}2 - {t('security_setting.ldap.search_filter_example2')}:
-                    <code>(sAMAccountName={'{{ username }}'})</code>
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_setting.optional')})
-            </h3>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong htmlFor="attrMapUsername">{t('username')}</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: uid"
-                  name="attrMapUsername"
-                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapUsername || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapUsername(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.username_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6">
-                <div className="custom-control custom-checkbox custom-checkbox-success">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="isSameUsernameTreatedAsIdenticalUser"
-                    checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                    onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="custom-control-label"
-                    htmlFor="isSameUsernameTreatedAsIdenticalUser"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong htmlFor="attrMapMail">{t('Email')}</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: mail"
-                  name="attrMapMail"
-                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapMail || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapMail(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_setting.ldap.mail_detail')}
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong htmlFor="attrMapName">{t('Name')}</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="attrMapName"
-                  defaultValue={adminLdapSecurityContainer.state.ldapAttrMapName || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapName(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_setting.ldap.name_detail')}
-                  </small>
-                </p>
-              </div>
-            </div>
-
-
-            <h3 className="alert-anchor border-bottom">
-              {t('security_setting.ldap.group_search_filter')} ({t('security_setting.optional')})
-            </h3>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong htmlFor="groupSearchBase">{t('security_setting.ldap.group_search_base_DN')}</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="groupSearchBase"
-                  defaultValue={adminLdapSecurityContainer.state.ldapGroupSearchBase || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupSearchBase(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_base_DN_detail') }} /><br />
-                    {t('security_setting.example')}: <code>ou=groups,dc=domain,dc=com</code>
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong htmlFor="groupSearchFilter">{t('security_setting.ldap.group_search_filter')}</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="groupSearchFilter"
-                  defaultValue={adminLdapSecurityContainer.state.ldapGroupSearchFilter || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupSearchFilter(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {/* eslint-disable react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail1') }} /><br />
-                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail2') }} /><br />
-                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail3') }} />
-                    {/* eslint-enable react/no-danger */}
-                  </small>
-                </p>
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_setting.example')}:
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_filter_detail4') }} />
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <label className="text-left text-md-right col-md-3 col-form-label">
-                <strong htmlFor="groupDnProperty">{t('security_setting.ldap.group_search_user_DN_property')}</strong>
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: uid"
-                  name="groupDnProperty"
-                  defaultValue={adminLdapSecurityContainer.state.ldapGroupDnProperty || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupDnProperty(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.ldap.group_search_user_DN_property_detail') }} />
-                </p>
-              </div>
-            </div>
-            <div className="row my-3">
-              <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminLdapSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-                <button
-                  type="button"
-                  className="btn btn-outline-secondary ml-2"
-                  onClick={this.openLdapAuthTestModal}
-                >{t('security_setting.ldap.test_config')}
-                </button>
-              </div>
-            </div>
-
-          </React.Fragment>
-        )}
-
-
-        <LdapAuthTestModal isOpen={this.state.isLdapAuthTestModalShown} onClose={this.closeLdapAuthTestModal} />
-
-      </React.Fragment>
-    );
+  if (adminLdapSecurityContainer.state.serverUrl === adminLdapSecurityContainer.dummyServerUrlForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <LdapSecuritySettingContents />;
 }
 
 LdapSecuritySetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
 };
 
-const LdapSecuritySettingWrapper = withUnstatedContainers(LdapSecuritySetting, [AppContainer, AdminGeneralSecurityContainer, AdminLdapSecurityContainer]);
+const LdapSecuritySettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(LdapSecuritySetting), [
+  AdminLdapSecurityContainer,
+]);
 
-export default withTranslation()(LdapSecuritySettingWrapper);
+export default LdapSecuritySettingWithUnstatedContainer;

+ 446 - 0
src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx

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

+ 27 - 169
src/client/js/components/Admin/Security/LocalSecuritySetting.jsx

@@ -1,188 +1,46 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import AppContainer from '../../../services/AppContainer';
-import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '../../../services/AdminLocalSecurityContainer';
 
-class LocalSecuritySetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRetrieving: true,
-    };
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async componentDidMount() {
-    const { adminLocalSecurityContainer } = this.props;
-
-    try {
-      await adminLocalSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.setState({ isRetrieving: false });
+import LocalSecuritySettingContents from './LocalSecuritySettingContents';
+
+let retrieveErrors = null;
+function LocalSecuritySetting(props) {
+  const { adminLocalSecurityContainer } = props;
+  if (adminLocalSecurityContainer.state.registrationMode === adminLocalSecurityContainer.dummyRegistrationMode) {
+    throw (async() => {
+      try {
+        await adminLocalSecurityContainer.retrieveSecurityData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        retrieveErrors = errs;
+        adminLocalSecurityContainer.setState({ registrationMode: adminLocalSecurityContainer.dummyRegistrationModeForError });
+      }
+    })();
   }
 
-
-  async onClickSubmit() {
-    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    try {
-      await adminLocalSecurityContainer.updateLocalSecuritySetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode } = adminLocalSecurityContainer.state;
-    const { isLocalEnabled } = adminGeneralSecurityContainer.state;
-
-    if (this.state.isRetrieving) {
-      return null;
-    }
-
-    return (
-      <React.Fragment>
-        {this.state.retrieveError != null && (
-          <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {this.state.err}</p>
-          </div>
-        )}
-        <h2 className="alert-anchor border-bottom">
-          {t('security_setting.Local.name')}
-        </h2>
-
-        {adminLocalSecurityContainer.state.useOnlyEnvVars && (
-          <p
-            className="alert alert-info"
-            // eslint-disable-next-line max-len
-            dangerouslySetInnerHTML={{ __html: t('security_setting.Local.note for the only env option', { env: 'LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
-          />
-        )}
-
-        <div className="row mb-5">
-          <div className="col-6 offset-3">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                type="checkbox"
-                className="custom-control-input"
-                id="isLocalEnabled"
-                checked={isLocalEnabled}
-                onChange={() => adminGeneralSecurityContainer.switchIsLocalEnabled()}
-                disabled={adminLocalSecurityContainer.state.useOnlyEnvVars}
-              />
-              <label className="custom-control-label" htmlFor="isLocalEnabled">
-                {t('security_setting.Local.enable_local')}
-              </label>
-            </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && isLocalEnabled)
-              && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-        {isLocalEnabled && (
-          <React.Fragment>
-
-            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
-
-            <div className="row">
-              <div className="col-12 col-md-3 text-left text-md-right py-2">
-                <strong>{t('Register limitation')}</strong>
-              </div>
-              <div className="col-12 col-md-6">
-                <div className="dropdown">
-                  <button
-                    className="btn btn-outline-secondary dropdown-toggle"
-                    type="button"
-                    id="dropdownMenuButton"
-                    data-toggle="dropdown"
-                    aria-haspopup="true"
-                    aria-expanded="true"
-                  >
-                    {registrationMode === 'Open' && t('security_setting.registration_mode.open')}
-                    {registrationMode === 'Restricted' && t('security_setting.registration_mode.restricted')}
-                    {registrationMode === 'Closed' && t('security_setting.registration_mode.closed')}
-                  </button>
-                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                    <button className="dropdown-item" type="button" onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Open') }}>
-                      {t('security_setting.registration_mode.open')}
-                    </button>
-                    <button className="dropdown-item" type="button" onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Restricted') }}>
-                      {t('security_setting.registration_mode.restricted')}
-                    </button>
-                    <button className="dropdown-item" type="button" onClick={() => { adminLocalSecurityContainer.changeRegistrationMode('Closed') }}>
-                      {t('security_setting.registration_mode.closed')}
-                    </button>
-                  </div>
-                </div>
-
-                <p className="form-text text-muted small">
-                  {t('security_setting.Register limitation desc')}
-                </p>
-              </div>
-            </div>
-            <div className="row">
-              <div className="col-12 col-md-3 text-left text-md-right">
-                <strong dangerouslySetInnerHTML={{ __html: t('The whitelist of registration permission E-mail address') }} />
-              </div>
-              <div className="col-12 col-md-6">
-                <textarea
-                  className="form-control"
-                  type="textarea"
-                  name="registrationWhiteList"
-                  defaultValue={adminLocalSecurityContainer.state.registrationWhiteList.join('\n')}
-                  onChange={e => adminLocalSecurityContainer.changeRegistrationWhiteList(e.target.value)}
-                />
-                <p className="form-text text-muted small">{t('security_setting.restrict_emails')}<br />{t('security_setting.for_example')}
-                  <code>@growi.org</code>{t('security_setting.in_this_case')}<br />
-                  {t('security_setting.insert_single')}
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div className="offset-3 col-6">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminLocalSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-              </div>
-            </div>
-          </React.Fragment>
-        )}
-
-
-      </React.Fragment>
-    );
+  if (adminLocalSecurityContainer.state.registrationMode === adminLocalSecurityContainer.dummyRegistrationModeForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <LocalSecuritySettingContents />;
 }
 
 LocalSecuritySetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
 };
 
-const LocalSecuritySettingWrapper = withUnstatedContainers(LocalSecuritySetting, [AppContainer, AdminGeneralSecurityContainer, AdminLocalSecurityContainer]);
+const LocalSecuritySettingWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(LocalSecuritySetting), [
+  AdminLocalSecurityContainer,
+]);
 
-export default withTranslation()(LocalSecuritySettingWrapper);
+export default LocalSecuritySettingWithUnstatedContainer;

+ 193 - 0
src/client/js/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -0,0 +1,193 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import AdminLocalSecurityContainer from '../../../services/AdminLocalSecurityContainer';
+
+class LocalSecuritySettingContents extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
+    try {
+      await adminLocalSecurityContainer.updateLocalSecuritySetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_setting.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  render() {
+    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
+    const { registrationMode } = adminLocalSecurityContainer.state;
+    const { isLocalEnabled } = adminGeneralSecurityContainer.state;
+
+    return (
+      <React.Fragment>
+        {adminLocalSecurityContainer.state.retrieveError != null && (
+          <div className="alert alert-danger">
+            <p>
+              {t('Error occurred')} : {adminLocalSecurityContainer.state.retrieveError}
+            </p>
+          </div>
+        )}
+        <h2 className="alert-anchor border-bottom">{t('security_setting.Local.name')}</h2>
+
+        {adminLocalSecurityContainer.state.useOnlyEnvVars && (
+          <p
+            className="alert alert-info"
+            // eslint-disable-next-line max-len
+            dangerouslySetInnerHTML={{
+              __html: t('security_setting.Local.note for the only env option', { env: 'LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }),
+            }}
+          />
+        )}
+
+        <div className="row mb-5">
+          <div className="col-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="isLocalEnabled"
+                checked={isLocalEnabled}
+                onChange={() => adminGeneralSecurityContainer.switchIsLocalEnabled()}
+                disabled={adminLocalSecurityContainer.state.useOnlyEnvVars}
+              />
+              <label className="custom-control-label" htmlFor="isLocalEnabled">
+                {t('security_setting.Local.enable_local')}
+              </label>
+            </div>
+            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && isLocalEnabled && (
+              <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>
+            )}
+          </div>
+        </div>
+
+        {isLocalEnabled && (
+          <React.Fragment>
+            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
+
+            <div className="row">
+              <div className="col-12 col-md-3 text-left text-md-right py-2">
+                <strong>{t('Register limitation')}</strong>
+              </div>
+              <div className="col-12 col-md-6">
+                <div className="dropdown">
+                  <button
+                    className="btn btn-outline-secondary dropdown-toggle"
+                    type="button"
+                    id="dropdownMenuButton"
+                    data-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="true"
+                  >
+                    {registrationMode === 'Open' && t('security_setting.registration_mode.open')}
+                    {registrationMode === 'Restricted' && t('security_setting.registration_mode.restricted')}
+                    {registrationMode === 'Closed' && t('security_setting.registration_mode.closed')}
+                  </button>
+                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => {
+                        adminLocalSecurityContainer.changeRegistrationMode('Open');
+                      }}
+                    >
+                      {t('security_setting.registration_mode.open')}
+                    </button>
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => {
+                        adminLocalSecurityContainer.changeRegistrationMode('Restricted');
+                      }}
+                    >
+                      {t('security_setting.registration_mode.restricted')}
+                    </button>
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => {
+                        adminLocalSecurityContainer.changeRegistrationMode('Closed');
+                      }}
+                    >
+                      {t('security_setting.registration_mode.closed')}
+                    </button>
+                  </div>
+                </div>
+
+                <p className="form-text text-muted small">{t('security_setting.Register limitation desc')}</p>
+              </div>
+            </div>
+            <div className="row">
+              <div className="col-12 col-md-3 text-left text-md-right">
+                <strong dangerouslySetInnerHTML={{ __html: t('The whitelist of registration permission E-mail address') }} />
+              </div>
+              <div className="col-12 col-md-6">
+                <textarea
+                  className="form-control"
+                  type="textarea"
+                  name="registrationWhiteList"
+                  defaultValue={adminLocalSecurityContainer.state.registrationWhiteList.join('\n')}
+                  onChange={e => adminLocalSecurityContainer.changeRegistrationWhiteList(e.target.value)}
+                />
+                <p className="form-text text-muted small">
+                  {t('security_setting.restrict_emails')}
+                  <br />
+                  {t('security_setting.for_example')}
+                  <code>@growi.org</code>
+                  {t('security_setting.in_this_case')}
+                  <br />
+                  {t('security_setting.insert_single')}
+                </p>
+              </div>
+            </div>
+
+            <div className="row my-3">
+              <div className="offset-3 col-6">
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={adminLocalSecurityContainer.state.retrieveError != null}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('Update')}
+                </button>
+              </div>
+            </div>
+          </React.Fragment>
+        )}
+      </React.Fragment>
+    );
+  }
+
+}
+
+LocalSecuritySettingContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
+};
+
+const LocalSecuritySettingContentsWrapper = withUnstatedContainers(LocalSecuritySettingContents, [
+  AppContainer,
+  AdminGeneralSecurityContainer,
+  AdminLocalSecurityContainer,
+]);
+
+export default withTranslation()(LocalSecuritySettingContentsWrapper);

+ 27 - 473
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -1,492 +1,46 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '../../../util/apiNotification';
+import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
 
-import AppContainer from '../../../services/AppContainer';
-import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 import AdminOidcSecurityContainer from '../../../services/AdminOidcSecurityContainer';
 
-class OidcSecurityManagement extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRetrieving: true,
-    };
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async componentDidMount() {
-    const { adminOidcSecurityContainer } = this.props;
-
-    try {
-      await adminOidcSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.setState({ isRetrieving: false });
+import OidcSecurityManagementContents from './OidcSecuritySettingContents';
+
+let retrieveErrors = null;
+function OidcSecurityManagement(props) {
+  const { adminOidcSecurityContainer } = props;
+  if (adminOidcSecurityContainer.state.oidcProviderName === adminOidcSecurityContainer.dummyOidcProviderName) {
+    throw (async() => {
+      try {
+        await adminOidcSecurityContainer.retrieveSecurityData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        retrieveErrors = errs;
+        adminOidcSecurityContainer.setState({ oidcProviderName: adminOidcSecurityContainer.dummyOidcProviderNameForError });
+      }
+    })();
   }
 
-  async onClickSubmit() {
-    const { t, adminOidcSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminOidcSecurityContainer.updateOidcSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.OAuth.OIDC.updated_oidc'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminOidcSecurityContainer } = this.props;
-    const { isOidcEnabled } = adminGeneralSecurityContainer.state;
-
-    if (this.state.isRetrieving) {
-      return null;
-    }
-
-    return (
-
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          {t('security_setting.OAuth.OIDC.name')}
-        </h2>
-
-        <div className="row mb-5 form-group">
-          <div className="offset-3 col-6">
-            <div className="custom-control custom-switch custom-checkbox-success">
-              <input
-                id="isOidcEnabled"
-                className="custom-control-input"
-                type="checkbox"
-                checked={adminGeneralSecurityContainer.state.isOidcEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
-              />
-              <label className="custom-control-label" htmlFor="isOidcEnabled">
-                {t('security_setting.OAuth.enable_oidc')}
-              </label>
-            </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('oidc') && isOidcEnabled)
-              && <div className="badge badge-warning">{t('security_setting.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-        <div className="row mb-5 form-group">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.callback_URL')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              value={adminOidcSecurityContainer.state.callbackUrl}
-              readOnly
-            />
-            <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
-              <div className="alert alert-danger">
-                <i
-                  className="icon-exclamation"
-                  // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('security_setting.alert_siteUrl_is_not_set', { link: `<a href="/admin/app">${t('App Settings')}<i class="icon-login"></i></a>` }) }}
-                />
-              </div>
-            )}
-          </div>
-        </div>
-
-        {isOidcEnabled && (
-          <React.Fragment>
-
-            <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcProviderName" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.providerName')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcProviderName"
-                  defaultValue={adminOidcSecurityContainer.state.oidcProviderName || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcProviderName(e.target.value)}
-                />
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcIssuerHost" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.issuerHost')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcIssuerHost"
-                  defaultValue={adminOidcSecurityContainer.state.oidcIssuerHost || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcIssuerHost(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_ISSUER_HOST' }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcClientId" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.clientID')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcClientId"
-                  defaultValue={adminOidcSecurityContainer.state.oidcClientId || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcClientId(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_ID' }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcClientSecret" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.client_secret')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcClientSecret"
-                  defaultValue={adminOidcSecurityContainer.state.oidcClientSecret || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcClientSecret(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_SECRET' }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcAuthorizationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
-                {t('security_setting.authorization_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAuthorizationEndpoint"
-                  defaultValue={adminOidcSecurityContainer.state.oidcAuthorizationEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAuthorizationEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcTokenEndpoint" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.token_endpoint')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcTokenEndpoint"
-                  defaultValue={adminOidcSecurityContainer.state.oidcTokenEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcTokenEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcRevocationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
-                {t('security_setting.revocation_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcRevocationEndpoint"
-                  defaultValue={adminOidcSecurityContainer.state.oidcRevocationEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcRevocationEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcIntrospectionEndpoint" className="text-left text-md-right col-md-3 col-form-label">
-                {t('security_setting.introspection_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcIntrospectionEndpoint"
-                  defaultValue={adminOidcSecurityContainer.state.oidcIntrospectionEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcIntrospectionEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcUserInfoEndpoint" className="text-left text-md-right col-md-3 col-form-label">
-                {t('security_setting.userinfo_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcUserInfoEndpoint"
-                  defaultValue={adminOidcSecurityContainer.state.oidcUserInfoEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcUserInfoEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcEndSessionEndpoint" className="text-left text-md-right col-md-3 col-form-label">
-                {t('security_setting.end_session_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcEndSessionEndpoint"
-                  defaultValue={adminOidcSecurityContainer.state.oidcEndSessionEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcEndSessionEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcRegistrationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
-                {t('security_setting.registration_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcRegistrationEndpoint"
-                  defaultValue={adminOidcSecurityContainer.state.oidcRegistrationEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcRegistrationEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcJWKSUri" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.jwks_uri')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcJWKSUri"
-                  defaultValue={adminOidcSecurityContainer.state.oidcJWKSUri || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcJWKSUri(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_setting.optional')})
-            </h3>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcAttrMapId" className="text-left text-md-right col-md-3 col-form-label">Identifier</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapId"
-                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapId || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapId(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.id_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcAttrMapUserName" className="text-left text-md-right col-md-3 col-form-label">{t('username')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapUserName"
-                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapUserName || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapUserName(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.username_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcAttrMapName" className="text-left text-md-right col-md-3 col-form-label">{t('Name')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapName"
-                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapName || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapName(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.name_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label htmlFor="oidcAttrMapEmail" className="text-left text-md-right col-md-3 col-form-label">{t('Email')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapEmail"
-                  defaultValue={adminOidcSecurityContainer.state.oidcAttrMapEmail || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapEmail(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.mapping_detail', { target: t('Email') }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <label className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.callback_URL')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  defaultValue={adminOidcSecurityContainer.state.callbackUrl || ''}
-                  readOnly
-                />
-                <p className="form-text text-muted 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-5 form-group">
-              <div className="offset-md-3 col-md-6">
-                <div className="custom-control custom-checkbox custom-checkbox-success">
-                  <input
-                    id="bindByUserName-oidc"
-                    className="custom-control-input"
-                    type="checkbox"
-                    checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                    onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="custom-control-label"
-                    htmlFor="bindByUserName-oidc"
-                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat username matching as identical_warn') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-5 form-group">
-              <div className="offset-md-3 col-md-6">
-                <div className="custom-control custom-checkbox custom-checkbox-success">
-                  <input
-                    id="bindByEmail-oidc"
-                    className="custom-control-input"
-                    type="checkbox"
-                    checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
-                    onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="custom-control-label"
-                    htmlFor="bindByEmail-oidc"
-                    dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.Treat email matching as identical_warn') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminOidcSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-              </div>
-            </div>
-          </React.Fragment>
-        )}
-
-
-        <hr />
-
-        <div style={{ minHeight: '300px' }}>
-          <h4>
-            <i className="icon-question" aria-hidden="true" />
-            <a href="#collapseHelpForOidcOauth" data-toggle="collapse"> {t('security_setting.OAuth.how_to.oidc')}</a>
-          </h4>
-          <ol id="collapseHelpForOidcOauth" className="collapse">
-            <li>{t('security_setting.OAuth.OIDC.register_1')}</li>
-            <li>{t('security_setting.OAuth.OIDC.register_2')}</li>
-            <li>{t('security_setting.OAuth.OIDC.register_3')}</li>
-          </ol>
-        </div>
-
-      </React.Fragment>
-    );
+  if (adminOidcSecurityContainer.state.oidcProviderName === adminOidcSecurityContainer.dummyOidcProviderNameForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <OidcSecurityManagementContents />;
 }
 
 OidcSecurityManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
 };
 
-const OidcSecurityManagementWrapper = withUnstatedContainers(OidcSecurityManagement, [AppContainer, AdminGeneralSecurityContainer, AdminOidcSecurityContainer]);
+const OidcSecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(OidcSecurityManagement), [
+  AdminOidcSecurityContainer,
+]);
 
-export default withTranslation()(OidcSecurityManagementWrapper);
+export default OidcSecurityManagementWithUnstatedContainer;

+ 476 - 0
src/client/js/components/Admin/Security/OidcSecuritySettingContents.jsx

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

+ 28 - 533
src/client/js/components/Admin/Security/SamlSecuritySetting.jsx

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

+ 516 - 0
src/client/js/components/Admin/Security/SamlSecuritySettingContents.jsx

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

+ 32 - 184
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -1,197 +1,45 @@
-import React, { Fragment } from 'react';
+import React from 'react';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  TabContent, TabPane, Nav, NavItem, NavLink,
-} from 'reactstrap';
+import { toastError } from '../../../util/apiNotification';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import AppContainer from '../../../services/AppContainer';
-import LdapSecuritySetting from './LdapSecuritySetting';
-import LocalSecuritySetting from './LocalSecuritySetting';
-import SamlSecuritySetting from './SamlSecuritySetting';
-import OidcSecuritySetting from './OidcSecuritySetting';
-import SecuritySetting from './SecuritySetting';
-import BasicSecuritySetting from './BasicSecuritySetting';
-import GoogleSecuritySetting from './GoogleSecuritySetting';
-import GitHubSecuritySetting from './GitHubSecuritySetting';
-import TwitterSecuritySetting from './TwitterSecuritySetting';
-import FacebookSecuritySetting from './FacebookSecuritySetting';
-import ShareLinkSetting from './ShareLinkSetting';
-
-class SecurityManagement extends React.Component {
-
-  constructor() {
-    super();
-
-    this.state = {
-      activeTab: 'passport-local',
-      // Prevent unnecessary rendering
-      activeComponents: new Set(['passport-local']),
-    };
-
-    this.toggleActiveTab = this.toggleActiveTab.bind(this);
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+import { withLoadingSppiner } from '../../SuspenseUtils';
+
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+import SecurityManagementContents from './SecurityManagementContents';
+
+let retrieveErrors = null;
+function SecurityManagement(props) {
+  const { adminGeneralSecurityContainer } = props;
+
+  if (adminGeneralSecurityContainer.state.currentRestrictGuestMode === adminGeneralSecurityContainer.dummyCurrentRestrictGuestMode) {
+    throw (async() => {
+      try {
+        await adminGeneralSecurityContainer.retrieveSecurityData();
+      }
+      catch (err) {
+        const errs = toArrayIfNot(err);
+        toastError(errs);
+        retrieveErrors = errs;
+        adminGeneralSecurityContainer.setState({
+          currentRestrictGuestMode: adminGeneralSecurityContainer.dummyCurrentRestrictGuestModeForError,
+        });
+      }
+    })();
   }
 
-  toggleActiveTab(activeTab) {
-    this.setState({
-      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
-    });
-  }
-
-  render() {
-    const { t } = this.props;
-    const { activeTab, activeComponents } = this.state;
-    return (
-      <Fragment>
-        <div className="mb-5">
-          <SecuritySetting />
-        </div>
-
-        {/* Shared Link List */}
-        <div className="mb-5">
-          <ShareLinkSetting />
-        </div>
-
-
-        {/* XSS configuration link */}
-        <div className="mb-5">
-          <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>
-          <div className="text-center">
-            <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
-              <i className="fa-fw icon-login"></i> {t('security_setting.xss_prevent_setting_link')}
-            </a>
-          </div>
-        </div>
-
-        <div className="auth-mechanism-configurations">
-          <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
-          <Nav tabs>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-local' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-local') }}
-                href="#passport-local"
-              >
-                <i className="fa fa-users" /> ID/Pass
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-ldap' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-ldap') }}
-                href="#passport-ldap"
-              >
-                <i className="fa fa-sitemap" /> LDAP
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-saml' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-saml') }}
-                href="#passport-saml"
-              >
-                <i className="fa fa-key" /> SAML
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-oidc' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-oidc') }}
-                href="#passport-oidc"
-              >
-                <i className="fa fa-openid" /> OIDC
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-basic' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-basic') }}
-                href="#passport-basic"
-              >
-                <i className="fa fa-lock" /> BASIC
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-google' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-google') }}
-                href="#passport-google"
-              >
-                <i className="fa fa-google" /> Google
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-github' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-github') }}
-                href="#passport-github"
-              >
-                <i className="fa fa-github" /> GitHub
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-twitter' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-twitter') }}
-                href="#passport-twitter"
-              >
-                <i className="fa fa-twitter" /> Twitter
-              </NavLink>
-            </NavItem>
-            <NavItem>
-              <NavLink
-                className={`${activeTab === 'passport-facebook' && 'active'} `}
-                onClick={() => { this.toggleActiveTab('passport-facebook') }}
-                href="#passport-facebook"
-              >
-                <i className="fa fa-facebook" /> (TBD) Facebook
-              </NavLink>
-            </NavItem>
-          </Nav>
-          <TabContent activeTab={activeTab} className="mt-2">
-            <TabPane tabId="passport-local">
-              {activeComponents.has('passport-local') && <LocalSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-ldap">
-              {activeComponents.has('passport-ldap') && <LdapSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-saml">
-              {activeComponents.has('passport-saml') && <SamlSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-oidc">
-              {activeComponents.has('passport-oidc') && <OidcSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-basic">
-              {activeComponents.has('passport-basic') && <BasicSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-google">
-              {activeComponents.has('passport-google') && <GoogleSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-github">
-              {activeComponents.has('passport-github') && <GitHubSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-twitter">
-              {activeComponents.has('passport-twitter') && <TwitterSecuritySetting />}
-            </TabPane>
-            <TabPane tabId="passport-facebook">
-              {activeComponents.has('passport-facebook') && <FacebookSecuritySetting />}
-            </TabPane>
-          </TabContent>
-        </div>
-      </Fragment>
-    );
+  if (adminGeneralSecurityContainer.state.currentRestrictGuestMode === adminGeneralSecurityContainer.dummyCurrentRestrictGuestModeForError) {
+    throw new Error(`${retrieveErrors.length} errors occured`);
   }
 
+  return <SecurityManagementContents />;
 }
 
 SecurityManagement.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  csrf: PropTypes.string,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
 };
 
-const SecurityManagementWrapper = withUnstatedContainers(SecurityManagement, [AppContainer]);
+const SecurityManagementWithUnstatedContainer = withUnstatedContainers(withLoadingSppiner(SecurityManagement), [AdminGeneralSecurityContainer]);
 
-export default withTranslation()(SecurityManagementWrapper);
+export default SecurityManagementWithUnstatedContainer;

+ 196 - 0
src/client/js/components/Admin/Security/SecurityManagementContents.jsx

@@ -0,0 +1,196 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import {
+  TabContent, TabPane, Nav, NavItem, NavLink,
+} from 'reactstrap';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import LdapSecuritySetting from './LdapSecuritySetting';
+import LocalSecuritySetting from './LocalSecuritySetting';
+import SamlSecuritySetting from './SamlSecuritySetting';
+import OidcSecuritySetting from './OidcSecuritySetting';
+import SecuritySetting from './SecuritySetting';
+import BasicSecuritySetting from './BasicSecuritySetting';
+import GoogleSecuritySetting from './GoogleSecuritySetting';
+import GitHubSecuritySetting from './GitHubSecuritySetting';
+import TwitterSecuritySetting from './TwitterSecuritySetting';
+import FacebookSecuritySetting from './FacebookSecuritySetting';
+import ShareLinkSetting from './ShareLinkSetting';
+
+class SecurityManagementContents extends React.Component {
+
+  constructor() {
+    super();
+
+    this.state = {
+      activeTab: 'passport-local',
+      // Prevent unnecessary rendering
+      activeComponents: new Set(['passport-local']),
+    };
+
+    this.toggleActiveTab = this.toggleActiveTab.bind(this);
+  }
+
+  toggleActiveTab(activeTab) {
+    this.setState({
+      activeTab, activeComponents: this.state.activeComponents.add(activeTab),
+    });
+  }
+
+  render() {
+    const { t } = this.props;
+    const { activeTab, activeComponents } = this.state;
+    return (
+      <Fragment>
+        <div className="mb-5">
+          <SecuritySetting />
+        </div>
+
+        {/* Shared Link List */}
+        <div className="mb-5">
+          <ShareLinkSetting />
+        </div>
+
+
+        {/* XSS configuration link */}
+        <div className="mb-5">
+          <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>
+          <div className="text-center">
+            <a style={{ fontSize: 'large' }} href="/admin/markdown/#preventXSS">
+              <i className="fa-fw icon-login"></i> {t('security_setting.xss_prevent_setting_link')}
+            </a>
+          </div>
+        </div>
+
+        <div className="auth-mechanism-configurations">
+          <h2 className="border-bottom">{t('security_setting.Authentication mechanism settings')}</h2>
+          <Nav tabs>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-local' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-local') }}
+                href="#passport-local"
+              >
+                <i className="fa fa-users" /> ID/Pass
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-ldap' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-ldap') }}
+                href="#passport-ldap"
+              >
+                <i className="fa fa-sitemap" /> LDAP
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-saml' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-saml') }}
+                href="#passport-saml"
+              >
+                <i className="fa fa-key" /> SAML
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-oidc' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-oidc') }}
+                href="#passport-oidc"
+              >
+                <i className="fa fa-openid" /> OIDC
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-basic' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-basic') }}
+                href="#passport-basic"
+              >
+                <i className="fa fa-lock" /> BASIC
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-google' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-google') }}
+                href="#passport-google"
+              >
+                <i className="fa fa-google" /> Google
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-github' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-github') }}
+                href="#passport-github"
+              >
+                <i className="fa fa-github" /> GitHub
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-twitter' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-twitter') }}
+                href="#passport-twitter"
+              >
+                <i className="fa fa-twitter" /> Twitter
+              </NavLink>
+            </NavItem>
+            <NavItem>
+              <NavLink
+                className={`${activeTab === 'passport-facebook' && 'active'} `}
+                onClick={() => { this.toggleActiveTab('passport-facebook') }}
+                href="#passport-facebook"
+              >
+                <i className="fa fa-facebook" /> (TBD) Facebook
+              </NavLink>
+            </NavItem>
+          </Nav>
+          <TabContent activeTab={activeTab} className="mt-2">
+            <TabPane tabId="passport-local">
+              {activeComponents.has('passport-local') && <LocalSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-ldap">
+              {activeComponents.has('passport-ldap') && <LdapSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-saml">
+              {activeComponents.has('passport-saml') && <SamlSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-oidc">
+              {activeComponents.has('passport-oidc') && <OidcSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-basic">
+              {activeComponents.has('passport-basic') && <BasicSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-google">
+              {activeComponents.has('passport-google') && <GoogleSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-github">
+              {activeComponents.has('passport-github') && <GitHubSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-twitter">
+              {activeComponents.has('passport-twitter') && <TwitterSecuritySetting />}
+            </TabPane>
+            <TabPane tabId="passport-facebook">
+              {activeComponents.has('passport-facebook') && <FacebookSecuritySetting />}
+            </TabPane>
+          </TabContent>
+        </div>
+      </Fragment>
+    );
+  }
+
+}
+
+SecurityManagementContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+const SecurityManagementContentsWrapper = withUnstatedContainers(SecurityManagementContents, [AppContainer]);
+
+export default withTranslation()(SecurityManagementContentsWrapper);

+ 3 - 18
src/client/js/components/Admin/Security/SecuritySetting.jsx

@@ -14,24 +14,9 @@ class SecuritySetting extends React.Component {
   constructor(props) {
     super(props);
 
-    this.state = {
-      retrieveError: null,
-    };
     this.putSecuritySetting = this.putSecuritySetting.bind(this);
   }
 
-  async componentDidMount() {
-    const { adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminGeneralSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ retrieveError: err.message });
-    }
-  }
-
   async putSecuritySetting() {
     const { t, adminGeneralSecurityContainer } = this.props;
     try {
@@ -52,9 +37,9 @@ class SecuritySetting extends React.Component {
         <h2 className="alert-anchor border-bottom">
           {t('security_settings')}
         </h2>
-        {this.state.retrieveError != null && (
+        {adminGeneralSecurityContainer.retrieveError != null && (
         <div className="alert alert-danger">
-          <p>{t('Error occurred')} : {this.state.retrieveError}</p>
+          <p>{t('Error occurred')} : {adminGeneralSecurityContainer.retrieveError}</p>
         </div>
           )}
 
@@ -206,7 +191,7 @@ class SecuritySetting extends React.Component {
         </div>
         <div className="row my-3">
           <div className="text-center text-md-left offset-md-3 col-md-5">
-            <button type="button" className="btn btn-primary" disabled={this.state.retrieveError != null} onClick={this.putSecuritySetting}>
+            <button type="button" className="btn btn-primary" disabled={adminGeneralSecurityContainer.retrieveError != null} onClick={this.putSecuritySetting}>
               {t('Update')}
             </button>
           </div>

+ 29 - 206
src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx

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

+ 206 - 0
src/client/js/components/Admin/Security/TwitterSecuritySettingContents.jsx

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

+ 4 - 3
src/client/js/components/Fab.jsx

@@ -6,7 +6,8 @@ import StickyEvents from 'sticky-events';
 
 import NavigationContainer from '../services/NavigationContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
-
+import CreatePageIcon from './Icons/CreatePageIcon';
+import ReturnTopIcon from './Icons/ReturnTopIcon';
 
 const logger = loggerFactory('growi:cli:Fab');
 
@@ -47,12 +48,12 @@ const Fab = (props) => {
           className="btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light"
           onClick={navigationContainer.openPageCreateModal}
         >
-          <i className="icon-pencil"></i>
+          <CreatePageIcon />
         </button>
       </div>
       <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
         <button type="button" className="btn btn-light btn-scroll-to-top rounded-circle p-0" onClick={() => navigationContainer.smoothScrollIntoView()}>
-          <i className="icon-control-start"></i>
+          <ReturnTopIcon />
         </button>
       </div>
     </div>

+ 4 - 2
src/client/js/components/Hotkeys/HotkeysDetector.jsx

@@ -44,12 +44,14 @@ const HotkeysDetector = (props) => {
    * evaluate the key user pressed and trigger onDetected
    */
   const checkHandler = useCallback((event) => {
-    event.preventDefault();
-
     const eventKey = getKeyExpression(event);
 
     hotkeyStrokes.forEach((hotkeyStroke) => {
+      // if any stroke is completed
       if (hotkeyStroke.evaluate(eventKey)) {
+        // cancel the key event
+        event.preventDefault();
+        // invoke detected handler
         onDetected(hotkeyStroke.stroke);
       }
     });

+ 26 - 0
src/client/js/components/Icons/CreatePageIcon.jsx

@@ -0,0 +1,26 @@
+import React from 'react';
+
+const CreatePageIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 27 30"
+  >
+    <path
+      d="M22.81,8.2a4.2,4.2,0,0,0,1.36-2.95,4,4,0,0,0-1.43-2.81,4.53,4.53,0,0,0-1.28-.89,3.26,3.26,0,
+      0,0-1.37-.31,4,4,0,0,0-2.91,1.29q-.42.4-14.83,14.84a.7.7,0,0,0-.26.33c-.07.26-.72,2.46-2,6.58a.73.73,0,
+      0,0,.3,1,.78.78,0,0,0,.7,0c3.3-1.08,5.45-1.76,6.47-2.06A.57.57,0,0,0,7.91,23l8.5-8.42Q22.25,8.81,22.81,8.2ZM1.93,
+      23.44c.16-.44,1.39-4.39,1.5-4.78A4.93,4.93,0,0,1,5.59,20a4.53,4.53,0,0,1,1.12,1.87Zm15-18.52a4.7,4.7,0,0,1,2.16,1.31,5.08,5.08,
+      0,0,1,.72,1,5.3,5.3,0,0,1,.37.8c.05.17.09.34.13.51Q17.19,11.65,8,20.79a6.42,6.42,0,0,0-1.29-1.92,6.67,6.67,0,0,0-2.2-1.48Zm4.64,
+      2.37a6.36,6.36,0,0,0-1.36-2.13,6.61,6.61,0,0,0-2.12-1.43s.29-.28.41-.38A3,3,0,0,1,19.17,3a2,2,0,0,1,.9-.21A1.87,1.87,0,0,1,20.9,3a2.53,2.53,0,0,
+      1,.79.56,3.81,3.81,0,0,1,.71.89,1.87,1.87,0,0,1,.25.87,2.75,2.75,0,0,1-.94,1.83Z"
+    />
+    <path d="M26.41,20.05H22.84V16.48a.72.72,0,0,0-1.43,0v3.57H17.84a.72.72,0,0,0,0,1.43h3.57v3.57a.72.72,0,0,0,
+    1.43.17V21.48h3.57a.72.72,0,1,0,.17-1.43A.48.48,0,0,0,26.41,20.05Z"
+    />
+    <rect fillOpacity="0" width="27" height="27" />
+  </svg>
+
+);
+
+
+export default CreatePageIcon;

+ 20 - 0
src/client/js/components/Icons/MoonIcon.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+
+const MoonIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <g transform="translate(-923.5 -688.5)">
+      <rect width="23" height="23" fill="none" transform="translate(923.5 688.5)" />
+      <path d="M934.893,710.532a10.646,10.646,0,0,1-10.378-8.416.7.7,0,0,1,1.138-.686,
+       7.621,7.621,0,0,0,10.721-10.744.7.7,0,0,1,.683-1.14,10.6,10.6,0,0,1-2.164,
+        20.986Zm-8.417-6.9A9.2,9.2,0,1,0,938.583,691.5a9.028,9.028,0,0,1-12.107,12.133Z"
+      />
+    </g>
+  </svg>
+
+);
+
+
+export default MoonIcon;

+ 20 - 0
src/client/js/components/Icons/ReturnTopIcon.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+
+const ReturnTopIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <path d="M.41,18.71a.82.82,0,0,0,0,.26.71.71,0,0,0,0,.29.5.5,0,0,0,.16.22.66.66,0,0,0,.51.21.67.67,0,0,0,
+    .51-.21l9.57-9.56,9.43,9.43a.71.71,0,0,0,.51.21.68.68,0,0,0,.51-.21.72.72,
+    0,0,0,0-1l-9.94-10a.78.78,0,0,0-.51-.19.76.76,0,0,0-.5.19L.58,18.46A.85.85,0,0,0,.41,18.71Z"
+    />
+    <path d="M22.35,4.61H.65a.65.65,0,0,1,0-1.3h21.7a.65.65,0,1,1,0,1.3Z" />
+    <rect fillOpacity="0" width="23" height="23" />
+
+  </svg>
+
+);
+
+
+export default ReturnTopIcon;

+ 20 - 0
src/client/js/components/Icons/SidebarDockIcon.jsx

@@ -0,0 +1,20 @@
+import React from 'react';
+
+const SidebarDockIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <rect width="23" height="23" fillOpacity="0" />
+    <path
+      d="M20.86,3.92a.64.64,0,0,1,.64.63v13.9a.64.64,0,0,1-.64.63H2.14a.64.64,0,0,
+      1-.64-.63V4.55a.64.64,0,0,1,.64-.63H20.86m0-1.5H2.14A2.13,2.13,0,0,0,0,4.55v13.9a2.13,
+      2.13,0,0,0,2.14,2.13H20.86A2.13,2.13,0,0,0,23,18.45V4.55a2.13,2.13,0,0,0-2.14-2.13Z"
+    />
+    <rect x="7.49" y="3.05" width="1.2" height="16.91" />
+  </svg>
+
+);
+
+
+export default SidebarDockIcon;

+ 25 - 0
src/client/js/components/Icons/SidebarDrawerIcon.jsx

@@ -0,0 +1,25 @@
+import React from 'react';
+
+const SidebarDrawerIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <rect width="23" height="23" fillOpacity="0" />
+    <path d="M20.9,3.9c0.3,0,0.6,0.3,0.6,0.6v13.9c0,0.3-0.3,0.6-0.6,0.6H2.1c-0.3,0-0.6-0.3-0.6-0.6V4.5c0-0.3,0.3-0.6,0.6-0.6H20.9
+      M20.9,2.4H2.1C1,2.4,0,3.4,0,4.5c0,0,0,0,0,0v13.9c0,1.2,1,2.1,2.1,2.1c0,0,0,0,0,0h18.7c1.2,0,2.1-0.9,2.1-2.1c0,0,0,0,0,0V4.5
+      C23,3.4,22,2.4,20.9,2.4C20.9,2.4,20.9,2.4,20.9,2.4z"
+    />
+    <rect x="7.5" y="3.9" width="1.2" height="0.8" />
+    <rect x="7.5" y="15.3" width="1.2" height="1.5" />
+    <rect x="7.5" y="12.3" width="1.2" height="1.5" />
+    <rect x="7.5" y="9.2" width="1.2" height="1.6" />
+    <rect x="7.5" y="6.1" width="1.2" height="1.6" />
+    <rect x="7.5" y="18.4" width="1.2" height="0.8" />
+    <path d="M15.1,14.9c-0.2,0-0.3-0.1-0.4-0.2l-2.8-2.8c-0.2-0.2-0.2-0.6,0-0.8l2.8-2.8c0.2-0.2,0.6-0.2,0.9,0s0.2,0.6,0,0.9l-2.4,2.4
+      l2.4,2.4c0.2,0.2,0.2,0.6,0,0.9C15.4,14.8,15.3,14.9,15.1,14.9z"
+    />
+  </svg>
+);
+
+export default SidebarDrawerIcon;

+ 28 - 0
src/client/js/components/Icons/SunIcon.jsx

@@ -0,0 +1,28 @@
+import React from 'react';
+
+const SunIcon = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    viewBox="0 0 23 23"
+  >
+    <g transform="translate(-888.497 -688.492)">
+      <rect width="23" height="23" transform="translate(888.503 688.509)" fillOpacity="0" />
+      <path d="M900,695.489a4.5,4.5,0,1,1-4.5,4.5,4.5,4.5,0,0,1,4.5-4.5m0-1.408a5.9,5.9,0,1,0,5.9,5.9,5.91,5.91,0,0,0-5.9-5.9Z" />
+      <path d="M893.968,694.573a.6.6,0,0,1-.426-.176l-1.681-1.681a.6.6,0,0,1,.853-.852l1.681,1.68a.6.6,0,0,1-.427,1.029Z" />
+      <path d="M907.707,708.295a.6.6,0,0,1-.427-.177l-1.681-1.68a.6.6,0,0,1,.854-.853l1.68,1.681a.6.6,0,0,1-.426,1.029Z" />
+
+      <path d="M899.991,692.074a.6.6,0,0,1-.6-.6v-2.377a.6.6,0,0,1,1.206,0v2.377A.6.6,0,0,1,899.991,692.074Z" />
+      <path d="M900,711.491a.6.6,0,0,1-.6-.6v-2.377a.6.6,0,1,1,1.206,0v2.377A.6.6,0,0,1,900,711.491Z" />
+
+      <path d="M906.017,694.564a.6.6,0,0,1-.426-1.029l1.68-1.68a.6.6,0,0,1,.853.854l-1.68,1.68A.6.6,0,0,1,906.017,694.564Z" />
+      <path d="M892.3,708.3a.6.6,0,0,1-.426-1.029l1.68-1.681a.6.6,0,1,1,.853.852l-1.68,1.681A.6.6,0,0,1,892.3,708.3Z" />
+
+      <path d="M910.894,700.587h-2.377a.6.6,0,1,1,0-1.2h2.377a.6.6,0,1,1,0,1.2Z" />
+      <path d="M891.477,700.6H889.1a.6.6,0,1,1,0-1.2h2.377a.6.6,0,1,1,0,1.2Z" />
+    </g>
+  </svg>
+
+);
+
+
+export default SunIcon;

+ 25 - 6
src/client/js/components/Me/PasswordSettings.jsx

@@ -3,7 +3,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-
 import { toastSuccess, toastError } from '../../util/apiNotification';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -23,6 +22,7 @@ class PasswordSettings extends React.Component {
       oldPassword: '',
       newPassword: '',
       newPasswordConfirm: '',
+      isPasswordSet: false,
     };
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
@@ -30,6 +30,21 @@ class PasswordSettings extends React.Component {
 
   }
 
+  async componentDidMount() {
+    const { appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Get('/personal-setting/is-password-set');
+      const { isPasswordSet } = res.data;
+      this.setState({ isPasswordSet });
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ retrieveError: err });
+    }
+
+  }
+
   async onClickSubmit() {
     const { t, appContainer, personalContainer } = this.props;
     const { oldPassword, newPassword, newPasswordConfirm } = this.state;
@@ -61,22 +76,26 @@ class PasswordSettings extends React.Component {
   }
 
   render() {
-    const { t, personalContainer } = this.props;
+    const { t } = this.props;
     const { newPassword, newPasswordConfirm } = this.state;
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
 
+    if (this.state.retrieveError != null) {
+      throw new Error(this.state.retrieveError.message);
+    }
+
     return (
       <React.Fragment>
-        { (!personalContainer.state.isPasswordSet) && (
+        { (!this.state.isPasswordSet) && (
           <div className="alert alert-warning">{ t('personal_settings.password_is_not_set') }</div>
         ) }
 
         <div className="container-fluid my-4">
-          {(personalContainer.state.isPasswordSet)
+          {(this.state.isPasswordSet)
             ? <h2 className="border-bottom">{t('personal_settings.update_password')}</h2>
           : <h2 className="border-bottom">{t('personal_settings.set_new_password')}</h2>}
         </div>
-        {(personalContainer.state.isPasswordSet)
+        {(this.state.isPasswordSet)
         && (
           <div className="row mb-3">
             <label htmlFor="oldPassword" className="col-md-3 text-md-right">{ t('personal_settings.current_password') }</label>
@@ -124,7 +143,7 @@ class PasswordSettings extends React.Component {
               type="button"
               className="btn btn-primary"
               onClick={this.onClickSubmit}
-              disabled={this.state.retrieveError != null || isIncorrectConfirmPassword}
+              disabled={isIncorrectConfirmPassword}
             >
               {t('Update')}
             </button>

+ 1 - 1
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -148,7 +148,7 @@ const GrowiSubNavigation = (props) => {
   }
 
   return (
-    <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact' : ''}`}>
+    <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}>
 
       {/* Left side */}
       <div className="d-flex">

+ 33 - 15
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -20,6 +20,12 @@ import {
 
 import UserPicture from '../User/UserPicture';
 
+import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
+import SidebarDockIcon from '../Icons/SidebarDockIcon';
+import MoonIcon from '../Icons/MoonIcon';
+import SunIcon from '../Icons/SunIcon';
+
+
 const PersonalDropdown = (props) => {
 
   const { t, appContainer, navigationContainer } = props;
@@ -79,16 +85,12 @@ const PersonalDropdown = (props) => {
   } = navigationContainer.state;
 
   /* eslint-disable react/prop-types */
-  const DrawerIcon = props => (
-    <>
-      <i id={props.id} className="icon-drawer px-2"></i>
-      <UncontrolledTooltip placement="bottom" fade={false} target={props.id}>Drawer</UncontrolledTooltip>
-    </>
-  );
-  const DockIcon = props => (
+  const IconWithTooltip = ({
+    id, label, children, additionalClasses,
+  }) => (
     <>
-      <i id={props.id} className="ti-layout-sidebar-left px-2"></i>
-      <UncontrolledTooltip placement="bottom" fade={false} target={props.id}>Dock</UncontrolledTooltip>
+      <div id={id} className={`px-2 grw-icon-container ${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
+      <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
     </>
   );
   /* eslint-enable react/prop-types */
@@ -129,11 +131,14 @@ const PersonalDropdown = (props) => {
 
         <div className="dropdown-divider"></div>
 
+        {/* Sidebar Mode */}
         <h6 className="dropdown-header">{t('personal_dropdown.sidebar_mode')}</h6>
         <form className="px-4">
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <DrawerIcon id="icon-prefer-drawer" />
+              <IconWithTooltip id="iwt-sidebar-drawer" label="Drawer">
+                <SidebarDrawerIcon />
+              </IconWithTooltip>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                   id="swSidebarMode"
@@ -144,15 +149,21 @@ const PersonalDropdown = (props) => {
                 />
                 <label className="custom-control-label" htmlFor="swSidebarMode"></label>
               </div>
-              <DockIcon id="icon-prefer-dock" />
+              <IconWithTooltip id="iwt-sidebar-dock" label="Dock">
+                <SidebarDockIcon />
+              </IconWithTooltip>
             </div>
           </div>
         </form>
+
+        {/* Sidebar Mode on Editor */}
         <h6 className="dropdown-header">{t('personal_dropdown.sidebar_mode_editor')}</h6>
         <form className="px-4">
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <DrawerIcon id="icon-prefer-drawer-on-edit" />
+              <IconWithTooltip id="iwt-sidebar-editor-drawer" label="Drawer">
+                <SidebarDrawerIcon />
+              </IconWithTooltip>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                   id="swSidebarModeOnEditor"
@@ -163,13 +174,16 @@ const PersonalDropdown = (props) => {
                 />
                 <label className="custom-control-label" htmlFor="swSidebarModeOnEditor"></label>
               </div>
-              <DockIcon id="icon-prefer-dock-on-edit" />
+              <IconWithTooltip id="iwt-sidebar-editor-dock" label="Dock">
+                <SidebarDockIcon />
+              </IconWithTooltip>
             </div>
           </div>
         </form>
 
         <div className="dropdown-divider"></div>
 
+        {/* Color Mode */}
         <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>
         <form className="px-4">
           <div className="form-row">
@@ -188,7 +202,9 @@ const PersonalDropdown = (props) => {
           </div>
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <span className={useOsSettings ? '' : 'text-muted'}>Light</span>
+              <IconWithTooltip id="iwt-light" label="Light" additionalClasses={useOsSettings ? 'grw-icon-container-muted' : ''}>
+                <SunIcon />
+              </IconWithTooltip>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                   id="swUserPreference"
@@ -200,7 +216,9 @@ const PersonalDropdown = (props) => {
                 />
                 <label className="custom-control-label" htmlFor="swUserPreference"></label>
               </div>
-              <span className={useOsSettings ? '' : 'text-muted'}>Dark</span>
+              <IconWithTooltip id="iwt-dark" label="Dark" additionalClasses={useOsSettings ? 'grw-icon-container-muted' : ''}>
+                <MoonIcon />
+              </IconWithTooltip>
             </div>
           </div>
         </form>

+ 11 - 2
src/client/js/components/Page/CopyDropdown.jsx

@@ -55,6 +55,15 @@ class CopyDropdown extends React.Component {
     return `${search}${hash}`;
   }
 
+  encodeSpaces(str) {
+    if (str == null) {
+      return null;
+    }
+
+    // Encode SPACE and IDEOGRAPHIC SPACE
+    return str.replace(/ /g, '%20').replace(/\u3000/g, '%E3%80%80');
+  }
+
   generatePagePathWithParams() {
     const { pagePath } = this.props;
     return decodeURI(`${pagePath}${this.uriParams}`);
@@ -62,7 +71,7 @@ class CopyDropdown extends React.Component {
 
   generatePagePathUrl() {
     const { origin } = window.location;
-    return `${origin}${this.generatePagePathWithParams()}`;
+    return `${origin}${this.encodeSpaces(this.generatePagePathWithParams())}`;
   }
 
   generatePermalink() {
@@ -75,7 +84,7 @@ class CopyDropdown extends React.Component {
       return decodeURI(`${origin}/share/${pageId}`);
     }
 
-    return decodeURI(`${origin}/${pageId}${this.uriParams}`);
+    return this.encodeSpaces(decodeURI(`${origin}/${pageId}${this.uriParams}`));
   }
 
   generateMarkdownLink() {

+ 13 - 0
src/client/js/components/Page/PageShareManagement.jsx

@@ -2,7 +2,9 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
+import urljoin from 'url-join';
 import { withUnstatedContainers } from '../UnstatedUtils';
+
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
 import OutsideShareLinkModal from '../OutsideShareLinkModal';
@@ -23,6 +25,14 @@ const PageShareManagement = (props) => {
     setIsOutsideShareLinkModalShown(false);
   }
 
+  async function exportPageHandler(format) {
+    const { pageId, revisionId } = pageContainer.state;
+    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+    url.searchParams.append('format', format);
+    url.searchParams.append('revisionId', revisionId);
+    window.location.href = url.href;
+  }
+
   function renderModals() {
     if (currentUser == null) {
       return null;
@@ -78,6 +88,9 @@ const PageShareManagement = (props) => {
           <i className="icon-fw icon-link"></i>{t('share_links.Shere this page link to public')}
           <span className="ml-2 badge badge-info badge-pill">{pageContainer.state.shareLinksNumber}</span>
         </button>
+        <button type="button" className="dropdown-item" onClick={() => { exportPageHandler('md') }}>
+          <span>{t('export_bulk.export_page_markdown')}</span>
+        </button>
       </div>
       {renderModals()}
     </>

+ 5 - 7
src/client/js/components/Page/RevisionLoader.jsx

@@ -40,22 +40,20 @@ class RevisionLoader extends React.Component {
       this.setState({ isLoading: true });
     }
 
-    const requestData = {
-      page_id: this.props.pageId,
-      revision_id: this.props.revisionId,
-    };
+    const { pageId, revisionId } = this.props;
+
 
     // load data with REST API
     try {
-      const res = await this.props.appContainer.apiGet('/revisions.get', requestData);
+      const res = await this.props.appContainer.apiv3Get(`/revisions/${revisionId}`, { pageId });
 
       this.setState({
-        markdown: res.revision.body,
+        markdown: res.data.revision.body,
         error: null,
       });
 
       if (this.props.onRevisionLoaded != null) {
-        this.props.onRevisionLoaded(res.revision);
+        this.props.onRevisionLoaded(res.data.revision);
       }
     }
     catch (error) {

+ 4 - 0
src/client/js/components/PageDuplicateModal.jsx

@@ -31,6 +31,8 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    */
   function ppacInputChangeHandler(value) {
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 
@@ -39,6 +41,8 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    */
   function inputChangeHandler(value) {
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 

+ 6 - 2
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -56,7 +56,9 @@ require('../../util/codemirror/autorefresh.ext');
 
 
 const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
-const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
+// TODO: activate by GW-3443
+// const MARKDOWN_LINK_ACTIVATED_CLASS = 'markdown-link-activated';
+const MARKDOWN_LINK_ACTIVATED_CLASS = '';
 
 export default class CodeMirrorEditor extends AbstractEditor {
 
@@ -766,7 +768,9 @@ export default class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Link"
-        onClick={this.showLinkEditHandler}
+        // TODO: activate by GW-3443
+        // onClick={this.showLinkEditHandler}
+        onClick={this.createReplaceSelectionHandler('[', ']()')}
       >
         <EditorIcon icon="Link" />
       </Button>,

Разница между файлами не показана из-за своего большого размера
+ 38 - 25
src/client/js/components/PageEditor/EditorIcon.jsx


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

@@ -56,7 +56,7 @@ class LinkEditModal extends React.PureComponent {
     this.getPreviewDebounced = debounce(200, this.getPreview.bind(this));
   }
 
-  componentDidUpdate(prevState) {
+  componentDidUpdate(prevProps, prevState) {
     const { linkInputValue: prevLinkInputValue } = prevState;
     const { linkInputValue } = this.state;
     if (linkInputValue !== prevLinkInputValue) {

+ 55 - 144
src/client/js/components/PageHistory.jsx

@@ -1,178 +1,89 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
-import { withTranslation } from 'react-i18next';
+import { withUnstatedContainers } from './UnstatedUtils';
+import { toastError } from '../util/apiNotification';
 
+import { withLoadingSppiner } from './SuspenseUtils';
 import PageRevisionList from './PageHistory/PageRevisionList';
 
-const logger = loggerFactory('growi:PageHistory');
-class PageHistory extends React.Component {
-
-  constructor(props) {
-    super(props);
+import PageHistroyContainer from '../services/PageHistoryContainer';
+import PaginationWrapper from './PaginationWrapper';
 
-    this.state = {
-      isLoaded: false,
-      isLoading: false,
-      errorMessage: null,
-      revisions: [],
-      diffOpened: {},
-    };
 
-    this.getPreviousRevision = this.getPreviousRevision.bind(this);
-    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
-  }
+const logger = loggerFactory('growi:PageHistory');
 
-  async componentWillMount() {
-    const pageId = this.props.pageId;
-    const shareLinkId = this.props.shareLinkId || null;
 
-    if (!pageId) {
-      return;
-    }
+function PageHistory(props) {
+  const { pageHistoryContainer } = props;
 
-    let res;
+  const handlePage = useCallback(async(selectedPage) => {
     try {
-      this.setState({ isLoading: true });
-      res = await this.props.crowi.apiGet('/revisions.ids', { page_id: pageId, share_link_id: shareLinkId });
+      await props.pageHistoryContainer.retrieveRevisions(selectedPage);
     }
     catch (err) {
+      toastError(err);
+      props.pageHistoryContainer.setState({ errorMessage: err.message });
       logger.error(err);
-      this.setState({ errorMessage: err });
-      return;
     }
-    finally {
-      this.setState({ isLoading: false });
-    }
-
-    const rev = res.revisions;
-    const diffOpened = {};
-    const lastId = rev.length - 1;
-    res.revisions.forEach((revision, i) => {
-      const user = revision.author;
-      if (user) {
-        rev[i].author = user;
-      }
-
-      if (i === 0 || i === lastId) {
-        diffOpened[revision._id] = true;
-      }
-      else {
-        diffOpened[revision._id] = false;
-      }
-    });
-
-    this.setState({
-      isLoaded: true,
-      revisions: rev,
-      diffOpened,
-    });
+  }, [props.pageHistoryContainer]);
 
-    // load 0, and last default
-    if (rev[0]) {
-      this.fetchPageRevisionBody(rev[0]);
-    }
-    if (rev[1]) {
-      this.fetchPageRevisionBody(rev[1]);
-    }
-    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-      this.fetchPageRevisionBody(rev[lastId]);
-    }
+  if (pageHistoryContainer.state.errorMessage != null) {
+    return (
+      <div className="my-5">
+        <div className="text-danger">{pageHistoryContainer.state.errorMessage}</div>
+      </div>
+    );
   }
 
-  getPreviousRevision(currentRevision) {
-    let cursor = null;
-    for (const revision of this.state.revisions) {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (cursor && cursor._id == currentRevision._id) {
-        cursor = revision;
-        break;
+  if (pageHistoryContainer.state.revisions === pageHistoryContainer.dummyRevisions) {
+    throw new Promise(async() => {
+      try {
+        await props.pageHistoryContainer.retrieveRevisions(1);
+      }
+      catch (err) {
+        toastError(err);
+        pageHistoryContainer.setState({ errorMessage: err.message });
+        logger.error(err);
       }
-
-      cursor = revision;
-    }
-
-    return cursor;
-  }
-
-  onDiffOpenClicked(revision) {
-    const diffOpened = this.state.diffOpened;
-    const revisionId = revision._id;
-
-    diffOpened[revisionId] = !(diffOpened[revisionId]);
-    this.setState({
-      diffOpened,
     });
-
-    this.fetchPageRevisionBody(revision);
-    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
   }
 
-  fetchPageRevisionBody(revision) {
-    const shareLinkId = this.props.shareLinkId || null;
-
-    if (revision.body) {
-      return;
-    }
-
-    this.props.crowi.apiGet('/revisions.get',
-      { page_id: this.props.pageId, revision_id: revision._id, share_link_id: shareLinkId })
-      .then((res) => {
-        if (res.ok) {
-          this.setState({
-            revisions: this.state.revisions.map((rev) => {
-              // comparing ObjectId
-              // eslint-disable-next-line eqeqeq
-              if (rev._id == res.revision._id) {
-                return res.revision;
-              }
-
-              return rev;
-            }),
-          });
-        }
-      })
-      .catch((err) => {
-
-      });
-  }
 
-  render() {
+  function pager() {
     return (
-      <div className="mt-4">
-        { this.state.isLoading && (
-          <div className="my-5 text-center">
-            <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
-          </div>
-        ) }
-        { this.state.errorMessage && (
-          <div className="my-5">
-            <div className="text-danger">{this.state.errorMessage}</div>
-          </div>
-        ) }
-        { this.state.isLoaded && (
-          <PageRevisionList
-            t={this.props.t}
-            revisions={this.state.revisions}
-            diffOpened={this.state.diffOpened}
-            getPreviousRevision={this.getPreviousRevision}
-            onDiffOpenClicked={this.onDiffOpenClicked}
-          />
-        ) }
+      <div className="my-3">
+        <PaginationWrapper
+          activePage={pageHistoryContainer.state.activePage}
+          changePage={handlePage}
+          totalItemsCount={pageHistoryContainer.state.totalPages}
+          pagingLimit={pageHistoryContainer.state.pagingLimit}
+        />
       </div>
     );
   }
 
+
+  return (
+    <div className="mt-4">
+      {pager()}
+      <PageRevisionList
+        revisions={pageHistoryContainer.state.revisions}
+        diffOpened={pageHistoryContainer.state.diffOpened}
+        getPreviousRevision={pageHistoryContainer.getPreviousRevision}
+        onDiffOpenClicked={pageHistoryContainer.onDiffOpenClicked}
+      />
+      {pager()}
+    </div>
+  );
+
 }
 
-PageHistory.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
+const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer]);
 
-  shareLinkId: PropTypes.string,
-  pageId: PropTypes.string,
-  crowi: PropTypes.object.isRequired,
+PageHistory.propTypes = {
+  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
 };
 
-export default withTranslation()(PageHistory);
+export default RenderPageHistoryWrapper;

+ 6 - 3
src/client/js/components/PageHistory/PageRevisionList.jsx

@@ -1,10 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
+
 import Revision from './Revision';
 import RevisionDiff from './RevisionDiff';
 
-export default class PageRevisionList extends React.Component {
+class PageRevisionList extends React.Component {
 
   constructor(props) {
     super(props);
@@ -65,8 +67,6 @@ export default class PageRevisionList extends React.Component {
     const { t } = this.props;
 
     const revisions = this.props.revisions;
-
-
     const revisionCount = this.props.revisions.length;
 
     let hasDiffPrev;
@@ -117,7 +117,10 @@ export default class PageRevisionList extends React.Component {
 
 PageRevisionList.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
   revisions: PropTypes.array,
   diffOpened: PropTypes.object,
   onDiffOpenClicked: PropTypes.func.isRequired,
 };
+
+export default withTranslation()(PageRevisionList);

+ 2 - 0
src/client/js/components/PageRenameModal.jsx

@@ -47,6 +47,8 @@ const PageRenameModal = (props) => {
    * @param {string} value
    */
   function inputChangeHandler(value) {
+    setErrorCode(null);
+    setErrorMessage(null);
     setPageNameInput(value);
   }
 

+ 3 - 1
src/client/js/components/ShareLinkForm.jsx

@@ -259,13 +259,15 @@ class ShareLinkForm extends React.Component {
 
 }
 
+/**
+ * Wrapper component for using unstated
+ */
 const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [AppContainer, PageContainer]);
 
 ShareLinkForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
   onCloseForm: PropTypes.func,
 };
 

+ 3 - 0
src/client/js/components/ShareLinkList.jsx

@@ -65,6 +65,9 @@ const ShareLinkList = (props) => {
   );
 };
 
+/**
+ * Wrapper component for using unstated
+ */
 const ShareLinkListWrapper = withUnstatedContainers(ShareLinkList, [AppContainer]);
 
 ShareLinkList.propTypes = {

+ 10 - 8
src/client/js/components/Sidebar.jsx

@@ -138,8 +138,8 @@ class Sidebar extends React.Component {
   }
 
   calcViewHeight() {
-    const containerElem = document.querySelector('#grw-sidebar-content-container');
-    return window.innerHeight - containerElem.getBoundingClientRect().top;
+    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
+    return window.innerHeight - scrollTargetElem.getBoundingClientRect().top;
   }
 
   renderGlobalNavigation = () => (
@@ -147,8 +147,7 @@ class Sidebar extends React.Component {
   );
 
   renderSidebarContents = () => {
-    // const scrollTargetSelector = 'div[data-testid="ContextualNavigation"] div[role="group"]';
-    const scrollTargetSelector = '#grw-sidebar-content-container';
+    const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
 
     return (
       <>
@@ -158,10 +157,13 @@ class Sidebar extends React.Component {
           stickyElemSelector=".grw-sidebar"
           calcViewHeightFunc={this.calcViewHeight}
         />
-        <div id="grw-sidebar-content-container" className="grw-sidebar-content-container">
-          <SidebarContents
-            isSharedUser={this.props.appContainer.isSharedUser}
-          />
+
+        <div id="grw-sidebar-contents-scroll-target">
+          <div id="grw-sidebar-content-container">
+            <SidebarContents
+              isSharedUser={this.props.appContainer.isSharedUser}
+            />
+          </div>
         </div>
 
         <DrawerToggler iconClass="icon-arrow-left" />

+ 33 - 23
src/client/js/components/SlackNotification.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { withTranslation } from 'react-i18next';
+
 /**
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -10,7 +12,7 @@ import PropTypes from 'prop-types';
  * @extends {React.Component}
  */
 
-export default class SlackNotification extends React.Component {
+class SlackNotification extends React.Component {
 
   constructor(props) {
     super(props);
@@ -34,32 +36,36 @@ export default class SlackNotification extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
+
     return (
-      <div className="input-group input-group-sm input-group-slack extended-setting">
-        <label className="input-group-addon bg-light">
-          <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
-          <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
+      <div className="grw-slack-notification">
+        <div className="input-group input-group-sm extended-setting">
+          <label className="input-group-addon bg-light">
+            <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
+            <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
+
+            <input
+              type="checkbox"
+              value="1"
+              checked={this.props.isSlackEnabled}
+              onChange={this.updateCheckboxHandler}
+            />
 
+          </label>
           <input
-            type="checkbox"
-            value="1"
-            checked={this.props.isSlackEnabled}
-            onChange={this.updateCheckboxHandler}
+            className="form-control"
+            type="text"
+            value={this.props.slackChannels}
+            placeholder="Input channels"
+            data-toggle="popover"
+            title={t('slack_notification.popover_title')}
+            data-content={t('slack_notification.popover_desc')}
+            data-trigger="focus"
+            data-placement="top"
+            onChange={this.updateSlackChannelsHandler}
           />
-
-        </label>
-        <input
-          className="form-control"
-          type="text"
-          value={this.props.slackChannels}
-          placeholder="slack channel name"
-          data-toggle="popover"
-          title="Slack通知"
-          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
-          data-trigger="focus"
-          data-placement="top"
-          onChange={this.updateSlackChannelsHandler}
-        />
+        </div>
       </div>
     );
   }
@@ -67,8 +73,12 @@ export default class SlackNotification extends React.Component {
 }
 
 SlackNotification.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   onEnabledFlagChange: PropTypes.func,
   onChannelChange: PropTypes.func,
 };
+
+export default withTranslation()(SlackNotification);

+ 7 - 2
src/client/js/components/StickyStretchableScroller.jsx

@@ -81,16 +81,21 @@ const StickyStretchableScroller = (props) => {
     logger.debug(`[${scrollTargetSelector}] viewHeight`, viewHeight);
     logger.debug(`[${scrollTargetSelector}] contentsHeight`, contentsHeight);
 
+    const isScrollEnabled = viewHeight === 'auto' || (viewHeight < contentsHeight);
+
     $(scrollTargetSelector).slimScroll({
       color: '#666',
       railColor: '#999',
       railVisible: true,
       position: 'right',
-      height: viewHeight,
+      height: isScrollEnabled ? viewHeight : contentsHeight,
     });
-    if (contentsHeight < viewHeight) {
+
+    // destroy
+    if (!isScrollEnabled) {
       $(scrollTargetSelector).slimScroll({ destroy: true });
     }
+
   }, [contentsElemSelector, calcViewHeightFunc, calcContentsHeightFunc, scrollTargetSelector]);
 
   const resetScrollbarDebounced = debounce(100, resetScrollbar);

+ 21 - 0
src/client/js/components/SuspenseUtils.jsx

@@ -0,0 +1,21 @@
+/* eslint-disable import/prefer-default-export */
+import React, { Suspense } from 'react';
+
+/**
+ * If you throw a Promise in the component, it will display a sppiner
+ * @param {object} Component A React.Component or functional component
+ */
+export function withLoadingSppiner(Component) {
+  return (props => (
+    // wrap with <Suspense></Suspense>
+    <Suspense
+      fallback={(
+        <div className="my-5 text-center">
+          <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <Component {...props} />
+    </Suspense>
+  ));
+}

+ 38 - 36
src/client/js/services/AdminAppContainer.js

@@ -1,11 +1,5 @@
 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
@@ -17,6 +11,7 @@ export default class AdminAppContainer extends Container {
 
     this.appContainer = appContainer;
     this.dummyTitle = 0;
+    this.dummyTitleForError = 1;
 
     this.state = {
       retrieveError: null,
@@ -75,36 +70,29 @@ export default class AdminAppContainer extends Container {
    * 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,
-        secretAccessKey: appSettingsParams.secretAccessKey,
-        isEnabledPlugins: appSettingsParams.isEnabledPlugins,
-      });
-
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to fetch data'));
-    }
+    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,
+      secretAccessKey: appSettingsParams.secretAccessKey,
+      isEnabledPlugins: appSettingsParams.isEnabledPlugins,
+    });
   }
 
   /**
@@ -250,6 +238,19 @@ export default class AdminAppContainer extends Container {
     return siteUrlSettingParams;
   }
 
+  /**
+   * Update from adress
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateFromAdressHandler() {
+    const response = await this.appContainer.apiv3.put('/app-settings/from-address', {
+      fromAddress: this.state.fromAddress,
+    });
+    const { mailSettingParams } = response.data;
+    return mailSettingParams;
+  }
+
   /**
    * Update mail setting
    * @memberOf AdminAppContainer
@@ -278,6 +279,7 @@ export default class AdminAppContainer extends Container {
       mailSettingParams,
     } = response.data;
     this.setState(mailSettingParams);
+    return mailSettingParams;
   }
 
   /**

+ 4 - 1
src/client/js/services/AdminBasicSecurityContainer.js

@@ -15,10 +15,13 @@ export default class AdminBasicSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyIsSameUsernameTreatedAsIdenticalUser = 0;
+    this.dummyIsSameUsernameTreatedAsIdenticalUserForError = 1;
 
     this.state = {
       retrieveError: null,
-      isSameUsernameTreatedAsIdenticalUser: false,
+      // set dummy value tile for using suspense
+      isSameUsernameTreatedAsIdenticalUser: this.dummyIsSameUsernameTreatedAsIdenticalUser,
     };
 
   }

+ 4 - 1
src/client/js/services/AdminCustomizeContainer.js

@@ -17,10 +17,13 @@ export default class AdminCustomizeContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyCurrentTheme = 0;
+    this.dummyCurrentThemeForError = 1;
 
     this.state = {
       retrieveError: null,
-      currentTheme: '',
+      // set dummy value tile for using suspense
+      currentTheme: this.dummyCurrentTheme,
       currentLayout: '',
       isEnabledTimeline: false,
       isSavedStatesOfTabChanges: false,

+ 5 - 1
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -13,10 +13,14 @@ export default class AdminGeneralSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyCurrentRestrictGuestMode = 0;
+    this.dummyCurrentRestrictGuestModeForError = 1;
 
     this.state = {
+      retrieveError: null,
       wikiMode: '',
-      currentRestrictGuestMode: 'Deny',
+      // set dummy value tile for using suspense
+      currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
       currentPageCompleteDeletionAuthority: 'adminOnly',
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,

+ 5 - 2
src/client/js/services/AdminGitHubSecurityContainer.js

@@ -17,11 +17,14 @@ export default class AdminGitHubSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyGithubClientId = 0;
+    this.dummyGithubClientIdForError = 1;
 
     this.state = {
       retrieveError: null,
-      appSiteUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/github/callback'),
-      githubClientId: '',
+      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/github/callback'),
+      // set dummy value tile for using suspense
+      githubClientId: this.dummyGithubClientId,
       githubClientSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
     };

+ 4 - 1
src/client/js/services/AdminGoogleSecurityContainer.js

@@ -17,11 +17,14 @@ export default class AdminGoogleSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyGoogleClientId = 0;
+    this.dummyGoogleClientIdForError = 1;
 
     this.state = {
       retrieveError: null,
       callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/google/callback'),
-      googleClientId: '',
+      // set dummy value tile for using suspense
+      googleClientId: this.dummyGoogleClientId,
       googleClientSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
     };

+ 160 - 0
src/client/js/services/AdminImportContainer.js

@@ -0,0 +1,160 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastSuccess, 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 AdminImportContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.dummyEsaTeamName = 0;
+    this.dummyEsaTeamNameForError = 1;
+
+    this.state = {
+      retrieveError: null,
+      // set dummy value tile for using suspense
+      esaTeamName: this.dummyEsaTeamName,
+      esaAccessToken: '',
+      qiitaTeamName: '',
+      qiitaAccessToken: '',
+    };
+
+    this.esaHandleSubmit = this.esaHandleSubmit.bind(this);
+    this.esaHandleSubmitTest = this.esaHandleSubmitTest.bind(this);
+    this.esaHandleSubmitUpdate = this.esaHandleSubmitUpdate.bind(this);
+    this.qiitaHandleSubmit = this.qiitaHandleSubmit.bind(this);
+    this.qiitaHandleSubmitTest = this.qiitaHandleSubmitTest.bind(this);
+    this.qiitaHandleSubmitUpdate = this.qiitaHandleSubmitUpdate.bind(this);
+    this.handleInputValue = this.handleInputValue.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminImportContainer';
+  }
+
+  /**
+   * retrieve app sttings data
+   */
+  async retrieveImportSettingsData() {
+    const response = await this.appContainer.apiv3.get('/import/');
+    const {
+      importSettingsParams,
+    } = response.data;
+
+    this.setState({
+      esaTeamName: importSettingsParams.esaTeamName,
+      esaAccessToken: importSettingsParams.esaAccessToken,
+      qiitaTeamName: importSettingsParams.qiitaTeamName,
+      qiitaAccessToken: importSettingsParams.qiitaAccessToken,
+    });
+  }
+
+  handleInputValue(event) {
+    this.setState({
+      [event.target.name]: event.target.value,
+    });
+  }
+
+  async esaHandleSubmit() {
+    try {
+      const params = {
+        'importer:esa:team_name': this.state.esaTeamName,
+        'importer:esa:access_token': this.state.esaAccessToken,
+      };
+      await this.appContainer.apiPost('/admin/import/esa', params);
+      toastSuccess('Import posts from esa success.');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Error occurred in importing pages from esa.io');
+    }
+  }
+
+  async esaHandleSubmitTest() {
+    try {
+      const params = {
+        'importer:esa:team_name': this.state.esaTeamName,
+        'importer:esa:access_token': this.state.esaAccessToken,
+      };
+      await this.appContainer.apiPost('/admin/import/testEsaAPI', params);
+      toastSuccess('Test connection to esa success.');
+    }
+    catch (error) {
+      toastError(error, 'Test connection to esa failed.');
+    }
+  }
+
+  async esaHandleSubmitUpdate() {
+    const params = {
+      'importer:esa:team_name': this.state.esaTeamName,
+      'importer:esa:access_token': this.state.esaAccessToken,
+    };
+    try {
+      await this.appContainer.apiPost('/admin/settings/importerEsa', params);
+      toastSuccess('Updated');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Errors');
+    }
+  }
+
+  async qiitaHandleSubmit() {
+    try {
+      const params = {
+        'importer:qiita:team_name': this.state.qiitaTeamName,
+        'importer:qiita:access_token': this.state.qiitaAccessToken,
+      };
+      await this.appContainer.apiPost('/admin/import/qiita', params);
+      toastSuccess('Import posts from qiita:team success.');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Error occurred in importing pages from qiita:team');
+    }
+  }
+
+
+  async qiitaHandleSubmitTest() {
+    try {
+      const params = {
+        'importer:qiita:team_name': this.state.qiitaTeamName,
+        'importer:qiita:access_token': this.state.qiitaAccessToken,
+      };
+      await this.appContainer.apiPost('/admin/import/testQiitaAPI', params);
+      toastSuccess('Test connection to qiita:team success.');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Test connection to qiita:team failed.');
+    }
+  }
+
+  async qiitaHandleSubmitUpdate() {
+    const params = {
+      'importer:qiita:team_name': this.state.qiitaTeamName,
+      'importer:qiita:access_token': this.state.qiitaAccessToken,
+    };
+    try {
+      await this.appContainer.apiPost('/admin/settings/importerQiita', params);
+      toastSuccess('Updated');
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(err, 'Errors');
+    }
+  }
+
+}

+ 4 - 1
src/client/js/services/AdminLdapSecurityContainer.js

@@ -15,10 +15,13 @@ export default class AdminLdapSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyServerUrl = 0;
+    this.dummyServerUrlForError = 1;
 
     this.state = {
       retrieveError: null,
-      serverUrl: '',
+      // set dummy value tile for using suspense
+      serverUrl: this.dummyServerUrl,
       isUserBind: false,
       ldapBindDN: '',
       ldapBindDNPassword: '',

+ 4 - 1
src/client/js/services/AdminLocalSecurityContainer.js

@@ -13,10 +13,13 @@ export default class AdminLocalSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyRegistrationMode = 0;
+    this.dummyRegistrationModeForError = 1;
 
     this.state = {
       retrieveError: null,
-      registrationMode: 'Open',
+      // set dummy value tile for using suspense
+      registrationMode: this.dummyRegistrationMode,
       registrationWhiteList: [],
       useOnlyEnvVars: false,
     };

+ 16 - 26
src/client/js/services/AdminMarkDownContainer.js

@@ -1,11 +1,5 @@
 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
@@ -16,10 +10,13 @@ export default class AdminMarkDownContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyIsEnabledLinebreaks = 0;
+    this.dummyIsEnabledLinebreaksForError = 1;
 
     this.state = {
       retrieveError: null,
-      isEnabledLinebreaks: false,
+      // set dummy value tile for using suspense
+      isEnabledLinebreaks: this.dummyIsEnabledLinebreaks,
       isEnabledLinebreaksInComments: false,
       pageBreakSeparator: 1,
       pageBreakCustomSeparator: '',
@@ -43,26 +40,19 @@ export default class AdminMarkDownContainer extends Container {
    * 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 || '',
-      });
+    const response = await this.appContainer.apiv3.get('/markdown-setting/');
+    const { markdownParams } = response.data;
 
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to fetch 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 || '',
+    });
   }
 
   /**

+ 15 - 26
src/client/js/services/AdminNotificationContainer.js

@@ -1,11 +1,5 @@
 import { Container } from 'unstated';
 
-import loggerFactory from '@alias/logger';
-
-import { toastError } from '../util/apiNotification';
-
-const logger = loggerFactory('growi:services:AdminNotificationContainer');
-
 /**
  * Service container for admin Notification setting page (NotificationSetting.jsx)
  * @extends {Container} unstated Container
@@ -16,11 +10,13 @@ export default class AdminNotificationContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyWebhookUrl = 0;
+    this.dummyWebhookUrlForError = 1;
 
     this.state = {
       retrieveError: null,
       selectSlackOption: 'Incoming Webhooks',
-      webhookUrl: '',
+      webhookUrl: this.dummyWebhookUrl,
       isIncomingWebhookPrioritized: false,
       slackToken: '',
       userNotifications: [],
@@ -42,25 +38,18 @@ export default class AdminNotificationContainer extends Container {
    * Retrieve notificationData
    */
   async retrieveNotificationData() {
-    try {
-      const response = await this.appContainer.apiv3.get('/notification-setting/');
-      const { notificationParams } = response.data;
-
-      this.setState({
-        webhookUrl: notificationParams.webhookUrl,
-        isIncomingWebhookPrioritized: notificationParams.isIncomingWebhookPrioritized,
-        slackToken: notificationParams.slackToken,
-        userNotifications: notificationParams.userNotifications,
-        isNotificationForOwnerPageEnabled: notificationParams.isNotificationForOwnerPageEnabled,
-        isNotificationForGroupPageEnabled: notificationParams.isNotificationForGroupPageEnabled,
-        globalNotifications: notificationParams.globalNotifications,
-      });
-
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to fetch data'));
-    }
+    const response = await this.appContainer.apiv3.get('/notification-setting/');
+    const { notificationParams } = response.data;
+
+    this.setState({
+      webhookUrl: notificationParams.webhookUrl,
+      isIncomingWebhookPrioritized: notificationParams.isIncomingWebhookPrioritized,
+      slackToken: notificationParams.slackToken,
+      userNotifications: notificationParams.userNotifications,
+      isNotificationForOwnerPageEnabled: notificationParams.isNotificationForOwnerPageEnabled,
+      isNotificationForGroupPageEnabled: notificationParams.isNotificationForGroupPageEnabled,
+      globalNotifications: notificationParams.globalNotifications,
+    });
   }
 
   /**

+ 4 - 1
src/client/js/services/AdminOidcSecurityContainer.js

@@ -17,11 +17,14 @@ export default class AdminOidcSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyOidcProviderName = 0;
+    this.dummyOidcProviderNameForError = 1;
 
     this.state = {
       retrieveError: null,
       callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/oidc/callback'),
-      oidcProviderName: '',
+      // set dummy value tile for using suspense
+      oidcProviderName: this.dummyOidcProviderName,
       oidcIssuerHost: '',
       oidcAuthorizationEndpoint: '',
       oidcTokenEndpoint: '',

+ 22 - 2
src/client/js/services/AdminSamlSecurityContainer.js

@@ -18,6 +18,8 @@ export default class AdminSamlSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummySamlEntryPoint = 0;
+    this.dummySamlEntryPointForError = 1;
 
     this.state = {
       retrieveError: null,
@@ -25,7 +27,8 @@ export default class AdminSamlSecurityContainer extends Container {
       useOnlyEnvVars: false,
       callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/saml/callback'),
       missingMandatoryConfigKeys: [],
-      samlEntryPoint: '',
+      // set dummy value tile for using suspense
+      samlEntryPoint: this.dummySamlEntryPoint,
       samlIssuer: '',
       samlCert: '',
       samlAttrMapId: '',
@@ -36,6 +39,15 @@ export default class AdminSamlSecurityContainer extends Container {
       isSameUsernameTreatedAsIdenticalUser: false,
       isSameEmailTreatedAsIdenticalUser: false,
       samlABLCRule: '',
+      envEntryPoint: '',
+      envIssuer: '',
+      envCert: '',
+      envAttrMapId: '',
+      envAttrMapUsername: '',
+      envAttrMapMail: '',
+      envAttrMapFirstName: '',
+      envAttrMapLastName: '',
+      envABLCRule: '',
     };
 
   }
@@ -61,8 +73,16 @@ export default class AdminSamlSecurityContainer extends Container {
         isSameUsernameTreatedAsIdenticalUser: samlAuth.isSameUsernameTreatedAsIdenticalUser,
         isSameEmailTreatedAsIdenticalUser: samlAuth.isSameEmailTreatedAsIdenticalUser,
         samlABLCRule: samlAuth.samlABLCRule,
+        envEntryPoint: samlAuth.samlEnvVarEntryPoint,
+        envIssuer: samlAuth.samlEnvVarIssuer,
+        envCert: samlAuth.samlEnvVarCert,
+        envAttrMapId: samlAuth.samlEnvVarAttrMapId,
+        envAttrMapUsername: samlAuth.samlEnvVarAttrMapUsername,
+        envAttrMapMail: samlAuth.samlEnvVarAttrMapMail,
+        envAttrMapFirstName: samlAuth.samlEnvVarAttrMapFirstName,
+        envAttrMapLastName: samlAuth.samlEnvVarAttrMapLastName,
+        envABLCRule: samlAuth.samlEnvVarABLCRule,
       });
-      return samlAuth;
     }
     catch (err) {
       this.setState({ retrieveError: err });

+ 4 - 1
src/client/js/services/AdminTwitterSecurityContainer.js

@@ -17,10 +17,13 @@ export default class AdminTwitterSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
+    this.dummyTwitterConsumerKey = 0;
+    this.dummyTwitterConsumerKeyForError = 1;
 
     this.state = {
       callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/twitter/callback'),
-      twitterConsumerKey: '',
+      // set dummy value tile for using suspense
+      twitterConsumerKey: this.dummyTwitterConsumerKey,
       twitterConsumerSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
     };

+ 162 - 0
src/client/js/services/PageHistoryContainer.js

@@ -0,0 +1,162 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+const logger = loggerFactory('growi:PageHistoryContainer');
+
+/**
+ * Service container for personal settings page (PageHistory.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class PageHistoryContainer extends Container {
+
+  constructor(appContainer, pageContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.pageContainer = pageContainer;
+
+    this.dummyRevisions = 0;
+
+    this.state = {
+      errorMessage: null,
+
+      // set dummy rivisions for using suspense
+      revisions: this.dummyRevisions,
+      diffOpened: {},
+
+      totalPages: 0,
+      activePage: 1,
+      pagingLimit: Infinity,
+    };
+
+    this.retrieveRevisions = this.retrieveRevisions.bind(this);
+    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
+    this.getPreviousRevision = this.getPreviousRevision.bind(this);
+    this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'PageHistoryContainer';
+  }
+
+  /**
+   * syncRevisions of selectedPage
+   * @param {number} selectedPage
+   */
+  async retrieveRevisions(selectedPage) {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+    if (!pageId) {
+      return;
+    }
+
+    const res = await this.appContainer.apiv3Get('/revisions/list', { pageId, shareLinkId, selectedPage });
+    const rev = res.data.docs;
+
+    // set Pagination state
+    this.setState({
+      activePage: selectedPage,
+      totalPages: res.data.totalDocs,
+      pagingLimit: res.data.limit,
+    });
+
+    const diffOpened = {};
+    const lastId = rev.length - 1;
+
+    res.data.docs.forEach((revision, i) => {
+      const user = revision.author;
+      if (user) {
+        rev[i].author = user;
+      }
+
+      if (i === 0 || i === lastId) {
+        diffOpened[revision._id] = true;
+      }
+      else {
+        diffOpened[revision._id] = false;
+      }
+    });
+
+    this.setState({ revisions: rev });
+    this.setState({ diffOpened });
+
+    // load 0, and last default
+    if (rev[0]) {
+      this.fetchPageRevisionBody(rev[0]);
+    }
+    if (rev[1]) {
+      this.fetchPageRevisionBody(rev[1]);
+    }
+    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
+      this.fetchPageRevisionBody(rev[lastId]);
+    }
+
+    return;
+  }
+
+  onDiffOpenClicked(revision) {
+    const { diffOpened } = this.state;
+    const revisionId = revision._id;
+
+    diffOpened[revisionId] = !(diffOpened[revisionId]);
+    this.setState(diffOpened);
+
+    this.fetchPageRevisionBody(revision);
+    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
+  }
+
+  getPreviousRevision(currentRevision) {
+    let cursor = null;
+    for (const revision of this.state.revisions) {
+      // comparing ObjectId
+      // eslint-disable-next-line eqeqeq
+      if (cursor && cursor._id == currentRevision._id) {
+        cursor = revision;
+        break;
+      }
+
+      cursor = revision;
+    }
+
+    return cursor;
+  }
+
+  /**
+   * fetch page revision body by revision in argument
+   * @param {object} revision
+   */
+  async fetchPageRevisionBody(revision) {
+    const { pageId, shareLinkId } = this.pageContainer.state;
+
+    if (revision.body) {
+      return;
+    }
+
+    try {
+      const res = await this.appContainer.apiv3Get(`/revisions/${revision._id}`, { pageId, shareLinkId });
+      this.setState({
+        revisions: this.state.revisions.map((rev) => {
+          // comparing ObjectId
+          // eslint-disable-next-line eqeqeq
+          if (rev._id == res.data.revision._id) {
+            return res.data.revision;
+          }
+
+          return rev;
+        }),
+      });
+    }
+    catch (err) {
+      toastError(err);
+      this.setState({ errorMessage: err.message });
+      logger.error(err);
+    }
+  }
+
+
+}

+ 0 - 2
src/client/js/services/PersonalContainer.js

@@ -29,7 +29,6 @@ export default class PersonalContainer extends Container {
       isUploadedPicture: false,
       uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
-      isPasswordSet: false,
       apiToken: '',
     };
 
@@ -55,7 +54,6 @@ export default class PersonalContainer extends Container {
         isEmailPublished: currentUser.isEmailPublished,
         lang: currentUser.lang,
         isGravatarEnabled: currentUser.isGravatarEnabled,
-        isPasswordSet: (currentUser.password != null),
         apiToken: currentUser.apiToken,
       });
     }

+ 1 - 1
src/client/styles/scss/_editor-navbar.scss

@@ -10,7 +10,7 @@
     }
 
     button {
-      padding: 8px;
+      padding: 0px;
       margin: 0 2px;
       font-size: 1rem;
       line-height: 1;

+ 7 - 4
src/client/styles/scss/_layout.scss

@@ -70,6 +70,10 @@ body {
     font-size: 24px;
 
     box-shadow: 2px 3px 6px #0000005d;
+    svg {
+      width: 28px;
+      height: 28px;
+    }
   }
 
   .btn-scroll-to-top {
@@ -77,10 +81,9 @@ body {
     height: 40px;
 
     opacity: 0.4;
-
-    i {
-      display: inline-block;
-      transform: rotate(90deg);
+    svg {
+      width: 18px;
+      height: 18px;
     }
   }
 }

+ 10 - 2
src/client/styles/scss/_navbar.scss

@@ -50,8 +50,16 @@
   }
 
   .grw-personal-dropdown {
-    .grw-email-sm {
-      font-size: 0.75em;
+    .dropdown-menu {
+      min-width: 15rem;
+
+      .grw-email-sm {
+        font-size: 0.75em;
+      }
+      .grw-icon-container svg {
+        width: 18px;
+        height: 18px;
+      }
     }
   }
 }

+ 0 - 11
src/client/styles/scss/_notification.scss

@@ -1,11 +0,0 @@
-// Slack
-.input-group-slack {
-  .input-group-addon {
-    padding: 2px 8px;
-    line-height: 1em;
-    img,
-    input {
-      vertical-align: middle;
-    }
-  }
-}

+ 20 - 0
src/client/styles/scss/_on-edit.scss

@@ -122,6 +122,26 @@ body.on-edit {
   .grw-editor-navbar-bottom {
     height: $grw-editor-navbar-bottom-height;
 
+    .grw-slack-notification {
+      .input-group-addon {
+        padding: 2px 8px;
+        line-height: 1em;
+        img,
+        input {
+          vertical-align: middle;
+        }
+      }
+      .form-control {
+        width: 80px;
+        @include media-breakpoint-up(sm) {
+          width: 130px;
+        }
+        @include media-breakpoint-up(md) {
+          width: 180px;
+        }
+      }
+    }
+
     .grw-grant-selector {
       @include media-breakpoint-down(sm) {
         .btn .label {

+ 36 - 1
src/client/styles/scss/_override-bootstrap-variables.scss

@@ -36,6 +36,9 @@ $font-family-base:        $font-family-sans-serif;
 $font-size-root: 14px;
 $line-height-base: 1.42857;
 
+$text-muted: $gray-500;
+$blockquote-small-color: $gray-500;
+
 //== Components
 //
 $border-radius:               .15rem;
@@ -43,16 +46,30 @@ $border-radius-sm:            .1rem;
 $border-radius-lg:            .25rem;
 $border-radius-xl:            .35rem;
 
+// Buttons
+//
+// For each of Bootstrap's buttons, define text, background, and border color.
+$btn-link-disabled-color: $gray-500;
 
 //== Forms
 //
+$input-border-color: $gray-300;
+
 $input-border-radius: $border-radius-sm;
 $input-border-radius-sm: $border-radius-sm;
 $input-border-radius-lg: $border-radius;
 
+$input-placeholder-color: $gray-500;
+
+$custom-control-indicator-border-color: $gray-400;
+$custom-control-label-disabled-color: $gray-500;
+$custom-select-disabled-color: $gray-500;
+$custom-range-thumb-disabled-bg: $gray-400;
+
 //== Navs
 $nav-link-padding-y: 0.75rem;
 $nav-link-padding-x: 1rem;
+$nav-link-disabled-color: $gray-500;
 
 //== Navbar
 $navbar-padding-y: 0;
@@ -61,11 +78,19 @@ $navbar-nav-link-padding-x: 1rem;
 
 //== Dropdowns
 $dropdown-border-radius: $border-radius-sm;
+$dropdown-link-disabled-color: $gray-500;
+$dropdown-header-color: $gray-500;
 
-//== card
+//== Pagination
+$pagination-disabled-color: $gray-500;
+
+//== Cards
 $card-spacer-y: 7px;
 $card-spacer-x: 15px;
 
+//== Toasts
+$toast-header-color: $gray-500;
+
 //== Modals
 $modal-content-border-width: 0;
 $modal-header-padding-y: 0.75rem;
@@ -82,6 +107,16 @@ $progress-border-radius: $border-radius-sm;
 $progress-bg: $gray-100;
 $progress-box-shadow: none;
 
+//== List group
+$list-group-disabled-color: $gray-500;
+
+//==  Figures
+$figure-caption-color: $gray-500;
+
+//==  Breadcrumbs
+$breadcrumb-divider-color: $gray-500;
+$breadcrumb-active-color: $gray-500;
+
 //== Code
 $pre-color: dummyinvalildcolor; // disable pre color specification with invalid value
 

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

@@ -45,7 +45,6 @@
 @import 'mirror_mode';
 @import 'navbar';
 @import 'navbar_kibela';
-@import 'notification';
 @import 'on-edit';
 @import 'page_list';
 @import 'page-path';

+ 19 - 0
src/client/styles/scss/theme/_apply-colors.scss

@@ -79,6 +79,15 @@ pre:not(.hljs):not(.CodeMirror-line) {
   color: $color-global;
 }
 
+.grw-personal-dropdown {
+  .grw-icon-container svg {
+    fill: $color-global;
+  }
+  .grw-icon-container-muted svg {
+    fill: $secondary;
+  }
+}
+
 .dropdown-item {
   color: $color-global;
   &:active,
@@ -442,3 +451,13 @@ mark.rbt-highlight-text {
 .bg-box {
   background-color: $bgcolor-global;
 }
+
+.grw-fab {
+  .btn-create-page {
+    fill: color-yiq($primary);
+  }
+
+  .btn-scroll-to-top {
+    fill: $gray-900;
+  }
+}

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