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

Merge branch 'master' into imprv/integrate-convert-to-useTranslation

ryoji-s 3 лет назад
Родитель
Сommit
eefc0bf3a3
63 измененных файлов с 902 добавлено и 537 удалено
  1. 1 0
      packages/app/config/webpack.common.js
  2. 1 0
      packages/app/resource/locales/en_US/translation.json
  3. 1 0
      packages/app/resource/locales/ja_JP/translation.json
  4. 2 1
      packages/app/resource/locales/zh_CN/translation.json
  5. 2 0
      packages/app/src/client/base.jsx
  6. 60 0
      packages/app/src/client/installer.jsx
  7. 2 1
      packages/app/src/client/legacy/crowi.js
  8. 67 65
      packages/app/src/client/nologin.jsx
  9. 0 15
      packages/app/src/client/services/AppContainer.js
  10. 2 3
      packages/app/src/client/services/CommentContainer.js
  11. 8 1
      packages/app/src/client/services/ContextExtractor.tsx
  12. 5 3
      packages/app/src/client/services/PageContainer.js
  13. 7 0
      packages/app/src/client/util/apiv1-client.ts
  14. 12 3
      packages/app/src/client/util/apiv3-client.ts
  15. 2 1
      packages/app/src/client/util/markdown-it/toc-and-anchor.js
  16. 2 2
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  17. 1 4
      packages/app/src/components/Admin/App/AwsSetting.jsx
  18. 3 13
      packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx
  19. 9 6
      packages/app/src/components/Common/CountBadge.tsx
  20. 6 9
      packages/app/src/components/Drawio.tsx
  21. 6 3
      packages/app/src/components/InstallerForm.jsx
  22. 11 6
      packages/app/src/components/LoginForm.jsx
  23. 5 16
      packages/app/src/components/Me/ProfileImageSettings.tsx
  24. 1 1
      packages/app/src/components/MyDraftList/Draft.jsx
  25. 2 2
      packages/app/src/components/Page/DisplaySwitcher.tsx
  26. 6 1
      packages/app/src/components/Page/RevisionBody.jsx
  27. 2 2
      packages/app/src/components/Page/RevisionRenderer.jsx
  28. 2 2
      packages/app/src/components/PageComment/Comment.jsx
  29. 5 6
      packages/app/src/components/PageComment/CommentEditor.jsx
  30. 15 20
      packages/app/src/components/PageComment/CommentPreview.jsx
  31. 9 12
      packages/app/src/components/PageEditor.tsx
  32. 5 2
      packages/app/src/components/PageEditor/Preview.tsx
  33. 1 9
      packages/app/src/components/PasswordResetExecutionForm.jsx
  34. 1 12
      packages/app/src/components/PasswordResetRequestForm.jsx
  35. 1 1
      packages/app/src/interfaces/page.ts
  36. 0 6
      packages/app/src/server/crowi/index.js
  37. 6 0
      packages/app/src/server/models/errors.ts
  38. 5 0
      packages/app/src/server/models/external-account.js
  39. 2 1
      packages/app/src/server/routes/apiv3/page.js
  40. 0 1
      packages/app/src/server/routes/index.js
  41. 7 1
      packages/app/src/server/routes/login-passport.js
  42. 0 4
      packages/app/src/server/routes/page.js
  43. 8 7
      packages/app/src/server/service/page-grant.ts
  44. 4 3
      packages/app/src/server/views/installer.html
  45. 4 0
      packages/app/src/stores/context.tsx
  46. 5 1
      packages/app/src/styles/theme/_apply-colors-light.scss
  47. 1 1
      packages/app/src/styles/theme/blackboard.scss
  48. 1 1
      packages/app/src/styles/theme/christmas.scss
  49. 2 2
      packages/app/src/styles/theme/fire-red.scss
  50. 2 2
      packages/app/src/styles/theme/future.scss
  51. 1 1
      packages/app/src/styles/theme/halloween.scss
  52. 1 1
      packages/app/src/styles/theme/hufflepuff.scss
  53. 1 1
      packages/app/src/styles/theme/jade-green.scss
  54. 1 1
      packages/app/src/styles/theme/nature.scss
  55. 1 1
      packages/app/src/styles/theme/spring.scss
  56. 1 1
      packages/app/src/styles/theme/wood.scss
  57. 0 264
      packages/app/test/integration/models/v5.page.test.js
  58. 306 2
      packages/app/test/integration/service/page-grant.test.js
  59. 270 0
      packages/app/test/integration/service/page.test.js
  60. 2 2
      packages/plugin-attachment-refs/src/client-entry.js
  61. 3 3
      packages/plugin-lsx/src/client-entry.js
  62. 0 1
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  63. 3 7
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

+ 1 - 0
packages/app/config/webpack.common.js

@@ -25,6 +25,7 @@ module.exports = (options) => {
       'js/app':                       './src/client/app',
       'js/app':                       './src/client/app',
       'js/admin':                     './src/client/admin',
       'js/admin':                     './src/client/admin',
       'js/nologin':                   './src/client/nologin',
       'js/nologin':                   './src/client/nologin',
+      'js/installer':                   './src/client/installer',
       'js/legacy':                    './src/client/legacy/crowi',
       'js/legacy':                    './src/client/legacy/crowi',
       'js/legacy-presentation':       './src/client/legacy/crowi-presentation',
       'js/legacy-presentation':       './src/client/legacy/crowi-presentation',
       'js/plugin':                    './src/client/plugin',
       'js/plugin':                    './src/client/plugin',

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -984,6 +984,7 @@
     "application_already_installed": "Application already installed.",
     "application_already_installed": "Application already installed.",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
     "user_id_is_not_available":"This User ID is not available.",
     "user_id_is_not_available":"This User ID is not available.",
+    "username_should_not_be_null":"Username should not be null. Please check Authentication Mechanism Settings on admin page",
     "email_address_is_already_registered":"This email address is already registered.",
     "email_address_is_already_registered":"This email address is already registered.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "failed_to_register":"Failed to register.",
     "failed_to_register":"Failed to register.",

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

@@ -977,6 +977,7 @@
     "application_already_installed": "アプリケーションのインストールが完了しました。",
     "application_already_installed": "アプリケーションのインストールが完了しました。",
     "email_address_could_not_be_used":"このメールアドレスは使用できません。(許可されたメールアドレスを確認してください。)",
     "email_address_could_not_be_used":"このメールアドレスは使用できません。(許可されたメールアドレスを確認してください。)",
     "user_id_is_not_available":"このユーザーIDは使用できません。",
     "user_id_is_not_available":"このユーザーIDは使用できません。",
+    "username_should_not_be_null":"Username が null になっています 管理画面の認証機構設定にて設定の確認をしてください",
     "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
     "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
     "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
     "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
     "failed_to_register":"登録に失敗しました。",
     "failed_to_register":"登録に失敗しました。",

+ 2 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -986,7 +986,8 @@
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
 		"application_already_installed": "应用程序已安装。",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
-		"user_id_is_not_available": "此用户ID不可用。",
+    "user_id_is_not_available": "此用户ID不可用。",
+    "username_should_not_be_null":"用户名不应为空。请检查管理页面上的身份验证机制设置",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"failed_to_register": "注册失败。",
 		"failed_to_register": "注册失败。",

+ 2 - 0
packages/app/src/client/base.jsx

@@ -6,6 +6,7 @@ import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 import PutbackPageModal from '~/components/PutbackPageModal';
 import PutbackPageModal from '~/components/PutbackPageModal';
+import InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -31,6 +32,7 @@ const xss = new Xss();
 window.xss = xss;
 window.xss = xss;
 
 
 window.globalEmitter = new EventEmitter();
 window.globalEmitter = new EventEmitter();
+window.interceptorManager = new InterceptorManager();
 
 
 // create unstated container instance
 // create unstated container instance
 const appContainer = new AppContainer();
 const appContainer = new AppContainer();

+ 60 - 0
packages/app/src/client/installer.jsx

@@ -0,0 +1,60 @@
+import React from 'react';
+
+import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
+import { SWRConfig } from 'swr';
+
+
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import InstallerForm from '../components/InstallerForm';
+
+import ContextExtractor from './services/ContextExtractor';
+import { i18nFactory } from './util/i18n';
+
+const i18n = i18nFactory();
+
+const componentMappings = {};
+
+// render InstallerForm
+const installerFormContainerElem = document.getElementById('installer-form-container');
+if (installerFormContainerElem) {
+  const userName = installerFormContainerElem.dataset.userName;
+  const name = installerFormContainerElem.dataset.name;
+  const email = installerFormContainerElem.dataset.email;
+
+  Object.assign(componentMappings, {
+    'installer-form-container': <InstallerForm userName={userName} name={name} email={email} />,
+  });
+}
+
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <SWRConfig value={swrGlobalConfiguration}>
+            {componentMappings[key]}
+          </SWRConfig>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
+  );
+}
+else {
+  renderMainComponents();
+}

+ 2 - 1
packages/app/src/client/legacy/crowi.js

@@ -13,7 +13,8 @@ if (!window) {
 window.Crowi = Crowi;
 window.Crowi = Crowi;
 
 
 Crowi.setCaretLine = function(line) {
 Crowi.setCaretLine = function(line) {
-  window.globalEmitter.emit('setCaretLine', line);
+  // eslint-disable-next-line no-undef
+  globalEmitter.emit('setCaretLine', line);
 };
 };
 
 
 // original: middleware.swigFilter
 // original: middleware.swigFilter

+ 67 - 65
packages/app/src/client/nologin.jsx

@@ -2,42 +2,32 @@ import React from 'react';
 
 
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
+import { SWRConfig } from 'swr';
 import { Provider } from 'unstated';
 import { Provider } from 'unstated';
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
-import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import LoginForm from '../components/LoginForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 
 
+import ContextExtractor from './services/ContextExtractor';
 import { i18nFactory } from './util/i18n';
 import { i18nFactory } from './util/i18n';
 
 
 const i18n = i18nFactory();
 const i18n = i18nFactory();
 
 
-// render InstallerForm
-const installerFormContainerElem = document.getElementById('installer-form-container');
-if (installerFormContainerElem) {
-  const userName = installerFormContainerElem.dataset.userName;
-  const name = installerFormContainerElem.dataset.name;
-  const email = installerFormContainerElem.dataset.email;
-  const csrf = installerFormContainerElem.dataset.csrf;
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <InstallerForm userName={userName} name={name} email={email} csrf={csrf} />
-    </I18nextProvider>,
-    installerFormContainerElem,
-  );
-}
+
+const componentMappings = {};
+
+const appContainer = new AppContainer();
+appContainer.initApp();
 
 
 // render loginForm
 // render loginForm
 const loginFormElem = document.getElementById('login-form');
 const loginFormElem = document.getElementById('login-form');
 if (loginFormElem) {
 if (loginFormElem) {
-  const appContainer = new AppContainer();
-  appContainer.initApp();
-
   const username = loginFormElem.dataset.username;
   const username = loginFormElem.dataset.username;
   const name = loginFormElem.dataset.name;
   const name = loginFormElem.dataset.name;
   const email = loginFormElem.dataset.email;
   const email = loginFormElem.dataset.email;
@@ -65,78 +55,90 @@ if (loginFormElem) {
     basic: loginFormElem.dataset.isBasicAuthEnabled === 'true',
     basic: loginFormElem.dataset.isBasicAuthEnabled === 'true',
   };
   };
 
 
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <LoginForm
-          username={username}
-          name={name}
-          email={email}
-          isRegistrationEnabled={isRegistrationEnabled}
-          isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
-          registrationMode={registrationMode}
-          registrationWhiteList={registrationWhiteList}
-          isPasswordResetEnabled={isPasswordResetEnabled}
-          isLocalStrategySetup={isLocalStrategySetup}
-          isLdapStrategySetup={isLdapStrategySetup}
-          objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
-        />
-      </Provider>
-    </I18nextProvider>,
-    loginFormElem,
-  );
+  Object.assign(componentMappings, {
+    [loginFormElem.id]: (
+      <LoginForm
+        username={username}
+        name={name}
+        email={email}
+        isRegistrationEnabled={isRegistrationEnabled}
+        isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
+        registrationMode={registrationMode}
+        registrationWhiteList={registrationWhiteList}
+        isPasswordResetEnabled={isPasswordResetEnabled}
+        isLocalStrategySetup={isLocalStrategySetup}
+        isLdapStrategySetup={isLdapStrategySetup}
+        objOfIsExternalAuthEnableds={objOfIsExternalAuthEnableds}
+      />
+    ),
+  });
 }
 }
 
 
-const appContainer = new AppContainer();
-appContainer.initApp();
-
-
 // render PasswordResetRequestForm
 // render PasswordResetRequestForm
 const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 if (passwordResetRequestFormElem) {
 if (passwordResetRequestFormElem) {
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <PasswordResetRequestForm />
-      </Provider>
-    </I18nextProvider>,
-    passwordResetRequestFormElem,
-  );
+  Object.assign(componentMappings, {
+    [passwordResetRequestFormElem.id]: <PasswordResetRequestForm />,
+  });
 }
 }
 
 
 // render PasswordResetExecutionForm
 // render PasswordResetExecutionForm
 const passwordResetExecutionFormElem = document.getElementById('password-reset-execution-form');
 const passwordResetExecutionFormElem = document.getElementById('password-reset-execution-form');
 if (passwordResetExecutionFormElem) {
 if (passwordResetExecutionFormElem) {
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[appContainer]}>
-        <PasswordResetExecutionForm />
-      </Provider>
-    </I18nextProvider>,
-    passwordResetExecutionFormElem,
-  );
+  Object.assign(componentMappings, {
+    [passwordResetExecutionFormElem.id]: <PasswordResetExecutionForm />,
+  });
 }
 }
 
 
 // render UserActivationForm
 // render UserActivationForm
 const UserActivationForm = document.getElementById('user-activation-form');
 const UserActivationForm = document.getElementById('user-activation-form');
 if (UserActivationForm) {
 if (UserActivationForm) {
-
   const messageErrors = UserActivationForm.dataset.messageErrors;
   const messageErrors = UserActivationForm.dataset.messageErrors;
   const inputs = UserActivationForm.dataset.inputs;
   const inputs = UserActivationForm.dataset.inputs;
   const email = UserActivationForm.dataset.email;
   const email = UserActivationForm.dataset.email;
   const token = UserActivationForm.dataset.token;
   const token = UserActivationForm.dataset.token;
 
 
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
+  Object.assign(componentMappings, {
+    [UserActivationForm.id]: (
       <CompleteUserRegistrationForm
       <CompleteUserRegistrationForm
         messageErrors={messageErrors}
         messageErrors={messageErrors}
         inputs={inputs}
         inputs={inputs}
         email={email}
         email={email}
         token={token}
         token={token}
       />
       />
-    </I18nextProvider>,
-    UserActivationForm,
+    ),
+  });
+}
+
+const renderMainComponents = () => {
+  Object.keys(componentMappings).forEach((key) => {
+    const elem = document.getElementById(key);
+    if (elem) {
+      ReactDOM.render(
+        <I18nextProvider i18n={i18n}>
+          <SWRConfig value={swrGlobalConfiguration}>
+            <Provider inject={[appContainer]}>
+              {componentMappings[key]}
+            </Provider>
+          </SWRConfig>
+        </I18nextProvider>,
+        elem,
+      );
+    }
+  });
+};
+
+// extract context before rendering main components
+const elem = document.getElementById('growi-context-extractor');
+if (elem != null) {
+  ReactDOM.render(
+    <SWRConfig value={swrGlobalConfiguration}>
+      <ContextExtractor></ContextExtractor>
+    </SWRConfig>,
+    elem,
+    renderMainComponents,
   );
   );
 }
 }
+else {
+  renderMainComponents();
+}

+ 0 - 15
packages/app/src/client/services/AppContainer.js

@@ -1,6 +1,5 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import InterceptorManager from '~/services/interceptor-manager';
 
 
 import GrowiRenderer from '../util/GrowiRenderer';
 import GrowiRenderer from '../util/GrowiRenderer';
 import { i18nFactory } from '../util/i18n';
 import { i18nFactory } from '../util/i18n';
@@ -14,11 +13,6 @@ export default class AppContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
-    // get csrf token from body element
-    // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
-    const body = document.querySelector('body');
-    this.csrfToken = body.dataset.csrftoken;
-
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
 
     const currentUserElem = document.getElementById('growi-current-user');
     const currentUserElem = document.getElementById('growi-current-user');
@@ -52,8 +46,6 @@ export default class AppContainer extends Container {
 
 
     this.originRenderer = new GrowiRenderer(this);
     this.originRenderer = new GrowiRenderer(this);
 
 
-    this.interceptorManager = new InterceptorManager();
-
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     if (isPluginEnabled) {
     if (isPluginEnabled) {
       this.initPlugins();
       this.initPlugins();
@@ -93,13 +85,6 @@ export default class AppContainer extends Container {
     return this.currentUser.username;
     return this.currentUser.username;
   }
   }
 
 
-  /**
-   * @return {Object} window.Crowi (js/legacy/crowi.js)
-   */
-  getCrowiForJquery() {
-    return window.Crowi;
-  }
-
   getConfig() {
   getConfig() {
     return this.config;
     return this.config;
   }
   }

+ 2 - 3
packages/app/src/client/services/CommentContainer.js

@@ -2,7 +2,7 @@ import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { apiGet, apiPost } from '../util/apiv1-client';
+import { apiGet, apiPost, apiPostForm } from '../util/apiv1-client';
 import { apiv3Put } from '../util/apiv3-client';
 import { apiv3Put } from '../util/apiv3-client';
 
 
 const logger = loggerFactory('growi:services:CommentContainer');
 const logger = loggerFactory('growi:services:CommentContainer');
@@ -157,12 +157,11 @@ export default class CommentContainer extends Container {
 
 
     const endpoint = '/attachments.add';
     const endpoint = '/attachments.add';
     const formData = new FormData();
     const formData = new FormData();
-    formData.append('_csrf', this.appContainer.csrfToken);
     formData.append('file', file);
     formData.append('file', file);
     formData.append('path', pagePath);
     formData.append('path', pagePath);
     formData.append('page_id', pageId);
     formData.append('page_id', pageId);
 
 
-    return apiPost(endpoint, formData);
+    return apiPostForm(endpoint, formData);
   }
   }
 
 
 }
 }

+ 8 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -18,7 +18,7 @@ import {
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken,
 } from '../../stores/context';
 } from '../../stores/context';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -32,6 +32,11 @@ const ContextExtractorOnce: FC = () => {
   const notFoundContent = document.getElementById('growi-not-found-context');
   const notFoundContent = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
   const forbiddenContent = document.getElementById('forbidden-page');
 
 
+  // get csrf token from body element
+  // DO NOT REMOVE: uploading attachment data requires appContainer.csrfToken
+  const body = document.querySelector('body');
+  const csrfToken = body?.dataset.csrftoken;
+
   /*
   /*
    * App Context from DOM
    * App Context from DOM
    */
    */
@@ -94,6 +99,8 @@ const ContextExtractorOnce: FC = () => {
   /*
   /*
    * use static swr
    * use static swr
    */
    */
+  useCsrfToken(csrfToken);
+
   // App
   // App
   useCurrentUser(currentUser);
   useCurrentUser(currentUser);
 
 

+ 5 - 3
packages/app/src/client/services/PageContainer.js

@@ -99,7 +99,7 @@ export default class PageContainer extends Container {
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
       logger.warn('The data of \'data-page-revision-author\' is invalid', e);
     }
     }
 
 
-    const { interceptorManager } = this.appContainer;
+    const { interceptorManager } = window;
     interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(), 10); // process as soon as possible
     interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(), 10); // process as soon as possible
     interceptorManager.addInterceptor(new DrawioInterceptor(), 20);
     interceptorManager.addInterceptor(new DrawioInterceptor(), 20);
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(), 900); // process as late as possible
     interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(), 900); // process as late as possible
@@ -219,7 +219,8 @@ export default class PageContainer extends Container {
 
 
     // Update PageEditor component
     // Update PageEditor component
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
-      window.globalEmitter.emit('updateEditorValue', newState.markdown);
+      // eslint-disable-next-line no-undef
+      globalEmitter.emit('updateEditorValue', newState.markdown);
     }
     }
 
 
     // PageEditorByHackmd component
     // PageEditorByHackmd component
@@ -459,7 +460,8 @@ export default class PageContainer extends Container {
 
 
     // Update PageEditor component
     // Update PageEditor component
     if (editorMode !== EditorMode.Editor) {
     if (editorMode !== EditorMode.Editor) {
-      window.globalEmitter.emit('updateEditorValue', markdown);
+      // eslint-disable-next-line no-undef
+      globalEmitter.emit('updateEditorValue', markdown);
     }
     }
 
 
     editorContainer.setState({ tags: res.tags });
     editorContainer.setState({ tags: res.tags });

+ 7 - 0
packages/app/src/client/util/apiv1-client.ts

@@ -58,6 +58,13 @@ export async function apiPost(path: string, params: any & ParamWithCsrfKey = {})
   return apiRequest('post', path, params);
   return apiRequest('post', path, params);
 }
 }
 
 
+export async function apiPostForm(path: string, formData: FormData): Promise<unknown> {
+  if (formData.get('_csrf') == null && csrfToken != null) {
+    formData.append('_csrf', csrfToken);
+  }
+  return apiPost(path, formData);
+}
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export async function apiDelete(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
 export async function apiDelete(path: string, params: any & ParamWithCsrfKey = {}): Promise<unknown> {
   if (params._csrf == null) {
   if (params._csrf == null) {

+ 12 - 3
packages/app/src/client/util/apiv3-client.ts

@@ -1,11 +1,12 @@
+// eslint-disable-next-line no-restricted-imports
+import { AxiosResponse } from 'axios';
 import * as urljoin from 'url-join';
 import * as urljoin from 'url-join';
 
 
 // eslint-disable-next-line no-restricted-imports
 // eslint-disable-next-line no-restricted-imports
-import { AxiosResponse } from 'axios';
 
 
-import loggerFactory from '~/utils/logger';
-import axios from '~/utils/axios';
 import { toArrayIfNot } from '~/utils/array-utils';
 import { toArrayIfNot } from '~/utils/array-utils';
+import axios from '~/utils/axios';
+import loggerFactory from '~/utils/logger';
 
 
 const apiv3Root = '/_api/v3';
 const apiv3Root = '/_api/v3';
 
 
@@ -57,6 +58,14 @@ export async function apiv3Post<T = any>(path: string, params: any & ParamWithCs
   return apiv3Request('post', path, params);
   return apiv3Request('post', path, params);
 }
 }
 
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
+  if (formData.get('_csrf') == null && csrfToken != null) {
+    formData.append('_csrf', csrfToken);
+  }
+  return apiv3Post<T>(path, formData);
+}
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export async function apiv3Put<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
 export async function apiv3Put<T = any>(path: string, params: any & ParamWithCsrfKey = {}): Promise<AxiosResponse<T>> {
   if (params._csrf == null) {
   if (params._csrf == null) {

+ 2 - 1
packages/app/src/client/util/markdown-it/toc-and-anchor.js

@@ -18,7 +18,8 @@ export default class TocAndAnchorConfigurer {
     // set toc render function
     // set toc render function
     md.set({
     md.set({
       tocCallback: (tocMarkdown, tocArray, tocHtml) => {
       tocCallback: (tocMarkdown, tocArray, tocHtml) => {
-        window.globalEmitter.emit('renderTocHtml', tocHtml);
+        // eslint-disable-next-line no-undef
+        globalEmitter.emit('renderTocHtml', tocHtml);
       },
       },
     });
     });
   }
   }

+ 2 - 2
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -28,7 +28,7 @@
       const section = sections[i];
       const section = sections[i];
       const markdown = marked.getMarkdownFromSlide(section);
       const markdown = marked.getMarkdownFromSlide(section);
       const context = { markdown };
       const context = { markdown };
-      const interceptorManager = appContainer.interceptorManager;
+      const { interceptorManager } = window.parent;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       // replace string '\n' to LF code.
       // replace string '\n' to LF code.
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
@@ -51,7 +51,7 @@
   function convertSlides() {
   function convertSlides() {
     const sections = document.querySelectorAll('[data-markdown]');
     const sections = document.querySelectorAll('[data-markdown]');
     let markdown;
     let markdown;
-    const interceptorManager = appContainer.interceptorManager;
+    const { interceptorManager } = window.parent;
 
 
     for (let i = 0, len = sections.length; i < len; i++) {
     for (let i = 0, len = sections.length; i < len; i++) {
       const section = sections[i];
       const section = sections[i];

+ 1 - 4
packages/app/src/components/Admin/App/AwsSetting.jsx

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AppContainer from '~/client/services/AppContainer';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -153,11 +152,9 @@ function AwsSetting(props) {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AppContainer, AdminAppContainer]);
+const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AdminAppContainer]);
 
 
 AwsSetting.propTypes = {
 AwsSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 };
 
 

+ 3 - 13
packages/app/src/components/Admin/ImportData/GrowiArchive/UploadForm.jsx

@@ -3,11 +3,8 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-import { apiv3Post } from '~/client/util/apiv3-client';
-
-import { withUnstatedContainers } from '../../../UnstatedUtils';
+import { apiv3PostForm } from '~/client/util/apiv3-client';
 
 
 class UploadForm extends React.Component {
 class UploadForm extends React.Component {
 
 
@@ -31,11 +28,10 @@ class UploadForm extends React.Component {
     e.preventDefault();
     e.preventDefault();
 
 
     const formData = new FormData();
     const formData = new FormData();
-    formData.append('_csrf', this.props.appContainer.csrfToken);
     formData.append('file', this.inputRef.current.files[0]);
     formData.append('file', this.inputRef.current.files[0]);
 
 
     try {
     try {
-      const { data } = await apiv3Post('/import/upload', formData);
+      const { data } = await apiv3PostForm('/import/upload', formData);
       // TODO: toastSuccess, toastError
       // TODO: toastSuccess, toastError
       this.props.onUpload(data);
       this.props.onUpload(data);
     }
     }
@@ -96,15 +92,9 @@ class UploadForm extends React.Component {
 
 
 UploadForm.propTypes = {
 UploadForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   onUpload: PropTypes.func.isRequired,
   onUpload: PropTypes.func.isRequired,
   isTheSameVersion: PropTypes.bool,
   isTheSameVersion: PropTypes.bool,
   onVersionMismatch: PropTypes.func,
   onVersionMismatch: PropTypes.func,
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const UploadFormWrapper = withUnstatedContainers(UploadForm, [AppContainer]);
-
-export default withTranslation()(UploadFormWrapper);
+export default withTranslation()(UploadForm);

+ 9 - 6
packages/app/src/components/Common/CountBadge.tsx

@@ -1,16 +1,19 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
 type CountProps = {
 type CountProps = {
-  count: number
+  count?: number,
+  offset?: number,
 }
 }
 
 
 const CountBadge: FC<CountProps> = (props:CountProps) => {
 const CountBadge: FC<CountProps> = (props:CountProps) => {
+  const { count, offset = 0 } = props;
+
+
   return (
   return (
-    <>
-      <span className="grw-count-badge px-2 badge badge-pill badge-light">
-        {props.count}
-      </span>
-    </>
+    <span className="grw-count-badge px-2 badge badge-pill badge-light">
+      { count == null && <span className="text-muted">―</span> }
+      { count != null && count + offset }
+    </span>
   );
   );
 };
 };
 
 

+ 6 - 9
packages/app/src/components/Drawio.tsx

@@ -10,11 +10,9 @@ import { debounce } from 'throttle-debounce';
 import NotAvailableForGuest from './NotAvailableForGuest';
 import NotAvailableForGuest from './NotAvailableForGuest';
 
 
 
 
-declare let window: {
-  globalEmitter: EventEmitter,
-  GraphViewer: {
-    createViewerForElement: (Element) => void,
-  };
+declare const globalEmitter: EventEmitter;
+declare const GraphViewer: {
+  createViewerForElement: (Element) => void,
 };
 };
 
 
 type Props = {
 type Props = {
@@ -35,7 +33,7 @@ const Drawio = (props: Props): JSX.Element => {
 
 
   const editButtonClickHandler = useCallback(() => {
   const editButtonClickHandler = useCallback(() => {
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
     const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
-    window.globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
+    globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
   }, [rangeLineNumberOfMarkdown]);
   }, [rangeLineNumberOfMarkdown]);
 
 
   const renderDrawio = useCallback(() => {
   const renderDrawio = useCallback(() => {
@@ -50,21 +48,20 @@ const Drawio = (props: Props): JSX.Element => {
 
 
       if (div != null) {
       if (div != null) {
         div.innerHTML = '';
         div.innerHTML = '';
-        window.GraphViewer.createViewerForElement(div);
+        GraphViewer.createViewerForElement(div);
       }
       }
     }
     }
   }, []);
   }, []);
 
 
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
   const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
 
 
-  const { GraphViewer } = window;
   useEffect(() => {
   useEffect(() => {
     if (GraphViewer == null) {
     if (GraphViewer == null) {
       return;
       return;
     }
     }
 
 
     renderDrawioWithDebounce();
     renderDrawioWithDebounce();
-  }, [GraphViewer, renderDrawioWithDebounce]);
+  }, [renderDrawioWithDebounce]);
 
 
   return (
   return (
     <div className="editable-with-drawio position-relative">
     <div className="editable-with-drawio position-relative">

+ 6 - 3
packages/app/src/components/InstallerForm.jsx

@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { localeMetadatas } from '~/client/util/i18n';
 import { localeMetadatas } from '~/client/util/i18n';
+import { useCsrfToken } from '~/stores/context';
 
 
 class InstallerForm extends React.Component {
 class InstallerForm extends React.Component {
 
 
@@ -175,7 +176,7 @@ class InstallerForm extends React.Component {
               />
               />
             </div>
             </div>
 
 
-            <input type="hidden" name="_csrf" value={this.props.csrf} />
+            <input type="hidden" name="_csrf" value={this.props.csrfToken} />
 
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
               <button
               <button
@@ -211,12 +212,14 @@ InstallerForm.propTypes = {
   userName: PropTypes.string,
   userName: PropTypes.string,
   name: PropTypes.string,
   name: PropTypes.string,
   email: PropTypes.string,
   email: PropTypes.string,
-  csrf: PropTypes.string,
+  csrfToken: PropTypes.string,
 };
 };
 
 
 const InstallerFormWrapperFC = (props) => {
 const InstallerFormWrapperFC = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  return <InstallerForm t={t} {...props} />;
+  const { data: csrfToken } = useCsrfToken();
+
+  return <InstallerForm t={t} csrfToken={csrfToken} {...props} />;
 };
 };
 
 
 export default InstallerFormWrapperFC;
 export default InstallerFormWrapperFC;

+ 11 - 6
packages/app/src/components/LoginForm.jsx

@@ -5,6 +5,7 @@ import ReactCardFlip from 'react-card-flip';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import { useCsrfToken } from '~/stores/context';
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 
@@ -36,12 +37,12 @@ class LoginForm extends React.Component {
 
 
   handleLoginWithExternalAuth(e) {
   handleLoginWithExternalAuth(e) {
     const auth = e.currentTarget.id;
     const auth = e.currentTarget.id;
-    const { csrf } = this.props.appContainer;
-    window.location.href = `/passport/${auth}?_csrf=${csrf}`;
+    const { csrfToken } = this.props;
+    window.location.href = `/passport/${auth}?_csrf=${csrfToken}`;
   }
   }
 
 
   renderLocalOrLdapLoginForm() {
   renderLocalOrLdapLoginForm() {
-    const { t, appContainer, isLdapStrategySetup } = this.props;
+    const { t, csrfToken, isLdapStrategySetup } = this.props;
 
 
     return (
     return (
       <form role="form" action="/login" method="post">
       <form role="form" action="/login" method="post">
@@ -71,7 +72,7 @@ class LoginForm extends React.Component {
         </div>
         </div>
 
 
         <div className="input-group my-4">
         <div className="input-group my-4">
-          <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+          <input type="hidden" name="_csrf" value={csrfToken} />
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto" data-testid="btnSubmitForLogin">
             <div className="eff"></div>
             <div className="eff"></div>
             <span className="btn-label">
             <span className="btn-label">
@@ -149,6 +150,7 @@ class LoginForm extends React.Component {
     const {
     const {
       t,
       t,
       appContainer,
       appContainer,
+      csrfToken,
       isEmailAuthenticationEnabled,
       isEmailAuthenticationEnabled,
       username,
       username,
       name,
       name,
@@ -252,7 +254,7 @@ class LoginForm extends React.Component {
           )}
           )}
 
 
           <div className="input-group justify-content-center my-4">
           <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
+            <input type="hidden" name="_csrf" value={csrfToken} />
             <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
             <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
               <div className="eff"></div>
               <div className="eff"></div>
               <span className="btn-label">
               <span className="btn-label">
@@ -333,6 +335,7 @@ LoginForm.propTypes = {
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
 
+  csrfToken: PropTypes.string,
   isRegistering: PropTypes.bool,
   isRegistering: PropTypes.bool,
   username: PropTypes.string,
   username: PropTypes.string,
   name: PropTypes.string,
   name: PropTypes.string,
@@ -349,7 +352,9 @@ LoginForm.propTypes = {
 
 
 const LoginFormWrapperFC = (props) => {
 const LoginFormWrapperFC = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  return <LoginForm t={t} {...props} />;
+  const { data: csrfToken } = useCsrfToken();
+
+  return <LoginForm t={t} csrfToken={csrfToken} {...props} />;
 };
 };
 
 
 /**
 /**

+ 5 - 16
packages/app/src/components/Me/ProfileImageSettings.tsx

@@ -3,29 +3,20 @@ import React, { useCallback, useState } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiPost } from '~/client/util/apiv1-client';
+import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
 import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-
 import ImageCropModal from './ImageCropModal';
 import ImageCropModal from './ImageCropModal';
 
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
 
 
 
-type Props = {
-  appContainer: AppContainer,
-}
-
-const ProfileImageSettings = (props: Props): JSX.Element => {
+const ProfileImageSettings = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { appContainer } = props;
-
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
   const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
   const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
@@ -55,9 +46,7 @@ const ProfileImageSettings = (props: Props): JSX.Element => {
     try {
     try {
       const formData = new FormData();
       const formData = new FormData();
       formData.append('file', croppedImage);
       formData.append('file', croppedImage);
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      formData.append('_csrf', appContainer.csrfToken!);
-      const response = await apiPost('/attachments.uploadProfileImage', formData);
+      const response = await apiPostForm('/attachments.uploadProfileImage', formData);
 
 
       toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
       toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
 
 
@@ -70,7 +59,7 @@ const ProfileImageSettings = (props: Props): JSX.Element => {
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [appContainer.csrfToken, t]);
+  }, [t]);
 
 
   const deleteImageHandler = useCallback(async() => {
   const deleteImageHandler = useCallback(async() => {
     try {
     try {
@@ -185,4 +174,4 @@ const ProfileImageSettings = (props: Props): JSX.Element => {
 
 
 };
 };
 
 
-export default withUnstatedContainers(ProfileImageSettings, [AppContainer]);
+export default ProfileImageSettings;

+ 1 - 1
packages/app/src/components/MyDraftList/Draft.jsx

@@ -59,7 +59,7 @@ class Draft extends React.Component {
     };
     };
 
 
     const growiRenderer = this.growiRenderer;
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.appContainer.interceptorManager;
+    const { interceptorManager } = window;
     await interceptorManager.process('prePreProcess', context)
     await interceptorManager.process('prePreProcess', context)
       .then(() => {
       .then(() => {
         context.markdown = growiRenderer.preProcess(context.markdown, context);
         context.markdown = growiRenderer.preProcess(context.markdown, context);

+ 2 - 2
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -76,7 +76,7 @@ const DisplaySwitcher = (): JSX.Element => {
                           <PageListIcon />
                           <PageListIcon />
                         </div>
                         </div>
                         {t('page_list')}
                         {t('page_list')}
-                        {currentPage?.descendantCount != null && <CountBadge count={currentPage.descendantCount + 1} />}
+                        <CountBadge count={currentPage?.descendantCount} offset={1} />
                       </button>
                       </button>
                     ) }
                     ) }
                   </div>
                   </div>
@@ -91,7 +91,7 @@ const DisplaySwitcher = (): JSX.Element => {
                       >
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
                         <span>Comments</span>
-                        {currentPage?.commentCount != null && <CountBadge count={currentPage.commentCount} />}
+                        <CountBadge count={currentPage?.commentCount} />
                       </button>
                       </button>
                     </div>
                     </div>
                   ) }
                   ) }

+ 6 - 1
packages/app/src/components/Page/RevisionBody.jsx

@@ -58,7 +58,12 @@ export default class RevisionBody extends React.PureComponent {
     const additionalClassName = this.props.additionalClassName || '';
     const additionalClassName = this.props.additionalClassName || '';
     return (
     return (
       <div
       <div
-        ref={this.props.inputRef}
+        ref={(elem) => {
+          this.element = elem;
+          if (this.props.inputRef != null) {
+            this.props.inputRef.current = elem;
+          }
+        }}
         id="wiki"
         id="wiki"
         className={`wiki ${additionalClassName}`}
         className={`wiki ${additionalClassName}`}
         // eslint-disable-next-line react/no-danger
         // eslint-disable-next-line react/no-danger

+ 2 - 2
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -58,7 +58,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     const HeaderLinkArray = Array.from(HeaderLink);
     const HeaderLinkArray = Array.from(HeaderLink);
     addSmoothScrollEvent(HeaderLinkArray, blinkElem);
     addSmoothScrollEvent(HeaderLinkArray, blinkElem);
 
 
-    const { interceptorManager } = this.props.appContainer;
+    const { interceptorManager } = window;
 
 
     interceptorManager.process('postRenderHtml', this.currentRenderingContext);
     interceptorManager.process('postRenderHtml', this.currentRenderingContext);
   }
   }
@@ -134,7 +134,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
       highlightKeywords,
       highlightKeywords,
     } = this.props;
     } = this.props;
 
 
-    const { interceptorManager } = appContainer;
+    const { interceptorManager } = window;
     const context = this.currentRenderingContext;
     const context = this.currentRenderingContext;
 
 
     await interceptorManager.process('preRender', context);
     await interceptorManager.process('preRender', context);

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

@@ -69,7 +69,7 @@ class Comment extends React.PureComponent {
       return;
       return;
     }
     }
 
 
-    const { interceptorManager } = this.props.appContainer;
+    const { interceptorManager } = window;
 
 
     interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
     interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
   }
   }
@@ -133,7 +133,7 @@ class Comment extends React.PureComponent {
   async renderHtml() {
   async renderHtml() {
 
 
     const { growiRenderer, appContainer } = this.props;
     const { growiRenderer, appContainer } = this.props;
-    const { interceptorManager } = appContainer;
+    const { interceptorManager } = window;
     const context = this.currentRenderingContext;
     const context = this.currentRenderingContext;
 
 
     await interceptorManager.process('preRenderComment', context);
     await interceptorManager.process('preRenderComment', context);

+ 5 - 6
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
@@ -224,7 +224,6 @@ class CommentEditor extends React.Component {
   getCommentHtml() {
   getCommentHtml() {
     return (
     return (
       <CommentPreview
       <CommentPreview
-        inputRef={(el) => { this.previewElement = el }}
         html={this.state.html}
         html={this.state.html}
       />
       />
     );
     );
@@ -236,7 +235,7 @@ class CommentEditor extends React.Component {
     };
     };
 
 
     const { growiRenderer } = this.props;
     const { growiRenderer } = this.props;
-    const interceptorManager = this.props.appContainer.interceptorManager;
+    const { interceptorManager } = window;
     interceptorManager.process('preRenderCommnetPreview', context)
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
       .then(() => {
@@ -281,7 +280,7 @@ class CommentEditor extends React.Component {
   }
   }
 
 
   renderReady() {
   renderReady() {
-    const { appContainer, commentContainer, isMobile } = this.props;
+    const { isMobile } = this.props;
     const { activeTab } = this.state;
     const { activeTab } = this.state;
 
 
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
@@ -446,9 +445,9 @@ const CommentEditorWrapper = (props) => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
 
 
-  const onSlackEnabledFlagChange = (isSlackEnabled) => {
+  const onSlackEnabledFlagChange = useCallback((isSlackEnabled) => {
     mutateIsSlackEnabled(isSlackEnabled, false);
     mutateIsSlackEnabled(isSlackEnabled, false);
-  };
+  }, [mutateIsSlackEnabled]);
 
 
   return (
   return (
     <CommentEditorHOCWrapper
     <CommentEditorHOCWrapper

+ 15 - 20
packages/app/src/components/PageComment/CommentPreview.jsx

@@ -1,4 +1,5 @@
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import RevisionBody from '../Page/RevisionBody';
 import RevisionBody from '../Page/RevisionBody';
@@ -6,29 +7,23 @@ import RevisionBody from '../Page/RevisionBody';
 /**
 /**
  * Wrapper component for Page/RevisionBody
  * Wrapper component for Page/RevisionBody
  */
  */
-export default class CommentPreview extends React.Component {
-
-  render() {
-    return (
-      <div
-        className="page-comment-preview-body"
-        ref={(elm) => {
-          this.previewElement = elm;
-          this.props.inputRef(elm);
-        }}
-      >
+const CommentPreview = (props) => {
 
 
-        <RevisionBody
-          {...this.props}
-          additionalClassName="comment"
-        />
-      </div>
-    );
-  }
+  return (
+    <div className="page-comment-preview-body">
+      <RevisionBody
+        html={props.html}
+        additionalClassName="comment"
+        isMathJaxEnabled
+        renderMathJaxInRealtime
+      />
+    </div>
+  );
 
 
-}
+};
 
 
 CommentPreview.propTypes = {
 CommentPreview.propTypes = {
   html: PropTypes.string,
   html: PropTypes.string,
-  inputRef: PropTypes.func.isRequired, // for getting div element
 };
 };
+
+export default CommentPreview;

+ 9 - 12
packages/app/src/components/PageEditor.tsx

@@ -11,7 +11,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import { apiGet, apiPost } from '~/client/util/apiv1-client';
+import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useIsEditable, useIsIndentSizeForced, useCurrentPagePath } from '~/stores/context';
 import { useIsEditable, useIsIndentSizeForced, useCurrentPagePath } from '~/stores/context';
 import {
 import {
@@ -36,9 +36,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 const logger = loggerFactory('growi:PageEditor');
 const logger = loggerFactory('growi:PageEditor');
 
 
 
 
-declare let window: {
-  globalEmitter: EventEmitter,
-};
+declare const globalEmitter: EventEmitter;
+
 
 
 type EditorRef = {
 type EditorRef = {
   setValue: (markdown: string) => void,
   setValue: (markdown: string) => void,
@@ -165,8 +164,6 @@ const PageEditor = (props: Props): JSX.Element => {
 
 
       const formData = new FormData();
       const formData = new FormData();
       const { pageId, path } = pageContainer.state;
       const { pageId, path } = pageContainer.state;
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      formData.append('_csrf', appContainer.csrfToken!);
       formData.append('file', file);
       formData.append('file', file);
       if (path != null) {
       if (path != null) {
         formData.append('path', path);
         formData.append('path', path);
@@ -175,7 +172,7 @@ const PageEditor = (props: Props): JSX.Element => {
         formData.append('page_id', pageId);
         formData.append('page_id', pageId);
       }
       }
 
 
-      res = await apiPost('/attachments.add', formData);
+      res = await apiPostForm('/attachments.add', formData);
       const attachment = res.attachment;
       const attachment = res.attachment;
       const fileName = attachment.originalName;
       const fileName = attachment.originalName;
 
 
@@ -201,7 +198,7 @@ const PageEditor = (props: Props): JSX.Element => {
     finally {
     finally {
       editorRef.current.terminateUploadingState();
       editorRef.current.terminateUploadingState();
     }
     }
-  }, [appContainer.csrfToken, editorMode, mutateGrant, pageContainer]);
+  }, [editorMode, mutateGrant, pageContainer]);
 
 
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -322,10 +319,10 @@ const PageEditor = (props: Props): JSX.Element => {
         scrollSyncHelper.scrollPreview(previewRef.current, line);
         scrollSyncHelper.scrollPreview(previewRef.current, line);
       }
       }
     };
     };
-    window.globalEmitter.on('setCaretLine', handler);
+    globalEmitter.on('setCaretLine', handler);
 
 
     return function cleanup() {
     return function cleanup() {
-      window.globalEmitter.removeListener('setCaretLine', handler);
+      globalEmitter.removeListener('setCaretLine', handler);
     };
     };
   }, []);
   }, []);
 
 
@@ -343,10 +340,10 @@ const PageEditor = (props: Props): JSX.Element => {
         editorRef.current.setValue(markdown);
         editorRef.current.setValue(markdown);
       }
       }
     };
     };
-    window.globalEmitter.on('updateEditorValue', handler);
+    globalEmitter.on('updateEditorValue', handler);
 
 
     return function cleanup() {
     return function cleanup() {
-      window.globalEmitter.removeListener('updateEditorValue', handler);
+      globalEmitter.removeListener('updateEditorValue', handler);
     };
     };
   }, []);
   }, []);
 
 

+ 5 - 2
packages/app/src/components/PageEditor/Preview.tsx

@@ -4,12 +4,16 @@ import React, {
 
 
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
+import InterceptorManager from '~/services/interceptor-manager';
 import { useEditorSettings } from '~/stores/editor';
 import { useEditorSettings } from '~/stores/editor';
 
 
 import RevisionBody from '../Page/RevisionBody';
 import RevisionBody from '../Page/RevisionBody';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
+declare const interceptorManager: InterceptorManager;
+
+
 type Props = {
 type Props = {
   appContainer: AppContainer,
   appContainer: AppContainer,
 
 
@@ -34,8 +38,7 @@ const Preview = (props: Props): JSX.Element => {
 
 
   const { data: editorSettings } = useEditorSettings();
   const { data: editorSettings } = useEditorSettings();
 
 
-  const { interceptorManager } = appContainer;
-  const growiRenderer = props.appContainer.getRenderer('editor');
+  const growiRenderer = appContainer.getRenderer('editor');
 
 
   const context = useMemo(() => {
   const context = useMemo(() => {
     return {
     return {

+ 1 - 9
packages/app/src/components/PasswordResetExecutionForm.jsx

@@ -1,21 +1,16 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 const logger = loggerFactory('growi:passwordReset');
 const logger = loggerFactory('growi:passwordReset');
 
 
 
 
 const PasswordResetExecutionForm = (props) => {
 const PasswordResetExecutionForm = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { appContainer } = props;
 
 
   const [newPassword, setNewPassword] = useState('');
   const [newPassword, setNewPassword] = useState('');
   const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
   const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
@@ -91,10 +86,7 @@ const PasswordResetExecutionForm = (props) => {
   );
   );
 };
 };
 
 
-const PasswordResetExecutionFormWrapper = withUnstatedContainers(PasswordResetExecutionForm, [AppContainer]);
-
 PasswordResetExecutionForm.propTypes = {
 PasswordResetExecutionForm.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 };
 
 
-export default PasswordResetExecutionFormWrapper;
+export default PasswordResetExecutionForm;

+ 1 - 12
packages/app/src/components/PasswordResetRequestForm.jsx

@@ -1,18 +1,13 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
 
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 
 
 const PasswordResetRequestForm = (props) => {
 const PasswordResetRequestForm = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { appContainer } = props;
   const [email, setEmail] = useState('');
   const [email, setEmail] = useState('');
 
 
   const changeEmail = (inputValue) => {
   const changeEmail = (inputValue) => {
@@ -57,13 +52,7 @@ const PasswordResetRequestForm = (props) => {
   );
   );
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const PasswordResetRequestFormWrapper = withUnstatedContainers(PasswordResetRequestForm, [AppContainer]);
-
 PasswordResetRequestForm.propTypes = {
 PasswordResetRequestForm.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 };
 
 
-export default PasswordResetRequestFormWrapper;
+export default PasswordResetRequestForm;

+ 1 - 1
packages/app/src/interfaces/page.ts

@@ -35,7 +35,7 @@ export interface IPage {
 export const PageGrant = {
 export const PageGrant = {
   GRANT_PUBLIC: 1,
   GRANT_PUBLIC: 1,
   GRANT_RESTRICTED: 2,
   GRANT_RESTRICTED: 2,
-  GRANT_SPECIFIED: 3,
+  GRANT_SPECIFIED: 3, // DEPRECATED
   GRANT_OWNER: 4,
   GRANT_OWNER: 4,
   GRANT_USER_GROUP: 5,
   GRANT_USER_GROUP: 5,
 };
 };

+ 0 - 6
packages/app/src/server/crowi/index.js

@@ -11,7 +11,6 @@ import mongoose from 'mongoose';
 import pkg from '^/package.json';
 import pkg from '^/package.json';
 
 
 import CdnResourcesService from '~/services/cdn-resources-service';
 import CdnResourcesService from '~/services/cdn-resources-service';
-import InterceptorManager from '~/services/interceptor-manager';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
@@ -72,7 +71,6 @@ function Crowi() {
   this.pageService = null;
   this.pageService = null;
   this.syncPageStatusService = null;
   this.syncPageStatusService = null;
   this.cdnResourcesService = new CdnResourcesService();
   this.cdnResourcesService = new CdnResourcesService();
-  this.interceptorManager = new InterceptorManager();
   this.slackIntegrationService = null;
   this.slackIntegrationService = null;
   this.inAppNotificationService = null;
   this.inAppNotificationService = null;
   this.activityService = null;
   this.activityService = null;
@@ -313,10 +311,6 @@ Crowi.prototype.getSlackLegacy = function() {
   return this.slackLegacy;
   return this.slackLegacy;
 };
 };
 
 
-Crowi.prototype.getInterceptorManager = function() {
-  return this.interceptorManager;
-};
-
 Crowi.prototype.getGlobalNotificationService = function() {
 Crowi.prototype.getGlobalNotificationService = function() {
   return this.globalNotificationService;
   return this.globalNotificationService;
 };
 };

+ 6 - 0
packages/app/src/server/models/errors.ts

@@ -10,3 +10,9 @@ export class PathAlreadyExistsError extends ExtensibleCustomError {
   }
   }
 
 
 }
 }
+
+
+/*
+* User Authentication
+*/
+export class NullUsernameToBeRegisteredError extends ExtensibleCustomError {}

+ 5 - 0
packages/app/src/server/models/external-account.js

@@ -1,5 +1,6 @@
 // disable no-return-await for model functions
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 /* eslint-disable no-return-await */
+import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 
 
 const debug = require('debug')('growi:models:external-account');
 const debug = require('debug')('growi:models:external-account');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
@@ -104,6 +105,10 @@ class ExternalAccount {
           return account;
           return account;
         }
         }
 
 
+        if (usernameToBeRegistered == null) {
+          throw new NullUsernameToBeRegisteredError('username_should_not_be_null');
+        }
+
         const User = ExternalAccount.crowi.model('User');
         const User = ExternalAccount.crowi.model('User');
 
 
         let promise = User.findOne({ username: usernameToBeRegistered });
         let promise = User.findOne({ username: usernameToBeRegistered });

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

@@ -246,7 +246,8 @@ module.exports = (crowi) => {
    *                  $ref: '#/components/schemas/Page'
    *                  $ref: '#/components/schemas/Page'
    */
    */
   router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
   router.get('/', certifySharedPage, accessTokenParser, loginRequired, validator.getPage, apiV3FormValidator, async(req, res) => {
-    const { pageId, path, user } = req.query;
+    const { user } = req;
+    const { pageId, path } = req.query;
 
 
     if (pageId == null && path == null) {
     if (pageId == null && path == null) {
       return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));
       return res.apiv3Err(new ErrorV3('Parameter path or pageId is required.', 'invalid-request'));

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

@@ -78,7 +78,6 @@ module.exports = function(crowi, app) {
   app.get('/login/invited'            , applicationInstalled, login.invited);
   app.get('/login/invited'            , applicationInstalled, login.invited);
   app.post('/login/activateInvited'   , apiLimiter , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrf, login.invited);
   app.post('/login/activateInvited'   , apiLimiter , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrf, login.invited);
   app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
   app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
-  app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
 
   app.post('/register'                , apiLimiter , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrf, login.register);
   app.post('/register'                , apiLimiter , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrf, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);

+ 7 - 1
packages/app/src/server/routes/login-passport.js

@@ -1,3 +1,4 @@
+import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
@@ -112,6 +113,7 @@ module.exports = function(crowi, app) {
     const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
     const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
     const nameToBeRegistered = ldapAccountInfo[attrMapName];
     const nameToBeRegistered = ldapAccountInfo[attrMapName];
     const mailToBeRegistered = ldapAccountInfo[attrMapMail];
     const mailToBeRegistered = ldapAccountInfo[attrMapMail];
+
     const userInfo = {
     const userInfo = {
       id: ldapAccountId,
       id: ldapAccountId,
       username: usernameToBeRegistered,
       username: usernameToBeRegistered,
@@ -578,7 +580,11 @@ module.exports = function(crowi, app) {
     }
     }
     catch (err) {
     catch (err) {
       /* eslint-disable no-else-return */
       /* eslint-disable no-else-return */
-      if (err.name === 'DuplicatedUsernameException') {
+      if (err instanceof NullUsernameToBeRegisteredError) {
+        req.flash('warningMessage', req.t(`message.${err.message}`));
+        return;
+      }
+      else if (err.name === 'DuplicatedUsernameException') {
         if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
         if (isSameEmailTreatedAsIdenticalUser || isSameUsernameTreatedAsIdenticalUser) {
           // associate to existing user
           // associate to existing user
           debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);
           debug(`ExternalAccount '${userInfo.username}' will be created and bound to the exisiting User account`);

+ 0 - 4
packages/app/src/server/routes/page.js

@@ -153,7 +153,6 @@ module.exports = function(crowi, app) {
   const getToday = require('../util/getToday');
   const getToday = require('../util/getToday');
 
 
   const { configManager, xssService } = crowi;
   const { configManager, xssService } = crowi;
-  const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
 
 
@@ -404,7 +403,6 @@ module.exports = function(crowi, app) {
 
 
     await addRenderVarsForPageTree(renderVars, portalPath, req.user);
     await addRenderVarsForPageTree(renderVars, portalPath, req.user);
 
 
-    await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
     return res.render(view, renderVars);
   }
   }
 
 
@@ -466,7 +464,6 @@ module.exports = function(crowi, app) {
 
 
     await addRenderVarsForPageTree(renderVars, path, req.user);
     await addRenderVarsForPageTree(renderVars, path, req.user);
 
 
-    await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
     return res.render(view, renderVars);
   }
   }
 
 
@@ -536,7 +533,6 @@ module.exports = function(crowi, app) {
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForScope(renderVars, page);
     addRenderVarsForScope(renderVars, page);
 
 
-    await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render('layout-growi/shared_page', renderVars);
     return res.render('layout-growi/shared_page', renderVars);
   };
   };
 
 

+ 8 - 7
packages/app/src/server/service/page-grant.ts

@@ -408,19 +408,20 @@ class PageGrantService {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
     const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
 
 
+    // -- Public only if top page
+    const isOnlyPublicApplicable = isTopPage(page.path);
+    if (isOnlyPublicApplicable) {
+      return {
+        [Page.GRANT_PUBLIC]: null,
+      };
+    }
+
     // Increment an object (type IRecordApplicableGrant)
     // Increment an object (type IRecordApplicableGrant)
     // grant is never public, anyone with the link, nor specified
     // grant is never public, anyone with the link, nor specified
     const data: IRecordApplicableGrant = {
     const data: IRecordApplicableGrant = {
       [Page.GRANT_RESTRICTED]: null, // any page can be restricted
       [Page.GRANT_RESTRICTED]: null, // any page can be restricted
     };
     };
 
 
-    // -- Public only if top page
-    const isOnlyPublicApplicable = isTopPage(page.path);
-    if (isOnlyPublicApplicable) {
-      data[Page.GRANT_PUBLIC] = null;
-      return data;
-    }
-
     // -- Any grant is allowed if parent is null
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
     if (isAnyGrantApplicable) {

+ 4 - 3
packages/app/src/server/views/installer.html

@@ -31,7 +31,7 @@
 
 
   {{ cdnStyleTagsByGroup('basis') }}
   {{ cdnStyleTagsByGroup('basis') }}
 
 
-  <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+  <script src="{{ webpack_asset('js/installer.js') }}" defer></script>
 
 
 </head>
 </head>
 {% endblock %}
 {% endblock %}
@@ -43,6 +43,8 @@
   data-csrftoken="{{ csrf() }}"
   data-csrftoken="{{ csrf() }}"
  >
  >
 
 
+<div id="growi-context-extractor"></div>
+
 <div id="wrapper">
 <div id="wrapper">
 
 
   <!-- Page Content -->
   <!-- Page Content -->
@@ -72,8 +74,7 @@
           <div id="installer-form-container"
           <div id="installer-form-container"
             data-user-name="{{ req.body.registerForm.username }}"
             data-user-name="{{ req.body.registerForm.username }}"
             data-name="{{ req.body.registerForm.name }}"
             data-name="{{ req.body.registerForm.name }}"
-            data-email="{{ req.body.registerForm.email }}"
-            data-csrf="{{ csrf() }}">
+            data-email="{{ req.body.registerForm.email }}">
           </div>
           </div>
         </div>
         </div>
       </div>{# /.row #}
       </div>{# /.row #}

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

@@ -12,6 +12,10 @@ import { useStaticSWR } from './use-static-swr';
 type Nullable<T> = T | null;
 type Nullable<T> = T | null;
 
 
 
 
+export const useCsrfToken = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('csrfToken', initialData);
+};
+
 export const useSiteUrl = (initialData?: string): SWRResponse<string, Error> => {
 export const useSiteUrl = (initialData?: string): SWRResponse<string, Error> => {
   return useStaticSWR<string, Error>('siteUrl', initialData);
   return useStaticSWR<string, Error>('siteUrl', initialData);
 };
 };

+ 5 - 1
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -178,7 +178,11 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 .grw-sidebar {
 .grw-sidebar {
   // List
   // List
   @include override-list-group-item($color-list, $bgcolor-sidebar-list-group, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
   @include override-list-group-item($color-list, $bgcolor-sidebar-list-group, $color-list-hover, $bgcolor-list-hover, $color-list-active, $bgcolor-list-active);
-
+  // sidebar-centent-bg
+  .grw-navigation-wrap {
+    // Drop a shadow on the light theme. The dark theme makes '$ bgcolor-sidebar-context' brighter than the body.
+    box-shadow: 0px 0px 3px rgba(black, 0.24);
+  }
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
     @include override-list-group-item-for-pagetree(

+ 1 - 1
packages/app/src/styles/theme/blackboard.scss

@@ -60,7 +60,7 @@ html[dark] {
   $color-resize-button-hover: $color-global;
   $color-resize-button-hover: $color-global;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
   // Sidebar contents
-  $bgcolor-sidebar-context: $subthemecolor;
+  $bgcolor-sidebar-context: lighten($subthemecolor, 8%);
   $color-sidebar-context: $color-global;
   $color-sidebar-context: $color-global;
   // Sidebar list group
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional
   // $bgcolor-sidebar-list-group: #; // optional

+ 1 - 1
packages/app/src/styles/theme/christmas.scss

@@ -85,7 +85,7 @@ html[dark] {
   $color-resize-button-hover: $color-reversal;
   $color-resize-button-hover: $color-reversal;
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   $color-sidebar-context: $linktext;
   $color-sidebar-context: $linktext;
-  $bgcolor-sidebar-context: #f4f6fc;
+  $bgcolor-sidebar-context: #f4fcf6;
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: #fafbff; // optional
   $bgcolor-sidebar-list-group: #fafbff; // optional
 
 

+ 2 - 2
packages/app/src/styles/theme/fire-red.scss

@@ -56,7 +56,7 @@ html[light] {
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   // Sidebar contents
   // Sidebar contents
   $color-sidebar-context: $color-global;
   $color-sidebar-context: $color-global;
-  $bgcolor-sidebar-context: #ebebeb;
+  $bgcolor-sidebar-context: #ececec;
   // Sidebar list group
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional
   // $bgcolor-sidebar-list-group: #; // optional
 
 
@@ -152,7 +152,7 @@ html[dark] {
   $color-resize-button-hover: $color-global;
   $color-resize-button-hover: $color-global;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
   // Sidebar contents
-  $bgcolor-sidebar-context: #2e2e2e;
+  $bgcolor-sidebar-context: #413f3f;
   $color-sidebar-context: $color-global;
   $color-sidebar-context: $color-global;
   // Sidebar list group
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional
   // $bgcolor-sidebar-list-group: #; // optional

+ 2 - 2
packages/app/src/styles/theme/future.scss

@@ -55,8 +55,8 @@ html[dark] {
   $text-shadow-sidebar-nav-item-active: 0px 0px 10px #969494; // optional
   $text-shadow-sidebar-nav-item-active: 0px 0px 10px #969494; // optional
 
 
   // Sidebar contents
   // Sidebar contents
-  $color-sidebar-context: #00c2c4;
-  $bgcolor-sidebar-context: #020b0b;
+  $color-sidebar-context: #2cfbff;
+  $bgcolor-sidebar-context: #184040;
 
 
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: #162126; // optional
   $bgcolor-sidebar-list-group: #162126; // optional

+ 1 - 1
packages/app/src/styles/theme/halloween.scss

@@ -80,7 +80,7 @@ html[dark] {
 
 
   // Sidebar contents
   // Sidebar contents
   $color-sidebar-context: #aa97cb;
   $color-sidebar-context: #aa97cb;
-  $bgcolor-sidebar-context: #1d2126;
+  $bgcolor-sidebar-context: #302b3c;
 
 
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: #2c2926; // optional
   $bgcolor-sidebar-list-group: #2c2926; // optional

+ 1 - 1
packages/app/src/styles/theme/hufflepuff.scss

@@ -216,7 +216,7 @@ html[dark] {
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 7%);
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 7%);
   // Sidebar contents
   // Sidebar contents
   $color-sidebar-context: $color-global;
   $color-sidebar-context: $color-global;
-  $bgcolor-sidebar-context: $subthemecolor;
+  $bgcolor-sidebar-context: lighten($themedark, 5%);
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: lighten($subthemecolor, 5%);
   $bgcolor-sidebar-list-group: lighten($subthemecolor, 5%);
 
 

+ 1 - 1
packages/app/src/styles/theme/jade-green.scss

@@ -152,7 +152,7 @@ html[dark] {
   $color-resize-button-hover: $color-global;
   $color-resize-button-hover: $color-global;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
   // Sidebar contents
-  $bgcolor-sidebar-context: #2e2e2e;
+  $bgcolor-sidebar-context: #3c403c;
   $color-sidebar-context: $color-global;
   $color-sidebar-context: $color-global;
   // Sidebar list group
   // Sidebar list group
   // $bgcolor-sidebar-list-group: #; // optional
   // $bgcolor-sidebar-list-group: #; // optional

+ 1 - 1
packages/app/src/styles/theme/nature.scss

@@ -72,7 +72,7 @@ html[dark] {
   $bgcolor-sidebar: #188f64;
   $bgcolor-sidebar: #188f64;
   // Sidebar contents
   // Sidebar contents
   $color-sidebar-context: #7e0044;
   $color-sidebar-context: #7e0044;
-  $bgcolor-sidebar-context: #f7f9e9;
+  $bgcolor-sidebar-context: #f4f5ec;
   // Sidebar resize button
   // Sidebar resize button
   $color-resize-button: white;
   $color-resize-button: white;
   $bgcolor-resize-button: $themecolor;
   $bgcolor-resize-button: $themecolor;

+ 1 - 1
packages/app/src/styles/theme/spring.scss

@@ -73,7 +73,7 @@ html[dark] {
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   // Sidebar contents
   // Sidebar contents
   $color-sidebar-context: $subthemecolor;
   $color-sidebar-context: $subthemecolor;
-  $bgcolor-sidebar-context: #f4f6fc;
+  $bgcolor-sidebar-context: #fdfffe;
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: #fafbff; // optional
   $bgcolor-sidebar-list-group: #fafbff; // optional
 
 

+ 1 - 1
packages/app/src/styles/theme/wood.scss

@@ -91,7 +91,7 @@ html[dark] {
   $bgcolor-sidebar: $themecolor;
   $bgcolor-sidebar: $themecolor;
   // Sidebar contents
   // Sidebar contents
   $color-sidebar-context: #9d7406;
   $color-sidebar-context: #9d7406;
-  $bgcolor-sidebar-context: lighten($themecolor, 32%);
+  $bgcolor-sidebar-context: lighten($themecolor, 38%);
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: rgba(#f7f5f1, 0.5);
   $bgcolor-sidebar-list-group: rgba(#f7f5f1, 0.5);
   // Sidebar resize button
   // Sidebar resize button

+ 0 - 264
packages/app/test/integration/models/v5.page.test.js

@@ -421,92 +421,6 @@ describe('Page', () => {
       },
       },
     ]);
     ]);
 
 
-    /**
-     * getParentAndFillAncestors
-     */
-    const pageIdPAF1 = new mongoose.Types.ObjectId();
-    const pageIdPAF2 = new mongoose.Types.ObjectId();
-    const pageIdPAF3 = new mongoose.Types.ObjectId();
-
-    await Page.insertMany([
-      {
-        _id: pageIdPAF1,
-        path: '/PAF1',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        parent: rootPage._id,
-        descendantCount: 0,
-      },
-      {
-        _id: pageIdPAF2,
-        path: '/emp_anc3',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-        descendantCount: 1,
-        parent: rootPage._id,
-      },
-      {
-        path: '/emp_anc3/PAF3',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        descendantCount: 0,
-        parent: pageIdPAF2,
-      },
-      {
-        _id: pageIdPAF3,
-        path: '/emp_anc4',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-        descendantCount: 1,
-        parent: rootPage._id,
-      },
-      {
-        path: '/emp_anc4/PAF4',
-        grant: Page.GRANT_PUBLIC,
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-        descendantCount: 0,
-        parent: pageIdPAF3,
-      },
-      {
-        path: '/emp_anc4',
-        grant: Page.GRANT_OWNER,
-        grantedUsers: [dummyUser1._id],
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1._id,
-        isEmpty: false,
-      },
-      {
-        path: '/get_parent_A',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: null,
-      },
-      {
-        path: '/get_parent_A/get_parent_B',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: null,
-      },
-      {
-        path: '/get_parent_C',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: rootPage._id,
-      },
-      {
-        path: '/get_parent_C/get_parent_D',
-        creator: dummyUser1,
-        lastUpdateUser: dummyUser1,
-        parent: null,
-      },
-    ]);
-
   });
   });
   describe('create', () => {
   describe('create', () => {
 
 
@@ -810,182 +724,4 @@ describe('Page', () => {
     });
     });
 
 
   });
   });
-
-  describe('getParentAndFillAncestors', () => {
-    test('return parent if exist', async() => {
-      const page1 = await Page.findOne({ path: '/PAF1' });
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, page1.path);
-      expect(parent).toBeTruthy();
-      expect(page1.parent).toStrictEqual(parent._id);
-    });
-    test('create parent and ancestors when they do not exist, and return the new parent', async() => {
-      const path1 = '/emp_anc1';
-      const path2 = '/emp_anc1/emp_anc2';
-      const path3 = '/emp_anc1/emp_anc2/PAF2';
-      const _page1 = await Page.findOne({ path: path1 }); // not exist
-      const _page2 = await Page.findOne({ path: path2 }); // not exist
-      const _page3 = await Page.findOne({ path: path3 }); // not exist
-      expect(_page1).toBeNull();
-      expect(_page2).toBeNull();
-      expect(_page3).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, path3);
-      const page1 = await Page.findOne({ path: path1 });
-      const page2 = await Page.findOne({ path: path2 });
-      const page3 = await Page.findOne({ path: path3 });
-
-      expect(parent._id).toStrictEqual(page2._id);
-      expect(parent.path).toStrictEqual(page2.path);
-      expect(parent.parent).toStrictEqual(page2.parent);
-
-      expect(parent).toBeTruthy();
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeNull();
-
-      expect(page1.parent).toStrictEqual(rootPage._id);
-      expect(page2.parent).toStrictEqual(page1._id);
-    });
-    test('return parent even if the parent page is empty', async() => {
-      const path1 = '/emp_anc3';
-      const path2 = '/emp_anc3/PAF3';
-      const _page1 = await Page.findOne({ path: path1, isEmpty: true });
-      const _page2 = await Page.findOne({ path: path2, isEmpty: false });
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
-      const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
-      const page2 = await Page.findOne({ path: path2, isEmpty: false });
-
-      // check for the parent (should be the same as page1)
-      expect(parent._id).toStrictEqual(page1._id);
-      expect(parent.path).toStrictEqual(page1.path);
-      expect(parent.parent).toStrictEqual(page1.parent);
-
-      expect(page1.parent).toStrictEqual(rootPage._id);
-      expect(page2.parent).toStrictEqual(page1._id);
-    });
-    test('should find parent while NOT updating private legacy page\'s parent', async() => {
-      const path1 = '/emp_anc4';
-      const path2 = '/emp_anc4/PAF4';
-      const _page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
-      const _page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
-      const _page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
-      expect(_page1).toBeTruthy();
-      expect(_page2).toBeTruthy();
-      expect(_page3).toBeTruthy();
-      expect(_page3.parent).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
-      const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
-      const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
-      const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
-      expect(page1).toBeTruthy();
-      expect(page2).toBeTruthy();
-      expect(page3).toBeTruthy();
-      expect(page3.parent).toBeNull(); // parent property of page in private legacy pages should be null
-
-      expect(page1._id).toStrictEqual(parent._id);
-      expect(page2.parent).toStrictEqual(parent._id);
-
-    });
-    test('should find parent while NOT creating unnecessary empty pages with all v4 public pages', async() => {
-      // All pages does not have parent (v4 schema)
-      const _pageA = await Page.findOne({
-        path: '/get_parent_A',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-        parent: null,
-      });
-      const _pageAB = await Page.findOne({
-        path: '/get_parent_A/get_parent_B',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-        parent: null,
-      });
-      const _emptyA = await Page.findOne({
-        path: '/get_parent_A',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-      const _emptyAB = await Page.findOne({
-        path: '/get_parent_A/get_parent_B',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-
-      expect(_pageA).not.toBeNull();
-      expect(_pageAB).not.toBeNull();
-      expect(_emptyA).toBeNull();
-      expect(_emptyAB).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_A/get_parent_B/get_parent_C');
-
-      const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const emptyA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: true });
-      const emptyAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: true });
-
-      // -- Check existance
-      expect(parent).not.toBeNull();
-      expect(pageA).not.toBeNull();
-      expect(pageAB).not.toBeNull();
-      expect(emptyA).toBeNull();
-      expect(emptyAB).toBeNull();
-
-      // -- Check parent
-      expect(pageA.parent).not.toBeNull();
-      expect(pageAB.parent).not.toBeNull();
-    });
-    test('should find parent while NOT creating unnecessary empty pages with some v5 public pages', async() => {
-      const _pageC = await Page.findOne({
-        path: '/get_parent_C',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-        parent: { $ne: null },
-      });
-      const _pageCD = await Page.findOne({
-        path: '/get_parent_C/get_parent_D',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: false,
-      });
-      const _emptyC = await Page.findOne({
-        path: '/get_parent_C',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-      const _emptyCD = await Page.findOne({
-        path: '/get_parent_C/get_parent_D',
-        grant: Page.GRANT_PUBLIC,
-        isEmpty: true,
-      });
-
-      expect(_pageC).not.toBeNull();
-      expect(_pageCD).not.toBeNull();
-      expect(_emptyC).toBeNull();
-      expect(_emptyCD).toBeNull();
-
-      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_C/get_parent_D/get_parent_E');
-
-      const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
-      const emptyC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: true });
-      const emptyCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: true });
-
-      // -- Check existance
-      expect(parent).not.toBeNull();
-      expect(pageC).not.toBeNull();
-      expect(pageCD).not.toBeNull();
-      expect(emptyC).toBeNull();
-      expect(emptyCD).toBeNull();
-
-      // -- Check parent attribute
-      expect(pageC.parent).toStrictEqual(rootPage._id);
-      expect(pageCD.parent).toStrictEqual(pageC._id);
-
-      // -- Check the found parent
-      expect(parent.toObject()).toStrictEqual(pageCD.toObject());
-    });
-  });
 });
 });

+ 306 - 2
packages/app/test/integration/service/page-grant.test.js

@@ -1,8 +1,10 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
-import { getInstance } from '../setup-crowi';
+import { PageGrant } from '~/interfaces/page';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 
 
+import { getInstance } from '../setup-crowi';
+
 /*
 /*
  * There are 3 grant types to test.
  * There are 3 grant types to test.
  * GRANT_PUBLIC, GRANT_OWNER, GRANT_USER_GROUP
  * GRANT_PUBLIC, GRANT_OWNER, GRANT_USER_GROUP
@@ -29,7 +31,9 @@ describe('PageGrantService', () => {
   let groupChild;
   let groupChild;
 
 
   let rootPage;
   let rootPage;
-
+  let rootPublicPage;
+  let rootOnlyMePage;
+  let rootOnlyInsideTheGroup;
   let emptyPage1;
   let emptyPage1;
   let emptyPage2;
   let emptyPage2;
   let emptyPage3;
   let emptyPage3;
@@ -42,6 +46,21 @@ describe('PageGrantService', () => {
   const pageRootPublicPath = '/Public';
   const pageRootPublicPath = '/Public';
   const pageRootGroupParentPath = '/GroupParent';
   const pageRootGroupParentPath = '/GroupParent';
 
 
+  const v4PageRootOnlyMePagePath = '/v4OnlyMe';
+  const v4PageRootAnyoneWithTheLinkPagePath = '/v4AnyoneWithTheLink';
+  const v4PageRootOnlyInsideTheGroupPagePath = '/v4OnlyInsideTheGroup';
+
+  const pagePublicOnlyMePath = `${pageRootPublicPath}/OnlyMe`;
+  const pagePublicAnyoneWithTheLinkPath = `${pageRootPublicPath}/AnyoneWithTheLink`;
+  const pagePublicOnlyInsideTheGroupPath = `${pageRootPublicPath}/OnlyInsideTheGroup`;
+
+  const pageOnlyMePublicPath = `${v4PageRootOnlyMePagePath}/Public`;
+  const pageOnlyMeAnyoneWithTheLinkPath = `${v4PageRootOnlyMePagePath}/AnyoneWithTheLink`;
+  const pageOnlyMeOnlyInsideTheGroupPath = `${v4PageRootOnlyMePagePath}/OnlyInsideTheGroup`;
+
+  const pageOnlyInsideTheGroupPublicPath = `${v4PageRootOnlyInsideTheGroupPagePath}/Public`;
+  const pageOnlyInsideTheGroupOnlyMePath = `${v4PageRootOnlyInsideTheGroupPagePath}/OnlyMe`;
+  const pageOnlyInsideTheGroupAnyoneWithTheLinkPath = `${v4PageRootOnlyInsideTheGroupPagePath}/AnyoneWithTheLink`;
   let pageE1Public;
   let pageE1Public;
   let pageE2User1;
   let pageE2User1;
   let pageE3GroupParent;
   let pageE3GroupParent;
@@ -151,6 +170,98 @@ describe('PageGrantService', () => {
       },
       },
     ]);
     ]);
 
 
+    await Page.insertMany([
+      // Root Page
+      {
+        path: rootPage,
+        grant: Page.GRANT_PUBLIC,
+        parent: null,
+      },
+      // OnlyMe v4
+      {
+        path: v4PageRootOnlyMePagePath,
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [user1._id],
+        parent: null,
+      },
+      // AnyoneWithTheLink v4
+      {
+        path: v4PageRootAnyoneWithTheLinkPagePath,
+        grant: Page.GRANT_RESTRICTED,
+        parent: null,
+      },
+      // OnlyInsideTheGroup v4
+      {
+        path: v4PageRootOnlyInsideTheGroupPagePath,
+        grant: Page.GRANT_USER_GROUP,
+        parent: null,
+        grantedGroup: groupParent._id,
+      },
+    ]);
+
+    rootPublicPage = await Page.findOne({ path: pageRootPublicPath });
+    rootOnlyMePage = await Page.findOne({ path: v4PageRootOnlyMePagePath });
+    rootOnlyInsideTheGroup = await Page.findOne({ path: v4PageRootOnlyInsideTheGroupPagePath });
+
+
+    // Leaf pages (Depth: 2)
+    await Page.insertMany([
+      /*
+      * Parent is public
+      */
+      {
+        path: pagePublicOnlyMePath,
+        grant: Page.GRANT_OWNER,
+        parent: rootPublicPage._id,
+      },
+      {
+        path: pagePublicAnyoneWithTheLinkPath,
+        grant: Page.GRANT_RESTRICTED,
+        parent: rootPublicPage._id,
+      },
+      {
+        path: pagePublicOnlyInsideTheGroupPath,
+        grant: Page.GRANT_USER_GROUP,
+        parent: rootPublicPage._id,
+      },
+      /*
+      * Parent is onlyMe
+      */
+      {
+        path: pageOnlyMePublicPath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyMePage._id,
+      },
+      {
+        path: pageOnlyMeAnyoneWithTheLinkPath,
+        grant: Page.GRANT_RESTRICTED,
+        parent: rootOnlyMePage._id,
+      },
+      {
+        path: pageOnlyMeOnlyInsideTheGroupPath,
+        grant: Page.GRANT_USER_GROUP,
+        parent: rootOnlyMePage._id,
+      },
+      /*
+      * Parent is OnlyInsideTheGroup
+      */
+      {
+        path: pageOnlyInsideTheGroupPublicPath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyInsideTheGroup._id,
+      },
+      {
+        path: pageOnlyInsideTheGroupOnlyMePath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyInsideTheGroup._id,
+      },
+      {
+        path: pageOnlyInsideTheGroupAnyoneWithTheLinkPath,
+        grant: Page.GRANT_PUBLIC,
+        parent: rootOnlyInsideTheGroup._id,
+      },
+    ]);
+
     emptyPage1 = await Page.findOne({ path: emptyPagePath1 });
     emptyPage1 = await Page.findOne({ path: emptyPagePath1 });
     emptyPage2 = await Page.findOne({ path: emptyPagePath2 });
     emptyPage2 = await Page.findOne({ path: emptyPagePath2 });
     emptyPage3 = await Page.findOne({ path: emptyPagePath3 });
     emptyPage3 = await Page.findOne({ path: emptyPagePath3 });
@@ -360,4 +471,197 @@ describe('PageGrantService', () => {
     });
     });
   });
   });
 
 
+
+  describe('Test for calcApplicableGrantData', () => {
+    test('Only Public is Applicable in case of top page', async() => {
+      const result = await pageGrantService.calcApplicableGrantData(rootPage, user1);
+
+      expect(result).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+        },
+      );
+    });
+
+    // parent property of all private pages is null
+    test('Any grant is allowed if parent is null', async() => {
+      const userGroupRelation = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+
+      // OnlyMe
+      const rootOnlyMePage = await Page.findOne({ path: v4PageRootOnlyMePagePath });
+      const rootOnlyMePageRes = await pageGrantService.calcApplicableGrantData(rootOnlyMePage, user1);
+      expect(rootOnlyMePageRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const rootAnyoneWithTheLinkPage = await Page.findOne({ path: v4PageRootAnyoneWithTheLinkPagePath });
+      const anyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(rootAnyoneWithTheLinkPage, user1);
+      expect(anyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const rootOnlyInsideTheGroupPage = await Page.findOne({ path: v4PageRootOnlyInsideTheGroupPagePath });
+      const onlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(rootOnlyInsideTheGroupPage, user1);
+      expect(onlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+    });
+
+
+    test('Any grant is allowed if parent is public', async() => {
+      const userGroupRelation = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user1);
+
+      // OnlyMe
+      const publicOnlyMePage = await Page.findOne({ path: pagePublicOnlyMePath });
+      const publicOnlyMeRes = await pageGrantService.calcApplicableGrantData(publicOnlyMePage, user1);
+      expect(publicOnlyMeRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const publicAnyoneWithTheLinkPage = await Page.findOne({ path: pagePublicAnyoneWithTheLinkPath });
+      const publicAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(publicAnyoneWithTheLinkPage, user1);
+      expect(publicAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const publicOnlyInsideTheGroupPage = await Page.findOne({ path: pagePublicOnlyInsideTheGroupPath });
+      const publicOnlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(publicOnlyInsideTheGroupPage, user1);
+      expect(publicOnlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_PUBLIC]: null,
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: userGroupRelation,
+        },
+      );
+    });
+
+
+    test('Only "GRANT_OWNER" is allowed if the user is the parent page\'s grantUser', async() => {
+      // Public
+      const onlyMePublicPage = await Page.findOne({ path: pageOnlyMePublicPath });
+      const onlyMePublicRes = await pageGrantService.calcApplicableGrantData(onlyMePublicPage, user1);
+      expect(onlyMePublicRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const onlyMeAnyoneWithTheLinkPage = await Page.findOne({ path: pageOnlyMeAnyoneWithTheLinkPath });
+      const onlyMeAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(onlyMeAnyoneWithTheLinkPage, user1);
+      expect(onlyMeAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const publicOnlyInsideTheGroupPage = await Page.findOne({ path: pageOnlyMeOnlyInsideTheGroupPath });
+      const publicOnlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(publicOnlyInsideTheGroupPage, user1);
+      expect(publicOnlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+        },
+      );
+    });
+
+    test('"GRANT_OWNER" is not allowed if the user is not the parent page\'s grantUser', async() => {
+      // Public
+      const onlyMePublicPage = await Page.findOne({ path: pageOnlyMePublicPath });
+      const onlyMePublicRes = await pageGrantService.calcApplicableGrantData(onlyMePublicPage, user2);
+      expect(onlyMePublicRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+        },
+      );
+
+      // AnyoneWithTheLink
+      const onlyMeAnyoneWithTheLinkPage = await Page.findOne({ path: pageOnlyMeAnyoneWithTheLinkPath });
+      const onlyMeAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(onlyMeAnyoneWithTheLinkPage, user2);
+      expect(onlyMeAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+        },
+      );
+
+      // OnlyInsideTheGroup
+      const publicOnlyInsideTheGroupPage = await Page.findOne({ path: pageOnlyMeOnlyInsideTheGroupPath });
+      const publicOnlyInsideTheGroupRes = await pageGrantService.calcApplicableGrantData(publicOnlyInsideTheGroupPage, user2);
+      expect(publicOnlyInsideTheGroupRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+        },
+      );
+    });
+
+    test('"GRANT_USER_GROUP" is allowed if the parent\'s grant is GRANT_USER_GROUP and the user is included in the group', async() => {
+      const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(groupParent, user1);
+
+      // Public
+      const onlyInsideGroupPublicPage = await Page.findOne({ path: pageOnlyInsideTheGroupPublicPath });
+      const onlyInsideGroupPublicRes = await pageGrantService.calcApplicableGrantData(onlyInsideGroupPublicPage, user1);
+      expect(onlyInsideGroupPublicRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups },
+        },
+      );
+
+      // OnlyMe
+      const onlyInsideTheGroupOnlyMePage = await Page.findOne({ path: pageOnlyInsideTheGroupOnlyMePath });
+      const onlyInsideTheGroupOnlyMeRes = await pageGrantService.calcApplicableGrantData(onlyInsideTheGroupOnlyMePage, user1);
+      expect(onlyInsideTheGroupOnlyMeRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups },
+        },
+      );
+
+      // AnyoneWithTheLink
+      const onlyInsideTheGroupAnyoneWithTheLinkPage = await Page.findOne({ path: pageOnlyInsideTheGroupAnyoneWithTheLinkPath });
+      const onlyInsideTheGroupAnyoneWithTheLinkRes = await pageGrantService.calcApplicableGrantData(onlyInsideTheGroupAnyoneWithTheLinkPage, user1);
+      expect(onlyInsideTheGroupAnyoneWithTheLinkRes).toStrictEqual(
+        {
+          [PageGrant.GRANT_RESTRICTED]: null,
+          [PageGrant.GRANT_OWNER]: null,
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups },
+        },
+      );
+    });
+  });
 });
 });

+ 270 - 0
packages/app/test/integration/service/page.test.js

@@ -8,6 +8,8 @@ const mongoose = require('mongoose');
 
 
 const { getInstance } = require('../setup-crowi');
 const { getInstance } = require('../setup-crowi');
 
 
+let rootPage;
+let dummyUser1;
 let testUser1;
 let testUser1;
 let testUser2;
 let testUser2;
 let parentTag;
 let parentTag;
@@ -79,6 +81,10 @@ describe('PageService', () => {
     testUser1 = await User.findOne({ username: 'someone1' });
     testUser1 = await User.findOne({ username: 'someone1' });
     testUser2 = await User.findOne({ username: 'someone2' });
     testUser2 = await User.findOne({ username: 'someone2' });
 
 
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+
+    rootPage = await Page.findOne({ path: '/' });
+
     await Page.insertMany([
     await Page.insertMany([
       {
       {
         path: '/parentForRename1',
         path: '/parentForRename1',
@@ -290,6 +296,92 @@ describe('PageService', () => {
     ]);
     ]);
 
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
+
+    /**
+     * getParentAndFillAncestors
+     */
+    const pageIdPAF1 = new mongoose.Types.ObjectId();
+    const pageIdPAF2 = new mongoose.Types.ObjectId();
+    const pageIdPAF3 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdPAF1,
+        path: '/PAF1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdPAF2,
+        path: '/emp_anc3',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc3/PAF3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF2,
+      },
+      {
+        _id: pageIdPAF3,
+        path: '/emp_anc4',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc4/PAF4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF3,
+      },
+      {
+        path: '/emp_anc4',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [dummyUser1._id],
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
+      {
+        path: '/get_parent_A',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+      {
+        path: '/get_parent_A/get_parent_B',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+      {
+        path: '/get_parent_C',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/get_parent_C/get_parent_D',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+    ]);
   });
   });
 
 
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
   describe('rename page without using renameDescendantsWithStreamSpy', () => {
@@ -723,4 +815,182 @@ describe('PageService', () => {
     });
     });
   });
   });
 
 
+  describe('getParentAndFillAncestors', () => {
+    test('return parent if exist', async() => {
+      const page1 = await Page.findOne({ path: '/PAF1' });
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, page1.path);
+      expect(parent).toBeTruthy();
+      expect(page1.parent).toStrictEqual(parent._id);
+    });
+    test('create parent and ancestors when they do not exist, and return the new parent', async() => {
+      const path1 = '/emp_anc1';
+      const path2 = '/emp_anc1/emp_anc2';
+      const path3 = '/emp_anc1/emp_anc2/PAF2';
+      const _page1 = await Page.findOne({ path: path1 }); // not exist
+      const _page2 = await Page.findOne({ path: path2 }); // not exist
+      const _page3 = await Page.findOne({ path: path3 }); // not exist
+      expect(_page1).toBeNull();
+      expect(_page2).toBeNull();
+      expect(_page3).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, path3);
+      const page1 = await Page.findOne({ path: path1 });
+      const page2 = await Page.findOne({ path: path2 });
+      const page3 = await Page.findOne({ path: path3 });
+
+      expect(parent._id).toStrictEqual(page2._id);
+      expect(parent.path).toStrictEqual(page2.path);
+      expect(parent.parent).toStrictEqual(page2.parent);
+
+      expect(parent).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeNull();
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test('return parent even if the parent page is empty', async() => {
+      const path1 = '/emp_anc3';
+      const path2 = '/emp_anc3/PAF3';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
+      const page2 = await Page.findOne({ path: path2, isEmpty: false });
+
+      // check for the parent (should be the same as page1)
+      expect(parent._id).toStrictEqual(page1._id);
+      expect(parent.path).toStrictEqual(page1.path);
+      expect(parent.parent).toStrictEqual(page1.parent);
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test('should find parent while NOT updating private legacy page\'s parent', async() => {
+      const path1 = '/emp_anc4';
+      const path2 = '/emp_anc4/PAF4';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const _page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_page3.parent).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, _page2.path);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page3.parent).toBeNull(); // parent property of page in private legacy pages should be null
+
+      expect(page1._id).toStrictEqual(parent._id);
+      expect(page2.parent).toStrictEqual(parent._id);
+
+    });
+    test('should find parent while NOT creating unnecessary empty pages with all v4 public pages', async() => {
+      // All pages does not have parent (v4 schema)
+      const _pageA = await Page.findOne({
+        path: '/get_parent_A',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: null,
+      });
+      const _pageAB = await Page.findOne({
+        path: '/get_parent_A/get_parent_B',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: null,
+      });
+      const _emptyA = await Page.findOne({
+        path: '/get_parent_A',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+      const _emptyAB = await Page.findOne({
+        path: '/get_parent_A/get_parent_B',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+
+      expect(_pageA).not.toBeNull();
+      expect(_pageAB).not.toBeNull();
+      expect(_emptyA).toBeNull();
+      expect(_emptyAB).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_A/get_parent_B/get_parent_C');
+
+      const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const emptyA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: true });
+      const emptyAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: true });
+
+      // -- Check existance
+      expect(parent).not.toBeNull();
+      expect(pageA).not.toBeNull();
+      expect(pageAB).not.toBeNull();
+      expect(emptyA).toBeNull();
+      expect(emptyAB).toBeNull();
+
+      // -- Check parent
+      expect(pageA.parent).not.toBeNull();
+      expect(pageAB.parent).not.toBeNull();
+    });
+    test('should find parent while NOT creating unnecessary empty pages with some v5 public pages', async() => {
+      const _pageC = await Page.findOne({
+        path: '/get_parent_C',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: { $ne: null },
+      });
+      const _pageCD = await Page.findOne({
+        path: '/get_parent_C/get_parent_D',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+      });
+      const _emptyC = await Page.findOne({
+        path: '/get_parent_C',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+      const _emptyCD = await Page.findOne({
+        path: '/get_parent_C/get_parent_D',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+
+      expect(_pageC).not.toBeNull();
+      expect(_pageCD).not.toBeNull();
+      expect(_emptyC).toBeNull();
+      expect(_emptyCD).toBeNull();
+
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, '/get_parent_C/get_parent_D/get_parent_E');
+
+      const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const emptyC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: true });
+      const emptyCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: true });
+
+      // -- Check existance
+      expect(parent).not.toBeNull();
+      expect(pageC).not.toBeNull();
+      expect(pageCD).not.toBeNull();
+      expect(emptyC).toBeNull();
+      expect(emptyCD).toBeNull();
+
+      // -- Check parent attribute
+      expect(pageC.parent).toStrictEqual(rootPage._id);
+      expect(pageCD.parent).toStrictEqual(pageC._id);
+
+      // -- Check the found parent
+      expect(parent.toObject()).toStrictEqual(pageCD.toObject());
+    });
+  });
+
 });
 });

+ 2 - 2
packages/plugin-attachment-refs/src/client-entry.js

@@ -1,9 +1,9 @@
 import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
 import RefsPostRenderInterceptor from './client/js/util/Interceptor/RefsPostRenderInterceptor';
 import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 import RefsPreRenderInterceptor from './client/js/util/Interceptor/RefsPreRenderInterceptor';
 
 
-export default (appContainer) => {
+export default () => {
   // add interceptors
   // add interceptors
-  appContainer.interceptorManager.addInterceptors([
+  global.interceptorManager.addInterceptors([
     new RefsPreRenderInterceptor(),
     new RefsPreRenderInterceptor(),
     new RefsPostRenderInterceptor(),
     new RefsPostRenderInterceptor(),
   ]);
   ]);

+ 3 - 3
packages/plugin-lsx/src/client-entry.js

@@ -2,11 +2,11 @@ import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInte
 import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
 import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
 import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
 
 
-export default (appContainer) => {
+export default () => {
   // add interceptors
   // add interceptors
-  appContainer.interceptorManager.addInterceptors([
+  global.interceptorManager.addInterceptors([
     new LsxLogoutInterceptor(),
     new LsxLogoutInterceptor(),
     new LsxPreRenderInterceptor(),
     new LsxPreRenderInterceptor(),
-    new LsxPostRenderInterceptor(appContainer),
+    new LsxPostRenderInterceptor(),
   ]);
   ]);
 };
 };

+ 0 - 1
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -239,7 +239,6 @@ export class Lsx extends React.Component {
 }
 }
 
 
 Lsx.propTypes = {
 Lsx.propTypes = {
-  appContainer: PropTypes.object.isRequired,
   lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
   lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
 
 
   forceToFetchData: PropTypes.bool,
   forceToFetchData: PropTypes.bool,

+ 3 - 7
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -1,5 +1,6 @@
-import { BasicInterceptor } from '@growi/core';
 import React from 'react';
 import React from 'react';
+
+import { BasicInterceptor } from '@growi/core';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 
 
 
 
@@ -13,11 +14,6 @@ import { LsxContext } from '../LsxContext';
  */
  */
 export class LsxPostRenderInterceptor extends BasicInterceptor {
 export class LsxPostRenderInterceptor extends BasicInterceptor {
 
 
-  constructor(appContainer) {
-    super();
-    this.appContainer = appContainer;
-  }
-
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
@@ -54,7 +50,7 @@ export class LsxPostRenderInterceptor extends BasicInterceptor {
 
 
   renderReactDOM(lsxContext, elem, isPreview) {
   renderReactDOM(lsxContext, elem, isPreview) {
     ReactDOM.render(
     ReactDOM.render(
-      <Lsx appContainer={this.appContainer} lsxContext={lsxContext} forceToFetchData={!isPreview} />,
+      <Lsx lsxContext={lsxContext} forceToFetchData={!isPreview} />,
       elem,
       elem,
     );
     );
   }
   }