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

Merge branch 'imprv/duplicate-Page-with-child' into imprv/gw3374

白石誠 5 лет назад
Родитель
Сommit
15aa5f22a9
74 измененных файлов с 4782 добавлено и 3687 удалено
  1. 57 0
      .github/workflows/list-unhealthy-branches.yml
  2. 2 0
      CHANGES.md
  3. 154 0
      bin/github-actions/list-branches.js
  4. 1 0
      resource/locales/en_US/translation.json
  5. 1 0
      resource/locales/ja_JP/translation.json
  6. 1 0
      resource/locales/zh_CN/translation.json
  7. 3 0
      src/client/js/admin.jsx
  8. 6 2
      src/client/js/app.jsx
  9. 22 12
      src/client/js/components/Admin/App/AppSettingsPage.jsx
  10. 63 57
      src/client/js/components/Admin/Customize/Customize.jsx
  11. 245 0
      src/client/js/components/Admin/ImportData/ImportDataPageContents.jsx
  12. 41 321
      src/client/js/components/Admin/ImportDataPage.jsx
  13. 38 52
      src/client/js/components/Admin/MarkdownSetting/MarkDownSetting.jsx
  14. 47 0
      src/client/js/components/Admin/MarkdownSetting/MarkDownSettingContents.jsx
  15. 37 92
      src/client/js/components/Admin/Notification/NotificationSetting.jsx
  16. 98 0
      src/client/js/components/Admin/Notification/NotificationSettingContents.jsx
  17. 46 128
      src/client/js/components/Admin/Security/BasicSecuritySetting.jsx
  18. 127 0
      src/client/js/components/Admin/Security/BasicSecuritySettingContents.jsx
  19. 41 198
      src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx
  20. 198 0
      src/client/js/components/Admin/Security/GitHubSecuritySettingContents.jsx
  21. 41 208
      src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx
  22. 208 0
      src/client/js/components/Admin/Security/GoogleSecuritySettingContents.jsx
  23. 42 441
      src/client/js/components/Admin/Security/LdapSecuritySetting.jsx
  24. 446 0
      src/client/js/components/Admin/Security/LdapSecuritySettingContents.jsx
  25. 41 170
      src/client/js/components/Admin/Security/LocalSecuritySetting.jsx
  26. 193 0
      src/client/js/components/Admin/Security/LocalSecuritySettingContents.jsx
  27. 41 474
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  28. 476 0
      src/client/js/components/Admin/Security/OidcSecuritySettingContents.jsx
  29. 42 534
      src/client/js/components/Admin/Security/SamlSecuritySetting.jsx
  30. 516 0
      src/client/js/components/Admin/Security/SamlSecuritySettingContents.jsx
  31. 44 184
      src/client/js/components/Admin/Security/SecurityManagement.jsx
  32. 196 0
      src/client/js/components/Admin/Security/SecurityManagementContents.jsx
  33. 3 18
      src/client/js/components/Admin/Security/SecuritySetting.jsx
  34. 43 207
      src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx
  35. 206 0
      src/client/js/components/Admin/Security/TwitterSecuritySettingContents.jsx
  36. 5 7
      src/client/js/components/Page/RevisionLoader.jsx
  37. 53 24
      src/client/js/components/PageDuplicateModal.jsx
  38. 1 1
      src/client/js/components/PageEditor/LinkEditModal.jsx
  39. 55 144
      src/client/js/components/PageHistory.jsx
  40. 6 3
      src/client/js/components/PageHistory/PageRevisionList.jsx
  41. 21 0
      src/client/js/components/SuspenseUtils.jsx
  42. 24 36
      src/client/js/services/AdminAppContainer.js
  43. 4 1
      src/client/js/services/AdminBasicSecurityContainer.js
  44. 4 1
      src/client/js/services/AdminCustomizeContainer.js
  45. 5 1
      src/client/js/services/AdminGeneralSecurityContainer.js
  46. 5 2
      src/client/js/services/AdminGitHubSecurityContainer.js
  47. 4 1
      src/client/js/services/AdminGoogleSecurityContainer.js
  48. 160 0
      src/client/js/services/AdminImportContainer.js
  49. 4 1
      src/client/js/services/AdminLdapSecurityContainer.js
  50. 4 1
      src/client/js/services/AdminLocalSecurityContainer.js
  51. 16 26
      src/client/js/services/AdminMarkDownContainer.js
  52. 15 26
      src/client/js/services/AdminNotificationContainer.js
  53. 4 1
      src/client/js/services/AdminOidcSecurityContainer.js
  54. 22 2
      src/client/js/services/AdminSamlSecurityContainer.js
  55. 4 1
      src/client/js/services/AdminTwitterSecurityContainer.js
  56. 162 0
      src/client/js/services/PageHistoryContainer.js
  57. 12 0
      src/server/crowi/index.js
  58. 2 26
      src/server/models/attachment.js
  59. 8 20
      src/server/models/page.js
  60. 2 0
      src/server/models/revision.js
  61. 4 2
      src/server/models/user.js
  62. 36 0
      src/server/routes/apiv3/import.js
  63. 2 0
      src/server/routes/apiv3/index.js
  64. 59 26
      src/server/routes/apiv3/pages.js
  65. 145 0
      src/server/routes/apiv3/revisions.js
  66. 14 31
      src/server/routes/attachment.js
  67. 0 5
      src/server/routes/index.js
  68. 0 190
      src/server/routes/revision.js
  69. 58 0
      src/server/service/attachment.js
  70. 34 5
      src/server/service/file-uploader/aws.js
  71. 26 3
      src/server/service/file-uploader/gcs.js
  72. 1 0
      src/server/service/file-uploader/local.js
  73. 35 0
      src/server/service/page.js
  74. 0 2
      src/server/util/swigFunctions.js

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

+ 2 - 0
CHANGES.md

@@ -3,6 +3,8 @@
 ## v4.1.3-RC
 
 * Feature: Create/edit linker with GUI
+* Improvement: Paging page histories
+* Fix: To be able to delete attachment metadata even when the actual data does not exist
 
 ## v4.1.2
 

+ 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 - 0
resource/locales/en_US/translation.json

@@ -329,6 +329,7 @@
       "Fail to get subordinated pages": "Fail to get subordinated pages",
       "Current page name": "Current page name",
       "Duplicate with child": "Duplicate with child",
+      "Duplicate without exist path": "Duplicate without exist path",
       "Same page already exists": "Same page already exists"
     }
   },

+ 1 - 0
resource/locales/ja_JP/translation.json

@@ -331,6 +331,7 @@
       "Fail to get subordinated pages": "配下ページの取得に失敗しました",
       "Current page name": "現在のページ名",
       "Duplicate with child": "配下のページも一緒に複製する",
+      "Duplicate without exist path": "存在するパス以外を複製する",
       "Same page already exists": "同じページがすでに存在します"
     }
   },

+ 1 - 0
resource/locales/zh_CN/translation.json

@@ -304,6 +304,7 @@
       "Fail to get subordinated pages": "Fail to get subordinated pages",
 			"Current page name": "Current page name",
       "Duplicate with child": "Duplicate with child",
+      "Duplicate without exist path": "Duplicate without exist path",
       "Same page already exists": "Same page already exists"
 		}
 	},

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

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

@@ -4,6 +4,7 @@ import loggerFactory from '@alias/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '../../../util/apiNotification';
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
 
 import AdminAppContainer from '../../../services/AdminAppContainer';
 
@@ -11,44 +12,53 @@ import AppSettingsPageContents from './AppSettingsPageContents';
 
 const logger = loggerFactory('growi:appSettings');
 
-function AppSettingsPage(props) {
+function AppSettingsPageWithContainerWithSuspense(props) {
   return (
     <Suspense
       fallback={(
         <div className="row">
           <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
         </div>
-)}
+      )}
     >
-      <RenderAppSettingsPageWrapper />
+      <AppSettingsPageWithUnstatedContainer />
     </Suspense>
   );
 }
 
-function RenderAppSettingsPage(props) {
+let retrieveErrors = null;
+function AppSettingsPage(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(AppSettingsPage, [AdminAppContainer]);
 
-export default AppSettingsPage;
+export default AppSettingsPageWithContainerWithSuspense;

+ 63 - 57
src/client/js/components/Admin/Customize/Customize.jsx

@@ -1,13 +1,13 @@
 
-import React, { Fragment } from 'react';
+import React, { Fragment, Suspense } 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 CustomizeLayoutSetting from './CustomizeLayoutSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
@@ -17,70 +17,76 @@ import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeTitle from './CustomizeTitle';
 
-class Customize extends React.Component {
+const logger = loggerFactory('growi:services:AdminCustomizePage');
 
-  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);
-    }
+function CustomizePageWithContainerWithSusupense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <CustomizePageWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
+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(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 CustomizePageWithContainerWithSusupense;

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

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

@@ -1,345 +1,65 @@
-import React, { Fragment } from 'react';
-import { withTranslation } from 'react-i18next';
+import React, { Suspense } 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 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 {
-
-  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');
-    }
-  }
-
-  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');
-    }
-  }
-
+function ImportDataPageWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <ImportDataPageWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-  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.');
-    }
+let retrieveErrors = null;
+function ImportDataPage(props) {
+  const { adminImportContainer } = props;
+
+  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 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(ImportDataPage, [AdminImportContainer]);
 
-export default withTranslation()(ImportDataPageWrapper);
+export default ImportDataPageWithContainerWithSuspense;

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

@@ -1,75 +1,61 @@
-import React from 'react';
-import { Card, CardBody } from 'reactstrap';
+import React, { Suspense } from 'react';
 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 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);
-    }
+function MarkdownSettingWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <MarkdownSettingWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
+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(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 MarkdownSettingWithContainerWithSuspense;

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

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

@@ -1,116 +1,61 @@
-import React from 'react';
+import React, { Suspense } 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 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);
-  }
-
-  async componentDidMount() {
-    const { adminNotificationContainer } = this.props;
-
-    try {
-      await adminNotificationContainer.retrieveNotificationData();
-    }
-    catch (err) {
-      toastError(err);
-      adminNotificationContainer.setState({ retrieveError: err });
-      logger.error(err);
-    }
+function NotificationSettingWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <NotificationSettingWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
+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 });
+      }
+    })();
   }
 
-  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(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 NotificationSettingWithContainerWithSuspense;

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

+ 46 - 128
src/client/js/components/Admin/Security/BasicSecuritySetting.jsx

@@ -1,146 +1,64 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { Suspense } 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 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 });
+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,
+        });
+
+      }
+    })();
   }
 
-  async onClickSubmit() {
-    const { t, adminBasicSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminBasicSecurityContainer.updateBasicSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.Basic.updated_basic'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminBasicSecurityContainer } = this.props;
-    const { isBasicEnabled } = adminGeneralSecurityContainer.state;
-
-    if (this.state.isRetrieving) {
-      return null;
-    }
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          { t('security_setting.Basic.name') }
-        </h2>
-
-        {this.state.retrieveError != null && (
-        <div className="alert alert-danger">
-          <p>{t('Error occurred')} : {this.state.err}</p>
-        </div>
-        )}
-
-        <div className="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(BasicSecurityManagement, [
+  AdminBasicSecurityContainer,
+]);
+
+function BasicSecurityManagementWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <BasicSecurityManagementWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(OidcSecurityManagementWrapper);
+export default BasicSecurityManagementWithContainerWithSuspense;

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

+ 41 - 198
src/client/js/components/Admin/Security/GitHubSecuritySetting.jsx

@@ -1,217 +1,60 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { Suspense } 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 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(GitHubSecurityManagement, [
+  AdminGitHubSecurityContainer,
+]);
+
+function GitHubSecurityManagementWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <GitHubSecurityManagementWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(GitHubSecurityManagementWrapper);
+export default GitHubSecurityManagementWithContainerWithSuspense;

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

+ 41 - 208
src/client/js/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -1,226 +1,59 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { Suspense } 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 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(GoogleSecurityManagement, [
+  AdminGoogleSecurityContainer,
+]);
+
+function GoogleSecurityManagementWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <GoogleSecurityManagementWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(GoogleSecurityManagementWrapper);
+export default GoogleSecurityManagementWithContainerWithSuspense;

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

+ 42 - 441
src/client/js/components/Admin/Security/LdapSecuritySetting.jsx

@@ -1,458 +1,59 @@
-import React from 'react';
+import React, { Suspense } 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 AppContainer from '../../../services/AppContainer';
-import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
 import AdminLdapSecurityContainer from '../../../services/AdminLdapSecurityContainer';
-import LdapAuthTestModal from './LdapAuthTestModal';
 
-
-class LdapSecuritySetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRetrieving: true,
-      isLdapAuthTestModalShown: false,
-    };
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-    this.openLdapAuthTestModal = this.openLdapAuthTestModal.bind(this);
-    this.closeLdapAuthTestModal = this.closeLdapAuthTestModal.bind(this);
-  }
-
-  async componentDidMount() {
-    const { adminLdapSecurityContainer } = this.props;
-
-    try {
-      await adminLdapSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.setState({ isRetrieving: false });
-  }
-
-  async onClickSubmit() {
-    const { t, adminLdapSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminLdapSecurityContainer.updateLdapSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.ldap.updated_ldap'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  openLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: true });
-  }
-
-  closeLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: false });
+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 });
+      }
+    })();
   }
 
-  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(LdapSecuritySetting, [
+  AdminLdapSecurityContainer,
+]);
+
+function LdapSecuritySettingWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <LdapSecuritySettingWithUnstatedContainer />
+    </Suspense>
+  );
+}
+
 
-export default withTranslation()(LdapSecuritySettingWrapper);
+export default LdapSecuritySettingWithContainerWithSuspense;

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

+ 41 - 170
src/client/js/components/Admin/Security/LocalSecuritySetting.jsx

@@ -1,188 +1,59 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { Suspense } 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 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(LocalSecuritySetting, [
+  AdminLocalSecurityContainer,
+]);
+
+function LocalSecuritySettingWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+)}
+    >
+      <LocalSecuritySettingWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(LocalSecuritySettingWrapper);
+export default LocalSecuritySettingWithContainerWithSuspense;

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

+ 41 - 474
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -1,492 +1,59 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { Suspense } 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 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(OidcSecurityManagement, [
+  AdminOidcSecurityContainer,
+]);
+
+function OidcSecurityManagementWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <OidcSecurityManagementWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(OidcSecurityManagementWrapper);
+export default OidcSecurityManagementWithContainerWithSuspense;

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

+ 42 - 534
src/client/js/components/Admin/Security/SamlSecuritySetting.jsx

@@ -1,551 +1,59 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { Suspense } 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 });
+import toArrayIfNot from '../../../../../lib/util/toArrayIfNot';
+
+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 });
+      }
+    })();
   }
 
-  async onClickSubmit() {
-    const { t, adminSamlSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminSamlSecurityContainer.updateSamlSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.SAML.updated_saml'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminSamlSecurityContainer } = this.props;
-    const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
-    const { isSamlEnabled } = adminGeneralSecurityContainer.state;
-
-    if (this.state.isRetrieving) {
-      return null;
-    }
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          {t('security_setting.SAML.name')}
-        </h2>
-
-        {useOnlyEnvVars && (
-          <p
-            className="alert alert-info"
-            dangerouslySetInnerHTML={{ __html: t('security_setting.SAML.note for the only env option', { env: 'SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
-          />
-        )}
-
-        <div className="row 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(SamlSecurityManagement, [
+  AdminSamlSecurityContainer,
+]);
+
+function SamlSecurityManagementWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <SamlSecurityManagementWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(SamlSecurityManagementWrapper);
+export default SamlSecurityManagementWithContainerWithSuspense;

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

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

@@ -1,197 +1,57 @@
-import React, { Fragment } from 'react';
+import React, { Suspense } 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 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(SecurityManagement, [AdminGeneralSecurityContainer]);
+
+function SecurityManagementWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <SecurityManagementWithUnstatedContainer {...props} />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(SecurityManagementWrapper);
+export default SecurityManagementWithContainerWithSuspense;

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

+ 43 - 207
src/client/js/components/Admin/Security/TwitterSecuritySetting.jsx

@@ -1,225 +1,61 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { Suspense } 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 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);
+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 componentDidMount() {
-    const { adminTwitterSecurityContainer } = this.props;
-
-    try {
-      await adminTwitterSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
-      toastError(err);
-    }
-    this.setState({ isRetrieving: false });
-  }
-
-  async onClickSubmit() {
-    const { t, adminTwitterSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminTwitterSecurityContainer.updateTwitterSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_setting.OAuth.Twitter.updated_twitter'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminTwitterSecurityContainer } = this.props;
-    const { isTwitterEnabled } = adminGeneralSecurityContainer.state;
-
-    if (this.state.isRetrieving) {
-      return null;
-    }
-    return (
-
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          {t('security_setting.OAuth.Twitter.name')}
-        </h2>
-
-        {this.state.retrieveError != null && (
-          <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {this.state.err}</p>
-          </div>
-        )}
-
-        <div className="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(TwitterSecurityManagement, [
+  AdminTwitterSecurityContainer,
+]);
+
+function TwitterSecurityManagementWithContainerWithSuspense(props) {
+  return (
+    <Suspense
+      fallback={(
+        <div className="row">
+          <i className="fa fa-5x fa-spinner fa-pulse mx-auto text-muted"></i>
+        </div>
+      )}
+    >
+      <TwitterSecurityManagementWithUnstatedContainer />
+    </Suspense>
+  );
+}
 
-export default withTranslation()(TwitterSecurityManagementWrapper);
+export default TwitterSecurityManagementWithContainerWithSuspense;

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

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

+ 53 - 24
src/client/js/components/PageDuplicateModal.jsx

@@ -13,6 +13,7 @@ import PageContainer from '../services/PageContainer';
 import PagePathAutoComplete from './PagePathAutoComplete';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
+
 const PageDuplicateModal = (props) => {
   const { t, appContainer, pageContainer } = props;
 
@@ -25,15 +26,35 @@ const PageDuplicateModal = (props) => {
 
   const [errs, setErrs] = useState(null);
 
-  const [subordinatedPaths, setSubordinatedPaths] = useState([]);
+  // ToDo: subordinatedPaths is not used yet so commented. Will use when all the child of { path } is needed.
+  // const [subordinatedPaths, setSubordinatedPaths] = useState([]);
+  // for now we use the code below.
+  const [setSubordinatedPaths] = useState([]);
   const [getSubordinatedError, setGetSuborinatedError] = useState(null);
   const [isDuplicateRecursively, setIsDuplicateRecursively] = useState(true);
+  const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
+  const [isDuplicateExistList, setIsDuplicateExistList] = useState([]);
+
+
+  function getSubordinatedDuplicateList(value) {
+
+    // ToDo: get the duplicated list from sever
+    // below is psuedo code
+    // let duplicatedList = get.apiv3......
+    // duplicatedList = duplicatedList.map((value) =>
+    // <li className="duplicate-exist" key={value}> {value}: { t('modal_duplicate.label.Same page already exists') } </li>; )
+    // setIsDuplicateExist(duplicatedList);
+
+    // ToDo: for now we use dummy path
+    setIsDuplicateExistList(['/test146/test147', value]);
+  }
 
   /**
    * change pageNameInput for PagePathAutoComplete
    * @param {string} value
    */
   function ppacInputChangeHandler(value) {
+    getSubordinatedDuplicateList(value);
     setPageNameInput(value);
   }
 
@@ -42,6 +63,7 @@ const PageDuplicateModal = (props) => {
    * @param {string} value
    */
   function inputChangeHandler(value) {
+    getSubordinatedDuplicateList(value);
     setPageNameInput(value);
   }
 
@@ -65,13 +87,16 @@ const PageDuplicateModal = (props) => {
     }
   }, [props.isOpen, getSubordinatedList]);
 
+  function changeIsDuplicateRecursivelyWithoutExistPathHandler() {
+    setIsDuplicateRecursivelyWithoutExistPath(!isDuplicateRecursivelyWithoutExistPath);
+  }
+
   async function duplicate() {
     setErrs(null);
 
     try {
-      const res = await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput });
-      const { page } = res.data;
-      window.location.href = encodeURI(`${page.path}?duplicated=${path}`);
+      await appContainer.apiv3Post('/pages/duplicate', { pageId, pageNameInput, isDuplicateRecursively });
+      window.location.href = encodeURI(`${pageNameInput}?duplicated=${path}`);
     }
     catch (err) {
       setErrs(err);
@@ -130,27 +155,31 @@ const PageDuplicateModal = (props) => {
           <label className="custom-control-label" htmlFor="cbDuplicateRecursively">
             { t('modal_duplicate.label.Duplicate with child') }
           </label>
-          <div>
-            <ul className="duplicate-name">
-              {isDuplicateRecursively && subordinatedPaths.map((duplicatedNewPath) => {
-                  // ToDo: The "true" statement below will be modified by task GW3503
-                  if (true) {
-                    return (
-                      <li
-                        className="duplicate-exist"
-                        key={duplicatedNewPath}
-                      >
-                        {duplicatedNewPath}: { t('modal_duplicate.label.Same page already exists') }
-                      </li>
-                    );
-                  }
-                  return <li key={duplicatedNewPath}>{duplicatedNewPath}</li>;
-                })
-              }
-            </ul>
-          </div>
-          <div> {getSubordinatedError} </div>
         </div>
+
+        <div
+          className="custom-control custom-checkbox custom-checkbox-warning"
+          style={{ display: (isDuplicateExistList.length !== 0) && isDuplicateRecursively ? '' : 'none' }}
+        >
+          <input
+            className="custom-control-input"
+            name="withoutExistRecursively"
+            id="cbDuplicatewithoutExistRecursively"
+            type="checkbox"
+            checked={isDuplicateRecursivelyWithoutExistPath}
+            onChange={changeIsDuplicateRecursivelyWithoutExistPathHandler}
+          />
+          <label className="custom-control-label" htmlFor="cbDuplicatewithoutExistRecursively">
+            { t('modal_duplicate.label.Duplicate without exist path') }
+          </label>
+        </div>
+
+        <div>
+          <ul className="duplicate-name">
+            {isDuplicateRecursively && isDuplicateExistList.length !== 0 && isDuplicateExistList}
+          </ul>
+        </div>
+        <div> {getSubordinatedError} </div>
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />

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

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

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

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

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

@@ -106,6 +106,7 @@ Crowi.prototype.init = async function() {
     this.setupSlack(),
     this.setupCsrf(),
     this.setUpFileUpload(),
+    this.setupAttachmentService(),
     this.setUpAcl(),
     this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
@@ -144,6 +145,7 @@ Crowi.prototype.initForTest = async function() {
     // this.setupSlack(),
     // this.setupCsrf(),
     // this.setUpFileUpload(),
+    // this.setupAttachmentService(),
     this.setUpAcl(),
     // this.setUpCustomize(),
     // this.setUpRestQiitaAPI(),
@@ -565,6 +567,16 @@ Crowi.prototype.setUpFileUpload = async function() {
   }
 };
 
+/**
+ * setup AttachmentService
+ */
+Crowi.prototype.setupAttachmentService = async function() {
+  const AttachmentService = require('../service/attachment');
+  if (this.attachmentService == null) {
+    this.attachmentService = new AttachmentService(this);
+  }
+};
+
 /**
  * setup RestQiitaAPIService
  */

+ 2 - 26
src/server/models/attachment.js

@@ -42,8 +42,7 @@ module.exports = function(crowi) {
   attachmentSchema.set('toJSON', { virtuals: true });
 
 
-  attachmentSchema.statics.create = async function(pageId, user, fileStream, originalName, fileFormat, fileSize) {
-    const fileUploader = require('../service/file-uploader')(crowi);
+  attachmentSchema.statics.createWithoutSave = function(pageId, user, fileStream, originalName, fileFormat, fileSize) {
     const Attachment = this;
 
     const extname = path.extname(originalName);
@@ -52,7 +51,7 @@ module.exports = function(crowi) {
       fileName = `${fileName}${extname}`;
     }
 
-    let attachment = new Attachment();
+    const attachment = new Attachment();
     attachment.page = pageId;
     attachment.creator = user._id;
     attachment.originalName = originalName;
@@ -61,31 +60,8 @@ module.exports = function(crowi) {
     attachment.fileSize = fileSize;
     attachment.createdAt = Date.now();
 
-    // upload file
-    await fileUploader.uploadFile(fileStream, attachment);
-    // save attachment
-    attachment = await attachment.save();
-
     return attachment;
   };
 
-  attachmentSchema.statics.removeAttachmentsByPageId = async function(pageId) {
-    const attachments = await this.find({ page: pageId });
-
-    const promises = attachments.map(async(attachment) => {
-      return this.removeWithSubstanceById(attachment._id);
-    });
-
-    return Promise.all(promises);
-  };
-
-  attachmentSchema.statics.removeWithSubstanceById = async function(id) {
-    const fileUploader = require('../service/file-uploader')(crowi);
-    // retrieve data from DB to get a completely populated instance
-    const attachment = await this.findById(id);
-    await fileUploader.deleteFile(attachment);
-    return await attachment.remove();
-  };
-
   return mongoose.model('Attachment', attachmentSchema);
 };

+ 8 - 20
src/server/models/page.js

@@ -1130,6 +1130,7 @@ module.exports = function(crowi) {
     }));
   };
 
+  // TODO: transplant to service/page.js because page deletion affects various models data
   pageSchema.statics.revertDeletedPage = async function(page, user, options = {}) {
     const newPath = this.getRevertDeletedPageName(page.path);
 
@@ -1173,31 +1174,16 @@ module.exports = function(crowi) {
   /**
    * This is danger.
    */
+  // TODO: transplant to service/page.js because page deletion affects various models data
   pageSchema.statics.completelyDeletePage = async function(pageData, user, options = {}) {
     validateCrowi();
 
-    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    const Bookmark = crowi.model('Bookmark');
-    const Attachment = crowi.model('Attachment');
-    const Comment = crowi.model('Comment');
-    const PageTagRelation = crowi.model('PageTagRelation');
-    const ShareLink = crowi.model('ShareLink');
-    const Revision = crowi.model('Revision');
-    const pageId = pageData._id;
+    const { _id, path } = pageData;
     const socketClientId = options.socketClientId || null;
 
-    debug('Completely delete', pageData.path);
-
-    await Promise.all([
-      Bookmark.removeBookmarksByPageId(pageId),
-      Attachment.removeAttachmentsByPageId(pageId),
-      Comment.removeCommentsByPageId(pageId),
-      PageTagRelation.remove({ relatedPage: pageId }),
-      ShareLink.remove({ relatedPage: pageId }),
-      Revision.removeRevisionsByPath(pageData.path),
-      this.findByIdAndRemove(pageId),
-      this.removeRedirectOriginPageByPath(pageData.path),
-    ]);
+    logger.debug('Deleting completely', path);
+
+    await crowi.pageService.deleteCompletely(_id, path);
 
     if (socketClientId != null) {
       pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
@@ -1208,6 +1194,7 @@ module.exports = function(crowi) {
   /**
    * Delete Bookmarks, Attachments, Revisions, Pages and emit delete
    */
+  // TODO: transplant to service/page.js because page deletion affects various models data
   pageSchema.statics.completelyDeletePageRecursively = async function(targetPage, user, options = {}) {
     const findOpts = { includeTrashed: true };
 
@@ -1304,6 +1291,7 @@ module.exports = function(crowi) {
     return targetPage;
   };
 
+  // TODO: transplant to service/page.js because page deletion affects various models data
   pageSchema.statics.handlePrivatePagesForDeletedGroup = async function(deletedGroup, action, transferToUserGroupId) {
     const Page = mongoose.model('Page');
 

+ 2 - 0
src/server/models/revision.js

@@ -6,6 +6,7 @@ module.exports = function(crowi) {
   const logger = require('@alias/logger')('growi:models:revision');
 
   const mongoose = require('mongoose');
+  const mongoosePaginate = require('mongoose-paginate-v2');
 
   const ObjectId = mongoose.Schema.Types.ObjectId;
   const revisionSchema = new mongoose.Schema({
@@ -24,6 +25,7 @@ module.exports = function(crowi) {
     createdAt: { type: Date, default: Date.now },
     hasDiffToPrev: { type: Boolean },
   });
+  revisionSchema.plugin(mongoosePaginate);
 
   /*
    * preparation for https://github.com/weseek/growi/issues/216

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

@@ -207,21 +207,23 @@ module.exports = function(crowi) {
     return userData;
   };
 
+  // TODO: create UserService and transplant this method because image uploading depends on AttachmentService
   userSchema.methods.updateImage = async function(attachment) {
     this.imageAttachment = attachment;
     await this.updateImageUrlCached();
     return this.save();
   };
 
+  // TODO: create UserService and transplant this method because image deletion depends on AttachmentService
   userSchema.methods.deleteImage = async function() {
     validateCrowi();
-    const Attachment = crowi.model('Attachment');
 
     // the 'image' field became DEPRECATED in v3.3.8
     this.image = undefined;
 
     if (this.imageAttachment != null) {
-      Attachment.removeWithSubstanceById(this.imageAttachment._id);
+      const { attachmentService } = crowi;
+      attachmentService.removeAttachment(this.imageAttachment._id);
     }
 
     this.imageAttachment = undefined;

+ 36 - 0
src/server/routes/apiv3/import.js

@@ -97,6 +97,42 @@ module.exports = (crowi) => {
     },
   });
 
+  /**
+   * @swagger
+   *
+   *  /import:
+   *    get:
+   *      tags: [Import]
+   *      operationId: getImportSettingsParams
+   *      summary: /import
+   *      description: Get import settings params
+   *      responses:
+   *        200:
+   *          description: import settings params
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  importSettingsParams:
+   *                    type: object
+   *                    description: import settings params
+   */
+  router.get('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+    try {
+      const importSettingsParams = {
+        esaTeamName: await crowi.configManager.getConfig('crowi', 'importer:esa:team_name'),
+        esaAccessToken: await crowi.configManager.getConfig('crowi', 'importer:esa:access_token'),
+        qiitaTeamName: await crowi.configManager.getConfig('crowi', 'importer:qiita:team_name'),
+        qiitaAccessToken: await crowi.configManager.getConfig('crowi', 'importer:qiita:access_token'),
+      };
+      return res.apiv3({
+        importSettingsParams,
+      });
+    }
+    catch (err) {
+      return res.apiv3Err(err, 500);
+    }
+  });
 
   /**
    * @swagger

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -39,6 +39,8 @@ module.exports = (crowi) => {
 
   router.use('/page', require('./page')(crowi));
   router.use('/pages', require('./pages')(crowi));
+  router.use('/revisions', require('./revisions')(crowi));
+
   router.use('/share-links', require('./share-links')(crowi));
 
   router.use('/bookmarks', require('./bookmarks')(crowi));

+ 59 - 26
src/server/routes/apiv3/pages.js

@@ -4,6 +4,7 @@ const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line
 
 const express = require('express');
 const pathUtils = require('growi-commons').pathUtils;
+const escapeStringRegexp = require('escape-string-regexp');
 
 const { body } = require('express-validator/check');
 const ErrorV3 = require('../../models/vo/error-apiv3');
@@ -144,6 +145,7 @@ module.exports = (crowi) => {
     duplicatePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageNameInput').trim().isLength({ min: 1 }).withMessage('pageNameInput is required'),
+      body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
     ],
   };
 
@@ -438,6 +440,51 @@ module.exports = (crowi) => {
     }
   });
 
+  async function duplicatePage(page, newPagePath, user) {
+    // populate
+    await page.populate({ path: 'revision', model: 'Revision', select: 'body' }).execPopulate();
+
+    // create option
+    const options = { page };
+    options.grant = page.grant;
+    options.grantUserGroupId = page.grantedGroup;
+    options.grantedUsers = page.grantedUsers;
+
+    const createdPage = await createPageAction({
+      path: newPagePath, user, body: page.revision.body, options,
+    });
+
+    const originTags = await page.findRelatedTagsById();
+    const savedTags = await saveTagsAction({ page, createdPage, pageTags: originTags });
+
+    // global notification
+    if (globalNotificationService != null) {
+      try {
+        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
+    }
+
+    return { page: pageService.serializeToObj(createdPage), tags: savedTags };
+  }
+
+  async function duplicatePageRecursively(page, newPagePath, user) {
+    const newPagePathPrefix = newPagePath;
+    const pathRegExp = new RegExp(`^${escapeStringRegexp(page.path)}`, 'i');
+
+    const pages = await Page.findManageableListWithDescendants(page, user);
+
+    const promise = pages.map(async(page) => {
+      const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
+      return duplicatePage(page, newPagePath, user);
+    });
+
+    return Promise.allSettled(promise);
+  }
+
+
   /**
    * @swagger
    *
@@ -456,6 +503,9 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/Page/properties/_id'
    *                  pageNameInput:
    *                    $ref: '#/components/schemas/Page/properties/path'
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: whether duplicate page with descendants
    *                required:
    *                  - pageId
    *        responses:
@@ -474,7 +524,7 @@ module.exports = (crowi) => {
    *            description: Internal server error.
    */
   router.post('/duplicate', accessTokenParser, loginRequiredStrictly, csrf, validator.duplicatePage, apiV3FormValidator, async(req, res) => {
-    const { pageId } = req.body;
+    const { pageId, isRecursively } = req.body;
 
     const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
 
@@ -493,36 +543,19 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('Not Founded the page', 'notfound_or_forbidden'), 404);
     }
 
-    // populate
-    await page.populate({ path: 'revision', model: 'Revision', select: 'body' }).execPopulate();
-
-    // create option
-    const options = { page };
-    options.grant = page.grant;
-    options.grantUserGroupId = page.grantedGroup;
-    options.grantedUsers = page.grantedUsers;
-
+    let result;
 
-    const createdPage = await createPageAction({
-      path: newPagePath, user: req.user, body: page.revision.body, options,
-    });
-    const originTags = await page.findRelatedTagsById();
-    const savedTags = await saveTagsAction({ page, createdPage, pageTags: originTags });
-    const result = { page: pageService.serializeToObj(createdPage), tags: savedTags };
-
-    // global notification
-    if (globalNotificationService != null) {
-      try {
-        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, createdPage, req.user);
-      }
-      catch (err) {
-        logger.error('Create grobal notification failed', err);
-      }
+    if (isRecursively) {
+      result = await duplicatePageRecursively(page, newPagePath, req.user);
+    }
+    else {
+      result = await duplicatePage(page, newPagePath, req.user);
     }
 
-    return res.apiv3(result);
+    return res.apiv3({ result });
   });
 
+
   router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
     const { path } = req.query;
 

+ 145 - 0
src/server/routes/apiv3/revisions.js

@@ -0,0 +1,145 @@
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:pages');
+
+const express = require('express');
+
+const { query, param } = require('express-validator/check');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+const router = express.Router();
+
+const PAGE_ITEMS = 30;
+
+/**
+ * @swagger
+ *  tags:
+ *    name: Revisions
+ */
+module.exports = (crowi) => {
+  const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
+
+  const {
+    Revision,
+    Page,
+    User,
+  } = crowi.models;
+
+  const validator = {
+    retrieveRevisions: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+      query('selectedPage').isInt({ min: 0 }).withMessage('selectedPage must be int'),
+    ],
+    retrieveRevisionById: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+      param('id').isMongoId().withMessage('id is required'),
+    ],
+  };
+
+  /**
+   * @swagger
+   *
+   *    /revisions/list:
+   *      get:
+   *        tags: [Revisions]
+   *        description: Get revisions by page id
+   *        parameters:
+   *          - in: query
+   *            name: pageId
+   *            schema:
+   *              type: string
+   *              description:  page id
+   *        responses:
+   *          200:
+   *            description: Return revisions belong to page
+   *
+   */
+  router.get('/list', certifySharedPage, accessTokenParser, loginRequired, validator.retrieveRevisions, apiV3FormValidator, async(req, res) => {
+    const pageId = req.query.pageId;
+    const { isSharedPage } = req;
+
+    const selectedPage = parseInt(req.query.selectedPage) || 1;
+
+    // check whether accessible
+    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+      return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+    }
+
+    try {
+      const page = await Page.findOne({ _id: pageId });
+
+      const paginateResult = await Revision.paginate(
+        { path: page.path },
+        {
+          page: selectedPage,
+          limit: PAGE_ITEMS,
+          sort: { createdAt: -1 },
+          populate: {
+            path: 'author',
+            select: User.USER_PUBLIC_FIELDS,
+          },
+        },
+      );
+
+      return res.apiv3(paginateResult);
+    }
+    catch (err) {
+      const msg = 'Error occurred in getting revisions by poge id';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revisions'), 500);
+    }
+
+  });
+
+  /**
+   * @swagger
+   *
+   *    /revisions/{id}:
+   *      get:
+   *        tags: [Revisions]
+   *        description: Get one revision by id
+   *        parameters:
+   *          - in: query
+   *            name: pageId
+   *            required: true
+   *            description: page id
+   *            schema:
+   *              type: string
+   *          - in: path
+   *            name: id
+   *            required: true
+   *            description: revision id
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Return revision
+   *
+   */
+  router.get('/:id', certifySharedPage, accessTokenParser, loginRequired, validator.retrieveRevisionById, apiV3FormValidator, async(req, res) => {
+    const revisionId = req.params.id;
+    const pageId = req.query.pageId;
+    const { isSharedPage } = req;
+
+    // check whether accessible
+    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+      return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+    }
+
+    try {
+      const revision = await Revision.findById(revisionId).populate('author', User.USER_PUBLIC_FIELDS);
+      return res.apiv3({ revision });
+    }
+    catch (err) {
+      const msg = 'Error occurred in getting revision data by id';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'faild-to-find-revision'), 500);
+    }
+
+  });
+
+  return router;
+};

+ 14 - 31
src/server/routes/attachment.js

@@ -3,8 +3,6 @@
 
 const logger = require('@alias/logger')('growi:routes:attachment');
 
-const fs = require('fs');
-
 const ApiResponse = require('../util/apiResponse');
 
 /**
@@ -131,7 +129,7 @@ module.exports = function(crowi, app) {
   const Attachment = crowi.model('Attachment');
   const User = crowi.model('User');
   const Page = crowi.model('Page');
-  const fileUploader = require('../service/file-uploader')(crowi, app);
+  const { fileUploadService, attachmentService } = crowi;
 
 
   /**
@@ -193,13 +191,13 @@ module.exports = function(crowi, app) {
       return res.sendStatus(304);
     }
 
-    if (fileUploader.canRespond()) {
-      return fileUploader.respond(res, attachment);
+    if (fileUploadService.canRespond()) {
+      return fileUploadService.respond(res, attachment);
     }
 
     let fileStream;
     try {
-      fileStream = await fileUploader.findDeliveryFile(attachment);
+      fileStream = await fileUploadService.findDeliveryFile(attachment);
     }
     catch (e) {
       logger.error(e);
@@ -234,32 +232,17 @@ module.exports = function(crowi, app) {
     }
   }
 
-  async function createAttachment(file, user, pageId = null) {
-    // check limit
-    const res = await fileUploader.checkLimit(file.size);
-    if (!res.isUploadable) {
-      throw new Error(res.errorMessage);
-    }
+  async function removeAttachment(attachmentId) {
+    const { fileUploadService } = crowi;
 
-    const fileStream = fs.createReadStream(file.path, {
-      flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
-    });
+    // retrieve data from DB to get a completely populated instance
+    const attachment = await Attachment.findById(attachmentId);
 
-    // create an Attachment document and upload file
-    let attachment;
-    try {
-      attachment = await Attachment.create(pageId, user, fileStream, file.originalname, file.mimetype, file.size);
-    }
-    catch (err) {
-      // delete temporary file
-      fs.unlink(file.path, (err) => { if (err) { logger.error('Error while deleting tmp file.') } });
-      throw err;
-    }
+    await fileUploadService.deleteFile(attachment);
 
-    return attachment;
+    return attachment.remove();
   }
 
-
   const actions = {};
   const api = {};
 
@@ -409,7 +392,7 @@ module.exports = function(crowi, app) {
    */
   api.limit = async function(req, res) {
     const fileSize = Number(req.query.fileSize);
-    return res.json(ApiResponse.success(await fileUploader.checkLimit(fileSize)));
+    return res.json(ApiResponse.success(await fileUploadService.checkLimit(fileSize)));
   };
 
   /**
@@ -522,7 +505,7 @@ module.exports = function(crowi, app) {
 
     let attachment;
     try {
-      attachment = await createAttachment(file, req.user, pageId);
+      attachment = await attachmentService.createAttachment(file, req.user, pageId);
     }
     catch (err) {
       logger.error(err);
@@ -618,7 +601,7 @@ module.exports = function(crowi, app) {
     let attachment;
     try {
       req.user.deleteImage();
-      attachment = await createAttachment(file, req.user);
+      attachment = await attachmentService.createAttachment(file, req.user);
       await req.user.updateImage(attachment);
     }
     catch (err) {
@@ -687,7 +670,7 @@ module.exports = function(crowi, app) {
     }
 
     try {
-      await Attachment.removeWithSubstanceById(id);
+      await removeAttachment(attachment);
     }
     catch (err) {
       logger.error(err);

+ 0 - 5
src/server/routes/index.js

@@ -10,7 +10,6 @@ module.exports = function(crowi, app) {
   const loginRequiredStrictly = require('../middlewares/login-required')(crowi);
   const loginRequired = require('../middlewares/login-required')(crowi, true);
   const adminRequired = require('../middlewares/admin-required')(crowi);
-  const certifySharedPage = require('../middlewares/certify-shared-page')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
 
@@ -26,7 +25,6 @@ module.exports = function(crowi, app) {
   const attachment = require('./attachment')(crowi, app);
   const comment = require('./comment')(crowi, app);
   const tag = require('./tag')(crowi, app);
-  const revision = require('./revision')(crowi, app);
   const search = require('./search')(crowi, app);
   const hackmd = require('./hackmd')(crowi, app);
 
@@ -164,9 +162,6 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
-  app.get('/_api/revisions.get'       , certifySharedPage , accessTokenParser , loginRequired , revision.api.get);
-  app.get('/_api/revisions.ids'       , certifySharedPage , accessTokenParser , loginRequired , revision.api.ids);
-
   app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
   app.get('/trash/$'                  , loginRequired , page.trashPageListShowWrapper);
   app.get('/trash/*/$'                , loginRequired , page.deletedPageListShowWrapper);

+ 0 - 190
src/server/routes/revision.js

@@ -1,190 +0,0 @@
-/**
- * @swagger
- *  tags:
- *    name: Revisions
- */
-
-/**
- * @swagger
- *
- *  components:
- *    schemas:
- *      Revision:
- *        description: Revision
- *        type: object
- *        properties:
- *          _id:
- *            type: string
- *            description: revision ID
- *            example: 5e0734e472560e001761fa68
- *          __v:
- *            type: number
- *            description: DB record version
- *            example: 0
- *          author:
- *            $ref: '#/components/schemas/User/properties/_id'
- *          body:
- *            type: string
- *            description: content body
- *            example: |
- *              # test
- *
- *              test
- *          format:
- *            type: string
- *            description: format
- *            example: markdown
- *          path:
- *            type: string
- *            description: path
- *            example: /user/alice/test
- *          createdAt:
- *            type: string
- *            description: date created at
- *            example: 2010-01-01T00:00:00.000Z
- */
-
-module.exports = function(crowi, app) {
-  const logger = require('@alias/logger')('growi:routes:revision');
-  const Page = crowi.model('Page');
-  const Revision = crowi.model('Revision');
-  const User = crowi.model('User');
-  const ApiResponse = require('../util/apiResponse');
-
-  const actions = {};
-  actions.api = {};
-
-  /**
-   * @swagger
-   *
-   *    /revisions.get:
-   *      get:
-   *        tags: [Revisions, CrowiCompatibles]
-   *        operationId: revisions.get
-   *        summary: /revisions.get
-   *        description: Get revision
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *            required: true
-   *          - in: query
-   *            name: revision_id
-   *            schema:
-   *              $ref: '#/components/schemas/Revision/properties/_id'
-   *            required: true
-   *        responses:
-   *          200:
-   *            description: Succeeded to get revision.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    revision:
-   *                      $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /revisions.get Get revision
-   * @apiName GetRevision
-   * @apiGroup Revision
-   *
-   * @apiParam {String} page_id Page Id.
-   * @apiParam {String} revision_id Revision Id.
-   */
-  actions.api.get = async function(req, res) {
-    const pageId = req.query.page_id;
-    const revisionId = req.query.revision_id;
-    const { isSharedPage } = req;
-
-    if (!pageId || !revisionId) {
-      return res.json(ApiResponse.error('Parameter page_id and revision_id are required.'));
-    }
-
-    // check whether accessible
-    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
-    }
-
-    try {
-      const revision = await Revision.findById(revisionId).populate('author', User.USER_PUBLIC_FIELDS);
-      return res.json(ApiResponse.success({ revision }));
-    }
-    catch (err) {
-      logger.error('Error revisios.get', err);
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
-  /**
-   * @swagger
-   *
-   *    /revisions.ids:
-   *      get:
-   *        tags: [Revisions, CrowiCompatibles]
-   *        operationId: revisions.ids
-   *        summary: /revisions.ids
-   *        description: Get revision id list of the page
-   *        parameters:
-   *          - in: query
-   *            name: page_id
-   *            schema:
-   *              $ref: '#/components/schemas/Page/properties/_id'
-   *            required: true
-   *        responses:
-   *          200:
-   *            description: Succeeded to get revision id list of the page.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    revisions:
-   *                      type: array
-   *                      items:
-   *                        $ref: '#/components/schemas/Revision'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /revisions.ids Get revision id list of the page
-   * @apiName ids
-   * @apiGroup Revision
-   *
-   * @apiParam {String} page_id      Page Id.
-   */
-  actions.api.ids = async function(req, res) {
-    const pageId = req.query.page_id;
-    const { isSharedPage } = req;
-
-    if (pageId == null) {
-      return res.json(ApiResponse.error('Parameter page_id is required.'));
-    }
-
-    // check whether accessible
-    if (!isSharedPage && !(await Page.isAccessiblePageByViewer(pageId, req.user))) {
-      return res.json(ApiResponse.error('Current user is not accessible to this page.'));
-    }
-
-    try {
-      const page = await Page.findOne({ _id: pageId });
-      const revisions = await Revision.findRevisionIdList(page.path);
-      return res.json(ApiResponse.success({ revisions }));
-    }
-    catch (err) {
-      logger.error('Error revisios.ids', err);
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
-  return actions;
-};

+ 58 - 0
src/server/service/attachment.js

@@ -0,0 +1,58 @@
+const logger = require('@alias/logger')('growi:service:AttachmentService'); // eslint-disable-line no-unused-vars
+
+const fs = require('fs');
+
+
+/**
+ * the service class for Attachment and file-uploader
+ */
+class AttachmentService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  async createAttachment(file, user, pageId = null) {
+    const { fileUploadService } = this.crowi;
+
+    // check limit
+    const res = await fileUploadService.checkLimit(file.size);
+    if (!res.isUploadable) {
+      throw new Error(res.errorMessage);
+    }
+
+    const Attachment = this.crowi.model('Attachment');
+
+    const fileStream = fs.createReadStream(file.path, {
+      flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
+    });
+
+    // create an Attachment document and upload file
+    let attachment;
+    try {
+      attachment = Attachment.createWithoutSave(pageId, user, fileStream, file.originalname, file.mimetype, file.size);
+      await fileUploadService.uploadFile(fileStream, attachment);
+      await attachment.save();
+    }
+    catch (err) {
+      // delete temporary file
+      fs.unlink(file.path, (err) => { if (err) { logger.error('Error while deleting tmp file.') } });
+      throw err;
+    }
+
+    return attachment;
+  }
+
+  async removeAttachment(attachmentId) {
+    const Attachment = this.crowi.model('Attachment');
+    const { fileUploadService } = this.crowi;
+
+    const attachment = await Attachment.findById(attachmentId);
+    await fileUploadService.deleteFile(attachment);
+
+    return attachment.remove();
+  }
+
+}
+
+module.exports = AttachmentService;

+ 34 - 5
src/server/service/file-uploader/aws.js

@@ -49,6 +49,23 @@ module.exports = function(crowi) {
     return filePath;
   }
 
+  async function isFileExists(s3, params) {
+    // check file exists
+    try {
+      await s3.headObject(params).promise();
+    }
+    catch (err) {
+      if (err != null && err.code === 'NotFound') {
+        return false;
+      }
+
+      // error except for 'NotFound
+      throw err;
+    }
+
+    return true;
+  }
+
   lib.isValidUploadSettings = function() {
     return this.configManager.getConfig('crowi', 'aws:accessKeyId') != null
       && this.configManager.getConfig('crowi', 'aws:secretAccessKey') != null
@@ -74,7 +91,12 @@ module.exports = function(crowi) {
       Key: filePath,
     };
 
-    // TODO: ensure not to throw error even when the file does not exist
+    // check file exists
+    const isExists = await isFileExists(s3, params);
+    if (!isExists) {
+      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
+      return;
+    }
 
     return s3.deleteObject(params).promise();
   };
@@ -108,12 +130,19 @@ module.exports = function(crowi) {
     const awsConfig = getAwsConfig();
     const filePath = getFilePathOnStorage(attachment);
 
+    const params = {
+      Bucket: awsConfig.bucket,
+      Key: filePath,
+    };
+
+    // check file exists
+    const isExists = await isFileExists(s3, params);
+    if (!isExists) {
+      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in AWS S3`);
+    }
+
     let stream;
     try {
-      const params = {
-        Bucket: awsConfig.bucket,
-        Key: filePath,
-      };
       stream = s3.getObject(params).createReadStream();
     }
     catch (err) {

+ 26 - 3
src/server/service/file-uploader/gcs.js

@@ -38,6 +38,16 @@ module.exports = function(crowi) {
     return filePath;
   }
 
+  /**
+   * check file existence
+   * @param {File} file https://googleapis.dev/nodejs/storage/latest/File.html
+   */
+  async function isFileExists(file) {
+    // check file exists
+    const res = await file.exists();
+    return res[0];
+  }
+
   lib.isValidUploadSettings = function() {
     return this.configManager.getConfig('crowi', 'gcs:apiKeyJsonPath') != null
       && this.configManager.getConfig('crowi', 'gcs:bucket') != null;
@@ -51,10 +61,16 @@ module.exports = function(crowi) {
   lib.deleteFileByFilePath = async function(filePath) {
     const gcs = getGcsInstance(this.getIsUploadable());
     const myBucket = gcs.bucket(getGcsBucket());
+    const file = myBucket.file(filePath);
 
-    // TODO: ensure not to throw error even when the file does not exist
+    // check file exists
+    const isExists = await isFileExists(file);
+    if (!isExists) {
+      logger.warn(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
+      return;
+    }
 
-    return myBucket.file(filePath).delete();
+    return file.delete();
   };
 
   lib.uploadFile = function(fileStream, attachment) {
@@ -80,10 +96,17 @@ module.exports = function(crowi) {
     const gcs = getGcsInstance(this.getIsUploadable());
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
+    const file = myBucket.file(filePath);
+
+    // check file exists
+    const isExists = await isFileExists(file);
+    if (!isExists) {
+      throw new Error(`Any object that relate to the Attachment (${filePath}) does not exist in GCS`);
+    }
 
     let stream;
     try {
-      stream = myBucket.file(filePath).createReadStream();
+      stream = file.createReadStream();
     }
     catch (err) {
       logger.error(err);

+ 1 - 0
src/server/service/file-uploader/local.js

@@ -42,6 +42,7 @@ module.exports = function(crowi) {
     }
     catch (err) {
       logger.warn(`Any AttachmentFile which path is '${filePath}' does not exist in local fs`);
+      return;
     }
 
     return fs.unlinkSync(filePath);

+ 35 - 0
src/server/service/page.js

@@ -24,6 +24,41 @@ class PageService {
     return returnObj;
   }
 
+  async deleteCompletely(pageId, pagePath) {
+    // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
+    const Bookmark = this.crowi.model('Bookmark');
+    const Comment = this.crowi.model('Comment');
+    const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
+    const ShareLink = this.crowi.model('ShareLink');
+    const Revision = this.crowi.model('Revision');
+
+    return Promise.all([
+      Bookmark.removeBookmarksByPageId(pageId),
+      Comment.removeCommentsByPageId(pageId),
+      PageTagRelation.remove({ relatedPage: pageId }),
+      ShareLink.remove({ relatedPage: pageId }),
+      Revision.removeRevisionsByPath(pagePath),
+      Page.findByIdAndRemove(pageId),
+      Page.removeRedirectOriginPageByPath(pagePath),
+      this.removeAllAttachments(pageId),
+    ]);
+  }
+
+  async removeAllAttachments(pageId) {
+    const Attachment = this.crowi.model('Attachment');
+    const { attachmentService } = this.crowi;
+
+    const attachments = await Attachment.find({ page: pageId });
+
+    const promises = attachments.map(async(attachment) => {
+      return attachmentService.removeAttachment(attachment._id);
+    });
+
+    return Promise.all(promises);
+  }
+
+
 }
 
 module.exports = PageService;

+ 0 - 2
src/server/util/swigFunctions.js

@@ -12,7 +12,6 @@ module.exports = function(crowi, req, locals) {
     passportService,
     appService,
     aclService,
-    fileUploadService,
     customizeService,
   } = crowi;
   debug('initializing swigFunctions');
@@ -69,7 +68,6 @@ module.exports = function(crowi, req, locals) {
    */
   locals.appService = appService;
   locals.aclService = aclService;
-  locals.fileUploadService = fileUploadService;
   locals.customizeService = customizeService;
   locals.passportService = passportService;
   locals.pathUtils = pathUtils;