Browse Source

Merge pull request #8728 from weseek/feat/144301-144437-select-unrelated-group-inheritance-on-normal-child-page-create

Feat/144301 144437 select unrelated group inheritance on normal child page create
Yuki Takei 1 year ago
parent
commit
febee4b1e8

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

@@ -421,6 +421,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

@@ -454,6 +454,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

@@ -411,6 +411,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",

+ 35 - 18
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -2,8 +2,9 @@ import { useCallback, useState } from 'react';
 
 
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import { exist } from '~/client/services/page-operation';
+import { exist, getIsNonUserRelatedGroupsGranted } from '~/client/services/page-operation';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
 import { useCurrentPagePath } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -25,7 +26,7 @@ type OnAborted = () => void;
  */
  */
 type OnTerminated = () => void;
 type OnTerminated = () => void;
 
 
-type CreatePageAndTransitOpts = {
+export type CreatePageAndTransitOpts = {
   shouldCheckPageExists?: boolean,
   shouldCheckPageExists?: boolean,
   onCreationStart?: OnCreated,
   onCreationStart?: OnCreated,
   onCreated?: OnCreated,
   onCreated?: OnCreated,
@@ -49,6 +50,7 @@ export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
 
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { mutate: mutateEditorMode } = useEditorMode();
+  const { open: openGrantedGroupsInheritanceSelectModal, close: closeGrantedGroupsInheritanceSelectModal } = useGrantedGroupsInheritanceSelectModal();
 
 
   const [isCreating, setCreating] = useState(false);
   const [isCreating, setCreating] = useState(false);
 
 
@@ -83,27 +85,42 @@ export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
       }
       }
     }
     }
 
 
-    // create and transit
-    try {
-      setCreating(true);
-      onCreationStart?.();
+    const _createAndTransit = async(onlyInheritUserRelatedGrantedGroups?: boolean) => {
+      try {
+        setCreating(true);
+        onCreationStart?.();
 
 
-      const response = await createPage(params);
+        params.onlyInheritUserRelatedGrantedGroups = onlyInheritUserRelatedGrantedGroups;
+        const response = await createPage(params);
 
 
-      await router.push(`/${response.page._id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
+        closeGrantedGroupsInheritanceSelectModal();
 
 
-      onCreated?.();
-    }
-    catch (err) {
-      throw err;
-    }
-    finally {
-      onTerminated?.();
-      setCreating(false);
+        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(_createAndTransit);
+        return;
+      }
     }
     }
 
 
-  }, [currentPagePath, mutateEditorMode, router]);
+    await _createAndTransit();
+  }, [currentPagePath, mutateEditorMode, router, openGrantedGroupsInheritanceSelectModal, closeGrantedGroupsInheritanceSelectModal]);
 
 
   return {
   return {
     isCreating,
     isCreating,

+ 3 - 1
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -27,7 +27,9 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
     if (isLoadingPagePath || !isCreatable) return;
     if (isLoadingPagePath || !isCreatable) return;
 
 
     return createAndTransit(
     return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`), wip: false, origin: Origin.View },
+      {
+        path: normalizePath(`${currentPagePath}/${label}`), parentPath: currentPagePath, wip: false, origin: Origin.View,
+      },
       { shouldCheckPageExists: true },
       { shouldCheckPageExists: true },
     );
     );
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);

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

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

@@ -0,0 +1,67 @@
+import { useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+
+export 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()} className="text-light">
+        {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>
+  );
+};

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

@@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 
 
 
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { apiv3Get } from '~/client/util/apiv3-client';
 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';
@@ -75,8 +76,12 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     }
     }
 
 
     try {
     try {
+      let parentPath = path?.split('/').slice(0, -1).join('/'); // does not have to exist
+      parentPath = parentPath === '' ? '/' : parentPath;
       await createAndTransit(
       await createAndTransit(
-        { path, wip: shouldCreateWipPage(path), origin: Origin.View },
+        {
+          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
+        },
         { shouldCheckPageExists: true },
         { shouldCheckPageExists: true },
       );
       );
     }
     }

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

+ 6 - 2
apps/app/src/components/PageCreateModal.tsx

@@ -14,6 +14,7 @@ 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 { useCreatePageAndTransit } from '~/client/services/create-page/use-create-page-and-transit';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
+import { apiv3Get } from '~/client/util/apiv3-client';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
 
 
@@ -95,7 +96,9 @@ const PageCreateModal: React.FC = () => {
   const createTodayPage = useCallback(async() => {
   const createTodayPage = useCallback(async() => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
     const joinedPath = [todaysParentPath, todayInput].join('/');
     return createAndTransit(
     return createAndTransit(
-      { path: joinedPath, wip: true, origin: Origin.View },
+      {
+        path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
+      },
       { shouldCheckPageExists: true, onTerminated: closeCreateModal },
       { shouldCheckPageExists: true, onTerminated: closeCreateModal },
     );
     );
   }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
   }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
@@ -107,12 +110,13 @@ const PageCreateModal: React.FC = () => {
     return createAndTransit(
     return createAndTransit(
       {
       {
         path: pageNameInput,
         path: pageNameInput,
+        parentPath: pathname,
         wip: true,
         wip: true,
         origin: Origin.View,
         origin: Origin.View,
       },
       },
       { shouldCheckPageExists: true, onTerminated: closeCreateModal },
       { shouldCheckPageExists: true, onTerminated: closeCreateModal },
     );
     );
-  }, [closeCreateModal, createAndTransit, pageNameInput]);
+  }, [closeCreateModal, createAndTransit, pageNameInput, pathname]);
 
 
   /**
   /**
    * access template page
    * access template page

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

@@ -6,6 +6,7 @@ import { format } from 'date-fns/format';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { useCreatePageAndTransit } from '~/client/services/create-page';
+import { apiv3Get } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
 
 
@@ -25,18 +26,21 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
 
 
   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(
     return createAndTransit(
-      { path: todaysPath, wip: true, origin: Origin.View },
+      {
+        path: todaysPath, parentPath, wip: true, origin: Origin.View,
+      },
       { shouldCheckPageExists: true },
       { shouldCheckPageExists: true },
     );
     );
-  }, [createAndTransit, isCreatable, todaysPath]);
+  }, [createAndTransit, isCreatable, todaysPath, parentPath]);
 
 
   return {
   return {
     isCreating,
     isCreating,

+ 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

+ 2 - 1
apps/app/src/pages/[[...path]].page.tsx

@@ -21,6 +21,7 @@ import { useRouter } from 'next/router';
 import superjson from 'superjson';
 import superjson from 'superjson';
 
 
 import { useEditorModeClassName } from '~/client/services/layout';
 import { useEditorModeClassName } from '~/client/services/layout';
+import { GrantedGroupsInheritanceSelectModal } from '~/components/GrantedGroupsInheritanceSelectModal';
 import { PageView } from '~/components/Page/PageView';
 import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
@@ -47,7 +48,6 @@ import {
 } from '~/stores/page';
 } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
-import { useSelectedGrant } from '~/stores/ui';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -370,6 +370,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <LinkEditModal />
       <LinkEditModal />
       <TagEditModal />
       <TagEditModal />
       <ConflictDiffModal />
       <ConflictDiffModal />
+      <GrantedGroupsInheritanceSelectModal />
     </>
     </>
   );
   );
 };
 };

+ 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<{
@@ -1024,10 +1024,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
@@ -1038,7 +1038,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

@@ -105,14 +105,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'),
@@ -229,10 +230,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;

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

@@ -2,7 +2,7 @@ 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';
@@ -22,6 +22,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 { publishPageHandlersFactory } from './publish-page';
 import { publishPageHandlersFactory } from './publish-page';
@@ -183,7 +185,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');
@@ -201,9 +203,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'),
     ],
     ],
@@ -566,7 +571,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');
@@ -634,6 +639,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 } = req.query;
+      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

@@ -3774,12 +3774,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,

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

@@ -8,6 +8,8 @@ import type { SWRResponse } from 'swr';
 
 
 
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
+import type { CreatePageAndTransitOpts } from '~/client/services/create-page';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import type {
 import type {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction, onDeletedBookmarkFolderFunction, OnSelectedFunction,
@@ -42,6 +44,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';
@@ -269,7 +269,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();