jam411 3 лет назад
Родитель
Сommit
50281e0d58
54 измененных файлов с 1032 добавлено и 564 удалено
  1. 2 2
      .github/workflows/reusable-app-prod.yml
  2. 1 7
      packages/app/src/client/services/AdminBasicSecurityContainer.js
  3. 1 6
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  4. 0 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  5. 0 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  6. 1 4
      packages/app/src/client/services/AdminLdapSecurityContainer.js
  7. 1 5
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  8. 1 5
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  9. 1 5
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  10. 18 27
      packages/app/src/components/Admin/Security/BasicSecuritySetting.jsx
  11. 2 5
      packages/app/src/components/Admin/Security/FacebookSecuritySetting.jsx
  12. 17 22
      packages/app/src/components/Admin/Security/GitHubSecuritySetting.jsx
  13. 13 6
      packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx
  14. 18 22
      packages/app/src/components/Admin/Security/GoogleSecuritySetting.jsx
  15. 14 8
      packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  16. 17 21
      packages/app/src/components/Admin/Security/LdapSecuritySetting.jsx
  17. 18 22
      packages/app/src/components/Admin/Security/LocalSecuritySetting.jsx
  18. 9 12
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  19. 17 22
      packages/app/src/components/Admin/Security/OidcSecuritySetting.jsx
  20. 19 13
      packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  21. 17 22
      packages/app/src/components/Admin/Security/SamlSecuritySetting.jsx
  22. 15 5
      packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  23. 14 21
      packages/app/src/components/Admin/Security/SecurityManagement.jsx
  24. 18 18
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  25. 17 25
      packages/app/src/components/Admin/Security/TwitterSecuritySetting.jsx
  26. 13 5
      packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx
  27. 10 9
      packages/app/src/components/LoginForm.jsx
  28. 3 5
      packages/app/src/components/Page/DisplaySwitcher.tsx
  29. 21 22
      packages/app/src/components/PageComment.tsx
  30. 3 3
      packages/app/src/components/PageComment/Comment.jsx
  31. 30 72
      packages/app/src/components/PageComment/CommentEditor.tsx
  32. 1 3
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  33. 0 29
      packages/app/src/components/PageComment/CommentPreview.jsx
  34. 30 0
      packages/app/src/components/PageComment/CommentPreview.tsx
  35. 4 13
      packages/app/src/components/PageComment/ReplayComments.tsx
  36. 15 18
      packages/app/src/components/PageContentFooter.tsx
  37. 5 5
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  38. 2 1
      packages/app/src/components/UnstatedUtils.tsx
  39. 7 0
      packages/app/src/interfaces/editor-settings.ts
  40. 0 1
      packages/app/src/interfaces/services/renderer.ts
  41. 38 26
      packages/app/src/pages/[[...path]].page.tsx
  42. 3 0
      packages/app/src/pages/_app.page.tsx
  43. 166 6
      packages/app/src/pages/_search.page.tsx
  44. 13 1
      packages/app/src/pages/admin/[[...path]].page.tsx
  45. 20 9
      packages/app/src/pages/installer.page.tsx
  46. 112 0
      packages/app/src/pages/login.page.tsx
  47. 133 4
      packages/app/src/server/models/page-redirect.ts
  48. 1 1
      packages/app/src/server/routes/index.js
  49. 12 12
      packages/app/src/server/routes/login.js
  50. 1 1
      packages/app/src/server/routes/page.js
  51. 13 0
      packages/app/src/stores/context.tsx
  52. 13 10
      packages/app/src/styles/_login.scss
  53. 1 1
      packages/app/src/styles/style-next.scss
  54. 111 0
      packages/app/test/integration/models/page-redirect.test.js

+ 2 - 2
.github/workflows/reusable-app-prod.yml

@@ -60,7 +60,7 @@ jobs:
     - name: Archive production files
       id: archive-prod-files
       run: |
-        tar -cf production.tar \
+        tar -zcf production.tar.gz \
           package.json \
           packages/app/.next \
           packages/app/config \
@@ -70,7 +70,7 @@ jobs:
           packages/app/.env.production* \
           packages/*/package.json \
           packages/*/dist
-        echo ::set-output name=file::production.tar
+        echo ::set-output name=file::production.tar.gz
 
     - name: Upload production files as artifact
       uses: actions/upload-artifact@v3

+ 1 - 7
packages/app/src/client/services/AdminBasicSecurityContainer.js

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

+ 1 - 6
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -18,15 +18,11 @@ export default class AdminGeneralSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
-    this.dummyCurrentRestrictGuestMode = 0;
-    this.dummyCurrentRestrictGuestModeForError = 1;
-
     this.state = {
       retrieveError: null,
       sessionMaxAge: null,
       wikiMode: '',
-      // set dummy value tile for using suspense
-      currentRestrictGuestMode: this.dummyCurrentRestrictGuestMode,
+      currentRestrictGuestMode: '',
       currentPageDeletionAuthority: PageSingleDeleteConfigValue.AdminOnly,
       currentPageRecursiveDeletionAuthority: PageRecursiveDeleteConfigValue.Inherit,
       currentPageCompleteDeletionAuthority: PageSingleDeleteCompConfigValue.AdminOnly,
@@ -37,7 +33,6 @@ export default class AdminGeneralSecurityContainer extends Container {
       expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
-      appSiteUrl: appContainer.config.crowi.url || '',
       isLocalEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,

+ 0 - 1
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -23,7 +23,6 @@ export default class AdminGitHubSecurityContainer extends Container {
 
     this.state = {
       retrieveError: null,
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/github/callback'),
       // set dummy value tile for using suspense
       githubClientId: this.dummyGithubClientId,
       githubClientSecret: '',

+ 0 - 1
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -23,7 +23,6 @@ export default class AdminGoogleSecurityContainer extends Container {
 
     this.state = {
       retrieveError: null,
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/google/callback'),
       // set dummy value tile for using suspense
       googleClientId: this.dummyGoogleClientId,
       googleClientSecret: '',

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

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

+ 1 - 5
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -19,14 +19,10 @@ 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'),
-      // set dummy value tile for using suspense
-      oidcProviderName: this.dummyOidcProviderName,
+      oidcProviderName: '',
       oidcIssuerHost: '',
       oidcAuthorizationEndpoint: '',
       oidcTokenEndpoint: '',

+ 1 - 5
packages/app/src/client/services/AdminSamlSecurityContainer.js

@@ -19,17 +19,13 @@ export default class AdminSamlSecurityContainer extends Container {
     super();
 
     this.appContainer = appContainer;
-    this.dummySamlEntryPoint = 0;
-    this.dummySamlEntryPointForError = 1;
 
     this.state = {
       retrieveError: null,
       // TODO GW-1324 ABLCRure DB value takes precedence
       useOnlyEnvVars: false,
-      callbackUrl: urljoin(pathUtils.removeTrailingSlash(appContainer.config.crowi.url), '/passport/saml/callback'),
       missingMandatoryConfigKeys: [],
-      // set dummy value tile for using suspense
-      samlEntryPoint: this.dummySamlEntryPoint,
+      samlEntryPoint: '',
       samlIssuer: '',
       samlCert: '',
       samlAttrMapId: '',

+ 1 - 5
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -19,13 +19,9 @@ 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'),
-      // set dummy value tile for using suspense
-      twitterConsumerKey: this.dummyTwitterConsumerKey,
+      twitterConsumerKey: '',
       twitterConsumerSecret: '',
       isSameUsernameTreatedAsIdenticalUser: false,
     };

+ 18 - 27
packages/app/src/components/Admin/Security/BasicSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,34 +10,26 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import BasicSecurityManagementContents from './BasicSecuritySettingContents';
 
-let retrieveErrors = null;
-function BasicSecurityManagement(props) {
+const 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,
-        });
-
-      }
-    })();
-  }
-
-  if (
-    adminBasicSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser === adminBasicSecurityContainer.dummyIsSameUsernameTreatedAsIdenticalUserForError
-  ) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchBasicSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminBasicSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminBasicSecurityContainer]);
+
+  useEffect(() => {
+    fetchBasicSecuritySettingsData();
+  }, [adminBasicSecurityContainer, fetchBasicSecuritySettingsData]);
+
 
   return <BasicSecurityManagementContents />;
-}
+};
 
 BasicSecurityManagement.propTypes = {
   adminBasicSecurityContainer: PropTypes.instanceOf(AdminBasicSecurityContainer).isRequired,

+ 2 - 5
packages/app/src/components/Admin/Security/FacebookSecuritySetting.jsx

@@ -1,4 +1,3 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
 import { withTranslation } from 'next-i18next';
@@ -14,15 +13,13 @@ class FacebookSecurityManagement extends React.Component {
   render() {
     const { t } = this.props;
     return (
-      <React.Fragment>
-
+      <>
         <h2 className="alert-anchor border-bottom">
           Facebook OAuth { t('security_setting.configuration') }
         </h2>
 
         <p className="well">(TBD)</p>
-
-      </React.Fragment>
+      </>
     );
   }
 

+ 17 - 22
packages/app/src/components/Admin/Security/GitHubSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -12,29 +11,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import GitHubSecuritySettingContents from './GitHubSecuritySettingContents';
 
-let retrieveErrors = null;
-function GitHubSecurityManagement(props) {
+const 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 });
-      }
-    })();
-  }
-
-  if (adminGitHubSecurityContainer.state.githubClientId === adminGitHubSecurityContainer.dummyGithubClientIdForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchGitHubSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminGitHubSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminGitHubSecurityContainer]);
+
+  useEffect(() => {
+    fetchGitHubSecuritySettingsData();
+  }, [adminGitHubSecurityContainer, fetchGitHubSecuritySettingsData]);
 
   return <GitHubSecuritySettingContents />;
-}
+};
 
 
 GitHubSecurityManagement.propTypes = {

+ 13 - 6
packages/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx

@@ -1,13 +1,16 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,8 +36,11 @@ class GitHubSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminGitHubSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
+    } = this.props;
     const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
+    const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
 
     return (
 
@@ -75,11 +81,11 @@ class GitHubSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminGitHubSecurityContainer.state.appSiteUrl}
+              value={gitHubCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -172,7 +178,7 @@ class GitHubSecurityManagementContents extends React.Component {
           <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_2', { url: gitHubCallbackUrl }) }} />
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.GitHub.register_3') }} />
           </ol>
         </div>
@@ -187,7 +193,8 @@ class GitHubSecurityManagementContents extends React.Component {
 
 const GitHubSecurityManagementContentsFC = (props) => {
   const { t } = useTranslation();
-  return <GitHubSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <GitHubSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 /**

+ 18 - 22
packages/app/src/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,26 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import GoogleSecurityManagementContents from './GoogleSecuritySettingContents';
 
-let retrieveErrors = null;
-function GoogleSecurityManagement(props) {
+const 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 });
-      }
-    })();
-  }
-
-  if (adminGoogleSecurityContainer.state.googleClientId === adminGoogleSecurityContainer.dummyGoogleClientIdForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchGoogleSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminGoogleSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminGoogleSecurityContainer]);
+
+
+  useEffect(() => {
+    fetchGoogleSecuritySettingsData();
+  }, [adminGoogleSecurityContainer, fetchGoogleSecuritySettingsData]);
 
   return <GoogleSecurityManagementContents />;
-}
+};
 
 
 GoogleSecurityManagement.propTypes = {

+ 14 - 8
packages/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -1,13 +1,14 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,8 +34,11 @@ class GoogleSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminGoogleSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
+    } = this.props;
     const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
+    const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
 
     return (
 
@@ -75,11 +79,11 @@ class GoogleSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminGoogleSecurityContainer.state.callbackUrl}
+              value={googleCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -179,7 +183,7 @@ class GoogleSecurityManagementContents extends React.Component {
             <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_4', { url: googleCallbackUrl }) }} />
             <li dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.Google.register_5') }} />
           </ol>
         </div>
@@ -194,7 +198,8 @@ class GoogleSecurityManagementContents extends React.Component {
 
 const GoogleSecurityManagementContentsFc = (props) => {
   const { t } = useTranslation();
-  return <GoogleSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 
@@ -202,6 +207,7 @@ GoogleSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [

+ 17 - 21
packages/app/src/components/Admin/Security/LdapSecuritySetting.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -10,29 +10,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import LdapSecuritySettingContents from './LdapSecuritySettingContents';
 
-let retrieveErrors = null;
-function LdapSecuritySetting(props) {
+const 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 });
-      }
-    })();
-  }
-
-  if (adminLdapSecurityContainer.state.serverUrl === adminLdapSecurityContainer.dummyServerUrlForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchLdapSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminLdapSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminLdapSecurityContainer]);
+
+  useEffect(() => {
+    fetchLdapSecuritySettingsData();
+  }, [adminLdapSecurityContainer, fetchLdapSecuritySettingsData]);
 
   return <LdapSecuritySettingContents />;
-}
+};
 
 LdapSecuritySetting.propTypes = {
   adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,

+ 18 - 22
packages/app/src/components/Admin/Security/LocalSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,26 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import LocalSecuritySettingContents from './LocalSecuritySettingContents';
 
-let retrieveErrors = null;
-function LocalSecuritySetting(props) {
+const 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 });
-      }
-    })();
-  }
-
-  if (adminLocalSecurityContainer.state.registrationMode === adminLocalSecurityContainer.dummyRegistrationModeForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchLocalSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminLocalSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminLocalSecurityContainer]);
+
+
+  useEffect(() => {
+    fetchLocalSecuritySettingsData();
+  }, [adminLocalSecurityContainer, fetchLocalSecuritySettingsData]);
 
   return <LocalSecuritySettingContents />;
-}
+};
 
 LocalSecuritySetting.propTypes = {
   adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,

+ 9 - 12
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -1,14 +1,13 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useIsMailerSetup } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -37,14 +36,13 @@ class LocalSecuritySettingContents extends React.Component {
       t,
       adminGeneralSecurityContainer,
       adminLocalSecurityContainer,
-      appContainer,
+      isMailerSetup,
     } = this.props;
     const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
-    const { isMailerSetup } = appContainer.config;
 
     return (
-      <React.Fragment>
+      <>
         {adminLocalSecurityContainer.state.retrieveError != null && (
           <div className="alert alert-danger">
             <p>
@@ -97,7 +95,7 @@ class LocalSecuritySettingContents extends React.Component {
         </div>
 
         {isLocalEnabled && (
-          <React.Fragment>
+          <>
             <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
 
             <div className="row">
@@ -236,9 +234,9 @@ class LocalSecuritySettingContents extends React.Component {
                 </button>
               </div>
             </div>
-          </React.Fragment>
+          </>
         )}
-      </React.Fragment>
+      </>
     );
   }
 
@@ -246,18 +244,17 @@ class LocalSecuritySettingContents extends React.Component {
 
 LocalSecuritySettingContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
 };
 
 const LocalSecuritySettingContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <LocalSecuritySettingContents t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <LocalSecuritySettingContents t={t} {...props} isMailerSetup={isMailerSetup ?? false} />;
 };
 
 const LocalSecuritySettingContentsWrapper = withUnstatedContainers(LocalSecuritySettingContentsWrapperFC, [
-  AppContainer,
   AdminGeneralSecurityContainer,
   AdminLocalSecurityContainer,
 ]);

+ 17 - 22
packages/app/src/components/Admin/Security/OidcSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import OidcSecurityManagementContents from './OidcSecuritySettingContents';
 
-let retrieveErrors = null;
-function OidcSecurityManagement(props) {
+const 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 });
-      }
-    })();
-  }
-
-  if (adminOidcSecurityContainer.state.oidcProviderName === adminOidcSecurityContainer.dummyOidcProviderNameForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchOidcSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminOidcSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminOidcSecurityContainer]);
+
+  useEffect(() => {
+    fetchOidcSecuritySettingsData();
+  }, [adminOidcSecurityContainer, fetchOidcSecuritySettingsData]);
 
   return <OidcSecurityManagementContents />;
-}
+};
 
 OidcSecurityManagement.propTypes = {
   adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,

+ 19 - 13
packages/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -1,13 +1,15 @@
-/* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,13 +35,15 @@ class OidcSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminOidcSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminOidcSecurityContainer, siteUrl,
+    } = this.props;
     const { isOidcEnabled } = adminGeneralSecurityContainer.state;
+    const oidcCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/oidc/callback');
 
     return (
 
-      <React.Fragment>
-
+      <>
         <h2 className="alert-anchor border-bottom">
           {t('security_setting.OAuth.OIDC.name')}
         </h2>
@@ -69,11 +73,11 @@ class OidcSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminOidcSecurityContainer.state.callbackUrl}
+              value={oidcCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -86,7 +90,7 @@ class OidcSecurityManagementContents extends React.Component {
         </div>
 
         {isOidcEnabled && (
-          <React.Fragment>
+          <>
 
             <h3 className="border-bottom">{t('security_setting.configuration')}</h3>
 
@@ -365,11 +369,11 @@ class OidcSecurityManagementContents extends React.Component {
                 <input
                   className="form-control"
                   type="text"
-                  defaultValue={adminOidcSecurityContainer.state.callbackUrl || ''}
+                  defaultValue={oidcCallbackUrl}
                   readOnly
                 />
                 <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-                {!adminGeneralSecurityContainer.state.appSiteUrl && (
+                {(siteUrl == null || siteUrl === '') && (
                   <div className="alert alert-danger">
                     <i
                       className="icon-exclamation"
@@ -437,7 +441,7 @@ class OidcSecurityManagementContents extends React.Component {
                 </button>
               </div>
             </div>
-          </React.Fragment>
+          </>
         )}
 
 
@@ -455,7 +459,7 @@ class OidcSecurityManagementContents extends React.Component {
           </ol>
         </div>
 
-      </React.Fragment>
+      </>
     );
   }
 
@@ -465,11 +469,13 @@ OidcSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const OidcSecurityManagementContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <OidcSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <OidcSecurityManagementContents t={t} {...props} siteUrl={siteUrl} />;
 };
 
 const OidcSecurityManagementContentsWrapper = withUnstatedContainers(OidcSecurityManagementContentsWrapperFC, [

+ 17 - 22
packages/app/src/components/Admin/Security/SamlSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -11,29 +10,25 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import SamlSecuritySettingContents from './SamlSecuritySettingContents';
 
-let retrieveErrors = null;
-function SamlSecurityManagement(props) {
+const 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 });
-      }
-    })();
-  }
-
-  if (adminSamlSecurityContainer.state.samlEntryPoint === adminSamlSecurityContainer.dummySamlEntryPointForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchSamlSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminSamlSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminSamlSecurityContainer]);
+
+  useEffect(() => {
+    fetchSamlSecuritySettingsData();
+  }, [adminSamlSecurityContainer, fetchSamlSecuritySettingsData]);
 
   return <SamlSecuritySettingContents />;
-}
+};
 
 SamlSecurityManagement.propTypes = {
   adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,

+ 15 - 5
packages/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -1,17 +1,21 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { Collapse } from 'reactstrap';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
+
 class SamlSecurityManagementContents extends React.Component {
 
   constructor(props) {
@@ -38,10 +42,14 @@ class SamlSecurityManagementContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminSamlSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminSamlSecurityContainer, siteUrl,
+    } = this.props;
     const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
     const { isSamlEnabled } = adminGeneralSecurityContainer.state;
 
+    const samlCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/saml/callback');
+
     return (
       <React.Fragment>
 
@@ -82,11 +90,11 @@ class SamlSecurityManagementContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              defaultValue={adminSamlSecurityContainer.state.callbackUrl}
+              defaultValue={samlCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'SAML Identity' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -534,11 +542,13 @@ SamlSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const SamlSecurityManagementContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <SamlSecurityManagementContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <SamlSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 const SamlSecurityManagementContentsWrapper = withUnstatedContainers(SamlSecurityManagementContentsWrapperFC, [

+ 14 - 21
packages/app/src/components/Admin/Security/SecurityManagement.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -10,29 +10,22 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 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,
-        });
-      }
-    })();
-  }
-
-  if (adminGeneralSecurityContainer.state.currentRestrictGuestMode === adminGeneralSecurityContainer.dummyCurrentRestrictGuestModeForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+  const fetchGeneralSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminGeneralSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminGeneralSecurityContainer]);
+
+  useEffect(() => {
+    fetchGeneralSecuritySettingsData();
+  }, [adminGeneralSecurityContainer, fetchGeneralSecuritySettingsData]);
 
   return <SecurityManagementContents />;
 }

+ 18 - 18
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -5,17 +5,17 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
 
-// import BasicSecuritySetting from './BasicSecuritySetting';
-// import FacebookSecuritySetting from './FacebookSecuritySetting';
-// import GitHubSecuritySetting from './GitHubSecuritySetting';
-// import GoogleSecuritySetting from './GoogleSecuritySetting';
-// import LdapSecuritySetting from './LdapSecuritySetting';
-// import LocalSecuritySetting from './LocalSecuritySetting';
-// import OidcSecuritySetting from './OidcSecuritySetting';
-// import SamlSecuritySetting from './SamlSecuritySetting';
+import BasicSecuritySetting from './BasicSecuritySetting';
+import FacebookSecuritySetting from './FacebookSecuritySetting';
+import GitHubSecuritySetting from './GitHubSecuritySetting';
+import GoogleSecuritySetting from './GoogleSecuritySetting';
+import LdapSecuritySetting from './LdapSecuritySetting';
+import LocalSecuritySetting from './LocalSecuritySetting';
+import OidcSecuritySetting from './OidcSecuritySetting';
+import SamlSecuritySetting from './SamlSecuritySetting';
 import SecuritySetting from './SecuritySetting';
 import ShareLinkSetting from './ShareLinkSetting';
-// import TwitterSecuritySetting from './TwitterSecuritySetting';
+import TwitterSecuritySetting from './TwitterSecuritySetting';
 
 const SecurityManagementContents = () => {
   const { t } = useTranslation();
@@ -112,31 +112,31 @@ const SecurityManagementContents = () => {
         />
         <TabContent activeTab={activeTab} className="p-5">
           <TabPane tabId="passport_local">
-            {/* {activeComponents.has('passport_local') && <LocalSecuritySetting />} */}
+            {activeComponents.has('passport_local') && <LocalSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_ldap">
-            {/* {activeComponents.has('passport_ldap') && <LdapSecuritySetting />} */}
+            {activeComponents.has('passport_ldap') && <LdapSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_saml">
-            {/* {activeComponents.has('passport_saml') && <SamlSecuritySetting />} */}
+            {activeComponents.has('passport_saml') && <SamlSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_oidc">
-            {/* {activeComponents.has('passport_oidc') && <OidcSecuritySetting />} */}
+            {activeComponents.has('passport_oidc') && <OidcSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_basic">
-            {/* {activeComponents.has('passport_basic') && <BasicSecuritySetting />} */}
+            {activeComponents.has('passport_basic') && <BasicSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_google">
-            {/* {activeComponents.has('passport_google') && <GoogleSecuritySetting />} */}
+            {activeComponents.has('passport_google') && <GoogleSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_github">
-            {/* {activeComponents.has('passport_github') && <GitHubSecuritySetting />} */}
+            {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_twitter">
-            {/* {activeComponents.has('passport_twitter') && <TwitterSecuritySetting />} */}
+            {activeComponents.has('passport_twitter') && <TwitterSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_facebook">
-            {/* {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />} */}
+            {activeComponents.has('passport_facebook') && <FacebookSecuritySetting />}
           </TabPane>
         </TabContent>
       </div>

+ 17 - 25
packages/app/src/components/Admin/Security/TwitterSecuritySetting.jsx

@@ -1,5 +1,4 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 import PropTypes from 'prop-types';
 
@@ -9,34 +8,27 @@ import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-
 import TwitterSecuritySettingContents from './TwitterSecuritySettingContents';
 
-let retrieveErrors = null;
-function TwitterSecurityManagement(props) {
+const 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,
-        });
-      }
-    })();
-  }
-
-  if (adminTwitterSecurityContainer.state.twitterConsumerKey === adminTwitterSecurityContainer.dummyTwitterConsumerKeyForError) {
-    throw new Error(`${retrieveErrors.length} errors occured`);
-  }
+
+  const fetchTwitterSecuritySettingsData = useCallback(async() => {
+    try {
+      await adminTwitterSecurityContainer.retrieveSecurityData();
+    }
+    catch (err) {
+      const errs = toArrayIfNot(err);
+      toastError(errs);
+    }
+  }, [adminTwitterSecurityContainer]);
+
+  useEffect(() => {
+    fetchTwitterSecuritySettingsData();
+  }, [adminTwitterSecurityContainer, fetchTwitterSecuritySettingsData]);
 
   return <TwitterSecuritySettingContents />;
-}
+};
 
 TwitterSecurityManagement.propTypes = {
   adminTwitterSecurityContainer: PropTypes.instanceOf(AdminTwitterSecurityContainer).isRequired,

+ 13 - 5
packages/app/src/components/Admin/Security/TwitterSecuritySettingContents.jsx

@@ -1,13 +1,16 @@
 /* eslint-disable react/no-danger */
 import React from 'react';
 
-import PropTypes from 'prop-types';
+import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
+import urljoin from 'url-join';
 
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useSiteUrl } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,8 +36,11 @@ class TwitterSecuritySettingContents extends React.Component {
   }
 
   render() {
-    const { t, adminGeneralSecurityContainer, adminTwitterSecurityContainer } = this.props;
+    const {
+      t, adminGeneralSecurityContainer, adminTwitterSecurityContainer, siteUrl,
+    } = this.props;
     const { isTwitterEnabled } = adminGeneralSecurityContainer.state;
+    const twitterCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/twitter/callback');
 
     return (
 
@@ -75,11 +81,11 @@ class TwitterSecuritySettingContents extends React.Component {
             <input
               className="form-control"
               type="text"
-              value={adminTwitterSecurityContainer.state.callbackUrl}
+              value={twitterCallbackUrl}
               readOnly
             />
             <p className="form-text text-muted small">{t('security_setting.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {!adminGeneralSecurityContainer.state.appSiteUrl && (
+            {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
                 <i
                   className="icon-exclamation"
@@ -197,11 +203,13 @@ TwitterSecuritySettingContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
   adminTwitterSecurityContainer: PropTypes.instanceOf(AdminTwitterSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
 };
 
 const TwitterSecuritySettingContentsWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <TwitterSecuritySettingContents t={t} {...props} />;
+  const { data: siteUrl } = useSiteUrl();
+  return <TwitterSecuritySettingContents t={t} siteUrl={siteUrl} {...props} />;
 };
 
 /**

+ 10 - 9
packages/app/src/components/LoginForm.jsx

@@ -4,11 +4,8 @@ import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import ReactCardFlip from 'react-card-flip';
 
-import AppContainer from '~/client/services/AppContainer';
 import { useCsrfToken } from '~/stores/context';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 class LoginForm extends React.Component {
 
   constructor(props) {
@@ -148,7 +145,7 @@ class LoginForm extends React.Component {
   renderRegisterForm() {
     const {
       t,
-      appContainer,
+      // appContainer,
       csrfToken,
       isEmailAuthenticationEnabled,
       username,
@@ -156,9 +153,9 @@ class LoginForm extends React.Component {
       email,
       registrationMode,
       registrationWhiteList,
+      isMailerSetup,
     } = this.props;
 
-    const { isMailerSetup } = appContainer.config;
     let registerAction = '/register';
 
     let submitText = t('Sign up');
@@ -288,8 +285,10 @@ class LoginForm extends React.Component {
       objOfIsExternalAuthEnableds,
     } = this.props;
 
+
     const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
-    const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
+    // const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
+    const isSomeExternalAuthEnabled = true;
 
     return (
       <div className="login-dialog mx-auto" id="login-dialog">
@@ -332,7 +331,7 @@ class LoginForm extends React.Component {
 LoginForm.propTypes = {
   // i18next
   t: PropTypes.func.isRequired,
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   csrfToken: PropTypes.string,
   isRegistering: PropTypes.bool,
@@ -347,6 +346,7 @@ LoginForm.propTypes = {
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,
+  isMailerSetup: PropTypes.bool,
 };
 
 const LoginFormWrapperFC = (props) => {
@@ -359,6 +359,7 @@ const LoginFormWrapperFC = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
+// const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
 
-export default LoginFormWrapper;
+// export default LoginForm;
+export default LoginFormWrapperFC;

+ 3 - 5
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -7,7 +7,7 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound, useIsNotCreatable,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -15,7 +15,7 @@ import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
 import PageListIcon from '../Icons/PageListIcon';
-import { NotCreatablePage } from '../NotCreatablePage';
+import NotFoundPage from '../NotFoundPage';
 import { Page } from '../Page';
 // import PageEditor from '../PageEditor';
 // import PageEditorByHackmd from '../PageEditorByHackmd';
@@ -48,7 +48,6 @@ const DisplaySwitcher = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
   const { data: isNotFound } = useIsNotFound();
-  const { data: isNotCreatable } = useIsNotCreatable();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const { data: editorMode } = useEditorMode();
@@ -69,8 +68,7 @@ const DisplaySwitcher = (): JSX.Element => {
             <div className="flex-grow-1 flex-basis-0 mw-0">
               { isUserPage && <UserInfo pageUser={pageUser} />}
               { !isNotFound && <Page /> }
-              { isNotFound && !isNotCreatable && <NotFoundPage /> }
-              { isNotFound && isNotCreatable && <NotCreatablePage /> }
+              { isNotFound && <NotFoundPage /> }
             </div>
 
             { !isNotFound && !currentPage?.isEmpty && (

+ 21 - 22
packages/app/src/components/PageComment.tsx

@@ -15,9 +15,9 @@ import { useSWRxPageComment } from '../stores/comment';
 
 
 import Comment from './PageComment/Comment';
-import CommentEditor from './PageComment/CommentEditor';
+import { CommentEditor } from './PageComment/CommentEditor';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
-import ReplayComments from './PageComment/ReplayComments';
+import { ReplayComments } from './PageComment/ReplayComments';
 
 type Props = {
   appContainer: AppContainer,
@@ -110,25 +110,6 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     }
   }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
 
-  const generateCommentInnerElement = (comment: ICommentHasId) => (
-    <Comment
-      rendererOptions={rendererOptions}
-      deleteBtnClicked={onClickDeleteButton}
-      comment={comment}
-      onComment={mutate}
-      isReadOnly={isReadOnly}
-    />
-  );
-
-  const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
-    <ReplayComments
-      replyList={replyComments}
-      deleteBtnClicked={onClickDeleteButton}
-      rendererOptions={rendererOptions}
-      isReadOnly={isReadOnly}
-    />
-  );
-
   const removeShowEditorId = useCallback((commentId: string) => {
     setShowEditorIds((previousState) => {
       const previousShowEditorIds = new Set(...previousState);
@@ -148,6 +129,25 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     return <></>;
   }
 
+  const generateCommentInnerElement = (comment: ICommentHasId) => (
+    <Comment
+      rendererOptions={rendererOptions}
+      deleteBtnClicked={onClickDeleteButton}
+      comment={comment}
+      onComment={mutate}
+      isReadOnly={isReadOnly}
+    />
+  );
+
+  const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
+    <ReplayComments
+      replyList={replyComments}
+      deleteBtnClicked={onClickDeleteButton}
+      isReadOnly={isReadOnly}
+      onComment={mutate}
+    />
+  );
+
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
@@ -191,7 +191,6 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
                     {/* display reply editor */}
                     {(!isReadOnly && showEditorIds.has(comment._id)) && (
                       <CommentEditor
-                        rendererOptions={rendererOptions}
                         replyTo={comment._id}
                         onCancelButtonClicked={() => {
                           removeShowEditorId(comment._id);

+ 3 - 3
packages/app/src/components/PageComment/Comment.jsx

@@ -2,12 +2,13 @@ import React from 'react';
 
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
+import { RendererOptions } from '~/services/renderer/renderer';
 import { useCurrentUser } from '~/stores/context';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
@@ -17,9 +18,8 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 import Username from '../User/Username';
 
 import CommentControl from './CommentControl';
-import CommentEditor from './CommentEditor';
+import { CommentEditor } from './CommentEditor';
 
-import { RendererOptions } from '~/services/renderer/renderer';
 
 /**
  *

+ 30 - 72
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -3,22 +3,17 @@ import React, {
 } from 'react';
 
 import { UserPicture } from '@growi/ui';
+import dynamic from 'next/dynamic';
 import {
-  Button,
-  TabContent, TabPane,
+  Button, TabContent, TabPane,
 } from 'reactstrap';
 import * as toastr from 'toastr';
 
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
 import { apiPostForm } from '~/client/util/apiv1-client';
-import { CustomWindow } from '~/interfaces/global';
-import { IInterceptorManager } from '~/interfaces/interceptor-manager';
-import { RendererOptions } from '~/services/renderer/renderer';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
-  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId,
+  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId, useIsSlackConfigured,
+  useEditorConfig,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
@@ -26,12 +21,9 @@ import { useIsMobile } from '~/stores/ui';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
 import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import CommentPreview from './CommentPreview';
 
+import { CommentPreview } from './CommentPreview';
 
 const navTabMapping = {
   comment_editor: {
@@ -47,14 +39,10 @@ const navTabMapping = {
 };
 
 type PropsType = {
-  appContainer: AppContainer,
-
-  rendererOptions: RendererOptions,
   isForNewComment?: boolean,
   replyTo?: string,
   currentCommentId?: string,
   commentBody?: string,
-  commentCreator?: string,
   onCancelButtonClicked?: () => void,
   onCommentButtonClicked?: () => void,
 }
@@ -65,11 +53,11 @@ type EditorRef = {
   terminateUploadingState: () => void,
 }
 
-const CommentEditor = (props: PropsType): JSX.Element => {
+export const CommentEditor = (props: PropsType): JSX.Element => {
 
   const {
-    appContainer, rendererOptions, isForNewComment, replyTo,
-    currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
+    isForNewComment, replyTo,
+    currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
   const { data: currentUser } = useCurrentUser();
   const { data: currentPagePath } = useCurrentPagePath();
@@ -79,57 +67,23 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-
-  const config = appContainer.getConfig();
-  const isUploadable = config.upload.image || config.upload.file;
-  const isUploadableFile = config.upload.file;
-  const isSlackConfigured = config.isSlackConfigured;
+  const { data: isSlackConfigured } = useIsSlackConfigured();
+  const { data: editorConfig } = useEditorConfig();
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
+  // TODO: Refactor comment and markdown variable names or logic after presentation
   const [comment, setComment] = useState(commentBody ?? '');
-  const [html, setHtml] = useState('');
+  const [markdown, setMarkdown] = useState('');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [error, setError] = useState();
   const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
 
   const editorRef = useRef<EditorRef>(null);
 
-  const renderHtml = useCallback((markdown: string) => {
-    const context = {
-      markdown,
-      parsedHTML: '',
-    };
-
-    // TODO: use ReactMarkdown
-
-    // const interceptorManager: IInterceptorManager = (window as CustomWindow).interceptorManager;
-    // interceptorManager.process('preRenderCommnetPreview', context)
-    //   .then(() => { return interceptorManager.process('prePreProcess', context) })
-    //   .then(() => {
-    //     context.markdown = rendererOptions.preProcess(context.markdown, context);
-    //   })
-    //   .then(() => { return interceptorManager.process('postPreProcess', context) })
-    //   .then(() => {
-    //     const parsedHTML = rendererOptions.process(context.markdown, context);
-    //     context.parsedHTML = parsedHTML;
-    //   })
-    //   .then(() => { return interceptorManager.process('prePostProcess', context) })
-    //   .then(() => {
-    //     context.parsedHTML = rendererOptions.postProcess(context.parsedHTML, context);
-    //   })
-    //   .then(() => { return interceptorManager.process('postPostProcess', context) })
-    //   .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-    //   .then(() => {
-    //     setHtml(context.parsedHTML);
-    //   })
-    //   // process interceptors for post rendering
-    //   .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }, [rendererOptions]);
-
   const handleSelect = useCallback((activeTab: string) => {
     setActiveTab(activeTab);
-    renderHtml(comment);
-  }, [comment, renderHtml]);
+    setMarkdown(comment);
+  }, [comment, setMarkdown]);
 
   useEffect(() => {
     if (slackChannels === undefined) { return }
@@ -138,7 +92,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
 
   const initializeEditor = useCallback(() => {
     setComment('');
-    setHtml('');
+    setMarkdown('');
     setActiveTab('comment_editor');
     setError(undefined);
     // reset value
@@ -247,13 +201,19 @@ const CommentEditor = (props: PropsType): JSX.Element => {
     }
   }, [apiErrorHandler, currentPageId, currentPagePath]);
 
+
   const getCommentHtml = useCallback(() => {
+    if (currentPagePath == null) {
+      return <></>;
+    }
+
     return (
       <CommentPreview
-        html={html}
+        markdown={markdown}
+        path={currentPagePath}
       />
     );
-  }, [html]);
+  }, [currentPagePath, markdown]);
 
   const renderBeforeReady = useCallback((): JSX.Element => {
     return (
@@ -292,9 +252,16 @@ const CommentEditor = (props: PropsType): JSX.Element => {
       </Button>
     );
 
+    const Editor = dynamic(() => import('../PageEditor/Editor'), { ssr: false });
     // TODO: typescriptize Editor
     const AnyEditor = Editor as any;
 
+    if (editorConfig === undefined) {
+      return <></>;
+    }
+    const isUploadable = editorConfig.upload.image || editorConfig.upload.file;
+    const isUploadableFile = editorConfig.upload.file;
+
     return (
       <>
         <div className="comment-write">
@@ -376,12 +343,3 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   );
 
 };
-
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorWrapper = withUnstatedContainers<unknown, Partial<PropsType>>(
-  CommentEditor, [AppContainer, PageContainer, EditorContainer],
-);
-
-export default CommentEditorWrapper;

+ 1 - 3
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -1,12 +1,10 @@
 import React from 'react';
 
+import { useSWRxPageComment } from '~/stores/comment';
 import { useCurrentPageId } from '~/stores/context';
 
-import { useSWRxPageComment } from '../../stores/comment';
-
 import { CommentEditor } from './CommentEditor';
 
-
 export const CommentEditorLazyRenderer = (): JSX.Element => {
 
   const { data: pageId } = useCurrentPageId();

+ 0 - 29
packages/app/src/components/PageComment/CommentPreview.jsx

@@ -1,29 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-import RevisionBody from '../Page/RevisionBody';
-
-/**
- * Wrapper component for Page/RevisionBody
- */
-const CommentPreview = (props) => {
-
-  return (
-    <div className="page-comment-preview-body">
-      <RevisionBody
-        html={props.html}
-        additionalClassName="comment"
-        isMathJaxEnabled
-        renderMathJaxInRealtime
-      />
-    </div>
-  );
-
-};
-
-CommentPreview.propTypes = {
-  html: PropTypes.string,
-};
-
-export default CommentPreview;

+ 30 - 0
packages/app/src/components/PageComment/CommentPreview.tsx

@@ -0,0 +1,30 @@
+import { useCommentPreviewOptions } from '~/stores/renderer';
+
+import RevisionRenderer from '../Page/RevisionRenderer';
+
+type CommentPreviewPorps = {
+  markdown: string,
+  path: string,
+}
+
+export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
+  const { markdown, path } = props;
+
+  const { data: commentPreviewOptions } = useCommentPreviewOptions();
+
+  if (commentPreviewOptions == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="page-comment-preview-body">
+      <RevisionRenderer
+        rendererOptions={commentPreviewOptions}
+        markdown={markdown}
+        additionalClassName="comment"
+        pagePath={path}
+      />
+    </div>
+  );
+
+};

+ 4 - 13
packages/app/src/components/PageComment/ReplayComments.tsx

@@ -3,16 +3,13 @@ import React, { useState } from 'react';
 
 import { Collapse } from 'reactstrap';
 
-import { RendererOptions } from '~/services/renderer/renderer';
-
 import { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
-import { useRendererConfig } from '../../stores/context';
+import { useRendererConfig, useIsAllReplyShown } from '../../stores/context';
 
-import { Comment } from './Comment';
+import Comment from './Comment';
 
 type ReplaycommentsProps = {
   deleteBtnClicked: (comment: ICommentHasId) => void,
-  rendererOptions: RendererOptions,
   isReadOnly: boolean,
   replyList: ICommentHasIdList,
   onComment: () => void,
@@ -20,9 +17,10 @@ type ReplaycommentsProps = {
 
 export const ReplayComments = (props: ReplaycommentsProps): JSX.Element => {
   const {
-    deleteBtnClicked, rendererOptions, isReadOnly, replyList, onComment,
+    deleteBtnClicked, isReadOnly, replyList, onComment,
   } = props;
   const { data: rendererConfig } = useRendererConfig();
+  const { data: isAllReplyShown } = useIsAllReplyShown();
 
   const [isOlderRepliesShown, setIsOlderRepliesShown] = useState(false);
 
@@ -33,7 +31,6 @@ export const ReplayComments = (props: ReplaycommentsProps): JSX.Element => {
         <Comment
           comment={reply}
           deleteBtnClicked={deleteBtnClicked}
-          rendererOptions={rendererOptions}
           isReadOnly={isReadOnly}
           onComment={onComment}
         />
@@ -41,12 +38,6 @@ export const ReplayComments = (props: ReplaycommentsProps): JSX.Element => {
     );
   };
 
-  // TODO: Remove isAllReplyShown from rendererconfig
-  if (rendererConfig === undefined) {
-    return <></>;
-  }
-  const isAllReplyShown = rendererConfig.isAllReplyShown || false;
-
   if (isAllReplyShown) {
     return (
       <>

+ 15 - 18
packages/app/src/components/PageContentFooter.tsx

@@ -1,29 +1,28 @@
-import React, { FC, memo } from 'react';
+import React, { memo } from 'react';
 
-import { Ref } from '@growi/core';
+import dynamic from 'next/dynamic';
 
-import { IUser } from '../interfaces/user';
+import { useSWRxCurrentPage } from '~/stores/page';
 
-import AuthorInfo from './Navbar/AuthorInfo';
+import { Skelton } from './Skelton';
 
-type Props = {
-  createdAt: Date,
-  updatedAt: Date,
-  creator: any,
-  revisionAuthor: Ref<IUser>,
-}
+export const PageContentFooter = memo((): JSX.Element => {
 
-const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
-  const {
-    createdAt, updatedAt, creator, revisionAuthor,
-  } = props;
+  const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'),
+    { ssr: false, loading: () => <Skelton width={300} height={20} additionalClass={'mb-3'} /> });
+
+  const { data: page } = useSWRxCurrentPage();
+
+  if (page == null) {
+    return <></>;
+  }
 
   return (
     <div className="page-content-footer py-4 d-edit-none d-print-none">
       <div className="grw-container-convertible">
         <div className="page-meta">
-          <AuthorInfo user={creator as IUser} date={createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={revisionAuthor as IUser} date={updatedAt} mode="update" locate="footer" />
+          <AuthorInfo user={page.creator} date={page.createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={page.revision.author} date={page.updatedAt} mode="update" locate="footer" />
         </div>
       </div>
     </div>
@@ -31,5 +30,3 @@ const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
 });
 
 PageContentFooter.displayName = 'PageContentFooter';
-
-export default PageContentFooter;

+ 5 - 5
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -25,7 +25,7 @@ import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
 import PageComment from '../PageComment';
-import PageContentFooter from '../PageContentFooter';
+import { PageContentFooter } from '../PageContentFooter';
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -216,10 +216,10 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
         />
         <PageComment appContainer={appContainer} pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
         <PageContentFooter
-          createdAt={new Date(pageWithMeta.data.createdAt)}
-          updatedAt={new Date(pageWithMeta.data.updatedAt)}
-          creator={pageWithMeta.data.creator}
-          revisionAuthor={pageWithMeta.data.lastUpdateUser}
+          // createdAt={new Date(pageWithMeta.data.createdAt)}
+          // updatedAt={new Date(pageWithMeta.data.updatedAt)}
+          // creator={pageWithMeta.data.creator}
+          // revisionAuthor={pageWithMeta.data.lastUpdateUser}
         />
       </div>
     </div>

+ 2 - 1
packages/app/src/components/UnstatedUtils.tsx

@@ -2,7 +2,7 @@
 
 import React from 'react';
 
-import { Subscribe } from 'unstated';
+import { Provider, Subscribe } from 'unstated';
 
 /**
  * generate K/V object by specified instances
@@ -15,6 +15,7 @@ import { Subscribe } from 'unstated';
  *     exampleContainer: <ExampleContainer />,
  *   }
  */
+
 function generateAutoNamedProps(instances) {
   const props = {};
 

+ 7 - 0
packages/app/src/interfaces/editor-settings.ts

@@ -29,3 +29,10 @@ export interface IEditorSettings {
   autoFormatMarkdownTable: boolean,
   textlintSettings: undefined | ITextlintSettings;
 }
+
+export type EditorConfig = {
+  upload: {
+    image: boolean,
+    file: boolean,
+  }
+}

+ 0 - 1
packages/app/src/interfaces/services/renderer.ts

@@ -14,7 +14,6 @@ export type RendererConfig = {
   adminPreferredIndentSize: number,
   isIndentSizeForced: boolean,
   highlightJsStyleBorder: boolean,
-  isAllReplyShown: boolean,
 
   plantumlUri: string | null,
   blockdiagUri: string | null,

+ 38 - 26
packages/app/src/pages/[[...path]].page.tsx

@@ -7,6 +7,7 @@ import {
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, isClient, isIPageInfoForEntity, isServer, IUser, IUserHasId, pagePathUtils, pathUtils,
 } from '@growi/core';
 import ExtensibleCustomError from 'extensible-custom-error';
+import mongoose from 'mongoose';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -19,16 +20,19 @@ import superjson from 'superjson';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { PageComments } from '~/components/PageComment/PageComments';
 // import { useTranslation } from '~/i18n';
+import { PageContentFooter } from '~/components/PageContentFooter';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useIndentSize } from '~/stores/editor';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
+import { EditorConfig } from '~/interfaces/editor-settings';
 import { CustomWindow } from '~/interfaces/global';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { PageModel, PageDocument } from '~/server/models/page';
+import { PageRedirectModel } from '~/server/models/page-redirect';
 import UserUISettings from '~/server/models/user-ui-settings';
 import Xss from '~/services/xss';
 import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
@@ -37,12 +41,15 @@ import {
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
+
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
+import ForbiddenPage from '../components/ForbiddenPage';
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
+import { NotCreatablePage } from '../components/NotCreatablePage';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
@@ -59,13 +66,13 @@ import {
   useIsAclEnabled, useIsUserPage, useIsNotCreatable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useIsBlinkedHeaderAtBoot, useRendererConfig, useEditingMarkdown,
+  useEditorConfig, useIsAllReplyShown,
 } from '../stores/context';
 import { useXss } from '../stores/xss';
 
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
 } from './utils/commons';
-import { registerTransformerForObjectId } from './utils/objectid-transformer';
 // import { useCurrentPageSWR } from '../stores/page';
 
 
@@ -80,9 +87,6 @@ const { removeHeadingSlash } = pathUtils;
 type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfoForEntity>;
 type IPageToShowRevisionWithMetaSerialized = IDataWithMeta<string, string>;
 
-// register custom serializer
-registerTransformerForObjectId();
-
 superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMetaSerialized>(
   {
     isApplicable: (v): v is IPageToShowRevisionWithMeta => {
@@ -123,8 +127,7 @@ type Props = CommonProps & {
 
   pageWithMeta: IPageToShowRevisionWithMeta,
   // pageUser?: any,
-  // redirectTo?: string;
-  // redirectFrom?: string;
+  redirectFrom?: string;
 
   // shareLinkId?: string;
   isLatestRevision?: boolean
@@ -148,9 +151,9 @@ type Props = CommonProps & {
   // mathJax: string,
   // noCdn: string,
   // highlightJsStyle: string,
-  // isAllReplyShown: boolean,
+  isAllReplyShown: boolean,
   // isContainerFluid: boolean,
-  // editorConfig: any,
+  editorConfig: EditorConfig,
   isEnabledStaleNotification: boolean,
   // isEnabledLinebreaks: boolean,
   // isEnabledLinebreaksInComments: boolean,
@@ -182,7 +185,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
   // commons
   useXss(new Xss());
-  // useEditorConfig(props.editorConfig);
+  useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
 
   // UserUISettings
@@ -226,7 +229,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useRendererConfig(props.rendererConfig);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
-
+  useIsAllReplyShown(props.isAllReplyShown);
 
   // const { data: editorMode } = useEditorMode();
 
@@ -302,10 +305,10 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
                 { !props.isIdenticalPathPage && (
                   <>
                     <PageAlerts />
-                    { props.isForbidden
-                      ? <>ForbiddenPage</>
-                      : <DisplaySwitcher />
-                    }
+                    { props.isForbidden && <ForbiddenPage /> }
+                    { props.IsNotCreatable && <NotCreatablePage />}
+                    { !props.isForbidden && !props.IsNotCreatable && <DisplaySwitcher />}
+                    {/* <DisplaySwitcher /> */}
                     <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
                     {/* <PageStatusAlert /> */}
                     PageStatusAlert
@@ -326,6 +329,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           {/* <PageComments /> */}
           PageComments
           <CommentEditorLazyRenderer />
+          <PageContentFooter />
         </footer>
 
         <UnsavedAlertDialog />
@@ -358,17 +362,28 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   const { revisionId } = req.query;
 
   const Page = crowi.model('Page') as PageModel;
+  const PageRedirect = mongoose.model('PageRedirect') as PageRedirectModel;
   const { pageService } = crowi;
 
-  const { currentPathname } = props;
+  let currentPathname = props.currentPathname;
 
   const pageId = getPageIdFromPathname(currentPathname);
   const isPermalink = _isPermalink(currentPathname);
 
   const { user } = req;
 
-  // check whether the specified page path hits to multiple pages
   if (!isPermalink) {
+    // check redirects
+    const chains = await PageRedirect.retrievePageRedirectEndpoints(currentPathname);
+    if (chains != null) {
+      // overwrite currentPathname
+      currentPathname = chains.end.toPath;
+      props.currentPathname = currentPathname;
+      // set redirectFrom
+      props.redirectFrom = chains.start.fromPath;
+    }
+
+    // check whether the specified page path hits to multiple pages
     const count = await Page.countByPathAndViewer(currentPathname, user, null, true);
     if (count > 1) {
       throw new MultiplePagesHitsError(currentPathname);
@@ -414,9 +429,7 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
   }
   else if (page == null) {
     props.isNotFound = true;
-
     props.IsNotCreatable = !isCreatablePage(currentPathname);
-
     // check the page is forbidden or just does not exist.
     const count = isPermalink ? await Page.count({ _id: pageId }) : await Page.count({ path: currentPathname });
     props.isForbidden = count > 0;
@@ -471,18 +484,18 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.mathJax = configManager.getConfig('crowi', 'app:mathJax');
   // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
-  // props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
+  props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');
   props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
-  // props.editorConfig = {
-  //   upload: {
-  //     image: crowi.fileUploadService.getIsUploadable(),
-  //     file: crowi.fileUploadService.getFileUploadEnabled(),
-  //   },
-  // };
+  props.editorConfig = {
+    upload: {
+      image: crowi.fileUploadService.getIsUploadable(),
+      file: crowi.fileUploadService.getFileUploadEnabled(),
+    },
+  };
   // props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   // props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
 
@@ -491,7 +504,6 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
-    isAllReplyShown: configManager.getConfig('crowi', 'customize:isAllReplyShown'),
 
     plantumlUri: process.env.PLANTUML_URI ?? null,
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,

+ 3 - 0
packages/app/src/pages/_app.page.tsx

@@ -18,6 +18,7 @@ import {
 } from '../stores/context';
 
 import { CommonProps } from './utils/commons';
+import { registerTransformerForObjectId } from './utils/objectid-transformer';
 // import { useInterceptorManager } from '~/stores/interceptor';
 
 const isDev = process.env.NODE_ENV === 'development';
@@ -25,6 +26,8 @@ const isDev = process.env.NODE_ENV === 'development';
 type GrowiAppProps = AppProps & {
   pageProps: CommonProps;
 };
+// register custom serializer
+registerTransformerForObjectId();
 
 function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   useI18nextHMR(isDev);

+ 166 - 6
packages/app/src/pages/_search.page.tsx

@@ -1,26 +1,186 @@
 import {
-  NextPage, GetServerSideProps,
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
+import Head from 'next/head';
 
-const SearchPage: NextPage = () => {
+import { BasicLayout } from '~/components/Layout/BasicLayout';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { RendererConfig } from '~/interfaces/services/renderer';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
+import { IUser, IUserHasId } from '~/interfaces/user';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import UserUISettings from '~/server/models/user-ui-settings';
+import Xss from '~/services/xss';
+import {
+  useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
+} from '~/stores/context';
+import {
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
+  useCurrentSidebarContents, useCurrentProductNavWidth,
+} from '~/stores/ui';
+import { useXss } from '~/stores/xss';
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
+} from './utils/commons';
+
+type Props = CommonProps & {
+  currentUser: IUser,
+
+  isSearchServiceConfigured: boolean,
+  isSearchServiceReachable: boolean,
+  isSearchScopeChildrenAsDefault: boolean,
+
+  // UI
+  userUISettings?: IUserUISettings
+  // Sidebar
+  sidebarConfig: ISidebarConfig,
+
+  // Render config
+  rendererConfig: RendererConfig,
+
+};
+
+const SearchPage: NextPage<Props> = (props: Props) => {
+  const { userUISettings } = props;
+
+  // commons
+  useXss(new Xss());
+  useCsrfToken(props.csrfToken);
+
+  useCurrentUser(props.currentUser ?? null);
+
+  // Search
+  useIsSearchPage(true);
+  useIsSearchServiceConfigured(props.isSearchServiceConfigured);
+  useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  // UserUISettings
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
+  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
+  useCurrentSidebarContents(userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
+
+  // render config
+  useRendererConfig(props.rendererConfig);
 
   const PutbackPageModal = (): JSX.Element => {
     const PutbackPageModal = dynamic(() => import('../components/PutbackPageModal'), { ssr: false });
     return <PutbackPageModal />;
   };
 
+  const classNames: string[] = [];
+  // if (props.isContainerFluid) {
+  //   classNames.push('growi-layout-fluid');
+  // }
+
   return (
     <>
-      SearchPage
-      <PutbackPageModal />
+      <Head>
+        {/*
+        {renderScriptTagByName('drawio-viewer')}
+        {renderScriptTagByName('highlight-addons')}
+        */}
+      </Head>
+      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+
+        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+        <div id="main" className="main search-page mt-0">
+
+          <div id="search-page">
+            Search Result Page
+            {/* render SearchPage component here */}
+          </div>
+
+        </div>
+        <PutbackPageModal />
+      </BasicLayout>
+
     </>
   );
 };
 
-export const getServerSideProps: GetServerSideProps = async() => {
+async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+
+  const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
+  if (userUISettings != null) {
+    props.userUISettings = userUISettings.toObject();
+  }
+}
+
+function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { configManager, searchService } = crowi;
+
+  props.isSearchServiceConfigured = searchService.isConfigured;
+  props.isSearchServiceReachable = searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+
+  props.sidebarConfig = {
+    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+  };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
+}
+
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  await injectUserUISettings(context, props);
+  injectServerConfigurations(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
   return {
-    props: { },
+    props,
   };
 };
 

+ 13 - 1
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -12,7 +12,7 @@ import { CrowiRequest } from '~/interfaces/crowi-request';
 import PluginUtils from '~/server/plugins/plugin-utils';
 import ConfigLoader from '~/server/service/config-loader';
 import {
-  useCurrentUser, /* useSearchServiceConfigured, */ useIsSearchServiceReachable, useSiteUrl,
+  useCurrentUser, /* useSearchServiceConfigured, */ useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
 } from '~/stores/context';
 
 import {
@@ -51,6 +51,7 @@ type Props = CommonProps & {
 
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
+  isMailerSetup: boolean,
 
   siteUrl: string,
 };
@@ -135,6 +136,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
   const title = content.title;
 
   useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
+  useIsMailerSetup(props.isMailerSetup);
 
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -150,6 +152,15 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
   );
 };
 
+
+function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { mailService } = crowi;
+
+  props.isMailerSetup = mailService.isMailerSetup;
+}
+
 /**
  * for Server Side Translations
  * @param context
@@ -182,6 +193,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     props.currentUser = JSON.stringify(user);
   }
 
+  injectServerConfigurations(context, props);
   injectNextI18NextConfigurations(context, props, ['admin']);
 
   props.siteUrl = appService.getSiteUrl();

+ 20 - 9
packages/app/src/pages/installer.page.tsx

@@ -48,18 +48,29 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   return (
     <>
       <RawLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-        <div id="page-wrapper">
-          <div className="main container-fluid">
+        <div className='nologin'>
+          <div id="wrapper">
+            <div id="page-wrapper">
+              <div className="main container-fluid">
+
+                <div className="row">
+
+                  <div className="col-md-12">
+                    <div className="login-header mx-auto">
+                      <div className="logo"></div>
+                      <h1 className="my-3">GROWI</h1>
+                      <div className="login-form-errors px-3"></div>
+                    </div>
+                  </div>
+
+                  <div className="col-md-12">
+                    <div id="installer-form-container">
+                      <InstallerForm />
+                    </div>
+                  </div>
 
-            <div className="row">
-              <div className="col-md-12">
-                <div className="login-header mx-auto">
-                  <h1 className="my-3">GROWI</h1>
                 </div>
               </div>
-              <div className="col-md-12">
-                <InstallerForm />
-              </div>
             </div>
           </div>
         </div>

+ 112 - 0
packages/app/src/pages/login.page.tsx

@@ -0,0 +1,112 @@
+import React from 'react';
+
+
+import { pagePathUtils } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import dynamic from 'next/dynamic';
+
+import { RawLayout } from '~/components/Layout/RawLayout';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+
+import {
+  useCsrfToken,
+  useCurrentPathname,
+} from '../stores/context';
+
+
+import {
+  CommonProps, getServerSideCommonProps, useCustomTitle,
+} from './utils/commons';
+
+type Props = CommonProps & {
+
+  pageWithMetaStr: string,
+  isMailerSetup: boolean,
+  enabledStrategies: unknown,
+  registrationWhiteList: string[],
+};
+
+const LoginPage: NextPage<Props> = (props: Props) => {
+
+  // commons
+  useCsrfToken(props.csrfToken);
+
+  // page
+  useCurrentPathname(props.currentPathname);
+
+  const classNames: string[] = [];
+
+  const LoginForm = dynamic(() => import('~/components/LoginForm'), {
+    ssr: false,
+  });
+
+  return (
+    <>
+      <RawLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+        <div className='nologin'>
+          <div id='wrapper'>
+            <div id="page-wrapper">
+              <LoginForm objOfIsExternalAuthEnableds={props.enabledStrategies} isLocalStrategySetup={true} isLdapStrategySetup={true}
+                isRegistrationEnabled={true} registrationWhiteList={props.registrationWhiteList} isPasswordResetEnabled={true} />
+            </div>
+          </div>
+        </div>
+      </RawLayout>
+    </>
+  );
+};
+
+function injectEnabledStrategies(context: GetServerSidePropsContext, props: Props): void {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    configManager,
+  } = crowi;
+
+  const enabledStrategies = {
+    google: configManager.getConfig('crowi', 'security:passport-google:isEnabled'),
+    github: configManager.getConfig('crowi', 'security:passport-github:isEnabled'),
+    facebook: false,
+    twitter: configManager.getConfig('crowi', 'security:passport-twitter:isEnabled'),
+    smal: configManager.getConfig('crowi', 'security:passport-saml:isEnabled'),
+    oidc: configManager.getConfig('crowi', 'security:passport-oidc:isEnabled'),
+    basic: configManager.getConfig('crowi', 'security:passport-basic:isEnabled'),
+  };
+
+  props.enabledStrategies = enabledStrategies;
+}
+
+async function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    mailService,
+    configManager,
+  } = crowi;
+
+  props.isMailerSetup = mailService.isMailerSetup;
+  props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  injectServerConfigurations(context, props);
+  injectEnabledStrategies(context, props);
+
+  return {
+    props,
+  };
+};
+
+export default LoginPage;

+ 133 - 4
packages/app/src/server/models/page-redirect.ts

@@ -4,19 +4,37 @@ import {
   Schema, Model, Document,
 } from 'mongoose';
 
+import loggerFactory from '~/utils/logger';
+
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-export interface IPageRedirect {
+
+const logger = loggerFactory('growi:models:page-redirects');
+
+
+export type IPageRedirect = {
   fromPath: string,
   toPath: string,
 }
 
+export type IPageRedirectEndpoints = {
+  start: IPageRedirect,
+  end: IPageRedirect,
+}
+
 export interface PageRedirectDocument extends IPageRedirect, Document {}
 
 export interface PageRedirectModel extends Model<PageRedirectDocument> {
-  [x:string]: any // TODO: improve type
+  retrievePageRedirectEndpoints(fromPath: string): Promise<IPageRedirectEndpoints>
+  removePageRedirectsByToPath(toPath: string): Promise<void>
 }
 
+const CHAINS_FIELD_NAME = 'chains';
+const DEPTH_FIELD_NAME = 'depth';
+type IPageRedirectWithChains = PageRedirectDocument & {
+  [CHAINS_FIELD_NAME]: (PageRedirectDocument & { [DEPTH_FIELD_NAME]: number })[]
+};
+
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   fromPath: {
     type: String, required: true, unique: true, index: true,
@@ -24,9 +42,120 @@ const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   toPath: { type: String, required: true },
 });
 
-schema.statics.removePageRedirectByToPath = async function(toPath: string): Promise<void> {
-  await this.deleteMany({ toPath });
+schema.statics.retrievePageRedirectEndpoints = async function(fromPath: string): Promise<IPageRedirectEndpoints|null> {
+  const aggResult: IPageRedirectWithChains[] = await this.aggregate([
+    { $match: { fromPath } },
+    {
+      $graphLookup: {
+        from: 'pageredirects',
+        startWith: '$toPath',
+        connectFromField: 'toPath',
+        connectToField: 'fromPath',
+        as: CHAINS_FIELD_NAME,
+        depthField: DEPTH_FIELD_NAME,
+      },
+    },
+  ]);
+  /* ---------- aggResult example ----------
+  {
+    "_id" : ObjectId("62e5650d6134d37aa0935e6d"),
+    "fromPath" : "/page1",
+    "toPath" : "/page2",
+    "chains" : [
+        {
+            "_id" : ObjectId("62e5651b6134d37aa0935e7a"),
+            "fromPath" : "/page2",
+            "toPath" : "/page3",
+            "depth" : NumberLong(0)
+        },
+        {
+            "_id" : ObjectId("62e565256134d37aa0935e80"),
+            "fromPath" : "/page3",
+            "toPath" : "/Sandbox",
+            "depth" : NumberLong(1)
+        }
+    ]
+  }
+  */
+
+  if (aggResult.length === 0) {
+    return null;
+  }
+
+  if (aggResult.length > 1) {
+    logger.warn(`Although two or more PageRedirect documents starts from '${fromPath}' exists, The first one is used.`);
+  }
+
+  const redirectWithChains = aggResult[0];
+
+  // sort chains in desc
+  const sortedChains = redirectWithChains[CHAINS_FIELD_NAME].sort((a, b) => b[DEPTH_FIELD_NAME] - a[DEPTH_FIELD_NAME]);
+
+  const start = { fromPath: redirectWithChains.fromPath, toPath: redirectWithChains.toPath };
+  const end = sortedChains.length === 0
+    ? start
+    : sortedChains[0];
+
+  return { start, end };
+};
+
+schema.statics.removePageRedirectsByToPath = async function(toPath: string): Promise<void> {
+  const aggResult: IPageRedirectWithChains[] = await this.aggregate([
+    { $match: { toPath } },
+    {
+      $graphLookup: {
+        from: 'pageredirects',
+        startWith: '$fromPath',
+        connectFromField: 'fromPath',
+        connectToField: 'toPath',
+        as: CHAINS_FIELD_NAME,
+      },
+    },
+  ]);
+  /* ---------- aggResult example ----------
+  // 1
+  {
+    "_id" : ObjectId("62e565256134d37aa0935e80"),
+    "fromPath" : "/page3",
+    "toPath" : "/page4",
+    "chains" : [
+        {
+            "_id" : ObjectId("62e5651b6134d37aa0935e7a"),
+            "fromPath" : "/page2",
+            "toPath" : "/page3",
+            "depth" : NumberLong(0)
+        },
+        {
+            "_id" : ObjectId("62e5650d6134d37aa0935e6d"),
+            "fromPath" : "/page1",
+            "toPath" : "/page2",
+            "depth" : NumberLong(1)
+        }
+    ]
+  }
+  // 2
+  {
+    "_id" : ObjectId("62e5937a6134d37aa0936405"),
+    "fromPath" : "/org/page4",
+    "toPath" : "/page4",
+    "chains" : []
+  }
+  */
+
+  if (aggResult.length === 0) {
+    return;
+  }
+
+  const idsToRemove = aggResult
+    .map((redirectWithChains) => {
+      return [
+        redirectWithChains._id,
+        redirectWithChains[CHAINS_FIELD_NAME].map(doc => doc._id),
+      ].flat();
+    })
+    .flat();
 
+  await this.deleteMany({ _id: { $in: idsToRemove } });
   return;
 };
 

+ 1 - 1
packages/app/src/server/routes/index.js

@@ -79,7 +79,7 @@ module.exports = function(crowi, app) {
   app.get('/'                         , applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, next.delegateToNext);
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
-  app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
+  app.get('/login'                    , applicationInstalled, login.preLogin, next.delegateToNext);
   app.get('/login/invited'            , applicationInstalled, login.invited);
   app.post('/login/activateInvited'   , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
   app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);

+ 12 - 12
packages/app/src/server/routes/login.js

@@ -67,18 +67,18 @@ module.exports = function(crowi, app) {
 
   actions.preLogin = function(req, res, next) {
     // user has already logged in
-    const { user } = req;
-    if (user != null && user.status === User.STATUS_ACTIVE) {
-      const { redirectTo } = req.session;
-      // remove session.redirectTo
-      delete req.session.redirectTo;
-      return res.safeRedirect(redirectTo);
-    }
-
-    // set referer to 'redirectTo'
-    if (req.session.redirectTo == null && req.headers.referer != null) {
-      req.session.redirectTo = req.headers.referer;
-    }
+    // const { user } = req;
+    // if (user != null && user.status === User.STATUS_ACTIVE) {
+    //   const { redirectTo } = req.session;
+    //   // remove session.redirectTo
+    //   delete req.session.redirectTo;
+    //   return res.safeRedirect(redirectTo);
+    // }
+
+    // // set referer to 'redirectTo'
+    // if (req.session.redirectTo == null && req.headers.referer != null) {
+    //   req.session.redirectTo = req.headers.referer;
+    // }
 
     next();
   };

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -1476,7 +1476,7 @@ module.exports = function(crowi, app) {
     const path = req.body.path;
 
     try {
-      await PageRedirect.removePageRedirectByToPath(path);
+      await PageRedirect.removePageRedirectsByToPath(path);
       logger.debug('Redirect Page deleted', path);
     }
     catch (err) {

+ 13 - 0
packages/app/src/stores/context.tsx

@@ -6,6 +6,7 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { SupportedActionType } from '~/interfaces/activity';
+import { EditorConfig } from '~/interfaces/editor-settings';
 // import { CustomWindow } from '~/interfaces/global';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { GrowiThemes } from '~/interfaces/theme';
@@ -172,6 +173,10 @@ export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse
   return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
 };
 
+export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, any> => {
+  return useStaticSWR('isMailerSetup', initialData);
+};
+
 export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData);
 };
@@ -220,10 +225,18 @@ export const useIsLatestRevision = (initialData?: boolean): SWRResponse<boolean,
   return useStaticSWR('isLatestRevision', initialData);
 };
 
+export const useEditorConfig = (initialData?: EditorConfig): SWRResponse<EditorConfig, Error> => {
+  return useStaticSWR<EditorConfig, Error>('editorConfig', initialData);
+};
+
 export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<RendererConfig, any> => {
   return useStaticSWR('growiRendererConfig', initialData);
 };
 
+export const useIsAllReplyShown = (initialData?: boolean): SWRResponse<boolean, any> => {
+  return useStaticSWR('isAllReplyShown', initialData);
+};
+
 export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
   return useStaticSWR('currentPageTocNode');
 };

+ 13 - 10
packages/app/src/styles/_login.scss

@@ -1,3 +1,6 @@
+@use '~/styles/bootstrap/init' as bs;
+
+
 .nologin {
   #page-wrapper {
     background: none;
@@ -92,40 +95,40 @@
 
   $btn-fill-colors: (
     'login': (
-      rgba($danger, 0.4),
+      rgba(bs.$danger, 0.4),
       rgba(#7e4153, 0.7),
     ),
     'register': (
-      rgba($success, 0.4),
+      rgba(bs.$success, 0.4),
       rgba(#3f7263, 0.7),
     ),
     'google': (
       rgba(#24292e, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'github': (
       rgba(lighten(black, 20%), 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'facebook': (
       rgba(#29487d, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'twitter': (
       rgba(#1da1f2, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'oidc': (
       rgba(#24292e, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'saml': (
       rgba(#55a79a, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
     'basic': (
       rgba(#24292e, 0.4),
-      $gray-700,
+      bs.$gray-700,
     ),
   );
 
@@ -153,7 +156,7 @@
   }
 
   .link-switch {
-    color: $gray-200;
+    color: bs.$gray-200;
 
     &:hover {
       color: white;

+ 1 - 1
packages/app/src/styles/style-next.scss

@@ -46,7 +46,7 @@
 // @import 'page-content-footer';
 // @import 'handsontable';
 @import 'layout';
-// @import 'login';
+@import 'login';
 // @import 'me';
 // @import 'mirror_mode';
 // @import 'modal';

+ 111 - 0
packages/app/test/integration/models/page-redirect.test.js

@@ -0,0 +1,111 @@
+import mongoose from 'mongoose';
+
+import { IPageRedirect, PageRedirectModel } from '../../../src/server/models/page-redirect';
+import { getInstance } from '../setup-crowi';
+
+describe('PageRedirect', () => {
+  // eslint-disable-next-line no-unused-vars
+  let crowi;
+  let PageRedirect;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    PageRedirect = mongoose.model('PageRedirect');
+  });
+
+  beforeEach(async() => {
+    // clear collection
+    await PageRedirect.deleteMany({});
+  });
+
+  describe('.removePageRedirectsByToPath', () => {
+    test('works fine', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/org/path1', toPath: '/path1' },
+        { fromPath: '/org/path2', toPath: '/path2' },
+        { fromPath: '/org/path3', toPath: '/path3' },
+        { fromPath: '/org/path33', toPath: '/org/path333' },
+        { fromPath: '/org/path333', toPath: '/path3' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/org/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path3' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path33' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path333' })).not.toBeNull();
+
+      // when:
+      // remove all documents that have { toPath: '/path/3' }
+      await PageRedirect.removePageRedirectsByToPath('/path3');
+
+      // then:
+      expect(await PageRedirect.findOne({ fromPath: '/org/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path3' })).toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path33' })).toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path333' })).toBeNull();
+    });
+  });
+
+  describe('.retrievePageRedirectEndpoints', () => {
+    test('shoud return null when data is not found', async() => {
+      // setup:
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).toBeNull();
+    });
+
+    test('shoud return IPageRedirectEnds (start and end is the same)', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/path1', toPath: '/path2' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).not.toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).not.toBeNull();
+      expect(endpoints.start).not.toBeNull();
+      expect(endpoints.start.fromPath).toEqual('/path1');
+      expect(endpoints.start.toPath).toEqual('/path2');
+      expect(endpoints.end).not.toBeNull();
+      expect(endpoints.end.fromPath).toEqual('/path1');
+      expect(endpoints.end.toPath).toEqual('/path2');
+    });
+
+    test('shoud return IPageRedirectEnds', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/path1', toPath: '/path2' },
+        { fromPath: '/path2', toPath: '/path3' },
+        { fromPath: '/path3', toPath: '/path4' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/path3' })).not.toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).not.toBeNull();
+      expect(endpoints.start).not.toBeNull();
+      expect(endpoints.start.fromPath).toEqual('/path1');
+      expect(endpoints.start.toPath).toEqual('/path2');
+      expect(endpoints.end).not.toBeNull();
+      expect(endpoints.end.fromPath).toEqual('/path3');
+      expect(endpoints.end.toPath).toEqual('/path4');
+    });
+  });
+
+});