2
0
Эх сурвалжийг харах

disable groups that cannot be granted page access, due to parent granted groups

Futa Arai 2 жил өмнө
parent
commit
f3516fe8a1

+ 1 - 6
apps/app/src/components/SavePageControls.tsx

@@ -43,16 +43,12 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: isEditable } = useIsEditable();
   const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
+  const { data: grantData } = useSelectedGrant();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
-  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
-    mutateGrant(grantData);
-  }, [mutateGrant]);
-
   const save = useCallback(async(): Promise<void> => {
     // save
     globalEmitter.emit('saveAndReturnToView', { slackChannels });
@@ -108,7 +104,6 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
               userRelatedGrantedGroups={userRelatedGrantedGroups}
-              onUpdateGrant={updateGrantHandler}
             />
           </div>
         )

+ 18 - 13
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -13,6 +13,8 @@ import {
 
 import type { IPageGrantData } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
+import { useSelectedGrant } from '~/stores/ui';
 
 import { useMyUserGroups } from './use-my-user-groups';
 
@@ -45,8 +47,6 @@ type Props = {
     name: string,
     type: GroupType,
   }[]
-
-  onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
 
 /**
@@ -58,7 +58,6 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const {
     disabled,
     userRelatedGrantedGroups,
-    onUpdateGrant,
     grant: currentGrant,
   } = props;
 
@@ -67,14 +66,21 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
   const { data: currentUser } = useCurrentUser();
 
-  const shouldFetch = isSelectGroupModalShown;
-  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch);
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const shouldFetch = isSelectGroupModalShown && currentPagePath != null;
+  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch, currentPagePath ?? '');
+  const { mutate: mutateGrant } = useSelectedGrant();
 
   const showSelectGroupModal = useCallback(() => {
     updateMyUserGroups();
     setIsSelectGroupModalShown(true);
   }, [updateMyUserGroups]);
 
+  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
+    mutateGrant(grantData);
+  }, [mutateGrant]);
+
   /**
    * change event handler for grant selector
    */
@@ -85,13 +91,11 @@ export const GrantSelector = (props: Props): JSX.Element => {
       return;
     }
 
-    if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, userRelatedGrantedGroups: undefined });
-    }
-  }, [onUpdateGrant, showSelectGroupModal]);
+    updateGrantHandler({ grant, userRelatedGrantedGroups: undefined });
+  }, [updateGrantHandler, showSelectGroupModal]);
 
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
-    if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
+    if (isPopulated(grantGroup.item)) {
       let userRelatedGrantedGroupsCopy = userRelatedGrantedGroups != null ? [...userRelatedGrantedGroups] : [];
       const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
       if (userRelatedGrantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
@@ -100,9 +104,9 @@ export const GrantSelector = (props: Props): JSX.Element => {
       else {
         userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
       }
-      onUpdateGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
+      updateGrantHandler({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
     }
-  }, [onUpdateGrant, userRelatedGrantedGroups]);
+  }, [updateGrantHandler, userRelatedGrantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -210,8 +214,9 @@ export const GrantSelector = (props: Props): JSX.Element => {
               type="button"
               key={group.item._id}
               onClick={() => groupListItemClickHandler(group)}
+              disabled={!group.canGrantPage}
             >
-              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} disabled={!group.canGrantPage} /></span>
               <h5 className="d-inline-block ml-3">{group.item.name}</h5>
               {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}

+ 9 - 7
apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts

@@ -4,9 +4,9 @@ import { useSWRxMyExternalUserGroups } from '~/features/external-user-group/clie
 import { useSWRxMyUserGroups } from '~/stores/user-group';
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const useMyUserGroups = (shouldFetch: boolean) => {
-  const { data: myUserGroups, mutate: mutateMyUserGroups } = useSWRxMyUserGroups(shouldFetch);
-  const { data: myExternalUserGroups, mutate: mutateMyExternalUserGroups } = useSWRxMyExternalUserGroups(shouldFetch);
+export const useMyUserGroups = (shouldFetch: boolean, path: string) => {
+  const { data: myUserGroups, mutate: mutateMyUserGroups } = useSWRxMyUserGroups(shouldFetch, path);
+  const { data: myExternalUserGroups, mutate: mutateMyExternalUserGroups } = useSWRxMyExternalUserGroups(shouldFetch, path);
 
   const update = () => {
     mutateMyUserGroups();
@@ -18,17 +18,19 @@ export const useMyUserGroups = (shouldFetch: boolean) => {
   }
 
   const myUserGroupsData = myUserGroups
-    .map((group) => {
+    .map((groupData) => {
       return {
-        item: group,
+        item: groupData.userGroup,
         type: GroupType.userGroup,
+        canGrantPage: groupData.canGrantPage,
       };
     });
   const myExternalUserGroupsData = myExternalUserGroups
-    .map((group) => {
+    .map((groupData) => {
       return {
-        item: group,
+        item: groupData.userGroup,
         type: GroupType.externalUserGroup,
+        canGrantPage: groupData.canGrantPage,
       };
     });
 

+ 8 - 6
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -1,13 +1,14 @@
 import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import {
+import type {
   IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
 } from '~/features/external-user-group/interfaces/external-user-group';
-import {
-  ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupListResult, UserGroupRelationListResult,
+import type {
+  ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, MyUserGroupsResult, UserGroupRelationListResult,
 } from '~/interfaces/user-group-response';
 
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
@@ -28,10 +29,11 @@ export const useSWRxKeycloakGroupSyncSettings = (): SWRResponse<KeycloakGroupSyn
   );
 };
 
-export const useSWRxMyExternalUserGroups = (shouldFetch: boolean): SWRResponse<IExternalUserGroupHasId[], Error> => {
+export const useSWRxMyExternalUserGroups = (shouldFetch: boolean, path: string):
+  SWRResponse<{ userGroup: IExternalUserGroupHasId, canGrantPage: boolean }[], Error> => {
   return useSWR(
     shouldFetch ? '/me/external-user-groups' : null,
-    endpoint => apiv3Get<UserGroupListResult<IExternalUserGroupHasId>>(endpoint).then(result => result.data.userGroups),
+    endpoint => apiv3Get<MyUserGroupsResult<IExternalUserGroupHasId>>(endpoint, { path }).then(result => result.data.userGroups),
   );
 };
 

+ 4 - 0
apps/app/src/interfaces/user-group-response.ts

@@ -13,6 +13,10 @@ export type UserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupH
   userGroups: TUSERGROUP[],
 };
 
+export type MyUserGroupsResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
+  userGroups: { userGroup: TUSERGROUP, canGrantPage: boolean }[],
+};
+
 export type ChildUserGroupListResult<TUSERGROUP extends IUserGroupHasId = IUserGroupHasId> = {
   childUserGroups: TUSERGROUP[],
   grandChildUserGroups: TUSERGROUP[],

+ 4 - 3
apps/app/src/server/models/user-group-relation.ts

@@ -1,10 +1,11 @@
 import { isPopulated, type IUserGroupHasId, type IUserGroupRelation } from '@growi/core';
-import mongoose, { Model, Schema, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import mongoose, { Schema } from 'mongoose';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-import { UserGroupDocument } from './user-group';
+import type { UserGroupDocument } from './user-group';
 
 const debug = require('debug')('growi:models:userGroupRelation');
 const mongoosePaginate = require('mongoose-paginate-v2');

+ 47 - 23
apps/app/src/server/routes/apiv3/me.ts

@@ -1,12 +1,16 @@
-import { type IUserHasId } from '@growi/core';
-import { Router, Request } from 'express';
+import { GroupType, type IUserHasId } from '@growi/core';
+import type { Request } from 'express';
+import { Router } from 'express';
+import { query } from 'express-validator';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import type { IPageGrantService } from '~/server/service/page-grant';
 import loggerFactory from '~/utils/logger';
 
 import UserGroupRelation from '../../models/user-group-relation';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 const logger = loggerFactory('growi:routes:apiv3:me');
 
@@ -16,39 +20,59 @@ interface AuthorizedRequest extends Request {
   user?: IUserHasId
 }
 
+const validator = {
+  getUserGroups: [
+    query('path').isString(),
+  ],
+  getExternalUserGroups: [
+    query('path').isString(),
+  ],
+};
+
 module.exports = function(crowi) {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
 
   const ApiResponse = require('../../util/apiResponse');
 
+  const pageGrantService = crowi.pageGrantService as IPageGrantService;
+
   /**
    * retrieve user-group documents
    */
-  router.get('/user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const userGroups = await UserGroupRelation.findAllGroupsForUser(req.user);
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (e) {
-      logger.error(e);
-      return res.apiv3Err(e, 500);
-    }
-  });
+  router.get('/user-groups', accessTokenParser, loginRequiredStrictly, validator.getUserGroups, apiV3FormValidator,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { path } = req.query;
+
+      try {
+        const userGroups = await UserGroupRelation.findAllGroupsForUser(req.user);
+        const userGroupsWithCanGrantPage = await pageGrantService.getCanGrantPageForUserGroups(userGroups, GroupType.userGroup, path as string);
+        return res.json(ApiResponse.success({ userGroups: userGroupsWithCanGrantPage }));
+      }
+      catch (e) {
+        logger.error(e);
+        return res.apiv3Err(e, 500);
+      }
+    });
 
   /**
    * retrieve external-user-group-relation documents
    */
-  router.get('/external-user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const userGroups = await ExternalUserGroupRelation.findAllGroupsForUser(req.user);
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (e) {
-      logger.error(e);
-      return res.apiv3Err(e, 500);
-    }
-  });
+  router.get('/external-user-groups',
+    accessTokenParser, loginRequiredStrictly, validator.getExternalUserGroups, apiV3FormValidator,
+    async(req: AuthorizedRequest, res: ApiV3Response) => {
+      const { path } = req.query;
+
+      try {
+        const userGroups = await ExternalUserGroupRelation.findAllGroupsForUser(req.user);
+        const userGroupsWithCanGrantPage = await pageGrantService.getCanGrantPageForUserGroups(userGroups, GroupType.userGroup, path as string);
+        return res.json(ApiResponse.success({ userGroups: userGroupsWithCanGrantPage }));
+      }
+      catch (e) {
+        logger.error(e);
+        return res.apiv3Err(e, 500);
+      }
+    });
 
   return router;
 };

+ 79 - 43
apps/app/src/server/service/page-grant.ts

@@ -1,3 +1,4 @@
+import type { IPage, IUserGroupHasId } from '@growi/core';
 import {
   type IGrantedGroup,
   PageGrant, GroupType, getIdForRef, isPopulated,
@@ -8,10 +9,12 @@ import {
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
+import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import type { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { PageDocument, PageModel } from '~/server/models/page';
+import type { UserGroupDocument } from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
@@ -100,7 +103,10 @@ export interface IPageGrantService {
   getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
-  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
+  getCanGrantPageForUserGroups: (
+    userGroups: UserGroupDocument[], groupType: GroupType, targetPath: string
+  ) => Promise<{ userGroup: UserGroupDocument, canGrantPage: boolean}[]>
 }
 
 class PageGrantService implements IPageGrantService {
@@ -112,7 +118,7 @@ class PageGrantService implements IPageGrantService {
   }
 
   private validateComparableTarget(comparable: ComparableTarget) {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     const { grant, grantedUserIds, grantedGroupIds } = comparable;
 
@@ -134,7 +140,7 @@ class PageGrantService implements IPageGrantService {
      */
     this.validateComparableTarget(target);
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     /*
      * ancestor side
@@ -284,53 +290,45 @@ class PageGrantService implements IPageGrantService {
    * Prepare ComparableTarget
    * @returns Promise<ComparableAncestor>
    */
-  private async generateComparableTarget(
-      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
+  private async generateComparableTargetWithApplicableData(
+      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined,
   ): Promise<ComparableTarget> {
-    if (includeApplicable) {
-      const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
-      let applicableUserIds: ObjectIdLike[] | undefined;
-      let applicableGroupIds: ObjectIdLike[] | undefined;
-
-      if (grant === Page.GRANT_USER_GROUP) {
-        if (grantedGroupIds == null || grantedGroupIds.length === 0) {
-          throw Error('Target user group is not given');
-        }
+    let applicableUserIds: ObjectIdLike[] | undefined;
+    let applicableGroupIds: ObjectIdLike[] | undefined;
 
-        const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroupIds);
-        const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
-        const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
-        if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
-          throw Error('Target user group does not exist');
-        }
+    if (grant === Page.GRANT_USER_GROUP) {
+      if (grantedGroupIds == null || grantedGroupIds.length === 0) {
+        throw Error('Target user group is not given');
+      }
 
-        const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: targetUserGroups.map(g => g._id) } });
-        const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: targetExternalUserGroups.map(g => g._id) } });
-        applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(u => u.relatedUser as ObjectIdLike)));
-
-        const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
-          return UserGroup.findGroupsWithDescendantsById(group._id);
-        }))).flat();
-        const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
-          return ExternalUserGroup.findGroupsWithDescendantsById(group._id);
-        }))).flat();
-        applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
+      const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroupIds);
+      const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
+      const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
+      if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
+        throw Error('Target user group does not exist');
       }
 
-      return {
-        grant,
-        grantedUserIds,
-        grantedGroupIds,
-        applicableUserIds,
-        applicableGroupIds,
-      };
+      const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: targetUserGroups.map(g => g._id) } });
+      const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: targetExternalUserGroups.map(g => g._id) } });
+      applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(u => u.relatedUser as ObjectIdLike)));
+
+      const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
+        return UserGroup.findGroupsWithDescendantsById(group._id);
+      }))).flat();
+      const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
+        return ExternalUserGroup.findGroupsWithDescendantsById(group._id);
+      }))).flat();
+      applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
     }
 
     return {
       grant,
       grantedUserIds,
       grantedGroupIds,
+      applicableUserIds,
+      applicableGroupIds,
     };
   }
 
@@ -340,7 +338,7 @@ class PageGrantService implements IPageGrantService {
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const { PageQueryBuilder } = Page;
 
     let applicableUserIds: ObjectIdLike[] | undefined;
@@ -395,7 +393,7 @@ class PageGrantService implements IPageGrantService {
    * @returns ComparableDescendants
    */
   private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     // Build conditions
     const $match: {$or: any} = {
@@ -515,16 +513,54 @@ class PageGrantService implements IPageGrantService {
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
-      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, false);
+      const comparableTarget: ComparableTarget = { grant, grantedUserIds, grantedGroupIds };
       return this.validateGrant(comparableTarget, comparableAncestor);
     }
 
-    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, true);
+    const comparableTarget = await this.generateComparableTargetWithApplicableData(grant, grantedUserIds, grantedGroupIds);
     const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
     return this.validateGrant(comparableTarget, comparableAncestor, comparableDescendants);
   }
 
+  /**
+   * Get 'canGrantPage' for each user group given, which shows if a page can be granted to a user group.
+   * To calculate 'canGrantPage', the same logic as isGrantNormalized will be executed, except only the ancestor info will be used.
+   *
+   * @param userGroups
+   * @param groupType
+   * @param targetPath
+   * @returns
+   */
+  async getCanGrantPageForUserGroups(
+      userGroups: UserGroupDocument[], groupType: GroupType, targetPath: string,
+  ): Promise<{ userGroup: UserGroupDocument, canGrantPage: boolean}[]> {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    if (isTopPage(targetPath)) {
+      return userGroups.map((group) => {
+        return { userGroup: group, canGrantPage: true };
+      });
+    }
+
+    const page = await Page.findByPath(targetPath);
+    const grantedUserIds = page?.grantedUsers?.map(user => getIdForRef(user)) ?? [];
+
+    const comparableAncestor = await this.generateComparableAncestor(targetPath, false);
+
+    const withCanGrantPage = userGroups.map((group) => {
+      const groupsToGrant = [...(page?.grantedGroups ?? []), { item: group._id, type: groupType }];
+      const comparableTarget: ComparableTarget = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantedUserIds,
+        grantedGroupIds: groupsToGrant,
+      };
+
+      return { userGroup: group, canGrantPage: this.validateGrant(comparableTarget, comparableAncestor) };
+    });
+
+    return withCanGrantPage;
+  }
+
   /**
    * Separate normalizable pages and NOT normalizable pages by PageService.prototype.isGrantNormalized method.
    * normalizable pages = Pages which are able to run normalizeParentRecursively method (grant & userGroup rule is correct)
@@ -564,7 +600,7 @@ class PageGrantService implements IPageGrantService {
   }
 
   async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     // -- Public only if top page
     const isOnlyPublicApplicable = isTopPage(page.path);

+ 8 - 5
apps/app/src/stores/user-group.tsx

@@ -2,21 +2,24 @@ import type {
   IPageHasId, IUserGroupHasId, IUserGroupRelationHasId,
 } from '@growi/core';
 import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import {
+import type {
   IUserGroupRelationHasIdPopulatedUser,
   UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
-  UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
+  UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult, MyUserGroupsResult,
 } from '~/interfaces/user-group-response';
 
 
-export const useSWRxMyUserGroups = (shouldFetch: boolean): SWRResponse<IUserGroupHasId[], Error> => {
+export const useSWRxMyUserGroups = (
+    shouldFetch: boolean, path: string,
+): SWRResponse<{ userGroup: IUserGroupHasId, canGrantPage: boolean }[], Error> => {
   return useSWR(
     shouldFetch ? '/me/user-groups' : null,
-    endpoint => apiv3Get<UserGroupListResult>(endpoint).then(result => result.data.userGroups),
+    endpoint => apiv3Get<MyUserGroupsResult>(endpoint, { path }).then(result => result.data.userGroups),
   );
 };