Преглед изворни кода

Merge branch 'feat/136137-137441-keep-groups-unrelated-to-user-on-group-grant-change' into feat/136137-136541-page-tree-grant-update-for-multi-group-grant

Futa Arai пре 2 година
родитељ
комит
c9b0023c59

+ 2 - 2
apps/app/_obsolete/src/components/PageEditorByHackmd.tsx

@@ -98,7 +98,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (grantData == null) {
     if (grantData == null) {
       return;
       return;
     }
     }
-    const grantedGroups = grantData.grantedGroups?.map((group) => {
+    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
       return { item: group.id, type: group.type };
       return { item: group.id, type: group.type };
     });
     });
     const optionsToSave = {
     const optionsToSave = {
@@ -106,7 +106,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       grant: grantData.grant,
       pageTags: pageTags ?? [],
       pageTags: pageTags ?? [],
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
     };
     };
     return optionsToSave;
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);
   }, [grantData, isSlackEnabled, pageTags]);

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

@@ -68,7 +68,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
         grant: selectedGrant,
-        grantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
+        userRelatedGrantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
           return { item: g.item._id, type: g.type };
           return { item: g.item._id, type: g.type };
         }) : null,
         }) : null,
       });
       });
@@ -99,10 +99,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
     }
 
 
     if (grantData.grant === 5) {
     if (grantData.grant === 5) {
-      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
+      if (grantData.userRelatedGrantedGroups == null || grantData.userRelatedGrantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.grantedGroups.map(g => g.name).join(', ')})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.userRelatedGrantedGroups.map(g => g.name).join(', ')})`;
     }
     }
 
 
     throw Error('cannot get grant label'); // this error can't be throwed
     throw Error('cannot get grant label'); // this error can't be throwed

+ 2 - 2
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -217,7 +217,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     if (grantData == null) {
     if (grantData == null) {
       return;
       return;
     }
     }
-    const grantedGroups = grantData.grantedGroups?.map((group) => {
+    const userRelatedGrantedGroups = grantData.userRelatedGrantedGroups?.map((group) => {
       return { item: group.id, type: group.type };
       return { item: group.id, type: group.type };
     });
     });
     const optionsToSave = {
     const optionsToSave = {
@@ -225,7 +225,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       grant: grantData.grant,
       // pageTags: pageTags ?? [],
       // pageTags: pageTags ?? [],
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
     };
     };
     return optionsToSave;
     return optionsToSave;
   }, [grantData, isSlackEnabled]);
   }, [grantData, isSlackEnabled]);

+ 2 - 2
apps/app/src/components/SavePageControls.tsx

@@ -67,7 +67,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     return null;
     return null;
   }
   }
 
 
-  const { grant, grantedGroups } = grantData;
+  const { grant, userRelatedGrantedGroups } = grantData;
 
 
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
   const labelSubmitButton = t('Update');
@@ -82,7 +82,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
             <GrantSelector
             <GrantSelector
               grant={grant}
               grant={grant}
               disabled={isGrantSelectorDisabledPage}
               disabled={isGrantSelectorDisabledPage}
-              grantedGroups={grantedGroups}
+              userRelatedGrantedGroups={userRelatedGrantedGroups}
               onUpdateGrant={updateGrantHandler}
               onUpdateGrant={updateGrantHandler}
             />
             />
           </div>
           </div>

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

@@ -34,7 +34,7 @@ const AVAILABLE_GRANTS = [
 type Props = {
 type Props = {
   disabled?: boolean,
   disabled?: boolean,
   grant: number,
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     id: string,
     name: string,
     name: string,
     type: GroupType,
     type: GroupType,
@@ -51,7 +51,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
 
   const {
   const {
     disabled,
     disabled,
-    grantedGroups,
+    userRelatedGrantedGroups,
     onUpdateGrant,
     onUpdateGrant,
     grant: currentGrant,
     grant: currentGrant,
   } = props;
   } = props;
@@ -80,23 +80,23 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
     }
 
 
     if (onUpdateGrant != null) {
     if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, grantedGroups: undefined });
+      onUpdateGrant({ grant, userRelatedGrantedGroups: undefined });
     }
     }
   }, [onUpdateGrant, showSelectGroupModal]);
   }, [onUpdateGrant, showSelectGroupModal]);
 
 
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      let grantedGroupsCopy = grantedGroups != null ? [...grantedGroups] : [];
+      let userRelatedGrantedGroupsCopy = userRelatedGrantedGroups != null ? [...userRelatedGrantedGroups] : [];
       const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
       const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
-      if (grantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
-        grantedGroupsCopy.push(grantGroupInfo);
+      if (userRelatedGrantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
+        userRelatedGrantedGroupsCopy.push(grantGroupInfo);
       }
       }
       else {
       else {
-        grantedGroupsCopy = grantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
+        userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
       }
       }
-      onUpdateGrant({ grant: 5, grantedGroups: grantedGroupsCopy });
+      onUpdateGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
     }
     }
-  }, [onUpdateGrant, grantedGroups]);
+  }, [onUpdateGrant, userRelatedGrantedGroups]);
 
 
   /**
   /**
    * Render grant selector DOM.
    * Render grant selector DOM.
@@ -107,7 +107,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleLabelElm;
     let dropdownToggleLabelElm;
 
 
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && grantedGroups != null && grantedGroups.length > 0)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
         : opt.label;
 
 
@@ -128,18 +128,18 @@ export const GrantSelector = (props: Props): JSX.Element => {
     });
     });
 
 
     // add specified group option
     // add specified group option
-    if (grantedGroups != null && grantedGroups.length > 0) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0) {
       const labelElm = (
       const labelElm = (
         <span>
         <span>
           <i className="icon icon-fw icon-organization"></i>
           <i className="icon icon-fw icon-organization"></i>
           <span className="label">
           <span className="label">
-            {grantedGroups.length > 1
+            {userRelatedGrantedGroups.length > 1
               ? (
               ? (
                 <span>
                 <span>
-                  {`${grantedGroups[0].name}... `}
-                  <span className="badge badge-purple">+{grantedGroups.length - 1}</span>
+                  {`${userRelatedGrantedGroups[0].name}... `}
+                  <span className="badge badge-purple">+{userRelatedGrantedGroups.length - 1}</span>
                 </span>
                 </span>
-              ) : grantedGroups[0].name}
+              ) : userRelatedGrantedGroups[0].name}
           </span>
           </span>
         </span>
         </span>
       );
       );
@@ -162,7 +162,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
         </UncontrolledDropdown>
       </div>
       </div>
     );
     );
-  }, [changeGrantHandler, currentGrant, disabled, grantedGroups, t]);
+  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t]);
 
 
   /**
   /**
    * Render select grantgroup modal.
    * Render select grantgroup modal.
@@ -195,7 +195,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     return (
     return (
       <>
       <>
         { myUserGroups.map((group) => {
         { myUserGroups.map((group) => {
-          const groupIsGranted = grantedGroups?.find(g => g.id === group.item._id) != null;
+          const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
           const activeClass = groupIsGranted ? 'active' : '';
           const activeClass = groupIsGranted ? 'active' : '';
 
 
           return (
           return (
@@ -216,7 +216,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       </>
       </>
     );
     );
 
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, grantedGroups]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
 
 
   return (
   return (
     <>
     <>

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

@@ -33,6 +33,6 @@ export type OptionsToSave = {
   isSlackEnabled: boolean;
   isSlackEnabled: boolean;
   slackChannels: string;
   slackChannels: string;
   grant: number;
   grant: number;
-  // grantUserGroupIds?: IGrantedGroup[];
+  // userRelatedGrantUserGroupIds?: IGrantedGroup[];
   // isSyncRevisionToHackmd?: boolean;
   // isSyncRevisionToHackmd?: boolean;
 };
 };

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

@@ -10,7 +10,7 @@ export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData
 
 
 export type IPageGrantData = {
 export type IPageGrantData = {
   grant: number,
   grant: number,
-  grantedGroups?: {
+  userRelatedGrantedGroups?: {
     id: string,
     id: string,
     name: string,
     name: string,
     type: GroupType,
     type: GroupType,

+ 1 - 1
apps/app/src/migrations/20230723061824-granted-group-to-array-of-objects.js

@@ -1,6 +1,6 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-const logger = loggerFactory('growi:remove-basic-auth-related-config');
+const logger = loggerFactory('growi:granted-group-to-array-of-objects');
 
 
 module.exports = {
 module.exports = {
   async up(db, client) {
   async up(db, client) {

+ 28 - 0
apps/app/src/migrations/20231223155127-non-null-granted-groups.js

@@ -0,0 +1,28 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:non-null-granted-groups');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+
+    const pageCollection = await db.collection('pages');
+
+    await pageCollection.updateMany(
+      { grantedGroups: { $eq: null } },
+      [
+        {
+          $set: {
+            grantedGroups: [],
+          },
+        },
+      ],
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    // No rollback
+  },
+};

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

@@ -463,8 +463,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
     if (ancestor != null) {
       ancestor.populate('grantedGroups.item');
       ancestor.populate('grantedGroups.item');
-      const userRelatedGrantedGroups = await pageService.getUserRelatedGrantedGroups(ancestor, user);
-      const grantedGroupsInfo = userRelatedGrantedGroups.map((group) => {
+      const userRelatedGrantedGroups = (await pageService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
         if (isPopulated(group.item)) {
         if (isPopulated(group.item)) {
           return {
           return {
             id: group.item._id,
             id: group.item._id,
@@ -476,7 +475,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       }).filter((info): info is NonNullable<{id: string, name: string, type: GroupType}> => info != null);
       }).filter((info): info is NonNullable<{id: string, name: string, type: GroupType}> => info != null);
       props.grantData = {
       props.grantData = {
         grant: ancestor.grant,
         grant: ancestor.grant,
-        grantedGroups: grantedGroupsInfo,
+        userRelatedGrantedGroups,
       };
       };
     }
     }
   }
   }

+ 1 - 1
apps/app/src/server/models/interfaces/page-operation.ts

@@ -23,7 +23,7 @@ export type IUserForResuming = {
 
 
 export type IOptionsForUpdate = {
 export type IOptionsForUpdate = {
   grant?: PageGrant,
   grant?: PageGrant,
-  grantUserGroupIds?: IGrantedGroup[],
+  userRelatedGrantUserGroupIds?: IGrantedGroup[],
   isSyncRevisionToHackmd?: boolean,
   isSyncRevisionToHackmd?: boolean,
   overwriteScopesOfDescendants?: boolean,
   overwriteScopesOfDescendants?: boolean,
 };
 };

+ 1 - 1
apps/app/src/server/models/obsolete-page.js

@@ -651,7 +651,7 @@ export const getPageSchema = (crowi) => {
         updateOne: {
         updateOne: {
           filter: { _id: page._id },
           filter: { _id: page._id },
           update: {
           update: {
-            grantedGroups: null,
+            grantedGroups: [],
             grant: this.GRANT_PUBLIC,
             grant: this.GRANT_PUBLIC,
           },
           },
         },
         },

+ 1 - 0
apps/app/src/server/models/page.ts

@@ -126,6 +126,7 @@ const schema = new Schema<PageDocument, PageModel>({
       return arr.length === uniqueItemValues.size;
       return arr.length === uniqueItemValues.size;
     }, 'grantedGroups contains non unique item'],
     }, 'grantedGroups contains non unique item'],
     default: [],
     default: [],
+    required: true,
   },
   },
   creator: { type: ObjectId, ref: 'User', index: true },
   creator: { type: ObjectId, ref: 'User', index: true },
   lastUpdateUser: { type: ObjectId, ref: 'User' },
   lastUpdateUser: { type: ObjectId, ref: 'User' },

+ 3 - 3
apps/app/src/server/routes/apiv3/page.js

@@ -565,10 +565,10 @@ module.exports = (crowi) => {
 
 
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
     const { pageId } = req.params;
-    const { grant, grantedGroups } = req.body;
+    const { grant, userRelatedGrantedGroups } = req.body;
 
 
     // TODO: remove in https://redmine.weseek.co.jp/issues/136137
     // TODO: remove in https://redmine.weseek.co.jp/issues/136137
-    if (grantedGroups != null && grantedGroups.length > 1) {
+    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 1) {
       return res.apiv3Err('Cannot grant multiple groups to page at the moment');
       return res.apiv3Err('Cannot grant multiple groups to page at the moment');
     }
     }
 
 
@@ -584,7 +584,7 @@ module.exports = (crowi) => {
     let data;
     let data;
     try {
     try {
       const shouldUseV4Process = false;
       const shouldUseV4Process = false;
-      const grantData = { grant, grantedGroups };
+      const grantData = { grant, userRelatedGrantedGroups };
       data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
       data = await crowi.pageService.updateGrant(page, req.user, grantData, shouldUseV4Process);
     }
     }
     catch (err) {
     catch (err) {

+ 0 - 1
apps/app/src/server/routes/apiv3/pages.js

@@ -306,7 +306,6 @@ module.exports = (crowi) => {
    */
    */
   router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
   router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
     const {
     const {
-      // body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
       body, grant, grantUserGroupIds, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
       body, grant, grantUserGroupIds, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
     } = req.body;
     } = req.body;
 
 

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

@@ -13,7 +13,7 @@ import ExternalUserGroupRelation from '~/features/external-user-group/server/mod
 import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { PageDocument, PageModel } from '~/server/models/page';
 import { PageDocument, PageModel } from '~/server/models/page';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
-import { includesObjectIds, excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
+import { includesObjectIds, excludeTestIdsFromTargetIds, hasIntersection } from '~/server/util/compare-objectId';
 
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import UserGroupRelation from '../models/user-group-relation';
 import UserGroupRelation from '../models/user-group-relation';
@@ -250,7 +250,7 @@ class PageGrantService implements IPageGrantService {
       if (grant !== PageGrant.GRANT_USER_GROUP) {
       if (grant !== PageGrant.GRANT_USER_GROUP) {
         return false;
         return false;
       }
       }
-      const pageGrantIncludesUserRelatedGroup = includesObjectIds(grantedGroupIds?.map(g => getIdForRef(g.item)) || [], userRelatedGroupIds);
+      const pageGrantIncludesUserRelatedGroup = hasIntersection(grantedGroupIds?.map(g => getIdForRef(g.item)) || [], userRelatedGroupIds);
       if (!pageGrantIncludesUserRelatedGroup) {
       if (!pageGrantIncludesUserRelatedGroup) {
         return false;
         return false;
       }
       }

+ 30 - 9
apps/app/src/server/service/page.ts

@@ -4054,12 +4054,12 @@ class PageService {
    * @param {UserDocument} user
    * @param {UserDocument} user
    * @param options
    * @param options
    */
    */
-  async updateGrant(page, user, grantData: {grant: PageGrant, grantedGroups: IGrantedGroup[]}): Promise<PageDocument> {
-    const { grant, grantedGroups } = grantData;
+  async updateGrant(page, user, grantData: {grant: PageGrant, userRelatedGrantedGroups: IGrantedGroup[]}): Promise<PageDocument> {
+    const { grant, userRelatedGrantedGroups } = grantData;
 
 
     const options: IOptionsForUpdate = {
     const options: IOptionsForUpdate = {
       grant,
       grant,
-      grantUserGroupIds: grantedGroups,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
       isSyncRevisionToHackmd: false,
       isSyncRevisionToHackmd: false,
     };
     };
 
 
@@ -4105,6 +4105,21 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
+  /**
+   * Get the new GrantedGroups for the page going through an update operation.
+   * It will include the groups specified by the operator, and groups which the user does not belong to, but was related to the page before the update.
+   * @param userRelatedGrantedGroups The groups specified by the operator
+   * @param page The page going through an update operation
+   * @param user The operator
+   * @returns The new GrantedGroups array to be set to the page
+   */
+  async getNewGrantedGroups(userRelatedGrantedGroups: IGrantedGroup[], page: PageDocument, user): Promise<IGrantedGroup[]> {
+    const previousGrantedGroups = page.grantedGroups;
+    const userRelatedPreviousGrantedGroups = (await this.getUserRelatedGrantedGroups(page, user)).map(g => getIdForRef(g.item));
+    const userUnrelatedPreviousGrantedGroups = previousGrantedGroups.filter(g => !userRelatedPreviousGrantedGroups.includes(getIdForRef(g.item)));
+    return [...userUnrelatedPreviousGrantedGroups, ...userRelatedGrantedGroups];
+  }
+
   async updatePage(
   async updatePage(
       pageData: PageDocument,
       pageData: PageDocument,
       body: string | null,
       body: string | null,
@@ -4128,8 +4143,11 @@ class PageService {
     const clonedPageData = Page.hydrate(pageData.toObject());
     const clonedPageData = Page.hydrate(pageData.toObject());
     const newPageData = pageData;
     const newPageData = pageData;
 
 
-    const grant = options.grant ?? clonedPageData.grant; // use the previous data if absence
-    const grantUserGroupIds = options.grantUserGroupIds ?? clonedPageData.grantedGroups;
+    // use the previous data if absent
+    const grant = options.grant ?? clonedPageData.grant;
+    const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
+      ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, clonedPageData, user))
+      : clonedPageData.grantedGroups;
 
 
     const grantedUserIds = clonedPageData.grantedUserIds || [user._id];
     const grantedUserIds = clonedPageData.grantedUserIds || [user._id];
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
@@ -4151,7 +4169,7 @@ class PageService {
       }
       }
 
 
       if (options.overwriteScopesOfDescendants) {
       if (options.overwriteScopesOfDescendants) {
-        const updateGrantInfo = await this.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.grantUserGroupIds);
+        const updateGrantInfo = await this.pageGrantService.generateUpdateGrantInfoToOverwriteDescendants(user, grant, options.userRelatedGrantUserGroupIds);
         const canOverwriteDescendants = await this.pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
         const canOverwriteDescendants = await this.pageGrantService.canOverwriteDescendants(clonedPageData.path, user, updateGrantInfo);
 
 
         if (!canOverwriteDescendants) {
         if (!canOverwriteDescendants) {
@@ -4240,11 +4258,14 @@ class PageService {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
     const Revision = mongoose.model('Revision') as any; // TODO: TypeScriptize model
 
 
-    const grant = options.grant || pageData.grant; // use the previous data if absence
-    const grantUserGroupIds = options.grantUserGroupIds || pageData.grantUserGroupIds; // use the previous data if absence
+    // use the previous data if absent
+    const grant = options.grant || pageData.grant;
+    const grantUserGroupIds = options.userRelatedGrantUserGroupIds != null
+      ? (await this.getNewGrantedGroups(options.userRelatedGrantUserGroupIds, pageData, user))
+      : pageData.grantedGroups;
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
 
 
-    // TODO 136137: validate multiple group grant before save using pageData and options
+    // validate multiple group grant before save using pageData and options
     await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);
     await this.pageGrantService.validateGrantChange(user, pageData.grantedGroups, grant, grantUserGroupIds);
 
 
     await this.validateAppliedScope(user, grant, grantUserGroupIds);
     await this.validateAppliedScope(user, grant, grantUserGroupIds);

+ 67 - 24
apps/app/test/integration/models/v5.page.test.js

@@ -257,7 +257,7 @@ describe('Page', () => {
         creator: upodUserB,
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
         grantedUsers: [upodUserB._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdgAB1,
         parent: upodPageIdgAB1,
       },
       },
       // case 2
       // case 2
@@ -268,7 +268,7 @@ describe('Page', () => {
         creator: upodUserA,
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
         parent: rootPage._id,
       },
       },
       {
       {
@@ -301,7 +301,7 @@ describe('Page', () => {
         creator: upodUserA,
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: [upodUserA._id],
         grantedUsers: [upodUserA._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic2,
         parent: upodPageIdPublic2,
       },
       },
       // case 3
       // case 3
@@ -312,7 +312,7 @@ describe('Page', () => {
         creator: upodUserA,
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
         parent: rootPage._id,
       },
       },
       // grant user A and B with a single group
       // grant user A and B with a single group
@@ -362,7 +362,7 @@ describe('Page', () => {
         creator: upodUserB,
         creator: upodUserB,
         lastUpdateUser: upodUserB,
         lastUpdateUser: upodUserB,
         grantedUsers: [upodUserB._id],
         grantedUsers: [upodUserB._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic3,
         parent: upodPageIdPublic3,
       },
       },
       // case 4
       // case 4
@@ -373,7 +373,7 @@ describe('Page', () => {
         creator: upodUserA,
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
         parent: rootPage._id,
       },
       },
       {
       {
@@ -408,7 +408,7 @@ describe('Page', () => {
         creator: upodUserA,
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
         parent: rootPage._id,
       },
       },
       {
       {
@@ -429,7 +429,7 @@ describe('Page', () => {
         creator: upodUserC,
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
         grantedUsers: [upodUserC._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic5,
         parent: upodPageIdPublic5,
       },
       },
       // case 6
       // case 6
@@ -440,7 +440,7 @@ describe('Page', () => {
         creator: upodUserA,
         creator: upodUserA,
         lastUpdateUser: upodUserA,
         lastUpdateUser: upodUserA,
         grantedUsers: null,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
         parent: rootPage._id,
       },
       },
       {
       {
@@ -449,7 +449,7 @@ describe('Page', () => {
         creator: upodUserC,
         creator: upodUserC,
         lastUpdateUser: upodUserC,
         lastUpdateUser: upodUserC,
         grantedUsers: [upodUserC._id],
         grantedUsers: [upodUserC._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: upodPageIdPublic6,
         parent: upodPageIdPublic6,
       },
       },
     ]);
     ]);
@@ -1014,6 +1014,18 @@ describe('Page', () => {
         parent: rootPage._id,
         parent: rootPage._id,
         descendantCount: 0,
         descendantCount: 0,
       },
       },
+      {
+        path: '/with_multiple_individual_granted_groups',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroups: [
+          { item: userGroupIdPModelA, type: GroupType.userGroup },
+          { item: userGroupIdPModelB, type: GroupType.userGroup },
+        ],
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage,
+      },
     ]);
     ]);
 
 
     await createDocumentsToTestUpdatePageOverwritingDescendants();
     await createDocumentsToTestUpdatePageOverwritingDescendants();
@@ -1032,7 +1044,7 @@ describe('Page', () => {
         expect(page1).toBeTruthy();
         expect(page1).toBeTruthy();
         expect(page2).toBeTruthy();
         expect(page2).toBeTruthy();
 
 
-        const options = { grant: Page.GRANT_RESTRICTED, grantUserGroupIds: null };
+        const options = { grant: Page.GRANT_RESTRICTED, userRelatedGrantUserGroupIds: null };
         await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
         await updatePage(page2, 'newRevisionBody', 'oldRevisionBody', dummyUser1, options);
 
 
         const _pageT = await Page.findOne({ path: pathT });
         const _pageT = await Page.findOne({ path: pathT });
@@ -1239,7 +1251,7 @@ describe('Page', () => {
 
 
           const options = {
           const options = {
             grant: Page.GRANT_USER_GROUP,
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
 
@@ -1269,7 +1281,7 @@ describe('Page', () => {
 
 
           const options = {
           const options = {
             grant: Page.GRANT_USER_GROUP,
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           };
           const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
           const updatedPage = await updatePage(_page1, 'new', 'old', pModelUser1, options); // from GRANT_RESTRICTED to GRANT_USER_GROUP(userGroupIdPModelA)
 
 
@@ -1307,7 +1319,7 @@ describe('Page', () => {
 
 
           const options = {
           const options = {
             grant: Page.GRANT_USER_GROUP,
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser1, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
 
 
@@ -1345,7 +1357,7 @@ describe('Page', () => {
           ];
           ];
           const options = {
           const options = {
             grant: Page.GRANT_USER_GROUP,
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: newGrantedGroups,
+            userRelatedGrantUserGroupIds: newGrantedGroups,
           };
           };
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
           const updatedPage = await updatePage(_page2, 'new', 'old', pModelUser3, options); // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelB)
 
 
@@ -1366,7 +1378,7 @@ describe('Page', () => {
             { item: userGroupIdPModelC, type: GroupType.userGroup },
             { item: userGroupIdPModelC, type: GroupType.userGroup },
             { item: externalUserGroupIdPModelC, type: GroupType.externalUserGroup },
             { item: externalUserGroupIdPModelC, type: GroupType.externalUserGroup },
           ];
           ];
-          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, grantUserGroupIds: secondRoundNewGrantedGroups }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
+          const secondRoundOptions = { grant: Page.GRANT_USER_GROUP, userRelatedGrantUserGroupIds: secondRoundNewGrantedGroups }; // from GRANT_USER_GROUP(userGroupIdPModelB) to GRANT_USER_GROUP(userGroupIdPModelC)
           // undo grantedGroups populate to prevent Page.hydrate error
           // undo grantedGroups populate to prevent Page.hydrate error
           _page2.grantedGroups.forEach((group) => {
           _page2.grantedGroups.forEach((group) => {
             group.item = group.item._id;
             group.item = group.item._id;
@@ -1397,7 +1409,7 @@ describe('Page', () => {
 
 
           const options = {
           const options = {
             grant: Page.GRANT_USER_GROUP,
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: [
+            userRelatedGrantUserGroupIds: [
               { item: userGroupIdPModelIsolate, type: GroupType.userGroup },
               { item: userGroupIdPModelIsolate, type: GroupType.userGroup },
               { item: externalUserGroupIdPModelIsolate, type: GroupType.externalUserGroup },
               { item: externalUserGroupIdPModelIsolate, type: GroupType.externalUserGroup },
             ],
             ],
@@ -1428,7 +1440,7 @@ describe('Page', () => {
 
 
           const options = {
           const options = {
             grant: Page.GRANT_USER_GROUP,
             grant: Page.GRANT_USER_GROUP,
-            grantUserGroupIds: [
+            userRelatedGrantUserGroupIds: [
               { item: userGroupIdPModelA, type: GroupType.userGroup },
               { item: userGroupIdPModelA, type: GroupType.userGroup },
               { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
               { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
             ],
             ],
@@ -1462,7 +1474,7 @@ describe('Page', () => {
           expect(_page1).toBeTruthy();
           expect(_page1).toBeTruthy();
           expect(_page2).toBeTruthy();
           expect(_page2).toBeTruthy();
 
 
-          const options = { grant: Page.GRANT_USER_GROUP, grantUserGroupIds: [{ item: userGroupIdPModelA, type: GroupType.userGroup }] };
+          const options = { grant: Page.GRANT_USER_GROUP, userRelatedGrantUserGroupIds: [{ item: userGroupIdPModelA, type: GroupType.userGroup }] };
           await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
           await expect(updatePage(_page2, 'new', 'old', pModelUser1, options)) // from GRANT_OWNER to GRANT_USER_GROUP(userGroupIdPModelA)
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
             .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
 
 
@@ -1475,9 +1487,40 @@ describe('Page', () => {
           expect(page2.grantedGroups.length).toBe(0); // no group should be set
           expect(page2.grantedGroups.length).toBe(0); // no group should be set
         });
         });
       });
       });
+      describe('update grant of a page from GRANT_USER_GROUP to GRANT_USER_GROUP', () => {
+        test('successfully change the granted groups, with the previous groups wich user is not related to remaining', async() => {
+          // path
+          const path = '/with_multiple_individual_granted_groups';
+          // page
+          const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP });
+          expect(_page).toBeTruthy();
 
 
-    });
+          const newUserRelatedGrantedGroups = [
+            { item: userGroupIdPModelA, type: GroupType.userGroup },
+            { item: externalUserGroupIdPModelA, type: GroupType.externalUserGroup },
+          ];
+
+          const options = {
+            grant: Page.GRANT_USER_GROUP,
+            userRelatedGrantUserGroupIds: newUserRelatedGrantedGroups,
+          };
+          const updatedPage = await updatePage(_page, 'new', 'old', pModelUser1, options); // from GRANT_PUBLIC to GRANT_USER_GROUP(userGroupIdPModelA)
 
 
+          const page = await Page.findById(_page._id);
+          expect(page).toBeTruthy();
+          expect(updatedPage).toBeTruthy();
+          expect(updatedPage._id).toStrictEqual(page._id);
+
+          // check page grant and group
+          expect(page.grant).toBe(Page.GRANT_USER_GROUP);
+          expect(normalizeGrantedGroups(page.grantedGroups)).toEqual(expect.arrayContaining([
+            ...newUserRelatedGrantedGroups,
+            // userB group remains, although options does not include it
+            { item: userGroupIdPModelB, type: GroupType.userGroup },
+          ]));
+        });
+      });
+    });
   });
   });
 
 
 
 
@@ -1578,7 +1621,7 @@ describe('Page', () => {
       // Update
       // Update
       const options = {
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
         ],
@@ -1634,7 +1677,7 @@ describe('Page', () => {
       // Update
       // Update
       const options = {
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
         ],
@@ -1662,7 +1705,7 @@ describe('Page', () => {
       // Update
       // Update
       const options = {
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
         ],
@@ -1685,7 +1728,7 @@ describe('Page', () => {
       // Update
       // Update
       const options = {
       const options = {
         grant: PageGrant.GRANT_USER_GROUP,
         grant: PageGrant.GRANT_USER_GROUP,
-        grantUserGroupIds: [
+        userRelatedGrantUserGroupIds: [
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodUserGroupIdAB, type: GroupType.userGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
           { item: upodExternalUserGroupIdAB, type: GroupType.externalUserGroup },
         ],
         ],

+ 4 - 4
apps/app/test/integration/service/page-grant.test.js

@@ -199,7 +199,7 @@ describe('PageGrantService', () => {
         creator: user1,
         creator: user1,
         lastUpdateUser: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: rootPage._id,
         parent: rootPage._id,
       },
       },
       {
       {
@@ -328,7 +328,7 @@ describe('PageGrantService', () => {
         creator: user1,
         creator: user1,
         lastUpdateUser: user1,
         lastUpdateUser: user1,
         grantedUsers: null,
         grantedUsers: null,
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage1._id,
         parent: emptyPage1._id,
       },
       },
       {
       {
@@ -337,7 +337,7 @@ describe('PageGrantService', () => {
         creator: user1,
         creator: user1,
         lastUpdateUser: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
         grantedUsers: [user1._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage2._id,
         parent: emptyPage2._id,
       },
       },
       {
       {
@@ -364,7 +364,7 @@ describe('PageGrantService', () => {
         creator: user1,
         creator: user1,
         lastUpdateUser: user1,
         lastUpdateUser: user1,
         grantedUsers: [user1._id],
         grantedUsers: [user1._id],
-        grantedGroups: null,
+        grantedGroups: [],
         parent: emptyPage3._id,
         parent: emptyPage3._id,
       },
       },
     ]);
     ]);

+ 4 - 4
apps/app/test/integration/service/v5.page.test.ts

@@ -370,7 +370,7 @@ describe('Test page service methods', () => {
           status: 'published',
           status: 'published',
           grant: 1,
           grant: 1,
           grantedUsers: [],
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
         },
@@ -399,7 +399,7 @@ describe('Test page service methods', () => {
           status: 'published',
           status: 'published',
           grant: 1,
           grant: 1,
           grantedUsers: [],
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
         },
@@ -428,7 +428,7 @@ describe('Test page service methods', () => {
           status: 'published',
           status: 'published',
           grant: 1,
           grant: 1,
           grantedUsers: [],
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
         },
@@ -457,7 +457,7 @@ describe('Test page service methods', () => {
           status: 'published',
           status: 'published',
           grant: Page.GRANT_PUBLIC,
           grant: Page.GRANT_PUBLIC,
           grantedUsers: [],
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
         },

+ 1 - 1
apps/app/test/integration/service/v5.public-page.test.ts

@@ -453,7 +453,7 @@ describe('PageService page operations with only public pages', () => {
           status: 'published',
           status: 'published',
           grant: 1,
           grant: 1,
           grantedUsers: [],
           grantedUsers: [],
-          grantedGroups: null,
+          grantedGroups: [],
           creator: dummyUser1._id,
           creator: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
           lastUpdateUser: dummyUser1._id,
         },
         },