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

Merge pull request #8812 from weseek/feat/select-unrelated-group-inheritance-on-child-pge-create

feat: Select unrelated group inheritance on child page create
Yuki Takei 1 год назад
Родитель
Сommit
8922a65fd7
28 измененных файлов с 493 добавлено и 206 удалено
  1. 6 0
      apps/app/public/static/locales/en_US/translation.json
  2. 6 0
      apps/app/public/static/locales/ja_JP/translation.json
  3. 6 0
      apps/app/public/static/locales/zh_CN/translation.json
  4. 1 2
      apps/app/src/client/services/create-page/index.ts
  5. 0 112
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  6. 138 0
      apps/app/src/client/services/create-page/use-create-page.tsx
  7. 7 6
      apps/app/src/client/services/create-page/use-create-template-page.ts
  8. 9 0
      apps/app/src/client/services/page-operation.ts
  9. 69 0
      apps/app/src/components/GrantedGroupsInheritanceSelectModal.tsx
  10. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  11. 9 6
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  12. 2 2
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  13. 12 9
      apps/app/src/components/PageCreateModal.tsx
  14. 4 4
      apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  15. 7 4
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  16. 9 7
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  17. 27 19
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  18. 7 7
      apps/app/src/interfaces/page-grant.ts
  19. 1 0
      apps/app/src/interfaces/page.ts
  20. 5 5
      apps/app/src/server/models/page.ts
  21. 8 5
      apps/app/src/server/routes/apiv3/page/create-page.ts
  22. 44 4
      apps/app/src/server/routes/apiv3/page/index.ts
  23. 1 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  24. 14 5
      apps/app/src/server/service/page-grant.ts
  25. 8 6
      apps/app/src/server/service/page/index.ts
  26. 32 0
      apps/app/src/stores/modal.tsx
  27. 2 2
      apps/app/src/stores/page.tsx
  28. 57 0
      apps/app/test/integration/service/v5.non-public-page.test.ts

+ 6 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -425,6 +425,12 @@
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",
   "duplicated_pages": "{{fromPath}} has been duplicated",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "Select groups that can access page",
+    "inherit_all_granted_groups_from_parent": "Inherit all groups that can access page from parent",
+    "only_inherit_related_groups": "Only inherit groups that you belong to from parent",
+    "create_page": "Create Page"
+  },
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "Put back page",
       "Put Back Page": "Put back page",

+ 6 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -458,6 +458,12 @@
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} を複製しました",
   "duplicated_pages": "{{fromPath}} を複製しました",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "閲覧権限のあるグループを選択",
+    "inherit_all_granted_groups_from_parent": "閲覧権限のあるグループを親ページから全て引き継ぐ",
+    "only_inherit_related_groups": "自分が所属するグループのみを親ページから引き継ぐ",
+    "create_page": "ページ作成"
+  },
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "ページを元に戻す",
       "Put Back Page": "ページを元に戻す",

+ 6 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -415,6 +415,12 @@
     }
     }
   },
   },
   "duplicated_pages": "{{fromPath}} 已重复",
   "duplicated_pages": "{{fromPath}} 已重复",
+  "modal_granted_groups_inheritance_select": {
+    "select_granted_groups": "Select groups that can access page",
+    "inherit_all_granted_groups_from_parent": "Inherit all groups that can access page from parent",
+    "only_inherit_related_groups": "Only inherit groups that you belong to from parent",
+    "create_page": "Create Page"
+  },
   "modal_putback": {
   "modal_putback": {
     "label": {
     "label": {
       "Put Back Page": "Put back page",
       "Put Back Page": "Put back page",

+ 1 - 2
apps/app/src/client/services/create-page/index.ts

@@ -1,3 +1,2 @@
-export * from './create-page';
-export * from './use-create-page-and-transit';
+export * from './use-create-page';
 export * from './use-create-template-page';
 export * from './use-create-template-page';

+ 0 - 112
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -1,112 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import { useRouter } from 'next/router';
-
-import { exist } from '~/client/services/page-operation';
-import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
-import { useCurrentPagePath } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import { createPage } from './create-page';
-
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
-/**
- * Invoked when creation and transition has finished
- */
-type OnCreated = () => void;
-/**
- * Invoked when either creation or transition has aborted
- */
-type OnAborted = () => void;
-/**
- * Always invoked after processing is terminated
- */
-type OnTerminated = () => void;
-
-type CreatePageAndTransitOpts = {
-  shouldCheckPageExists?: boolean,
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onTerminated?: OnTerminated,
-}
-
-type CreatePageAndTransit = (
-  params: IApiv3PageCreateParams,
-  opts?: CreatePageAndTransitOpts,
-) => Promise<void>;
-
-type UseCreatePageAndTransit = () => {
-  isCreating: boolean,
-  createAndTransit: CreatePageAndTransit,
-};
-
-export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
-
-  const router = useRouter();
-
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const [isCreating, setCreating] = useState(false);
-
-  const createAndTransit: CreatePageAndTransit = useCallback(async(params, opts = {}) => {
-    const {
-      shouldCheckPageExists,
-      onCreationStart, onCreated, onAborted, onTerminated,
-    } = opts;
-
-    // check the page existence
-    if (shouldCheckPageExists && params.path != null) {
-      const pagePath = params.path;
-
-      try {
-        const { isExist } = await exist(pagePath);
-
-        if (isExist) {
-          // routing
-          if (pagePath !== currentPagePath) {
-            await router.push(`${pagePath}#edit`);
-          }
-          mutateEditorMode(EditorMode.Editor);
-          onAborted?.();
-          return;
-        }
-      }
-      catch (err) {
-        throw err;
-      }
-      finally {
-        onTerminated?.();
-      }
-    }
-
-    // create and transit
-    try {
-      setCreating(true);
-      onCreationStart?.();
-
-      const response = await createPage(params);
-
-      await router.push(`/${response.page._id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-
-      onCreated?.();
-    }
-    catch (err) {
-      throw err;
-    }
-    finally {
-      onTerminated?.();
-      setCreating(false);
-    }
-
-  }, [currentPagePath, mutateEditorMode, router]);
-
-  return {
-    isCreating,
-    createAndTransit,
-  };
-};

+ 138 - 0
apps/app/src/client/services/create-page/use-create-page.tsx

@@ -0,0 +1,138 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+import { useTranslation } from 'react-i18next';
+
+import { exist, getIsNonUserRelatedGroupsGranted } from '~/client/services/page-operation';
+import { toastWarning } from '~/client/util/toastr';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
+import { createPage } from './create-page';
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+export type CreatePageOpts = {
+  skipPageExistenceCheck?: boolean,
+  skipTransition?: boolean,
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePage = (
+  params: IApiv3PageCreateParams,
+  opts?: CreatePageOpts,
+) => Promise<void>;
+
+type UseCreatePage = () => {
+  isCreating: boolean,
+  create: CreatePage,
+};
+
+export const useCreatePage: UseCreatePage = () => {
+
+  const router = useRouter();
+  const { t } = useTranslation();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { mutate: mutateEditorMode } = useEditorMode();
+  const { open: openGrantedGroupsInheritanceSelectModal, close: closeGrantedGroupsInheritanceSelectModal } = useGrantedGroupsInheritanceSelectModal();
+
+  const [isCreating, setCreating] = useState(false);
+
+  const create: CreatePage = useCallback(async(params, opts = {}) => {
+    const {
+      onCreationStart, onCreated, onAborted, onTerminated,
+    } = opts;
+    const skipPageExistenceCheck = opts.skipPageExistenceCheck ?? false;
+    const skipTransition = opts.skipTransition ?? false;
+
+    // check the page existence
+    if (!skipPageExistenceCheck && params.path != null) {
+      const pagePath = params.path;
+
+      try {
+        const { isExist } = await exist(pagePath);
+
+        if (isExist) {
+          if (!skipTransition) {
+            // routing
+            if (pagePath !== currentPagePath) {
+              await router.push(`${pagePath}#edit`);
+            }
+            mutateEditorMode(EditorMode.Editor);
+          }
+          else {
+            toastWarning(t('duplicated_page_alert.same_page_name_exists', { pageName: pagePath }));
+          }
+          onAborted?.();
+          return;
+        }
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+      }
+    }
+
+    const _create = async(onlyInheritUserRelatedGrantedGroups?: boolean) => {
+      try {
+        setCreating(true);
+        onCreationStart?.();
+
+        params.onlyInheritUserRelatedGrantedGroups = onlyInheritUserRelatedGrantedGroups;
+        const response = await createPage(params);
+
+        closeGrantedGroupsInheritanceSelectModal();
+
+        if (!skipTransition) {
+          await router.push(`/${response.page._id}#edit`);
+          mutateEditorMode(EditorMode.Editor);
+        }
+
+        onCreated?.();
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+        setCreating(false);
+      }
+    };
+
+    // If parent page is granted to non-user-related groups, let the user select whether or not to inherit them.
+    if (params.parentPath != null) {
+      const { isNonUserRelatedGroupsGranted } = await getIsNonUserRelatedGroupsGranted(params.parentPath);
+      if (isNonUserRelatedGroupsGranted) {
+        // create and transit request will be made from modal
+        openGrantedGroupsInheritanceSelectModal(_create);
+        return;
+      }
+    }
+
+    await _create();
+  }, [currentPagePath, mutateEditorMode, router, openGrantedGroupsInheritanceSelectModal, closeGrantedGroupsInheritanceSelectModal, t]);
+
+  return {
+    isCreating,
+    create,
+  };
+};

+ 7 - 6
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -8,7 +8,7 @@ import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 
 
 
 
-import { useCreatePageAndTransit } from './use-create-page-and-transit';
+import { useCreatePage } from './use-create-page';
 
 
 type UseCreateTemplatePage = () => {
 type UseCreateTemplatePage = () => {
   isCreatable: boolean,
   isCreatable: boolean,
@@ -20,17 +20,18 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
 
 
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
 
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
   const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
   const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
 
 
   const createTemplate = useCallback(async(label: LabelType) => {
   const createTemplate = useCallback(async(label: LabelType) => {
     if (isLoadingPagePath || !isCreatable) return;
     if (isLoadingPagePath || !isCreatable) return;
 
 
-    return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`), wip: false, origin: Origin.View },
-      { shouldCheckPageExists: true },
+    return create(
+      {
+        path: normalizePath(`${currentPagePath}/${label}`), parentPath: currentPagePath, wip: false, origin: Origin.View,
+      },
     );
     );
-  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+  }, [currentPagePath, isCreatable, isLoadingPagePath, create]);
 
 
   return {
   return {
     isCreatable,
     isCreatable,

+ 9 - 0
apps/app/src/client/services/page-operation.ts

@@ -156,6 +156,15 @@ export const exist = async(path: string): Promise<PageExistResponse> => {
   return res.data;
   return res.data;
 };
 };
 
 
+interface NonUserRelatedGroupsGrantedResponse {
+  isNonUserRelatedGroupsGranted: boolean,
+}
+
+export const getIsNonUserRelatedGroupsGranted = async(path: string): Promise<NonUserRelatedGroupsGrantedResponse> => {
+  const res = await apiv3Get<NonUserRelatedGroupsGrantedResponse>('/page/non-user-related-groups-granted', { path });
+  return res.data;
+};
+
 export const publish = async(pageId: string): Promise<IPageHasId> => {
 export const publish = async(pageId: string): Promise<IPageHasId> => {
   const res = await apiv3Put(`/page/${pageId}/publish`);
   const res = await apiv3Put(`/page/${pageId}/publish`);
   return res.data;
   return res.data;

+ 69 - 0
apps/app/src/components/GrantedGroupsInheritanceSelectModal.tsx

@@ -0,0 +1,69 @@
+import { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+
+const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: modalData, close: closeModal } = useGrantedGroupsInheritanceSelectModal();
+  const [onlyInheritUserRelatedGrantedGroups, setOnlyInheritUserRelatedGrantedGroups] = useState(false);
+
+  const onCreateBtnClick = async() => {
+    await modalData?.onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
+    setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
+  };
+  const isOpened = modalData?.isOpened ?? false;
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={() => closeModal()}
+    >
+      <ModalHeader tag="h4" toggle={() => closeModal()}>
+        {t('modal_granted_groups_inheritance_select.select_granted_groups')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="px-3 pt-3">
+          <div className="form-check radio-primary mb-3">
+            <input
+              type="radio"
+              id="inheritAllGroupsRadio"
+              className="form-check-input"
+              form="formImageType"
+              checked={!onlyInheritUserRelatedGrantedGroups}
+              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(false) }}
+            />
+            <label className="form-check-label" htmlFor="inheritAllGroupsRadio">
+              {t('modal_granted_groups_inheritance_select.inherit_all_granted_groups_from_parent')}
+            </label>
+          </div>
+          <div className="form-check radio-primary">
+            <input
+              type="radio"
+              id="onlyInheritRelatedGroupsRadio"
+              className="form-check-input"
+              form="formImageType"
+              checked={onlyInheritUserRelatedGrantedGroups}
+              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(true) }}
+            />
+            <label className="form-check-label" htmlFor="onlyInheritRelatedGroupsRadio">
+              {t('modal_granted_groups_inheritance_select.only_inherit_related_groups')}
+            </label>
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter className="grw-modal-footer">
+        <button type="button" className="me-2 btn btn-secondary" onClick={() => closeModal()}>{t('Cancel')}</button>
+        <button className="btn btn-primary" type="button" onClick={onCreateBtnClick}>
+          {t('modal_granted_groups_inheritance_select.create_page')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+export default GrantedGroupsInheritanceSelectModal;

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -29,6 +29,7 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
+const GrantedGroupsInheritanceSelectModal = dynamic(() => import('../GrantedGroupsInheritanceSelectModal'), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 
 
@@ -72,6 +73,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
       <HotkeysManager />
       <HotkeysManager />
 
 
       <ShortcutsModal />
       <ShortcutsModal />
+      <GrantedGroupsInheritanceSelectModal />
       <SystemVersion showShortcutsButton />
       <SystemVersion showShortcutsButton />
     </RawLayout>
     </RawLayout>
   );
   );

+ 9 - 6
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,9 +1,10 @@
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
@@ -67,7 +68,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { data: currentPageYjsData } = useCurrentPageYjsData();
   const { data: currentPageYjsData } = useCurrentPageYjsData();
 
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
 
   const editButtonClickedHandler = useCallback(async() => {
   const editButtonClickedHandler = useCallback(async() => {
     if (isNotFound == null || isNotFound === false) {
     if (isNotFound == null || isNotFound === false) {
@@ -76,15 +77,17 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     }
     }
 
 
     try {
     try {
-      await createAndTransit(
-        { path, wip: shouldCreateWipPage(path), origin: Origin.View },
-        { shouldCheckPageExists: true },
+      const parentPath = path != null ? normalizePath(path.split('/').slice(0, -1).join('/')) : undefined; // does not have to exist
+      await create(
+        {
+          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
+        },
       );
       );
     }
     }
     catch (err) {
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
       toastError(t('toaster.create_failed', { target: path }));
     }
     }
-  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
+  }, [create, isNotFound, mutateEditorMode, path, t]);
 
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
 

+ 2 - 2
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
 import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
-import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 
 
@@ -17,7 +17,7 @@ type ModalProps = {
   isOpen: boolean
   isOpen: boolean
   pageId: string
   pageId: string
   dataApplicableGrant: IRecordApplicableGrant
   dataApplicableGrant: IRecordApplicableGrant
-  currentAndParentPageGrantData: IResIsGrantNormalizedGrantData
+  currentAndParentPageGrantData: IResGrantData
   close(): void
   close(): void
 }
 }
 
 

+ 12 - 9
apps/app/src/components/PageCreateModal.tsx

@@ -12,7 +12,7 @@ import {
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
 import { useCreateTemplatePage } from '~/client/services/create-page';
-import { useCreatePageAndTransit } from '~/client/services/create-page/use-create-page-and-transit';
+import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
@@ -36,7 +36,7 @@ const PageCreateModal: React.FC = () => {
   const path = pageCreateModalData?.path;
   const path = pageCreateModalData?.path;
   const isOpened = pageCreateModalData?.isOpened ?? false;
   const isOpened = pageCreateModalData?.isOpened ?? false;
 
 
-  const { createAndTransit } = useCreatePageAndTransit();
+  const { create } = useCreatePage();
   const { createTemplate } = useCreateTemplatePage();
   const { createTemplate } = useCreateTemplatePage();
 
 
   const { data: isReachable } = useIsSearchServiceReachable();
   const { data: isReachable } = useIsSearchServiceReachable();
@@ -94,25 +94,28 @@ const PageCreateModal: React.FC = () => {
    */
    */
   const createTodayPage = useCallback(async() => {
   const createTodayPage = useCallback(async() => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
     const joinedPath = [todaysParentPath, todayInput].join('/');
-    return createAndTransit(
-      { path: joinedPath, wip: true, origin: Origin.View },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+    return create(
+      {
+        path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
+      },
+      { onTerminated: closeCreateModal },
     );
     );
-  }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
+  }, [closeCreateModal, create, todayInput, todaysParentPath]);
 
 
   /**
   /**
    * access input page
    * access input page
    */
    */
   const createInputPage = useCallback(async() => {
   const createInputPage = useCallback(async() => {
-    return createAndTransit(
+    return create(
       {
       {
         path: pageNameInput,
         path: pageNameInput,
+        parentPath: pathname,
         wip: true,
         wip: true,
         origin: Origin.View,
         origin: Origin.View,
       },
       },
-      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+      { onTerminated: closeCreateModal },
     );
     );
-  }, [closeCreateModal, createAndTransit, pageNameInput]);
+  }, [closeCreateModal, create, pageNameInput, pathname]);
 
 
   /**
   /**
    * access template page
    * access template page

+ 4 - 4
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -3,16 +3,16 @@ import { useCallback } from 'react';
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 
 
 export const SidebarNotFound = (): JSX.Element => {
 export const SidebarNotFound = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { createAndTransit } = useCreatePageAndTransit();
+  const { create } = useCreatePage();
 
 
   const clickCreateButtonHandler = useCallback(async() => {
   const clickCreateButtonHandler = useCallback(async() => {
-    createAndTransit({ path: '/Sidebar', wip: false, origin: Origin.View });
-  }, [createAndTransit]);
+    create({ path: '/Sidebar', wip: false, origin: Origin.View }, { skipPageExistenceCheck: true });
+  }, [create]);
 
 
   return (
   return (
     <div>
     <div>

+ 7 - 4
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
 
 
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 
 
 
 
@@ -14,20 +14,23 @@ type UseCreateNewPage = () => {
 export const useCreateNewPage: UseCreateNewPage = () => {
 export const useCreateNewPage: UseCreateNewPage = () => {
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
   const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
 
 
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
 
   const createNewPage = useCallback(async() => {
   const createNewPage = useCallback(async() => {
     if (isLoadingPagePath) return;
     if (isLoadingPagePath) return;
 
 
-    return createAndTransit(
+    return create(
       {
       {
         parentPath: currentPagePath,
         parentPath: currentPagePath,
         optionalParentPath: '/',
         optionalParentPath: '/',
         wip: true,
         wip: true,
         origin: Origin.View,
         origin: Origin.View,
       },
       },
+      {
+        skipPageExistenceCheck: true,
+      },
     );
     );
-  }, [createAndTransit, currentPagePath, isLoadingPagePath]);
+  }, [create, currentPagePath, isLoadingPagePath]);
 
 
   return {
   return {
     isCreating,
     isCreating,

+ 9 - 7
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -5,7 +5,7 @@ import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { format } from 'date-fns/format';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
 
 
@@ -19,24 +19,26 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
-  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const { isCreating, create } = useCreatePage();
 
 
   const isCreatable = currentUser != null;
   const isCreatable = currentUser != null;
 
 
   const parentDirName = t('create_page_dropdown.todays.memo');
   const parentDirName = t('create_page_dropdown.todays.memo');
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
+  const parentPath = `${userHomepagePath(currentUser)}/${parentDirName}`;
   const todaysPath = isCreatable
   const todaysPath = isCreatable
-    ? `${userHomepagePath(currentUser)}/${parentDirName}/${now}`
+    ? `${parentPath}/${now}`
     : null;
     : null;
 
 
   const createTodaysMemo = useCallback(async() => {
   const createTodaysMemo = useCallback(async() => {
     if (!isCreatable || todaysPath == null) return;
     if (!isCreatable || todaysPath == null) return;
 
 
-    return createAndTransit(
-      { path: todaysPath, wip: true, origin: Origin.View },
-      { shouldCheckPageExists: true },
+    return create(
+      {
+        path: todaysPath, parentPath, wip: true, origin: Origin.View,
+      },
     );
     );
-  }, [createAndTransit, isCreatable, todaysPath]);
+  }, [create, isCreatable, todaysPath, parentPath]);
 
 
   return {
   return {
     isCreating,
     isCreating,

+ 27 - 19
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -11,7 +11,7 @@ import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { createPage } from '~/client/services/create-page';
+import { useCreatePage } from '~/client/services/create-page';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
 import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
@@ -60,6 +60,7 @@ export const useNewPageInput = (): UseNewPageInput => {
   const Input: FC<TreeItemToolProps> = (props) => {
   const Input: FC<TreeItemToolProps> = (props) => {
 
 
     const { t } = useTranslation();
     const { t } = useTranslation();
+    const { create: createPage } = useCreatePage();
 
 
     const { itemNode, stateHandlers, isEnableActions } = props;
     const { itemNode, stateHandlers, isEnableActions } = props;
     const { page, children } = itemNode;
     const { page, children } = itemNode;
@@ -107,23 +108,30 @@ export const useNewPageInput = (): UseNewPageInput => {
       setShowInput(false);
       setShowInput(false);
 
 
       try {
       try {
-        await createPage({
-          path: newPagePath,
-          body: undefined,
-          // keep grant info undefined to inherit from parent
-          grant: undefined,
-          grantUserGroupIds: undefined,
-          origin: Origin.View,
-          wip: shouldCreateWipPage(newPagePath),
-        });
-
-        mutatePageTree();
-
-        if (!hasDescendants) {
-          stateHandlers?.setIsOpen(true);
-        }
-
-        toastSuccess(t('successfully_saved_the_page'));
+        await createPage(
+          {
+            path: newPagePath,
+            parentPath,
+            body: undefined,
+            // keep grant info undefined to inherit from parent
+            grant: undefined,
+            grantUserGroupIds: undefined,
+            origin: Origin.View,
+            wip: shouldCreateWipPage(newPagePath),
+          },
+          {
+            skipTransition: true,
+            onCreated: () => {
+              mutatePageTree();
+
+              if (!hasDescendants) {
+                stateHandlers?.setIsOpen(true);
+              }
+
+              toastSuccess(t('successfully_saved_the_page'));
+            },
+          },
+        );
       }
       }
       catch (err) {
       catch (err) {
         toastError(err);
         toastError(err);
@@ -131,7 +139,7 @@ export const useNewPageInput = (): UseNewPageInput => {
       finally {
       finally {
         setProcessingSubmission(false);
         setProcessingSubmission(false);
       }
       }
-    }, [cancel, hasDescendants, page.path, stateHandlers, t]);
+    }, [cancel, hasDescendants, page.path, stateHandlers, t, createPage]);
 
 
     const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
     const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
     const isInvalid = validationResult != null;
     const isInvalid = validationResult != null;

+ 7 - 7
apps/app/src/interfaces/page-grant.ts

@@ -1,9 +1,9 @@
-import { PageGrant, GroupType } from '@growi/core';
+import type { PageGrant, GroupType } from '@growi/core';
 
 
-import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
-import { UserGroupDocument } from '~/server/models/user-group';
+import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
+import type { UserGroupDocument } from '~/server/models/user-group';
 
 
-import { IPageGrantData } from './page';
+import type { IPageGrantData } from './page';
 
 
 
 
 type UserGroupType = typeof GroupType.userGroup;
 type UserGroupType = typeof GroupType.userGroup;
@@ -18,12 +18,12 @@ export type IRecordApplicableGrant = Partial<Record<PageGrant, IDataApplicableGr
 export type IResApplicableGrant = {
 export type IResApplicableGrant = {
   data?: IRecordApplicableGrant
   data?: IRecordApplicableGrant
 }
 }
-export type IResIsGrantNormalizedGrantData = {
+export type IResGrantData = {
   isForbidden: boolean,
   isForbidden: boolean,
   currentPageGrant: IPageGrantData,
   currentPageGrant: IPageGrantData,
   parentPageGrant?: IPageGrantData
   parentPageGrant?: IPageGrantData
 }
 }
-export type IResIsGrantNormalized = {
+export type IResCurrentGrantData = {
   isGrantNormalized: boolean,
   isGrantNormalized: boolean,
-  grantData: IResIsGrantNormalizedGrantData
+  grantData: IResGrantData
 };
 };

+ 1 - 0
apps/app/src/interfaces/page.ts

@@ -70,6 +70,7 @@ export type IOptionsForUpdate = {
 export type IOptionsForCreate = {
 export type IOptionsForCreate = {
   grant?: PageGrant,
   grant?: PageGrant,
   grantUserGroupIds?: IGrantedGroup[],
   grantUserGroupIds?: IGrantedGroup[],
+  onlyInheritUserRelatedGrantedGroups?: boolean,
   overwriteScopesOfDescendants?: boolean,
   overwriteScopesOfDescendants?: boolean,
 
 
   origin?: Origin
   origin?: Origin

+ 5 - 5
apps/app/src/server/models/page.ts

@@ -77,7 +77,7 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
   generateGrantCondition(
     user, userGroups: string[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
     user, userGroups: string[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   ): { $or: any[] }
-  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | undefined>
+  findNonEmptyClosestAncestor(path: string): Promise<PageDocument | null>
   findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
   findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
   findTemplate(path: string): Promise<{
   findTemplate(path: string): Promise<{
@@ -1027,10 +1027,10 @@ function generateGrantConditionForSystemDeletion(): { $or: any[] } {
 
 
 schema.statics.generateGrantConditionForSystemDeletion = generateGrantConditionForSystemDeletion;
 schema.statics.generateGrantConditionForSystemDeletion = generateGrantConditionForSystemDeletion;
 
 
-// find ancestor page with isEmpty: false. If parameter path is '/', return undefined
-schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | undefined> {
+// find ancestor page with isEmpty: false. If parameter path is '/', return null
+schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | null> {
   if (path === '/') {
   if (path === '/') {
-    return;
+    return null;
   }
   }
 
 
   const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
   const builderForAncestors = new PageQueryBuilder(this.find(), false); // empty page not included
@@ -1041,7 +1041,7 @@ schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promi
     .query
     .query
     .exec();
     .exec();
 
 
-  return ancestors[0];
+  return ancestors[0] ?? null;
 };
 };
 
 
 schema.statics.removeGroupsToDeleteFromPages = async function(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]) {
 schema.statics.removeGroupsToDeleteFromPages = async function(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]) {

+ 8 - 5
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -107,14 +107,15 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
   // define validators for req.body
   // define validators for req.body
   const validator: ValidationChain[] = [
   const validator: ValidationChain[] = [
     body('path').optional().not().isEmpty({ ignore_whitespace: true })
     body('path').optional().not().isEmpty({ ignore_whitespace: true })
-      .withMessage("The empty value is not allowd for the 'path'"),
+      .withMessage("Empty value is not allowed for 'path'"),
     body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
     body('parentPath').optional().not().isEmpty({ ignore_whitespace: true })
-      .withMessage("The empty value is not allowd for the 'parentPath'"),
+      .withMessage("Empty value is not allowed for 'parentPath'"),
     body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
     body('optionalParentPath').optional().not().isEmpty({ ignore_whitespace: true })
-      .withMessage("The empty value is not allowd for the 'optionalParentPath'"),
+      .withMessage("Empty value is not allowed for 'optionalParentPath'"),
     body('body').optional().isString()
     body('body').optional().isString()
       .withMessage('body must be string or undefined'),
       .withMessage('body must be string or undefined'),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
+    body('onlyInheritUserRelatedGrantedGroups').optional().isBoolean().withMessage('onlyInheritUserRelatedGrantedGroups must be boolean'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('pageTags').optional().isArray().withMessage('pageTags must be array'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
@@ -231,10 +232,12 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
       let createdPage;
       let createdPage;
       try {
       try {
         const {
         const {
-          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip, origin,
+          grant, grantUserGroupIds, onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
         } = req.body;
         } = req.body;
 
 
-        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip, origin };
+        const options: IOptionsForCreate = {
+          onlyInheritUserRelatedGrantedGroups, overwriteScopesOfDescendants, wip, origin,
+        };
         if (grant != null) {
         if (grant != null) {
           options.grant = grant;
           options.grant = grant;
           options.grantUserGroupIds = grantUserGroupIds;
           options.grantUserGroupIds = grantUserGroupIds;

+ 44 - 4
apps/app/src/server/routes/apiv3/page/index.ts

@@ -2,10 +2,11 @@ import path from 'path';
 
 
 import type { IPage } from '@growi/core';
 import type { IPage } from '@growi/core';
 import {
 import {
-  AllSubscriptionStatusType, SubscriptionStatusType,
+  AllSubscriptionStatusType, PageGrant, SubscriptionStatusType,
 } from '@growi/core';
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
 
 
@@ -22,6 +23,8 @@ import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
 import { createPageHandlersFactory } from './create-page';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
@@ -184,7 +187,7 @@ module.exports = (crowi) => {
   const addActivity = generateAddActivityMiddleware(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const { Page } = crowi.models;
+  const Page = mongoose.model<IPage, PageModel>('Page');
   const { pageService, exportService } = crowi;
   const { pageService, exportService } = crowi;
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
@@ -202,9 +205,12 @@ module.exports = (crowi) => {
     info: [
     info: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
-    isGrantNormalized: [
+    getGrantData: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
+    nonUserRelatedGroupsGranted: [
+      query('path').isString(),
+    ],
     applicableGrant: [
     applicableGrant: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
     ],
@@ -567,7 +573,7 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.get('/grant-data', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+  router.get('/grant-data', loginRequiredStrictly, validator.getGrantData, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
     const { pageId } = req.query;
 
 
     const Page = mongoose.model<IPage, PageModel>('Page');
     const Page = mongoose.model<IPage, PageModel>('Page');
@@ -635,6 +641,40 @@ module.exports = (crowi) => {
     return res.apiv3({ isGrantNormalized, grantData });
     return res.apiv3({ isGrantNormalized, grantData });
   });
   });
 
 
+  // Check if non user related groups are granted page access.
+  // If specified page does not exist, check the closest ancestor.
+  router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
+    async(req, res: ApiV3Response) => {
+      const { user } = req;
+      const path = normalizePath(req.query.path);
+      const pageGrantService = crowi.pageGrantService as IPageGrantService;
+      try {
+        const page = await Page.findByPath(path, true) ?? await Page.findNonEmptyClosestAncestor(path);
+        if (page == null) {
+          // 'page' should always be non empty, since every page stems back to root page.
+          // If it is empty, there is a problem with the server logic.
+          return res.apiv3Err(new ErrorV3('No page on the page tree could be retrived.', 'page_could_not_be_retrieved'), 500);
+        }
+
+        const userRelatedGroups = await pageGrantService.getUserRelatedGroups(user);
+        const isUserGrantedPageAccess = await pageGrantService.isUserGrantedPageAccess(page, user, userRelatedGroups);
+        if (!isUserGrantedPageAccess) {
+          return res.apiv3Err(new ErrorV3('Cannot access page or ancestor.', 'cannot_access_page'), 403);
+        }
+
+        if (page.grant !== PageGrant.GRANT_USER_GROUP) {
+          return res.apiv3({ isNonUserRelatedGroupsGranted: false });
+        }
+
+        const nonUserRelatedGrantedGroups = await pageGrantService.getNonUserRelatedGrantedGroups(page, user);
+        return res.apiv3({ isNonUserRelatedGroupsGranted: nonUserRelatedGrantedGroups.length > 0 });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
     const { pageId } = req.query;
 
 

+ 1 - 1
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -69,7 +69,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       .isEmpty({ ignore_whitespace: true })
       .isEmpty({ ignore_whitespace: true })
       .withMessage("'revisionId' must be specified"),
       .withMessage("'revisionId' must be specified"),
     body('body').exists().isString()
     body('body').exists().isString()
-      .withMessage("The empty value is not allowd for the 'body'"),
+      .withMessage("Empty value is not allowed for 'body'"),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
     body('grant').optional().isInt({ min: 0, max: 5 }).withMessage('grant must be integer from 1 to 5'),
     body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
     body('userRelatedGrantUserGroupIds').optional().isArray().withMessage('userRelatedGrantUserGroupIds must be an array of group id'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),

+ 14 - 5
apps/app/src/server/service/page-grant.ts

@@ -1,7 +1,7 @@
 import type { IPage } from '@growi/core';
 import type { IPage } from '@growi/core';
 import {
 import {
   type IGrantedGroup,
   type IGrantedGroup,
-  PageGrant, GroupType, getIdForRef, isPopulated,
+  PageGrant, GroupType, getIdForRef,
 } from '@growi/core';
 } from '@growi/core';
 import {
 import {
   pagePathUtils, pathUtils, pageUtils,
   pagePathUtils, pathUtils, pageUtils,
@@ -105,6 +105,7 @@ export interface IPageGrantService {
   getPopulatedGrantedGroups: (grantedGroups: IGrantedGroup[]) => Promise<PopulatedGrantedGroup[]>,
   getPopulatedGrantedGroups: (grantedGroups: IGrantedGroup[]) => Promise<PopulatedGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
+  getNonUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
   isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
   getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
   getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
   calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
   calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
@@ -770,10 +771,18 @@ class PageGrantService implements IPageGrantService {
   getUserRelatedGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument): IGrantedGroup[] {
   getUserRelatedGrantedGroupsSyncronously(userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument): IGrantedGroup[] {
     const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
     const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
     return page.grantedGroups?.filter((group) => {
     return page.grantedGroups?.filter((group) => {
-      if (isPopulated(group.item)) {
-        return userRelatedGroupIds.includes(group.item._id.toString());
-      }
-      return userRelatedGroupIds.includes(group.item);
+      return userRelatedGroupIds.includes(getIdForRef(group.item).toString());
+    }) || [];
+  }
+
+  /*
+   * get all groups of Page that user is not related to
+   */
+  async getNonUserRelatedGrantedGroups(page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const userRelatedGroups = (await this.getUserRelatedGroups(user));
+    const userRelatedGroupIds: string[] = userRelatedGroups.map(ug => ug.item._id.toString());
+    return page.grantedGroups?.filter((group) => {
+      return !userRelatedGroupIds.includes(getIdForRef(group.item).toString());
     }) || [];
     }) || [];
   }
   }
 
 

+ 8 - 6
apps/app/src/server/service/page/index.ts

@@ -3786,12 +3786,14 @@ class PageService implements IPageService {
     // Determine grantData
     // Determine grantData
     const grant = options.grant ?? closestAncestor?.grant ?? PageGrant.GRANT_PUBLIC;
     const grant = options.grant ?? closestAncestor?.grant ?? PageGrant.GRANT_PUBLIC;
     const grantUserIds = grant === PageGrant.GRANT_OWNER ? [user._id] : undefined;
     const grantUserIds = grant === PageGrant.GRANT_OWNER ? [user._id] : undefined;
-    const grantUserGroupIds = options.grantUserGroupIds
-      ?? (
-        closestAncestor != null
-          ? await this.pageGrantService.getUserRelatedGrantedGroups(closestAncestor, user)
-          : undefined
-      );
+    const getGrantedGroupsFromClosestAncestor = async() => {
+      if (closestAncestor == null) return undefined;
+      if (options.onlyInheritUserRelatedGrantedGroups) {
+        return this.pageGrantService.getUserRelatedGrantedGroups(closestAncestor, user);
+      }
+      return closestAncestor.grantedGroups;
+    };
+    const grantUserGroupIds = options.grantUserGroupIds ?? await getGrantedGroupsFromClosestAncestor();
     const grantData = {
     const grantData = {
       grant,
       grant,
       grantUserIds,
       grantUserIds,

+ 32 - 0
apps/app/src/stores/modal.tsx

@@ -41,6 +41,38 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
   };
 };
 };
 
 
+/*
+* GrantedGroupsInheritanceSelectModal
+*/
+type GrantedGroupsInheritanceSelectModalStatus = {
+  isOpened: boolean,
+  onCreateBtnClick?: (onlyInheritUserRelatedGrantedGroups?: boolean) => Promise<void>,
+}
+
+type GrantedGroupsInheritanceSelectModalStatusUtils = {
+  open(onCreateBtnClick?: (onlyInheritUserRelatedGrantedGroups?: boolean) => Promise<void>): Promise<GrantedGroupsInheritanceSelectModalStatus | undefined>
+  close(): Promise<GrantedGroupsInheritanceSelectModalStatus | undefined>
+}
+
+export const useGrantedGroupsInheritanceSelectModal = (
+    status?: GrantedGroupsInheritanceSelectModalStatus,
+): SWRResponse<GrantedGroupsInheritanceSelectModalStatus, Error> & GrantedGroupsInheritanceSelectModalStatusUtils => {
+  const initialData: GrantedGroupsInheritanceSelectModalStatus = { isOpened: false };
+  const swrResponse = useSWRStatic<GrantedGroupsInheritanceSelectModalStatus, Error>(
+    'grantedGroupsInheritanceSelectModalStatus', status, { fallbackData: initialData },
+  );
+
+  const { mutate } = swrResponse;
+
+  return {
+    ...swrResponse,
+    open: useCallback(
+      (onCreateBtnClick?: (onlyInheritUserRelatedGrantedGroups?: boolean) => Promise<void>) => mutate({ isOpened: true, onCreateBtnClick }), [mutate],
+    ),
+    close: useCallback(() => mutate({ isOpened: false }), [mutate]),
+  };
+};
+
 /*
 /*
 * PageDeleteModal
 * PageDeleteModal
 */
 */

+ 2 - 2
apps/app/src/stores/page.tsx

@@ -18,7 +18,7 @@ import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import type { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
+import type { IRecordApplicableGrant, IResCurrentGrantData } from '~/interfaces/page-grant';
 import type { AxiosResponse } from '~/utils/axios';
 import type { AxiosResponse } from '~/utils/axios';
 
 
 import type { IPageTagsInfo } from '../interfaces/tag';
 import type { IPageTagsInfo } from '../interfaces/tag';
@@ -270,7 +270,7 @@ export const useSWRxInfinitePageRevisions = (
  */
  */
 export const useSWRxCurrentGrantData = (
 export const useSWRxCurrentGrantData = (
     pageId: string | null | undefined,
     pageId: string | null | undefined,
-): SWRResponse<IResIsGrantNormalized, Error> => {
+): SWRResponse<IResCurrentGrantData, Error> => {
 
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();

+ 57 - 0
apps/app/test/integration/service/v5.non-public-page.test.ts

@@ -358,6 +358,19 @@ describe('PageService page operations with non-public pages', () => {
         parent: rootPage._id,
         parent: rootPage._id,
         descendantCount: 0,
         descendantCount: 0,
       },
       },
+      {
+        path: '/mc6_top',
+        grant: Page.GRANT_USER_GROUP,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+        grantedGroups: [
+          { item: groupIdIsolate, type: GroupType.userGroup },
+          { item: groupIdB, type: GroupType.userGroup },
+        ],
+      },
     ]);
     ]);
 
 
     /**
     /**
@@ -958,6 +971,50 @@ describe('PageService page operations with non-public pages', () => {
         expect(isGrantNormalizedSpy).toBeCalledTimes(1);
         expect(isGrantNormalizedSpy).toBeCalledTimes(1);
       });
       });
     });
     });
+    describe('Creating a page under a page with grant USER_GROUP', () => {
+      describe('When onlyInheritUserRelatedGrantedGroups is true', () => {
+        test('Only user related groups should be inherited', async() => {
+          const pathT = '/mc6_top';
+          const pageT = await Page.findOne({ path: pathT });
+          expect(pageT).toBeTruthy();
+
+          const pathN = '/mc6_top/onlyRelatedGroupsInherited'; // path to create
+          await create(pathN, 'new body', npDummyUser1, { grant: Page.GRANT_USER_GROUP, onlyInheritUserRelatedGrantedGroups: true });
+
+          const _pageT = await Page.findOne({ path: pathT });
+          const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_USER_GROUP }); // newly crated
+          expect(_pageT).toBeTruthy();
+          expect(_pageN).toBeTruthy();
+          expect(_pageN.parent).toStrictEqual(_pageT._id);
+          expect(_pageT.descendantCount).toStrictEqual(1);
+          expect(normalizeGrantedGroups(_pageN.grantedGroups)).toStrictEqual([
+            { item: groupIdIsolate, type: GroupType.userGroup },
+          ]);
+        });
+      });
+
+      describe('When onlyInheritUserRelatedGrantedGroups is false', () => {
+        test('All groups should be inherited', async() => {
+          const pathT = '/mc6_top';
+          const pageT = await Page.findOne({ path: pathT });
+          expect(pageT).toBeTruthy();
+
+          const pathN = '/mc6_top/allGroupsInherited'; // path to create
+          await create(pathN, 'new body', npDummyUser1, { grant: Page.GRANT_USER_GROUP, onlyInheritUserRelatedGrantedGroups: false });
+
+          const _pageT = await Page.findOne({ path: pathT });
+          const _pageN = await Page.findOne({ path: pathN, grant: Page.GRANT_USER_GROUP }); // newly crated
+          expect(_pageT).toBeTruthy();
+          expect(_pageN).toBeTruthy();
+          expect(_pageN.parent).toStrictEqual(_pageT._id);
+          expect(_pageT.descendantCount).toStrictEqual(2);
+          expect(normalizeGrantedGroups(_pageN.grantedGroups)).toStrictEqual([
+            { item: groupIdIsolate, type: GroupType.userGroup },
+            { item: groupIdB, type: GroupType.userGroup },
+          ]);
+        });
+      });
+    });
 
 
   });
   });