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

Merge branch 'master' into feat/90392-argument-of-override-list-group-item-for-pagetree

cao 3 лет назад
Родитель
Сommit
7766fb35dc
34 измененных файлов с 1460 добавлено и 354 удалено
  1. 27 0
      packages/app/resource/locales/en_US/translation.json
  2. 27 0
      packages/app/resource/locales/ja_JP/translation.json
  3. 27 0
      packages/app/resource/locales/zh_CN/translation.json
  4. 3 0
      packages/app/src/client/app.jsx
  5. 3 1
      packages/app/src/client/services/ContextExtractor.tsx
  6. 1 1
      packages/app/src/client/util/interceptor/drawio-interceptor.js
  7. 278 0
      packages/app/src/components/Page/FixPageGrantAlert.tsx
  8. 2 2
      packages/app/src/components/Page/RevisionRenderer.jsx
  9. 1 1
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  10. 1 1
      packages/app/src/components/PageEditor/MarkdownTableInterceptor.js
  11. 2 2
      packages/app/src/components/PageEditor/Preview.tsx
  12. 44 8
      packages/app/src/components/PrivateLegacyPages.tsx
  13. 1 0
      packages/app/src/interfaces/errors/v5-conversion-error.ts
  14. 20 0
      packages/app/src/interfaces/page-grant.ts
  15. 18 1
      packages/app/src/interfaces/page.ts
  16. 1 1
      packages/app/src/server/events/user.js
  17. 15 8
      packages/app/src/server/models/obsolete-page.js
  18. 122 261
      packages/app/src/server/models/page.ts
  19. 58 0
      packages/app/src/server/models/user-group-relation.js
  20. 168 2
      packages/app/src/server/routes/apiv3/page.js
  21. 23 16
      packages/app/src/server/routes/apiv3/pages.js
  22. 1 1
      packages/app/src/server/routes/attachment.js
  23. 1 1
      packages/app/src/server/routes/page.js
  24. 29 1
      packages/app/src/server/service/comment.ts
  25. 1 6
      packages/app/src/server/service/installer.ts
  26. 72 3
      packages/app/src/server/service/page-grant.ts
  27. 468 22
      packages/app/src/server/service/page.ts
  28. 1 1
      packages/app/src/server/service/slack-command-handler/create-page-service.js
  29. 1 1
      packages/app/src/server/util/createGrowiPagesFromImports.js
  30. 2 0
      packages/app/src/server/views/widget/page_alerts.html
  31. 1 0
      packages/app/src/server/views/widget/page_content.html
  32. 4 0
      packages/app/src/stores/context.tsx
  33. 25 1
      packages/app/src/stores/page.tsx
  34. 12 12
      packages/app/test/integration/models/v5.page.test.js

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

@@ -664,6 +664,8 @@
     },
     "by_path_modal": {
       "title": "Convert to new v5 compatible format",
+      "alert": "This operation cannot be undone, and pages that the user cannot view are also subject to processing.",
+      "checkbox_label": "Understood",
       "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
       "button_label": "Convert",
       "success": "Successfully requested conversion.",
@@ -1075,5 +1077,30 @@
     "select_group": "Select group",
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",
+      "need_to_fix_grant": "The permissions associated with this page must be modified in order to use the functionality correctly. <br> Please select from the options below to make the change.",
+      "grant_label": {
+        "isForbidden": "Authority not allowed to view",
+        "currentPageGrantLabel": "Authorization for this page: ",
+        "parentPageGrantLabel": "Authority of parent page: ",
+        "docLink": "For more information on modifying permissions, please refer to <a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>"
+      },
+      "radio_btn": {
+        "restrected": "Only those who know the link",
+        "only_me": "only to oneself",
+        "grant_group": "Only specific groups"
+      },
+      "select_group_default_text": "Select Group",
+      "alert_message_select_group": "No group selected",
+      "btn_label": "Conversion",
+      "title": "Modify authority"
+    },
+    "alert": {
+      "description": "You need to modify the permission settings for this page.",
+      "btn_label": "Revision"
+    }
   }
 }

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

@@ -664,6 +664,8 @@
     },
     "by_path_modal": {
       "title": "新しい v5 互換形式への変換",
+      "alert": "この操作は取り消すことができず、ユーザーが閲覧できないページも処理の対象になります。",
+      "checkbox_label": "理解しました",
       "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
       "button_label": "変換",
       "success": "正常に変換を開始しました",
@@ -1068,5 +1070,30 @@
     "select_group": "グループを選ぶ",
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
+      "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
+      "grant_label": {
+        "isForbidden": "権限の閲覧が許可されていません",
+        "currentPageGrantLabel": "このページの権限: ",
+        "parentPageGrantLabel": "親のページの権限: ",
+        "docLink": "権限の修正についての詳細は<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>こちらのリンク</a>を参照してください"
+      },
+      "radio_btn": {
+        "restrected": "リンクを知っている人のみ",
+        "only_me": "自分のみ",
+        "grant_group": "特定グループのみ"
+      },
+      "select_group_default_text": "グループを選択",
+      "alert_message_select_group": "グループが選択されていません",
+      "btn_label": "変換",
+      "title": "権限を修正"
+    },
+    "alert": {
+      "description": "このページの権限設定を修正する必要があります。",
+      "btn_label": "修正"
+    }
   }
 }

+ 27 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -951,6 +951,8 @@
     },
     "by_path_modal": {
       "title": "转换为新的v5兼容格式",
+      "alert": "这一操作不能被撤销,用户不能查看的页面也要进行处理。",
+      "checkbox_label": "明白了",
       "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
       "button_label": "转换",
       "success": "成功地请求转换。",
@@ -1078,5 +1080,30 @@
     "select_group": "选择组别",
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
+      "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
+      "grant_label": {
+        "isForbidden": "无权查看的机构",
+        "currentPageGrantLabel": "本页的权限: ",
+        "parentPageGrantLabel": "父页的权限: ",
+        "docLink": "关于修改授权的更多信息,请参见此<a href='https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html'>此链接</a>"
+      },
+      "radio_btn": {
+        "restrected": "只有那些知道链接的人",
+        "only_me": "只对自己说",
+        "grant_group": "仅限特定群体"
+      },
+      "select_group_default_text": "选择组别",
+      "alert_message_select_group": "未选择组别",
+      "btn_label": "蜕变",
+      "title": "修改后的授权书"
+    },
+    "alert": {
+      "description": "本页的授权设置需要修改。",
+      "btn_label": "修改"
+    }
   }
 }

+ 3 - 0
packages/app/src/client/app.jsx

@@ -33,6 +33,7 @@ import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationS
 import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
+import FixPageGrantAlert from '../components/Page/FixPageGrantAlert';
 import NotFoundAlert from '../components/Page/NotFoundAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
@@ -98,6 +99,8 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
+  'fix-page-grant-alert': <FixPageGrantAlert />,
+
   'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,

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

@@ -16,7 +16,7 @@ import {
   useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
-  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
+  useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
   useDefaultIndentSize, useIsIndentSizeForced,
 } from '../../stores/context';
@@ -71,6 +71,7 @@ const ContextExtractorOnce: FC = () => {
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
   const hasChildren = JSON.parse(mainContent?.getAttribute('data-page-has-children') || jsonNull);
+  const hasParent = JSON.parse(mainContent?.getAttribute('data-has-parent') || jsonNull);
   const templateTagData = mainContent?.getAttribute('data-template-tags') || null;
   const shareLinksNumber = mainContent?.getAttribute('data-share-links-number');
   const shareLinkId = JSON.parse(mainContent?.getAttribute('data-share-link-id') || jsonNull);
@@ -144,6 +145,7 @@ const ContextExtractorOnce: FC = () => {
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
+  useHasParent(hasParent);
 
   // Navigation
   usePreferDrawerModeByUser();

+ 1 - 1
packages/app/src/client/util/interceptor/drawio-interceptor.js

@@ -104,7 +104,7 @@ export class DrawioInterceptor extends BasicInterceptor {
    */
   drawioPostRender(contextName, context) {
     const isPreview = (contextName === 'postRenderPreviewHtml');
-    const renderDrawioInRealtime = context.editorSettings?.renderDrawioInRealtime;
+    const renderDrawioInRealtime = context.renderDrawioInRealtime;
 
     Object.keys(context.DrawioMap).forEach((domId) => {
       const elem = document.getElementById(domId);

+ 278 - 0
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -0,0 +1,278 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { PageGrant, IPageGrantData } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { useCurrentPageId, useHasParent } from '~/stores/context';
+import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
+
+type ModalProps = {
+  isOpen: boolean
+  pageId: string
+  dataApplicableGrant: IRecordApplicableGrant
+  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  close(): void
+}
+
+const FixPageGrantModal = (props: ModalProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, pageId, dataApplicableGrant, currentAndParentPageGrantData, close,
+  } = props;
+
+  const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
+  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  // Alert message state
+  const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
+
+  const applicableGroups = dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
+
+  // Reset state when opened
+  useEffect(() => {
+    if (isOpen) {
+      setSelectedGrant(PageGrant.GRANT_RESTRICTED);
+      setSelectedGroup(undefined);
+      setShowModalAlert(false);
+    }
+  }, [isOpen]);
+
+  const submit = async() => {
+    // Validate input values
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+      setShowModalAlert(true);
+      return;
+    }
+
+    close();
+
+    try {
+      await apiv3Put(`/page/${pageId}/grant`, {
+        grant: selectedGrant,
+        grantedGroup: selectedGroup?._id,
+      });
+
+      toastSuccess(t('Successfully updated'));
+    }
+    catch (err) {
+      toastError(t('Failed to update'));
+    }
+  };
+
+  const getGrantLabel = useCallback((isForbidden: boolean, grantData?: IPageGrantData): string => {
+
+    if (isForbidden) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData == null) {
+      return t('fix_page_grant.modal.grant_label.isForbidden');
+    }
+
+    if (grantData.grant === 4) {
+      return t('fix_page_grant.modal.radio_btn.only_me');
+    }
+
+    if (grantData.grant === 5) {
+      if (grantData.grantedGroup == null) {
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+    }
+
+    throw Error('cannnot get grant label'); // this error can't be throwed
+  }, [t]);
+
+  const renderGrantDataLabel = useCallback(() => {
+    const { isForbidden, currentPageGrant, parentPageGrant } = currentAndParentPageGrantData;
+
+    const currentGrantLabel = getGrantLabel(false, currentPageGrant);
+    const parentGrantLabel = getGrantLabel(isForbidden, parentPageGrant);
+
+    return (
+      <>
+        <p className="mt-3">{ t('fix_page_grant.modal.grant_label.parentPageGrantLabel') + parentGrantLabel }</p>
+        <p>{ t('fix_page_grant.modal.grant_label.currentPageGrantLabel') + currentGrantLabel }</p>
+        {/* eslint-disable-next-line react/no-danger */}
+        <p dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.grant_label.docLink') }} />
+      </>
+    );
+  }, [t, currentAndParentPageGrantData, getGrantLabel]);
+
+  const renderModalBodyAndFooter = () => {
+    const isGrantAvailable = Object.keys(dataApplicableGrant || {}).length > 0;
+
+    if (!isGrantAvailable) {
+      return (
+        <p className="m-5">
+          { t('fix_page_grant.modal.no_grant_available') }
+        </p>
+      );
+    }
+
+    return (
+      <>
+        <ModalBody>
+          <div className="form-group">
+            {/* eslint-disable-next-line react/no-danger */}
+            <p className="mb-2" dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.need_to_fix_grant') }} />
+
+            {/* grant data label */}
+            {renderGrantDataLabel()}
+
+            <div className="ml-2">
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantRestricted"
+                  id="grantRestricted"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_RESTRICTED in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_RESTRICTED}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_RESTRICTED)}
+                />
+                <label className="custom-control-label" htmlFor="grantRestricted">
+                  { t('fix_page_grant.modal.radio_btn.restrected') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUser"
+                  id="grantUser"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_OWNER in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_OWNER}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_OWNER)}
+                />
+                <label className="custom-control-label" htmlFor="grantUser">
+                  { t('fix_page_grant.modal.radio_btn.only_me') }
+                </label>
+              </div>
+              <div className="custom-control custom-radio d-flex mb-3">
+                <input
+                  className="custom-control-input"
+                  name="grantUserGroup"
+                  id="grantUserGroup"
+                  type="radio"
+                  disabled={!(PageGrant.GRANT_USER_GROUP in dataApplicableGrant)}
+                  checked={selectedGrant === PageGrant.GRANT_USER_GROUP}
+                  onChange={() => setSelectedGrant(PageGrant.GRANT_USER_GROUP)}
+                />
+                <label className="custom-control-label" htmlFor="grantUserGroup">
+                  { t('fix_page_grant.modal.radio_btn.grant_group') }
+                </label>
+                <div className="dropdown ml-2">
+                  <button
+                    type="button"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
+                    disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                  >
+                    <span className="float-left ml-2">
+                      {
+                        selectedGroup == null
+                          ? t('fix_page_grant.modal.select_group_default_text')
+                          : selectedGroup.name
+                      }
+                    </span>
+                  </button>
+                  <div className="dropdown-menu">
+                    {
+                      applicableGroups != null && applicableGroups.map(g => (
+                        <button
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => setSelectedGroup(g)}
+                        >
+                          {g.name}
+                        </button>
+                      ))
+                    }
+                  </div>
+                </div>
+              </div>
+              {
+                shouldShowModalAlert && (
+                  <p className="alert alert-warning">
+                    {t('fix_page_grant.modal.alert_message')}
+                  </p>
+                )
+              }
+            </div>
+          </div>
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-primary" onClick={submit}>
+            { t('fix_page_grant.modal.btn_label') }
+          </button>
+        </ModalFooter>
+      </>
+    );
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('fix_page_grant.modal.title') }
+      </ModalHeader>
+      {renderModalBodyAndFooter()}
+    </Modal>
+  );
+};
+
+const FixPageGrantAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  const { data: pageId } = useCurrentPageId();
+  const { data: hasParent } = useHasParent();
+  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(pageId);
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
+
+  // Dependencies
+  if (!hasParent) {
+    return <></>;
+  }
+  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+        <div className="flex-grow-1 d-flex align-items-center">
+          <i className="icon-fw icon-exclamation ml-1" aria-hidden="true" />
+          {t('fix_page_grant.alert.description')}
+        </div>
+        <div className="d-flex align-items-end align-items-lg-center">
+          <button type="button" className="btn btn-info btn-sm rounded-pill px-3" onClick={() => setOpen(true)}>
+            {t('fix_page_grant.alert.btn_label')}
+          </button>
+        </div>
+      </div>
+
+      {
+        pageId != null && dataApplicableGrant != null && (
+          <FixPageGrantModal
+            isOpen={isOpen}
+            pageId={pageId}
+            dataApplicableGrant={dataApplicableGrant}
+            currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+            close={() => setOpen(false)}
+          />
+        )
+      }
+    </>
+  );
+};
+
+export default FixPageGrantAlert;

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

@@ -33,7 +33,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
       pagePath: this.props.pagePath,
-      editorSettings: this.editorSettings,
+      renderDrawioInRealtime: this.props.editorSettings.renderDrawioInRealtime,
       currentPathname: decodeURIComponent(window.location.pathname),
     };
   }
@@ -178,7 +178,7 @@ LegacyRevisionRenderer.propTypes = {
   pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
-  editorSettings: PropTypes.any,
+  editorSettings: PropTypes.any.isRequired,
 };
 
 /**

+ 1 - 1
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -514,7 +514,7 @@ class CodeMirrorEditor extends AbstractEditor {
     const context = {
       handlers: [], // list of handlers which process enter key
       editor: this,
-      editorSettings: this.props.editorSettings,
+      autoFormatMarkdownTable: this.props.editorSettings.autoFormatMarkdownTable,
     };
 
     const interceptorManager = this.interceptorManager;

+ 1 - 1
packages/app/src/components/PageEditor/MarkdownTableInterceptor.js

@@ -58,7 +58,7 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
     const context = Object.assign(args[0]); // clone
     const editor = context.editor; // AbstractEditor instance
     // "autoFormatMarkdownTable" may be undefined, so it is compared to true and converted to bool.
-    const noIntercept = (context.editorSettings?.autoFormatMarkdownTable === false);
+    const noIntercept = (context.autoFormatMarkdownTable === false);
 
     // do nothing if editor is not a CodeMirrorEditor or no intercept
     if (editor == null || editor.getCodeMirror() == null || noIntercept) {

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

@@ -42,11 +42,11 @@ const Preview = (props: Props): JSX.Element => {
     return {
       markdown,
       pagePath,
-      editorSettings,
+      renderDrawioInRealtime: editorSettings?.renderDrawioInRealtime,
       currentPathname: decodeURIComponent(window.location.pathname),
       parsedHTML: null,
     };
-  }, [markdown, pagePath, editorSettings]);
+  }, [markdown, pagePath, editorSettings?.renderDrawioInRealtime]);
 
   const renderPreview = useCallback(async() => {
     if (interceptorManager != null) {

+ 44 - 8
packages/app/src/components/PrivateLegacyPages.tsx

@@ -15,6 +15,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
+import { useCurrentUser } from '~/stores/context';
 import {
   ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
@@ -139,6 +140,11 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   const { t } = useTranslation();
 
   const [currentInput, setInput] = useState<string>('');
+  const [checked, setChecked] = useState<boolean>(false);
+
+  useEffect(() => {
+    setChecked(false);
+  }, [props.isOpen]);
 
   return (
     <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
@@ -148,9 +154,26 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
       <ModalBody>
         <p>{t('private_legacy_pages.by_path_modal.description')}</p>
         <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
+        <div className="alert alert-danger mt-3" role="alert">
+          { t('private_legacy_pages.by_path_modal.alert') }
+        </div>
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-primary" onClick={() => props.onSubmit?.(currentInput)}>
+        <div className="form-check">
+          <input
+            className="form-check-input"
+            type="checkbox"
+            id="understoodCheckbox"
+            onChange={e => setChecked(e.target.checked)}
+          />
+          <label className="form-check-label" htmlFor="understoodCheckbox">{ t('private_legacy_pages.by_path_modal.checkbox_label') }</label>
+        </div>
+        <button
+          type="button"
+          className="btn btn-primary"
+          disabled={!checked}
+          onClick={() => props.onSubmit?.(currentInput)}
+        >
           <i className="icon-fw icon-refresh" aria-hidden="true"></i>
           { t('private_legacy_pages.by_path_modal.button_label') }
         </button>
@@ -159,7 +182,6 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   );
 });
 
-
 /**
  * LegacyPage
  */
@@ -170,6 +192,9 @@ type Props = {
 
 const PrivateLegacyPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+
+  const isAdmin = currentUser?.admin;
 
   const {
     appContainer,
@@ -310,8 +335,23 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
     mutate();
   }, [limit, mutate]);
 
+  const openConvertModalHandler = useCallback(() => {
+    if (!isAdmin) { return }
+    setOpenConvertModal(true);
+  }, [isAdmin]);
+
   const hitsCount = data?.meta.hitsCount;
 
+  const renderOpenModalButton = useCallback(() => {
+    return (
+      <div className="d-flex pl-md-2">
+        <button type="button" className="btn btn-light" onClick={() => openConvertModalHandler()}>
+          {t('private_legacy_pages.input_path_to_convert')}
+        </button>
+      </div>
+    );
+  }, [t, openConvertModalHandler]);
+
   const searchControlAllAction = useMemo(() => {
     const isCheckboxDisabled = hitsCount === 0;
 
@@ -342,11 +382,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
             </UncontrolledButtonDropdown>
           </OperateAllControl>
         </div>
-        <div className="d-flex pl-md-2">
-          <button type="button" className="btn btn-light" onClick={() => setOpenConvertModal(true)}>
-            {t('private_legacy_pages.input_path_to_convert')}
-          </button>
-        </div>
+        {isAdmin && renderOpenModalButton()}
       </div>
     );
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
@@ -418,7 +454,7 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
         close={() => setOpenConvertModal(false)}
         onSubmit={async(convertPath: string) => {
           try {
-            await apiv3Post<void>('/pages/legacy-pages-migration', {
+            await apiv3Post<void>('/pages/convert-pages-by-path', {
               convertPath,
             });
             toastSuccess(t('private_legacy_pages.by_path_modal.success'));

+ 1 - 0
packages/app/src/interfaces/errors/v5-conversion-error.ts

@@ -2,6 +2,7 @@ export const V5ConversionErrCode = {
   GRANT_INVALID: 'GrantInvalid',
   PAGE_NOT_FOUND: 'PageNotFound',
   DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
+  FORBIDDEN: 'Forbidden',
 } as const;
 
 export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];

+ 20 - 0
packages/app/src/interfaces/page-grant.ts

@@ -0,0 +1,20 @@
+import { PageGrant, IPageGrantData } from './page';
+
+export type IDataApplicableGroup = {
+  applicableGroups?: {_id: string, name: string}[] // TODO: Typescriptize model
+}
+
+export type IDataApplicableGrant = null | IDataApplicableGroup;
+export type IRecordApplicableGrant = Record<PageGrant, IDataApplicableGrant>
+export type IResApplicableGrant = {
+  data?: IRecordApplicableGrant
+}
+export type IResIsGrantNormalizedGrantData = {
+  isForbidden: boolean,
+  currentPageGrant: IPageGrantData,
+  parentPageGrant?: IPageGrantData
+}
+export type IResIsGrantNormalized = {
+  isGrantNormalized: boolean,
+  grantData: IResIsGrantNormalizedGrantData
+};

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

@@ -18,7 +18,7 @@ export interface IPage {
   parent: Ref<IPage> | null,
   descendantCount: number,
   isEmpty: boolean,
-  grant: number,
+  grant: PageGrant,
   grantedUsers: Ref<IUser>[],
   grantedGroup: Ref<any>,
   lastUpdateUser: Ref<IUser>,
@@ -32,6 +32,15 @@ export interface IPage {
   deletedAt: Date,
 }
 
+export const PageGrant = {
+  GRANT_PUBLIC: 1,
+  GRANT_RESTRICTED: 2,
+  GRANT_SPECIFIED: 3,
+  GRANT_OWNER: 4,
+  GRANT_USER_GROUP: 5,
+};
+export type PageGrant = typeof PageGrant[keyof typeof PageGrant];
+
 export type IPageHasId = IPage & HasObjectId;
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
@@ -108,6 +117,14 @@ export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 export type IPageToDeleteWithMeta = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string }), IPageInfoForEntity | unknown>;
 export type IPageToRenameWithMeta = IPageToDeleteWithMeta;
 
+export type IPageGrantData = {
+  grant: number,
+  grantedGroup?: {
+    id: string,
+    name: string
+  }
+}
+
 export type IDeleteSinglePageApiv1Result = {
   ok: boolean
   path: string,

+ 1 - 1
packages/app/src/server/events/user.js

@@ -21,7 +21,7 @@ UserEvent.prototype.onActivated = async function(user) {
 
     // create user page
     try {
-      await Page.create(userPagePath, body, user, {});
+      await this.crowi.pageService.create(userPagePath, body, user, {});
 
       // page created
       debug('User page created', page);

+ 15 - 8
packages/app/src/server/models/obsolete-page.js

@@ -248,14 +248,14 @@ export const getPageSchema = (crowi) => {
   };
 
   pageSchema.methods.applyScope = function(user, grant, grantUserGroupId) {
-    // reset
+    // Reset
     this.grantedUsers = [];
     this.grantedGroup = null;
 
     this.grant = grant || GRANT_PUBLIC;
 
-    if (grant !== GRANT_PUBLIC && grant !== GRANT_USER_GROUP && grant !== GRANT_RESTRICTED) {
-      this.grantedUsers.push(user._id);
+    if (grant === GRANT_OWNER) {
+      this.grantedUsers.push(user?._id ?? user);
     }
 
     if (grant === GRANT_USER_GROUP) {
@@ -742,14 +742,21 @@ export const getPageSchema = (crowi) => {
 
     // update existing page
     let savedPage = await pageData.save();
-    const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
 
-    if (isSyncRevisionToHackmd) {
-      savedPage = await this.syncRevisionToHackmd(savedPage);
+    // Update revision
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await this.syncRevisionToHackmd(savedPage);
+      }
     }
 
+
     pageEvent.emit('update', savedPage, user);
 
     return savedPage;

+ 122 - 261
packages/app/src/server/models/page.ts

@@ -10,15 +10,14 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import { IPage, IPageHasId, PageGrant } from '~/interfaces/page';
 import { IUserHasId } from '~/interfaces/user';
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
-import { IPage, IPageHasId } from '../../interfaces/page';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { PageRedirectModel } from './page-redirect';
 
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
@@ -36,7 +35,9 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 
-export interface PageDocument extends IPage, Document { }
+export interface PageDocument extends IPage, Document {
+  [x:string]: any // for obsolete methods
+}
 
 
 type TargetAndAncestorsResult = {
@@ -51,11 +52,9 @@ type PaginatedPages = {
   offset: number
 }
 
-export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
+export type CreateMethod = (path: string, body: string, user, options: PageCreateOptions) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
-  [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
-  getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
+  [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
@@ -392,7 +391,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionAsMigrated() {
+  addConditionAsOnTree() {
     this.query = this.query
       .and(
         {
@@ -481,58 +480,6 @@ export class PageQueryBuilder {
 
 }
 
-/**
- * Create empty pages if the page in paths didn't exist
- * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
- * an empty page will not be created at that page's path.
- */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: any | null, onlyMigratedAsExistingPages = true, filter?): Promise<void> {
-  const aggregationPipeline: any[] = [];
-  // 1. Filter by paths
-  aggregationPipeline.push({ $match: { path: { $in: paths } } });
-  // 2. Normalized condition
-  if (onlyMigratedAsExistingPages) {
-    aggregationPipeline.push({
-      $match: {
-        $or: [
-          { grant: GRANT_PUBLIC },
-          { parent: { $ne: null } },
-          { path: '/' },
-        ],
-      },
-    });
-  }
-  // 3. Add custom pipeline
-  if (filter != null) {
-    aggregationPipeline.push({ $match: filter });
-  }
-  // 4. Add grant conditions
-  let userGroups = null;
-  if (user != null) {
-    const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
-    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
-  }
-  const grantCondition = this.generateGrantCondition(user, userGroups);
-  aggregationPipeline.push({ $match: grantCondition });
-
-  // Run aggregation
-  const existingPages = await this.aggregate(aggregationPipeline);
-
-
-  const existingPagePaths = existingPages.map(page => page.path);
-  // paths to create empty pages
-  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
-
-  // insertMany empty pages
-  try {
-    await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
-  }
-  catch (err) {
-    logger.error('Failed to insert empty pages.', err);
-    throw err;
-  }
-};
-
 schema.statics.createEmptyPage = async function(
     path: string, parent: any, descendantCount = 0, // TODO: improve type including IPage at https://redmine.weseek.co.jp/issues/86506
 ): Promise<PageDocument & { _id: any }> {
@@ -600,73 +547,6 @@ schema.statics.replaceTargetWithPage = async function(exPage, pageToReplaceWith?
   return this.findById(newTarget._id);
 };
 
-/**
- * Find parent or create parent if not exists.
- * It also updates parent of ancestors
- * @param path string
- * @returns Promise<PageDocument>
- */
-schema.statics.getParentAndFillAncestors = async function(path: string, user): Promise<PageDocument> {
-  const parentPath = nodePath.dirname(path);
-
-  const builder1 = new PageQueryBuilder(this.find({ path: parentPath }), true);
-  const pagesCanBeParent = await builder1
-    .addConditionAsMigrated()
-    .query
-    .exec();
-
-  if (pagesCanBeParent.length >= 1) {
-    return pagesCanBeParent[0]; // the earliest page will be the result
-  }
-
-  /*
-   * Fill parents if parent is null
-   */
-  const ancestorPaths = collectAncestorPaths(path); // paths of parents need to be created
-
-  // just create ancestors with empty pages
-  await this.createEmptyPagesByPaths(ancestorPaths, user);
-
-  // find ancestors
-  const builder2 = new PageQueryBuilder(this.find(), true);
-
-  // avoid including not normalized pages
-  builder2.addConditionToFilterByApplicableAncestors(ancestorPaths);
-
-  const ancestors = await builder2
-    .addConditionToListByPathsArray(ancestorPaths)
-    .addConditionToSortPagesByDescPath()
-    .query
-    .exec();
-
-  const ancestorsMap = new Map(); // Map<path, page>
-  ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
-
-  // bulkWrite to update ancestors
-  const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
-  const operations = nonRootAncestors.map((page) => {
-    const parentPath = nodePath.dirname(page.path);
-    return {
-      updateOne: {
-        filter: {
-          _id: page._id,
-        },
-        update: {
-          parent: ancestorsMap.get(parentPath)._id,
-        },
-      },
-    };
-  });
-  await this.bulkWrite(operations);
-
-  const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
-  const createdParent = await this.findOne({ _id: parentId });
-  if (createdParent == null) {
-    throw Error('updated parent not Found');
-  }
-  return createdParent;
-};
-
 // Utility function to add viewer condition to PageQueryBuilder instance
 const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroups = null): Promise<void> => {
   let relatedUserGroups = userGroups;
@@ -766,7 +646,7 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
   await addViewerCondition(queryBuilder, user, userGroups);
 
   const _targetAndAncestors: PageDocument[] = await queryBuilder
-    .addConditionAsMigrated()
+    .addConditionAsOnTree()
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortPagesByDescPath()
@@ -814,7 +694,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   const queryBuilder = new PageQueryBuilder(this.find({ path: { $in: regexps } }), true);
   await addViewerCondition(queryBuilder, user, userGroups);
   const _pages = await queryBuilder
-    .addConditionAsMigrated()
+    .addConditionAsOnTree()
     .addConditionToMinimizeDataForRendering()
     .addConditionToSortPagesByAscPath()
     .query
@@ -845,14 +725,49 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
   return pathToChildren;
 };
 
+/**
+ * Create empty pages at paths at which no pages exist
+ * @param paths Page paths
+ * @param aggrPipelineForExistingPages AggregationPipeline object to find existing pages at paths
+ */
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], aggrPipelineForExistingPages: any[]): Promise<void> {
+  const existingPages = await this.aggregate(aggrPipelineForExistingPages);
+
+  const existingPagePaths = existingPages.map(page => page.path);
+  const notExistingPagePaths = paths.filter(path => !existingPagePaths.includes(path));
+
+  await this.insertMany(notExistingPagePaths.map(path => ({ path, isEmpty: true })));
+};
+
+/**
+ * Find a parent page by path
+ * @param {string} path
+ * @returns {Promise<PageDocument | null>}
+ */
+schema.statics.findParentByPath = async function(path: string): Promise<PageDocument | null> {
+  const parentPath = nodePath.dirname(path);
+
+  const builder = new PageQueryBuilder(this.find({ path: parentPath }), true);
+  const pagesCanBeParent = await builder
+    .addConditionAsOnTree()
+    .query
+    .exec();
+
+  if (pagesCanBeParent.length >= 1) {
+    return pagesCanBeParent[0]; // the earliest page will be the result
+  }
+
+  return null;
+};
+
 /*
  * Utils from obsolete-page.js
  */
-async function pushRevision(pageData, newRevision, user) {
+export async function pushRevision(pageData, newRevision, user) {
   await newRevision.save();
 
   pageData.revision = newRevision;
-  pageData.lastUpdateUser = user;
+  pageData.lastUpdateUser = user?._id ?? user;
   pageData.updatedAt = Date.now();
 
   return pageData.save();
@@ -999,6 +914,40 @@ schema.statics.removeEmptyPages = async function(pageIdsToNotRemove: ObjectIdLik
   });
 };
 
+/**
+ * Find a not empty parent recursively.
+ * @param {string} path
+ * @returns {Promise<PageDocument | null>}
+ */
+schema.statics.findNotEmptyParentByPathRecursively = async function(path: string): Promise<PageDocument | null> {
+  const parent = await this.findParentByPath(path);
+  if (parent == null) {
+    return null;
+  }
+
+  const recursive = async(page: PageDocument): Promise<PageDocument> => {
+    if (!page.isEmpty) {
+      return page;
+    }
+
+    const next = await this.findById(page.parent);
+
+    if (next == null || isTopPage(next.path)) {
+      return page;
+    }
+
+    return recursive(next);
+  };
+
+  const notEmptyParent = await recursive(parent);
+
+  return notEmptyParent;
+};
+
+schema.statics.findParent = async function(pageId): Promise<PageDocument | null> {
+  return this.findOne({ _id: pageId });
+};
+
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 export function generateGrantCondition(
@@ -1059,125 +1008,6 @@ export default (crowi: Crowi): any => {
     pageEvent = crowi.event('page');
   }
 
-  schema.statics.create = async function(path: string, body: string, user, options: PageCreateOptions = {}) {
-    if (crowi.pageGrantService == null || crowi.configManager == null || crowi.pageService == null || crowi.pageOperationService == null) {
-      throw Error('Crowi is not setup');
-    }
-
-    const isV5Compatible = crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
-    // v4 compatible process
-    if (!isV5Compatible) {
-      return this.createV4(path, body, user, options);
-    }
-
-    const canOperate = await crowi.pageOperationService.canOperate(false, null, path);
-    if (!canOperate) {
-      throw Error(`Cannot operate create to path "${path}" right now.`);
-    }
-
-    const Page = this;
-    const Revision = crowi.model('Revision');
-    const {
-      format = 'markdown', grantUserGroupId,
-    } = options;
-    let grant = options.grant;
-
-    // sanitize path
-    path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
-    // throw if exists
-    const isExist = (await this.count({ path, isEmpty: false })) > 0; // not validate empty page
-    if (isExist) {
-      throw new Error('Cannot create new page to existed path');
-    }
-    // force public
-    if (isTopPage(path)) {
-      grant = GRANT_PUBLIC;
-    }
-
-    // find an existing empty page
-    const emptyPage = await Page.findOne({ path, isEmpty: true });
-
-    /*
-     * UserGroup & Owner validation
-     */
-    if (grant !== GRANT_RESTRICTED) {
-      let isGrantNormalized = false;
-      try {
-        // It must check descendants as well if emptyTarget is not null
-        const shouldCheckDescendants = emptyPage != null;
-        const newGrantedUserIds = grant === GRANT_OWNER ? [user._id] as IObjectId[] : undefined;
-
-        isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(user, path, grant, newGrantedUserIds, grantUserGroupId, shouldCheckDescendants);
-      }
-      catch (err) {
-        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
-        throw err;
-      }
-      if (!isGrantNormalized) {
-        throw Error('The selected grant or grantedGroup is not assignable to this page.');
-      }
-    }
-
-    /*
-     * update empty page if exists, if not, create a new page
-     */
-    let page;
-    if (emptyPage != null && grant !== GRANT_RESTRICTED) {
-      page = emptyPage;
-      const descendantCount = await this.recountDescendantCount(page._id);
-
-      page.descendantCount = descendantCount;
-      page.isEmpty = false;
-    }
-    else {
-      page = new Page();
-    }
-
-    page.path = path;
-    page.creator = user;
-    page.lastUpdateUser = user;
-    page.status = STATUS_PUBLISHED;
-
-    // set parent to null when GRANT_RESTRICTED
-    const isGrantRestricted = grant === GRANT_RESTRICTED;
-    if (isTopPage(path) || isGrantRestricted) {
-      page.parent = null;
-    }
-    else {
-      const parent = await Page.getParentAndFillAncestors(path, user);
-      page.parent = parent._id;
-    }
-
-    page.applyScope(user, grant, grantUserGroupId);
-
-    let savedPage = await page.save();
-
-    /*
-     * After save
-     */
-    // Delete PageRedirect if exists
-    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
-    try {
-      await PageRedirect.deleteOne({ fromPath: path });
-      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
-    }
-    catch (err) {
-      // no throw
-      logger.error('Failed to delete PageRedirect');
-    }
-
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
-
-    pageEvent.emit('create', savedPage, user);
-
-    // update descendantCount asynchronously
-    await crowi.pageService.updateDescendantCountOfAncestors(savedPage._id, 1, false);
-
-    return savedPage;
-  };
-
   const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
     const isRestricted = grant === GRANT_RESTRICTED;
     return !isRestricted && (!isV5Compatible || !isOnTree);
@@ -1187,7 +1017,31 @@ export default (crowi: Crowi): any => {
     pageEvent.emit('update', page, user);
   };
 
-  schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
+  /**
+   * A wrapper method of schema.statics.updatePage for updating grant only.
+   * @param {PageDocument} page
+   * @param {UserDocument} user
+   * @param options
+   */
+  schema.statics.updateGrant = async function(page, user, grantData: {grant: PageGrant, grantedGroup: ObjectIdLike}) {
+    const { grant, grantedGroup } = grantData;
+
+    const options = {
+      grant,
+      grantUserGroupId: grantedGroup,
+      isSyncRevisionToHackmd: false,
+    };
+
+    return this.updatePage(page, null, null, user, options);
+  };
+
+  schema.statics.updatePage = async function(
+      pageData,
+      body: string | null,
+      previousBody: string | null,
+      user,
+      options: {grant?: PageGrant, grantUserGroupId?: ObjectIdLike, isSyncRevisionToHackmd?: boolean} = {},
+  ) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
     }
@@ -1202,10 +1056,9 @@ export default (crowi: Crowi): any => {
       return this.updatePageV4(pageData, body, previousBody, user, options);
     }
 
-    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const grant = options.grant || pageData.grant; // use the previous data if absence
+    const grant = options.grant ?? pageData.grant; // use the previous data if absence
     const grantUserGroupId: undefined | ObjectIdLike = options.grantUserGroupId ?? pageData.grantedGroup?._id.toString();
-    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+
     const grantedUserIds = pageData.grantedUserIds || [user._id];
     const shouldBeOnTree = grant !== GRANT_RESTRICTED;
     const isChildrenExist = await this.count({ path: new RegExp(`^${escapeStringRegexp(addTrailingSlash(pageData.path))}`), parent: { $ne: null } });
@@ -1226,7 +1079,7 @@ export default (crowi: Crowi): any => {
       }
 
       if (!wasOnTree) {
-        const newParent = await this.getParentAndFillAncestors(newPageData.path, user);
+        const newParent = await crowi.pageService.getParentAndFillAncestorsByUser(user, newPageData.path);
         newPageData.parent = newParent._id;
       }
     }
@@ -1248,14 +1101,23 @@ export default (crowi: Crowi): any => {
 
     // update existing page
     let savedPage = await newPageData.save();
-    const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
-    savedPage = await pushRevision(savedPage, newRevision, user);
-    await savedPage.populateDataToShowRevision();
 
-    if (isSyncRevisionToHackmd) {
-      savedPage = await this.syncRevisionToHackmd(savedPage);
+    // Update body
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
+    const isBodyPresent = body != null && previousBody != null;
+    const shouldUpdateBody = isBodyPresent;
+    if (shouldUpdateBody) {
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      savedPage = await pushRevision(savedPage, newRevision, user);
+      await savedPage.populateDataToShowRevision();
+
+      if (isSyncRevisionToHackmd) {
+        savedPage = await this.syncRevisionToHackmd(savedPage);
+      }
     }
 
+
     this.emitPageEventUpdate(savedPage, user);
 
     // Update ex children's parent
@@ -1292,7 +1154,6 @@ export default (crowi: Crowi): any => {
     }
 
     // 2. Delete unnecessary empty pages
-
     const shouldRemoveLeafEmpPages = wasOnTree && !isChildrenExist;
     if (shouldRemoveLeafEmpPages) {
       await this.removeLeafEmptyPagesRecursively(exParent);

+ 58 - 0
packages/app/src/server/models/user-group-relation.js

@@ -313,6 +313,64 @@ class UserGroupRelation {
     await this.bulkWrite(insertOperations);
   }
 
+  /**
+   * Recursively finds descendant groups by populating relations.
+   * @static
+   * @param {UserGroupDocument[]} groups
+   * @param {UserDocument} user
+   * @returns UserGroupDocument[]
+   */
+  static async findGroupsWithDescendantsByGroupAndUser(group, user) {
+    const descendantGroups = [group];
+
+    const incrementGroupsRecursively = async(groups, user) => {
+      const groupIds = groups.map(g => g._id);
+
+      const populatedRelations = await this.aggregate([
+        {
+          $match: {
+            relatedUser: user._id,
+          },
+        },
+        {
+          $lookup: {
+            from: 'usergroups',
+            localField: 'relatedGroup',
+            foreignField: '_id',
+            as: 'relatedGroup',
+          },
+        },
+        {
+          $unwind: {
+            path: '$relatedGroup',
+          },
+        },
+        {
+          $match: {
+            'relatedGroup.parent': { $in: groupIds },
+          },
+        },
+      ]);
+
+      const nextGroups = populatedRelations.map(d => d.relatedGroup);
+
+      // End
+      const shouldEnd = nextGroups.length === 0;
+      if (shouldEnd) {
+        return;
+      }
+
+      // Increment
+      descendantGroups.push(...nextGroups);
+
+      return incrementGroupsRecursively(nextGroups, user);
+    };
+
+    await incrementGroupsRecursively([group], user);
+
+    return descendantGroups;
+  }
+
 }
 
 module.exports = function(crowi) {

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

@@ -2,6 +2,7 @@ import { pagePathUtils } from '@growi/core';
 
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
+import UserGroup from '~/server/models/user-group';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -9,10 +10,10 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
-const { body, query } = require('express-validator');
+const { body, query, param } = require('express-validator');
 
 const router = express.Router();
-const { convertToNewAffiliationPath } = pagePathUtils;
+const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -179,6 +180,17 @@ module.exports = (crowi) => {
     info: [
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
+    isGrantNormalized: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+    ],
+    applicableGrant: [
+      query('pageId').isMongoId().withMessage('pageId is required'),
+    ],
+    updateGrant: [
+      param('pageId').isMongoId().withMessage('pageId is required'),
+      body('grant').isInt().withMessage('grant is required'),
+      body('grantedGroup').optional().isMongoId().withMessage('grantedGroup must be a mongo id'),
+    ],
     export: [
       query('format').isString().isIn(['md', 'pdf']),
       query('revisionId').isString(),
@@ -379,6 +391,160 @@ module.exports = (crowi) => {
 
   });
 
+  /**
+   * @swagger
+   *
+   *    /page/is-grant-normalized:
+   *      get:
+   *        tags: [Page]
+   *        summary: /page/info
+   *        description: Retrieve current page's isGrantNormalized value
+   *        operationId: getIsGrantNormalized
+   *        parameters:
+   *          - name: pageId
+   *            in: query
+   *            description: page id
+   *            schema:
+   *              $ref: '#/components/schemas/Page/properties/_id'
+   *        responses:
+   *          200:
+   *            description: Successfully retrieved current isGrantNormalized.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    isGrantNormalized:
+   *                      type: boolean
+   *          400:
+   *            description: Bad request. Page is unreachable or empty.
+   *          500:
+   *            description: Internal server error.
+   */
+  router.get('/is-grant-normalized', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
+
+    const Page = crowi.model('Page');
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    const {
+      path, grant, grantedUsers, grantedGroup,
+    } = page;
+
+    let isGrantNormalized;
+    try {
+      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroup, false, false);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing isGrantNormalized.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    const currentPageUserGroup = await UserGroup.findOne({ _id: grantedGroup });
+    const currentPageGrant = {
+      grant,
+      grantedGroup: currentPageUserGroup != null
+        ? {
+          id: currentPageUserGroup._id,
+          name: currentPageUserGroup.name,
+        }
+        : null,
+    };
+
+    // page doesn't have parent page
+    if (page.parent == null) {
+      const grantData = {
+        isForbidden: false,
+        currentPageGrant,
+        parentPageGrant: null,
+      };
+      return res.apiv3({ isGrantNormalized, grantData });
+    }
+
+    const parentPage = await Page.findByIdAndViewer(page.parent, req.user, null, false);
+
+    // user isn't allowed to see parent's grant
+    if (parentPage == null) {
+      const grantData = {
+        isForbidden: true,
+        currentPageGrant,
+        parentPageGrant: null,
+      };
+      return res.apiv3({ isGrantNormalized, grantData });
+    }
+
+    const parentPageUserGroup = await UserGroup.findOne({ _id: parentPage.grantedGroup });
+    const parentPageGrant = {
+      grant: parentPage.grant,
+      grantedGroup: parentPageUserGroup != null
+        ? {
+          id: parentPageUserGroup._id,
+          name: parentPageUserGroup.name,
+        }
+        : null,
+    };
+
+    const grantData = {
+      isForbidden: false,
+      currentPageGrant,
+      parentPageGrant,
+    };
+
+    return res.apiv3({ isGrantNormalized, grantData });
+  });
+
+  router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.query;
+
+    const Page = crowi.model('Page');
+    const page = await Page.findByIdAndViewer(pageId, req.user, null);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    let data;
+    try {
+      data = await crowi.pageGrantService.calcApplicableGrantData(page, req.user);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing calcApplicableGrantData.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(data);
+  });
+
+  router.put('/:pageId/grant', loginRequiredStrictly, validator.updateGrant, apiV3FormValidator, async(req, res) => {
+    const { pageId } = req.params;
+    const { grant, grantedGroup } = req.body;
+
+    const Page = crowi.model('Page');
+
+    const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+
+    if (page == null) {
+      return res.apiv3Err(new ErrorV3('Page is unreachable or empty.', 'page_unreachable_or_empty'), 400);
+    }
+
+    let data;
+    try {
+      const shouldUseV4Process = false;
+      const grantData = { grant, grantedGroup };
+      data = await Page.updateGrant(page, req.user, grantData, shouldUseV4Process);
+    }
+    catch (err) {
+      logger.error('Error occurred while processing calcApplicableGrantData.', err);
+      return res.apiv3Err(err, 500);
+    }
+
+    return res.apiv3(data);
+  });
+
   /**
   * @swagger
   *

+ 23 - 16
packages/app/src/server/routes/apiv3/pages.js

@@ -204,12 +204,15 @@ module.exports = (crowi) => {
         .custom(v => v === 'true' || v === true || v == null)
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
+    convertPagesByPath: [
+      body('convertPath').optional().isString().withMessage('convertPath must be a string'),
+    ],
   };
 
   async function createPageAction({
     path, body, user, options,
   }) {
-    const createdPage = await Page.create(path, body, user, options);
+    const createdPage = await crowi.pageService.create(path, body, user, options);
     return createdPage;
   }
 
@@ -823,29 +826,33 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
 
+
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { convertPath, pageIds: _pageIds, isRecursively } = req.body;
+  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, adminRequired, csrf, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
+    const { convertPath } = req.body;
 
     // Convert by path
-    if (convertPath != null) {
-      const normalizedPath = pathUtils.normalizePath(convertPath);
-      try {
-        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
-      }
-      catch (err) {
-        logger.error(err);
-
-        if (isV5ConversionError(err)) {
-          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
-        }
+    const normalizedPath = pathUtils.normalizePath(convertPath);
+    try {
+      await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
+    }
+    catch (err) {
+      logger.error(err);
 
-        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      if (isV5ConversionError(err)) {
+        return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
       }
 
-      return res.apiv3({});
+      return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
     }
 
+    return res.apiv3({});
+  });
+
+  // eslint-disable-next-line max-len
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+    const { pageIds: _pageIds, isRecursively } = req.body;
+
     // Convert by pageIds
     const pageIds = _pageIds == null ? [] : _pageIds;
 

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

@@ -437,7 +437,7 @@ module.exports = function(crowi, app) {
     if (pageId == null) {
       logger.debug('Create page before file upload');
 
-      page = await Page.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
+      page = await crowi.pageService.create(pagePath, `# ${pagePath}`, req.user, { grant: Page.GRANT_OWNER });
       pageCreated = true;
       pageId = page._id;
     }

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

@@ -792,7 +792,7 @@ module.exports = function(crowi, app) {
       options.grantUserGroupId = grantUserGroupId;
     }
 
-    const createdPage = await Page.create(pagePath, body, req.user, options);
+    const createdPage = await crowi.pageService.create(pagePath, body, req.user, options);
 
     let savedTags;
     if (pageTags != null) {

+ 29 - 1
packages/app/src/server/service/comment.ts

@@ -7,6 +7,8 @@ import { stringifySnapshot } from '~/models/serializers/in-app-notification-snap
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 
+// https://regex101.com/r/Ztxj2j/1
+const USERNAME_PATTERN = new RegExp(/\B@[\w@.-]+/g);
 
 const logger = loggerFactory('growi:service:CommentService');
 
@@ -99,11 +101,37 @@ class CommentService {
     let targetUsers: Types.ObjectId[] = [];
     targetUsers = await activity.getNotificationTargetUsers();
 
-    // Create and send notifications
+    // Add mentioned users to targetUsers
+    const mentionedUsers = await this.getMentionedUsers(activity.event);
+    targetUsers = targetUsers.concat(mentionedUsers);
+
     await this.inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
     await this.inAppNotificationService.emitSocketIo(targetUsers);
   };
 
+  getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
+    const Comment = getModelSafely('Comment') || require('../models/comment')(this.crowi);
+    const User = getModelSafely('User') || require('../models/user')(this.crowi);
+
+    // Get comment by comment ID
+    const commentData = await Comment.findOne({ _id: commentId });
+    const { comment } = commentData;
+
+    const usernamesFromComment = comment.match(USERNAME_PATTERN);
+
+    // Get username from comment and remove duplicate username
+    const mentionedUsernames = [...new Set(usernamesFromComment?.map((username) => {
+      return username.slice(1);
+    }))];
+
+    // Get mentioned users ID
+    const mentionedUserIDs = await User.find({ username: { $in: mentionedUsernames } });
+    return mentionedUserIDs?.map((user) => {
+      return user._id;
+    });
+  }
+
 }
 
+
 module.exports = CommentService;

+ 1 - 6
packages/app/src/server/service/installer.ts

@@ -43,14 +43,9 @@ export class InstallerService {
   }
 
   private async createPage(filePath, pagePath, owner): Promise<IPage|undefined> {
-
-    // TODO typescriptize models/user.js and remove eslint-disable-next-line
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const Page = mongoose.model('Page') as any;
-
     try {
       const markdown = fs.readFileSync(filePath);
-      return Page.create(pagePath, markdown, owner, {}) as IPage;
+      return this.crowi.pageService.create(pagePath, markdown, owner, {}) as IPage;
     }
     catch (err) {
       logger.error(`Failed to create ${pagePath}`, err);

+ 72 - 3
packages/app/src/server/service/page-grant.ts

@@ -1,9 +1,10 @@
-import mongoose from 'mongoose';
 import { pagePathUtils, pathUtils, pageUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
+import mongoose from 'mongoose';
 
-import UserGroup from '~/server/models/user-group';
+import { IRecordApplicableGrant } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
+import UserGroup from '~/server/models/user-group';
 import { isIncludesObjectId, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 
 const { addTrailingSlash } = pathUtils;
@@ -226,7 +227,7 @@ class PageGrantService {
      */
     const builderForAncestors = new PageQueryBuilder(Page.find(), false);
     if (!includeNotMigratedPages) {
-      builderForAncestors.addConditionAsMigrated();
+      builderForAncestors.addConditionAsOnTree();
     }
     const ancestors = await builderForAncestors
       .addConditionToListOnlyAncestors(targetPath)
@@ -403,6 +404,74 @@ class PageGrantService {
     return [normalizable, nonNormalizable];
   }
 
+  async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const UserGroupRelation = mongoose.model('UserGroupRelation') as any; // TODO: Typescriptize model
+
+    // Increment an object (type IRecordApplicableGrant)
+    // grant is never public, anyone with the link, nor specified
+    const data: IRecordApplicableGrant = {
+      [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
+    const isAnyGrantApplicable = page.parent == null;
+    if (isAnyGrantApplicable) {
+      data[Page.GRANT_PUBLIC] = null;
+      data[Page.GRANT_OWNER] = null;
+      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+      return data;
+    }
+
+    const parent = await Page.findById(page.parent);
+    if (parent == null) {
+      throw Error('The page\'s parent does not exist.');
+    }
+
+    const {
+      grant, grantedUsers, grantedGroup,
+    } = parent;
+
+    if (grant === Page.GRANT_PUBLIC) {
+      data[Page.GRANT_PUBLIC] = null;
+      data[Page.GRANT_OWNER] = null;
+      data[Page.GRANT_USER_GROUP] = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    else if (grant === Page.GRANT_OWNER) {
+      const grantedUser = grantedUsers[0];
+
+      const isUserApplicable = grantedUser.toString() === user._id.toString();
+
+      if (isUserApplicable) {
+        data[Page.GRANT_OWNER] = null;
+      }
+    }
+    else if (grant === Page.GRANT_USER_GROUP) {
+      const group = await UserGroup.findById(grantedGroup);
+      if (group == null) {
+        throw Error('Group not found to calculate grant data.');
+      }
+
+      const applicableGroups = await UserGroupRelation.findGroupsWithDescendantsByGroupAndUser(group, user);
+
+      const isUserExistInGroup = await UserGroupRelation.countByGroupIdAndUser(group, user) > 0;
+
+      if (isUserExistInGroup) {
+        data[Page.GRANT_OWNER] = null;
+      }
+      data[Page.GRANT_USER_GROUP] = { applicableGroups };
+    }
+
+    return data;
+  }
+
 }
 
 export default PageGrantService;

+ 468 - 22
packages/app/src/server/service/page.ts

@@ -20,7 +20,7 @@ import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
-  CreateMethod, PageCreateOptions, PageModel, PageDocument,
+  CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision,
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
 import loggerFactory from '~/utils/logger';
@@ -525,7 +525,7 @@ class PageService {
       newParent = await this.getParentAndforceCreateEmptyTree(page, newPagePath);
     }
     else {
-      newParent = await Page.getParentAndFillAncestors(newPagePath, user);
+      newParent = await this.getParentAndFillAncestorsByUser(user, newPagePath);
     }
 
     // 3. Put back target page to tree (also update the other attrs)
@@ -979,12 +979,12 @@ class PageService {
     };
     let duplicatedTarget;
     if (page.isEmpty) {
-      const parent = await Page.getParentAndFillAncestors(newPagePath, user);
+      const parent = await this.getParentAndFillAncestorsByUser(user, newPagePath);
       duplicatedTarget = await Page.createEmptyPage(newPagePath, parent);
     }
     else {
       await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
-      duplicatedTarget = await (Page.create as CreateMethod)(
+      duplicatedTarget = await (this.create as CreateMethod)(
         newPagePath, page.revision.body, user, options,
       );
     }
@@ -1067,7 +1067,6 @@ class PageService {
   }
 
   async duplicateV4(page, newPagePath, user, isRecursively) {
-    const Page = this.crowi.model('Page');
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: Typescriptize model
     // populate
     await page.populate({ path: 'revision', model: 'Revision', select: 'body' });
@@ -1080,7 +1079,7 @@ class PageService {
 
     newPagePath = this.crowi.xss.process(newPagePath); // eslint-disable-line no-param-reassign
 
-    const createdPage = await Page.create(
+    const createdPage = await this.crowi.pageService.create(
       newPagePath, page.revision.body, user, options,
     );
     this.pageEvent.emit('duplicate', page, user);
@@ -1910,7 +1909,7 @@ class PageService {
     }
 
     // 2. Revert target
-    const parent = await Page.getParentAndFillAncestors(newPath, user);
+    const parent = await this.getParentAndFillAncestorsByUser(user, newPath);
     const updatedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
@@ -2255,13 +2254,28 @@ class PageService {
 
   async normalizeParentByPath(path: string, user): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    // This validation is not 100% correct since it ignores user to count
+    const builder = new PageQueryBuilder(Page.find());
+    builder.addConditionAsNotMigrated();
+    builder.addConditionToListWithDescendants(path);
+    const nEstimatedNormalizationTarget: number = await builder.query.exec('count');
+    if (nEstimatedNormalizationTarget === 0) {
+      throw Error('No page is available for conversion');
+    }
 
     const pages = await Page.findByPathAndViewer(path, user, null, false);
     if (pages == null || !Array.isArray(pages)) {
       throw Error('Something went wrong while converting pages.');
     }
+
+
     if (pages.length === 0) {
-      throw new V5ConversionError(`Could not find the page "${path}" to convert.`, V5ConversionErrCode.PAGE_NOT_FOUND);
+      const isForbidden = await Page.count({ path, isEmpty: false }) > 0;
+      if (isForbidden) {
+        throw new V5ConversionError('It is not allowed to convert this page.', V5ConversionErrCode.FORBIDDEN);
+      }
     }
     if (pages.length > 1) {
       throw new V5ConversionError(
@@ -2270,10 +2284,33 @@ class PageService {
       );
     }
 
-    const page = pages[0];
-    const {
-      grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
-    } = page;
+    let page;
+    let systematicallyCreatedPage;
+
+    const shouldCreateNewPage = pages[0] == null;
+    if (shouldCreateNewPage) {
+      const notEmptyParent = await Page.findNotEmptyParentByPathRecursively(path);
+
+      const options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] | undefined } = {
+        grant: notEmptyParent.grant,
+        grantUserGroupId: notEmptyParent.grantedGroup,
+        grantedUsers: notEmptyParent.grantedUsers,
+      };
+
+      systematicallyCreatedPage = await this.createBySystem(
+        path,
+        '',
+        options,
+      );
+      page = systematicallyCreatedPage;
+    }
+    else {
+      page = pages[0];
+    }
+
+    const grant = page.grant;
+    const grantedUserIds = page.grantedUsers;
+    const grantedGroupId = page.grantedGroup;
 
     /*
      * UserGroup & Owner validation
@@ -2311,7 +2348,6 @@ class PageService {
       throw err;
     }
 
-    // no await
     this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
   }
 
@@ -2412,7 +2448,7 @@ class PageService {
       normalizedPage = await Page.findById(page._id);
     }
     else {
-      const parent = await Page.getParentAndFillAncestors(page.path, user);
+      const parent = await this.getParentAndFillAncestorsByUser(user, page.path);
       normalizedPage = await Page.findOneAndUpdate({ _id: page._id }, { parent: parent._id }, { new: true });
     }
 
@@ -2466,7 +2502,7 @@ class PageService {
       const Page = mongoose.model('Page') as unknown as PageModel;
       const { PageQueryBuilder } = Page;
       const builder = new PageQueryBuilder(Page.findOne());
-      builder.addConditionAsMigrated();
+      builder.addConditionAsOnTree();
       builder.addConditionToListByPathsArray([page.path]);
       const existingPage = await builder.query.exec();
 
@@ -2509,18 +2545,19 @@ class PageService {
     }
   }
 
-  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
+  async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<number> {
     // Save prevDescendantCount for sub-operation
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.findOne(), true);
-    builder.addConditionAsMigrated();
+    builder.addConditionAsOnTree();
     builder.addConditionToListByPathsArray([page.path]);
     const exPage = await builder.query.exec();
     const options = { prevDescendantCount: exPage?.descendantCount ?? 0 };
 
+    let count: number;
     try {
-      await this.normalizeParentRecursively([page.path], user);
+      count = await this.normalizeParentRecursively([page.path], user);
     }
     catch (err) {
       logger.error('V5 initial miration failed.', err);
@@ -2536,6 +2573,8 @@ class PageService {
     }
 
     await this.normalizeParentRecursivelySubOperation(page, user, pageOp._id, options);
+
+    return count;
   }
 
   async normalizeParentRecursivelySubOperation(page, user, pageOpId: ObjectIdLike, options: {prevDescendantCount: number}): Promise<void> {
@@ -2674,7 +2713,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    */
-  async normalizeParentRecursively(paths: string[], user: any | null): Promise<void> {
+  async normalizeParentRecursively(paths: string[], user: any | null): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2740,7 +2779,7 @@ class PageService {
 
   private async _normalizeParentRecursively(
       pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }, user, count = 0, skiped = 0, isFirst = true,
-  ): Promise<void> {
+  ): Promise<number> {
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
 
@@ -2779,6 +2818,9 @@ class PageService {
     let nextCount = count;
     let nextSkiped = skiped;
 
+    // eslint-disable-next-line max-len
+    const buildPipelineToCreateEmptyPagesByUser = this.buildPipelineToCreateEmptyPagesByUser.bind(this);
+
     const migratePagesStream = new Writable({
       objectMode: true,
       async write(pages, encoding, callback) {
@@ -2817,7 +2859,8 @@ class PageService {
           { path: { $nin: publicPathsToNormalize }, status: Page.STATUS_PUBLISHED },
         ];
         const filterForApplicableAncestors = { $or: orFilters };
-        await Page.createEmptyPagesByPaths(parentPaths, user, false, filterForApplicableAncestors);
+        const aggregationPipeline = await buildPipelineToCreateEmptyPagesByUser(user, parentPaths, false, filterForApplicableAncestors);
+        await Page.createEmptyPagesByPaths(parentPaths, aggregationPipeline);
 
         // 3. Find parents
         const addGrantCondition = (builder) => {
@@ -2910,6 +2953,8 @@ class PageService {
 
     // End
     socket.emit(SocketEventName.PMEnded, { isSucceeded: true });
+
+    return nextCount;
   }
 
   private async _v5NormalizeIndex() {
@@ -2963,7 +3008,7 @@ class PageService {
     const { PageQueryBuilder } = Page;
 
     const builder = new PageQueryBuilder(Page.find(), true);
-    builder.addConditionAsMigrated();
+    builder.addConditionAsOnTree();
     builder.addConditionToListWithDescendants(path);
     builder.addConditionToSortPagesByDescPath();
 
@@ -3008,6 +3053,407 @@ class PageService {
     socket.emit(SocketEventName.UpdateDescCount, data);
   }
 
+  /**
+   * Build the base aggregation pipeline for fillAncestors--- methods
+   * @param onlyMigratedAsExistingPages Determine whether to include non-migrated pages as existing pages. If a page exists,
+   * an empty page will not be created at that page's path.
+   */
+  private buildBasePipelineToCreateEmptyPages(paths: string[], onlyMigratedAsExistingPages = true, andFilter?): any[] {
+    const aggregationPipeline: any[] = [];
+
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // -- Filter by paths
+    aggregationPipeline.push({ $match: { path: { $in: paths } } });
+    // -- Normalized condition
+    if (onlyMigratedAsExistingPages) {
+      aggregationPipeline.push({
+        $match: {
+          $or: [
+            { grant: Page.GRANT_PUBLIC },
+            { parent: { $ne: null } },
+            { path: '/' },
+          ],
+        },
+      });
+    }
+    // -- Add custom pipeline
+    if (andFilter != null) {
+      aggregationPipeline.push({ $match: andFilter });
+    }
+
+    return aggregationPipeline;
+  }
+
+  private async buildPipelineToCreateEmptyPagesByUser(user, paths: string[], onlyMigratedAsExistingPages = true, andFilter?): Promise<any[]> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const pipeline = this.buildBasePipelineToCreateEmptyPages(paths, onlyMigratedAsExistingPages, andFilter);
+    let userGroups = null;
+    if (user != null) {
+      const UserGroupRelation = mongoose.model('UserGroupRelation') as any;
+      userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+    }
+    const grantCondition = Page.generateGrantCondition(user, userGroups);
+    pipeline.push({ $match: grantCondition });
+
+    return pipeline;
+  }
+
+  private buildPipelineToCreateEmptyPagesBySystem(paths: string[]): any[] {
+    return this.buildBasePipelineToCreateEmptyPages(paths);
+  }
+
+  private async connectPageTree(path: string): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+    const { PageQueryBuilder } = Page;
+
+    const ancestorPaths = collectAncestorPaths(path);
+
+    // Find ancestors
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToFilterByApplicableAncestors(ancestorPaths); // avoid including not normalized pages
+    const ancestors = await builder
+      .addConditionToListByPathsArray(ancestorPaths)
+      .addConditionToSortPagesByDescPath()
+      .query
+      .exec();
+
+    // Update parent attrs
+    const ancestorsMap = new Map(); // Map<path, page>
+    ancestors.forEach(page => !ancestorsMap.has(page.path) && ancestorsMap.set(page.path, page)); // the earlier element should be the true ancestor
+
+    const nonRootAncestors = ancestors.filter(page => !isTopPage(page.path));
+    const operations = nonRootAncestors.map((page) => {
+      const parentPath = pathlib.dirname(page.path);
+      return {
+        updateOne: {
+          filter: {
+            _id: page._id,
+          },
+          update: {
+            parent: ancestorsMap.get(parentPath)._id,
+          },
+        },
+      };
+    });
+    await Page.bulkWrite(operations);
+  }
+
+  /**
+   * Find parent or create parent if not exists.
+   * It also updates parent of ancestors
+   * @param path string
+   * @returns Promise<PageDocument>
+   */
+  async getParentAndFillAncestorsByUser(user, path: string): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Find parent
+    const parent = await Page.findParentByPath(path);
+    if (parent != null) {
+      return parent;
+    }
+
+    const ancestorPaths = collectAncestorPaths(path);
+
+    // Fill ancestors
+    const aggregationPipeline: any[] = await this.buildPipelineToCreateEmptyPagesByUser(user, ancestorPaths);
+
+    await Page.createEmptyPagesByPaths(ancestorPaths, aggregationPipeline);
+
+    // Connect ancestors
+    await this.connectPageTree(path);
+
+    // Return the created parent
+    const createdParent = await Page.findParentByPath(path);
+    if (createdParent == null) {
+      throw Error('Failed to find the created parent by getParentAndFillAncestorsByUser');
+    }
+    return createdParent;
+  }
+
+  async getParentAndFillAncestorsBySystem(path: string): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Find parent
+    const parent = await Page.findParentByPath(path);
+    if (parent != null) {
+      return parent;
+    }
+
+    // Fill ancestors
+    const ancestorPaths = collectAncestorPaths(path);
+    const aggregationPipeline: any[] = this.buildPipelineToCreateEmptyPagesBySystem(ancestorPaths);
+
+    await Page.createEmptyPagesByPaths(ancestorPaths, aggregationPipeline);
+
+    // Connect ancestors
+    await this.connectPageTree(path);
+
+    // Return the created parent
+    const createdParent = await Page.findParentByPath(path);
+    if (createdParent == null) {
+      throw Error('Failed to find the created parent by getParentAndFillAncestorsByUser');
+    }
+
+    return createdParent;
+  }
+
+  // --------- Create ---------
+
+  private async preparePageDocumentToCreate(path: string, shouldNew: boolean): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const emptyPage = await Page.findOne({ path, isEmpty: true });
+
+    // Use empty page if exists, if not, create a new page
+    let page;
+    if (shouldNew) {
+      page = new Page();
+    }
+    else if (emptyPage != null) {
+      page = emptyPage;
+      const descendantCount = await Page.recountDescendantCount(page._id);
+
+      page.descendantCount = descendantCount;
+      page.isEmpty = false;
+    }
+    else {
+      page = new Page();
+    }
+
+    return page;
+  }
+
+  private setFieldExceptForGrantRevisionParent(
+      pageDocument: PageDocument,
+      path: string,
+      user?,
+  ): void {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    pageDocument.path = path;
+    pageDocument.creator = user;
+    pageDocument.lastUpdateUser = user;
+    pageDocument.status = Page.STATUS_PUBLISHED;
+  }
+
+  private async canProcessCreate(
+      path: string,
+      grantData: {
+        grant: number,
+        grantedUserIds?: ObjectIdLike[],
+        grantUserGroupId?: ObjectIdLike,
+      },
+      shouldValidateGrant: boolean,
+      user?,
+  ): Promise<boolean> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Operatability validation
+    const canOperate = await this.crowi.pageOperationService.canOperate(false, null, path);
+    if (!canOperate) {
+      logger.error(`Cannot operate create to path "${path}" right now.`);
+      return false;
+    }
+
+    // Existance validation
+    const isExist = (await Page.count({ path, isEmpty: false })) > 0; // not validate empty page
+    if (isExist) {
+      logger.error('Cannot create new page to existed path');
+      return false;
+    }
+
+    // UserGroup & Owner validation
+    const { grant, grantedUserIds, grantUserGroupId } = grantData;
+    if (shouldValidateGrant) {
+      if (user == null) {
+        throw Error('user is required to validate grant');
+      }
+
+      let isGrantNormalized = false;
+      try {
+        // It must check descendants as well if emptyTarget is not null
+        const isEmptyPageAlreadyExist = await Page.count({ path, isEmpty: true }) > 0;
+        const shouldCheckDescendants = isEmptyPageAlreadyExist;
+
+        isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantUserGroupId, shouldCheckDescendants);
+      }
+      catch (err) {
+        logger.error(`Failed to validate grant of page at "${path}" of grant ${grant}:`, err);
+        throw err;
+      }
+      if (!isGrantNormalized) {
+        throw Error('The selected grant or grantedGroup is not assignable to this page.');
+      }
+    }
+
+    return true;
+  }
+
+  async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    // Switch method
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      return Page.createV4(path, body, user, options);
+    }
+
+    // Values
+    // eslint-disable-next-line no-param-reassign
+    path = this.crowi.xss.process(path); // sanitize path
+    const {
+      format = 'markdown', grantUserGroupId,
+    } = options;
+    const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
+    const grantData = {
+      grant,
+      grantedUserIds: grant === Page.GRANT_OWNER ? [user._id] : undefined,
+      grantUserGroupId,
+    };
+
+    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+
+    // Validate
+    const shouldValidateGrant = !isGrantRestricted;
+    const canProcessCreate = await this.canProcessCreate(path, grantData, shouldValidateGrant, user);
+    if (!canProcessCreate) {
+      throw Error('Cannnot process create');
+    }
+
+    // Prepare a page document
+    const shouldNew = isGrantRestricted;
+    const page = await this.preparePageDocumentToCreate(path, shouldNew);
+
+    // Set field
+    this.setFieldExceptForGrantRevisionParent(page, path, user);
+
+    // Apply scope
+    page.applyScope(user, grant, grantUserGroupId);
+
+    // Set parent
+    if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
+      page.parent = null;
+    }
+    else {
+      const parent = await this.getParentAndFillAncestorsByUser(user, path);
+      page.parent = parent._id;
+    }
+
+    // Save
+    let savedPage = await page.save();
+
+    // Create revision
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    savedPage = await pushRevision(savedPage, newRevision, user);
+    await savedPage.populateDataToShowRevision();
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
+    // Emit create event
+    this.pageEvent.emit('create', savedPage, user);
+
+    // Delete PageRedirect if exists
+    const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
+    try {
+      await PageRedirect.deleteOne({ fromPath: path });
+      logger.warn(`Deleted page redirect after creating a new page at path "${path}".`);
+    }
+    catch (err) {
+      // no throw
+      logger.error('Failed to delete PageRedirect');
+    }
+
+    return savedPage;
+  }
+
+  private async canProcessCreateBySystem(
+      path: string,
+      grantData: {
+        grant: number,
+        grantedUserIds?: ObjectIdLike[],
+        grantUserGroupId?: ObjectIdLike,
+      },
+  ): Promise<boolean> {
+    return this.canProcessCreate(path, grantData, false);
+  }
+
+  async createBySystem(path: string, body: string, options: PageCreateOptions & { grantedUsers?: ObjectIdLike[] }): Promise<PageDocument> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
+    if (!isV5Compatible) {
+      throw Error('This method is available only when v5 compatible');
+    }
+
+    // Values
+    // eslint-disable-next-line no-param-reassign
+    path = this.crowi.xss.process(path); // sanitize path
+
+    const {
+      format = 'markdown', grantUserGroupId, grantedUsers,
+    } = options;
+    const grant = isTopPage(path) ? Page.GRANT_PUBLIC : options.grant;
+
+    const isGrantRestricted = grant === Page.GRANT_RESTRICTED;
+    const isGrantOwner = grant === Page.GRANT_OWNER;
+
+    const grantData = {
+      grant,
+      grantedUserIds: isGrantOwner ? grantedUsers : undefined,
+      grantUserGroupId,
+    };
+
+    // Validate
+    if (isGrantOwner && grantedUsers?.length !== 1) {
+      throw Error('grantedUser must exist when grant is GRANT_OWNER');
+    }
+    const canProcessCreateBySystem = await this.canProcessCreateBySystem(path, grantData);
+    if (!canProcessCreateBySystem) {
+      throw Error('Cannnot process createBySystem');
+    }
+
+    // Prepare a page document
+    const shouldNew = !isGrantRestricted;
+    const page = await this.preparePageDocumentToCreate(path, shouldNew);
+
+    // Set field
+    this.setFieldExceptForGrantRevisionParent(page, path);
+
+    // Apply scope
+    page.applyScope({ _id: grantedUsers?.[0] }, grant, grantUserGroupId);
+
+    // Set parent
+    if (isTopPage(path) || isGrantRestricted) { // set parent to null when GRANT_RESTRICTED
+      page.parent = null;
+    }
+    else {
+      const parent = await this.getParentAndFillAncestorsBySystem(path);
+      page.parent = parent._id;
+    }
+
+    // Save
+    let savedPage = await page.save();
+
+    // Create revision
+    const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
+    const dummyUser = { _id: new mongoose.Types.ObjectId() };
+    const newRevision = Revision.prepareRevision(savedPage, body, null, dummyUser, { format });
+    savedPage = await pushRevision(savedPage, newRevision, dummyUser);
+
+    // Update descendantCount
+    await this.updateDescendantCountOfAncestors(savedPage._id, 1, false);
+
+    // Emit create event
+    this.pageEvent.emit('create', savedPage, dummyUser);
+
+    return savedPage;
+  }
+
 }
 
 export default PageService;

+ 1 - 1
packages/app/src/server/service/slack-command-handler/create-page-service.js

@@ -21,7 +21,7 @@ class CreatePageService {
 
     // generate a dummy id because Operation to create a page needs ObjectId
     const dummyObjectIdOfUser = userId != null ? userId : new mongoose.Types.ObjectId();
-    const page = await Page.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
+    const page = await this.crowi.pageService.create(normalizedPath, reshapedContentsBody, dummyObjectIdOfUser, {});
 
     // Send a message when page creation is complete
     const growiUri = this.crowi.appService.getSiteUrl();

+ 1 - 1
packages/app/src/server/util/createGrowiPagesFromImports.js

@@ -27,7 +27,7 @@ module.exports = (crowi) => {
 
       if (isCreatableName && !isPageNameTaken) {
         try {
-          const promise = Page.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
+          const promise = crowi.pageService.create(path, body, user, { grant: Page.GRANT_PUBLIC, grantUserGroupId: null });
           promises.push(promise);
         }
         catch (err) {

+ 2 - 0
packages/app/src/server/views/widget/page_alerts.html

@@ -69,5 +69,7 @@
     {% if isTrashPage(page.path) %}
       <div id="trash-page-alert"></div>
     {% endif %}
+
+    <div id="fix-page-grant-alert"></div>
   </div>
 </div>

+ 1 - 0
packages/app/src/server/views/widget/page_content.html

@@ -25,6 +25,7 @@
   data-page-user="{% if pageUser %}{{ pageUser|json }}{% else %}null{% endif %}"
   data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   data-share-link-id="{% if sharelink %}{{ sharelink._id|json }}{% endif %}"
+  data-has-parent="{{ page.parent != null }}"
   >
 {% else %}
 <div id="content-main" class="content-main d-flex"

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

@@ -156,6 +156,10 @@ export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRRespon
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 
+export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('hasParent', initialData);
+};
+
 export const useIsIndentSizeForced = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isIndentSizeForced', initialData);
 };

+ 25 - 1
packages/app/src/stores/page.tsx

@@ -1,12 +1,13 @@
 import useSWR, { SWRResponse } from 'swr';
-import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRImmutable from 'swr/immutable';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import {
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
+import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import { IPagingResult } from '~/interfaces/paging-result';
 
 import { apiGet } from '../client/util/apiv1-client';
@@ -146,3 +147,26 @@ export const useSWRxPageInfoForList = (
     },
   };
 };
+
+/*
+ * Grant normalization fetching hooks
+ */
+export const useSWRxIsGrantNormalized = (
+    pageId: string | null | undefined,
+): SWRResponse<IResIsGrantNormalized, Error> => {
+
+  return useSWRImmutable(
+    pageId != null ? ['/page/is-grant-normalized', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+  );
+};
+
+export const useSWRxApplicableGrant = (
+    pageId: string | null | undefined,
+): SWRResponse<IRecordApplicableGrant, Error> => {
+
+  return useSWRImmutable(
+    pageId != null ? ['/page/applicable-grant', pageId] : null,
+    (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
+  );
+};

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

@@ -511,13 +511,13 @@ describe('Page', () => {
   describe('create', () => {
 
     test('Should create single page', async() => {
-      const page = await Page.create('/v5_create1', 'create1', dummyUser1, {});
+      const page = await crowi.pageService.create('/v5_create1', 'create1', dummyUser1, {});
       expect(page).toBeTruthy();
       expect(page.parent).toStrictEqual(rootPage._id);
     });
 
     test('Should create empty-child and non-empty grandchild', async() => {
-      const grandchildPage = await Page.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
+      const grandchildPage = await crowi.pageService.create('/v5_empty_create2/v5_create_3', 'grandchild', dummyUser1, {});
       const childPage = await Page.findOne({ path: '/v5_empty_create2' });
 
       expect(childPage.isEmpty).toBe(true);
@@ -531,7 +531,7 @@ describe('Page', () => {
       const beforeCreatePage = await Page.findOne({ path: '/v5_empty_create_4' });
       expect(beforeCreatePage.isEmpty).toBe(true);
 
-      const childPage = await Page.create('/v5_empty_create_4', 'body', dummyUser1, {});
+      const childPage = await crowi.pageService.create('/v5_empty_create_4', 'body', dummyUser1, {});
       const grandchildPage = await Page.findOne({ parent: childPage._id });
 
       expect(childPage).toBeTruthy();
@@ -557,7 +557,7 @@ describe('Page', () => {
         expect(page3).toBeNull();
 
         // use existing path
-        await Page.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+        await crowi.pageService.create(path1, 'new body', dummyUser1, { grant: Page.GRANT_RESTRICTED });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
@@ -582,7 +582,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page2).toBeNull();
 
-        await Page.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
+        await crowi.pageService.create(pathN, 'new body', dummyUser1, { grant: Page.GRANT_PUBLIC });
 
         const _pageT = await Page.findOne({ path: pathT });
         const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_RESTRICTED });
@@ -814,7 +814,7 @@ describe('Page', () => {
   describe('getParentAndFillAncestors', () => {
     test('return parent if exist', async() => {
       const page1 = await Page.findOne({ path: '/PAF1' });
-      const parent = await Page.getParentAndFillAncestors(page1.path, dummyUser1);
+      const parent = await crowi.pageService.getParentAndFillAncestorsByUser(dummyUser1, page1.path);
       expect(parent).toBeTruthy();
       expect(page1.parent).toStrictEqual(parent._id);
     });
@@ -829,7 +829,7 @@ describe('Page', () => {
       expect(_page2).toBeNull();
       expect(_page3).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors(path3, dummyUser1);
+      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 });
@@ -854,7 +854,7 @@ describe('Page', () => {
       expect(_page1).toBeTruthy();
       expect(_page2).toBeTruthy();
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      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 });
 
@@ -877,7 +877,7 @@ describe('Page', () => {
       expect(_page3).toBeTruthy();
       expect(_page3.parent).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      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 });
@@ -920,7 +920,7 @@ describe('Page', () => {
       expect(_emptyA).toBeNull();
       expect(_emptyAB).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_A/get_parent_B/get_parent_C', dummyUser1);
+      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 });
@@ -966,7 +966,7 @@ describe('Page', () => {
       expect(_emptyC).toBeNull();
       expect(_emptyCD).toBeNull();
 
-      const parent = await Page.getParentAndFillAncestors('/get_parent_C/get_parent_D/get_parent_E', dummyUser1);
+      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 });
@@ -985,7 +985,7 @@ describe('Page', () => {
       expect(pageCD.parent).toStrictEqual(pageC._id);
 
       // -- Check the found parent
-      expect(parent).toStrictEqual(pageCD);
+      expect(parent.toObject()).toStrictEqual(pageCD.toObject());
     });
   });
 });