Ver código fonte

Merge pull request #8518 from weseek/fix/141309-141311-multi-group-grant-page-becomes-public-when-one-of-groups-deleted

fix: Multi group grant page becomes public when one of groups deleted
Futa Arai 2 anos atrás
pai
commit
5cc1d2e855

+ 3 - 2
apps/app/public/static/locales/en_US/admin.json

@@ -834,9 +834,10 @@
       "dropdown_desc": "Choose an action for private pages",
       "dropdown_desc": "Choose an action for private pages",
       "select_group": "Select a group",
       "select_group": "Select a group",
       "no_groups": "No groups to select",
       "no_groups": "No groups to select",
-      "publish_pages": "Publish all",
+      "publish_pages": "Publish pages that are publishable",
       "delete_pages": "Delete all",
       "delete_pages": "Delete all",
-      "transfer_pages": "Transfer to another group"
+      "transfer_pages": "Transfer to another group",
+      "option_explanation": "A \"publishable\" page is a page visible only to the group you want to delete. Pages that can be viewed by other groups will not be published."
     },
     },
     "update_parent_confirm_modal": {
     "update_parent_confirm_modal": {
       "header": "The parent of the group will be changed",
       "header": "The parent of the group will be changed",

+ 3 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -844,9 +844,10 @@
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "select_group": "グループを選択してください",
       "select_group": "グループを選択してください",
       "no_groups": "グループがありません",
       "no_groups": "グループがありません",
-      "publish_pages": "全て公開する",
+      "publish_pages": "公開可能なページを公開する",
       "delete_pages": "全て削除する",
       "delete_pages": "全て削除する",
-      "transfer_pages": "全て他のグループに移譲する"
+      "transfer_pages": "全て他のグループに移譲する",
+      "option_explanation": "「公開可能なページ」とは、削除するグループにのみ限定公開されているページを指します。他のグループも閲覧可能なページは公開対象となりません。"
     },
     },
     "update_parent_confirm_modal": {
     "update_parent_confirm_modal": {
       "header": "グループの親が変更されます",
       "header": "グループの親が変更されます",

+ 3 - 2
apps/app/public/static/locales/zh_CN/admin.json

@@ -843,9 +843,10 @@
       "dropdown_desc": "为私人页选择操作",
       "dropdown_desc": "为私人页选择操作",
       "select_group": "选择组",
       "select_group": "选择组",
       "no_groups": "没有可选择的组",
       "no_groups": "没有可选择的组",
-      "publish_pages": "全部发布",
+      "publish_pages": "发布可以发布的页面",
       "delete_pages": "全部删除",
       "delete_pages": "全部删除",
-      "transfer_pages": "转移到另一组"
+      "transfer_pages": "转移到另一组",
+      "option_explanation": "\"可发布页面\"是指仅对您要删除的群组可见的页面。其他群组可以查看的页面将不会被发布。"
     },
     },
     "update_parent_confirm_modal": {
     "update_parent_confirm_modal": {
       "header": "该组的父组被改变",
       "header": "该组的父组被改变",

+ 22 - 23
apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useCallback, useState, useMemo,
-} from 'react';
+import type { FC } from 'react';
+import React, { useCallback, useState, useMemo } from 'react';
 
 
 import type { IUserGroupHasId } from '@growi/core';
 import type { IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -8,6 +7,8 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
+
 
 
 /**
 /**
  * Delete User Group Select component
  * Delete User Group Select component
@@ -19,26 +20,19 @@ import {
 type Props = {
 type Props = {
   userGroups: IUserGroupHasId[],
   userGroups: IUserGroupHasId[],
   deleteUserGroup?: IUserGroupHasId,
   deleteUserGroup?: IUserGroupHasId,
-  onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
+  onDelete?: (deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => Promise<void> | void,
   isShow: boolean,
   isShow: boolean,
   onHide?: () => Promise<void> | void,
   onHide?: () => Promise<void> | void,
 };
 };
 
 
 type AvailableOption = {
 type AvailableOption = {
   id: number,
   id: number,
-  actionForPages: string,
+  actionForPages: PageActionOnGroupDelete,
   iconClass: string,
   iconClass: string,
   styleClass: string,
   styleClass: string,
   label: string,
   label: string,
 };
 };
 
 
-// actionName master constants
-const actionForPages = {
-  public: 'public',
-  delete: 'delete',
-  transfer: 'transfer',
-};
-
 export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -51,21 +45,21 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     return [
     return [
       {
       {
         id: 1,
         id: 1,
-        actionForPages: actionForPages.public,
+        actionForPages: PageActionOnGroupDelete.publicize,
         iconClass: 'icon-people',
         iconClass: 'icon-people',
         styleClass: '',
         styleClass: '',
         label: t('admin:user_group_management.delete_modal.publish_pages'),
         label: t('admin:user_group_management.delete_modal.publish_pages'),
       },
       },
       {
       {
         id: 2,
         id: 2,
-        actionForPages: actionForPages.delete,
+        actionForPages: PageActionOnGroupDelete.delete,
         iconClass: 'icon-trash',
         iconClass: 'icon-trash',
         styleClass: 'text-danger',
         styleClass: 'text-danger',
         label: t('admin:user_group_management.delete_modal.delete_pages'),
         label: t('admin:user_group_management.delete_modal.delete_pages'),
       },
       },
       {
       {
         id: 3,
         id: 3,
-        actionForPages: actionForPages.transfer,
+        actionForPages: PageActionOnGroupDelete.transfer,
         iconClass: 'icon-options',
         iconClass: 'icon-options',
         styleClass: '',
         styleClass: '',
         label: t('admin:user_group_management.delete_modal.transfer_pages'),
         label: t('admin:user_group_management.delete_modal.transfer_pages'),
@@ -76,14 +70,14 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   /*
   /*
    * State
    * State
    */
    */
-  const [actionName, setActionName] = useState<string>('');
+  const [actionName, setActionName] = useState<PageActionOnGroupDelete | null>(null);
   const [transferToUserGroupId, setTransferToUserGroupId] = useState<string>('');
   const [transferToUserGroupId, setTransferToUserGroupId] = useState<string>('');
 
 
   /*
   /*
    * Function
    * Function
    */
    */
   const resetStates = useCallback(() => {
   const resetStates = useCallback(() => {
-    setActionName('');
+    setActionName(null);
     setTransferToUserGroupId('');
     setTransferToUserGroupId('');
   }, []);
   }, []);
 
 
@@ -107,7 +101,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   }, []);
   }, []);
 
 
   const handleSubmit = useCallback((e) => {
   const handleSubmit = useCallback((e) => {
-    if (onDelete == null || deleteUserGroup == null) {
+    if (onDelete == null || deleteUserGroup == null || actionName == null) {
       return;
       return;
     }
     }
 
 
@@ -130,7 +124,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         name="actionName"
         name="actionName"
         className="form-control"
         className="form-control"
         placeholder="select"
         placeholder="select"
-        value={actionName}
+        value={actionName ?? ''}
         onChange={handleActionChange}
         onChange={handleActionChange}
       >
       >
         <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
         <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
@@ -158,7 +152,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     return (
     return (
       <select
       <select
         name="transferToUserGroupId"
         name="transferToUserGroupId"
-        className={`form-control ${actionName === actionForPages.transfer ? '' : 'd-none'}`}
+        className={`form-control ${actionName === PageActionOnGroupDelete.transfer ? '' : 'd-none'}`}
         value={transferToUserGroupId}
         value={transferToUserGroupId}
         onChange={handleGroupChange}
         onChange={handleGroupChange}
       >
       >
@@ -171,10 +165,10 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   const validateForm = useCallback(() => {
   const validateForm = useCallback(() => {
     let isValid = true;
     let isValid = true;
 
 
-    if (actionName === '') {
+    if (actionName === null) {
       isValid = false;
       isValid = false;
     }
     }
-    else if (actionName === actionForPages.transfer) {
+    else if (actionName === PageActionOnGroupDelete.transfer) {
       isValid = transferToUserGroupId !== '';
       isValid = transferToUserGroupId !== '';
     }
     }
 
 
@@ -196,7 +190,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
         <form className="d-flex justify-content-between w-100" onSubmit={handleSubmit}>
         <form className="d-flex justify-content-between w-100" onSubmit={handleSubmit}>
-          <div className="d-flex mb-0">
+          <div className="d-flex mb-0 me-3">
             {renderPageActionSelector()}
             {renderPageActionSelector()}
             {renderGroupSelector()}
             {renderGroupSelector()}
           </div>
           </div>
@@ -204,6 +198,11 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
             <span className="material-symbols-outlined">delete_forever</span> {t('Delete')}
             <span className="material-symbols-outlined">delete_forever</span> {t('Delete')}
           </button>
           </button>
         </form>
         </form>
+        {actionName === PageActionOnGroupDelete.publicize && (
+          <div className="form-text text-muted">
+            <small>{t('admin:user_group_management.delete_modal.option_explanation')}</small>
+          </div>
+        )}
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );

+ 4 - 2
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 import type { IUserGroup, IUserGroupHasId } from '@growi/core';
 import type { IUserGroup, IUserGroupHasId } from '@growi/core';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
@@ -9,6 +10,7 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 
 
 
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
@@ -126,7 +128,7 @@ export const UserGroupPage: FC = () => {
     }
     }
   }, [t, mutateUserGroups, hideUpdateModal]);
   }, [t, mutateUserGroups, hideUpdateModal]);
 
 
-  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     try {
     try {
       await apiv3Delete(`/user-groups/${deleteGroupId}`, {
       await apiv3Delete(`/user-groups/${deleteGroupId}`, {
         actionName,
         actionName,

+ 4 - 3
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -13,8 +13,9 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
-import { SearchTypes, SearchType } from '~/interfaces/user-group';
+import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { PageActionOnGroupDelete, SearchType } from '~/interfaces/user-group';
+import { SearchTypes } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
@@ -296,7 +297,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     setDeleteModalShown(false);
     setDeleteModalShown(false);
   }, [setSelectedUserGroup, setDeleteModalShown]);
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
 
-  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
     try {
       const res = await apiv3Delete(url, {
       const res = await apiv3Delete(url, {

+ 5 - 5
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -1,6 +1,5 @@
-import {
-  FC, useCallback, useMemo, useState,
-} from 'react';
+import type { FC } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
@@ -11,13 +10,14 @@ import { UserGroupDeleteModal } from '~/components/Admin/UserGroup/UserGroupDele
 import { UserGroupModal } from '~/components/Admin/UserGroup/UserGroupModal';
 import { UserGroupModal } from '~/components/Admin/UserGroup/UserGroupModal';
 import { UserGroupTable } from '~/components/Admin/UserGroup/UserGroupTable';
 import { UserGroupTable } from '~/components/Admin/UserGroup/UserGroupTable';
 import CustomNav from '~/components/CustomNavigation/CustomNav';
 import CustomNav from '~/components/CustomNavigation/CustomNav';
-import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 
 
 import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '../../stores/external-user-group';
 import { useSWRxChildExternalUserGroupList, useSWRxExternalUserGroupList, useSWRxExternalUserGroupRelationList } from '../../stores/external-user-group';
 
 
 import { KeycloakGroupManagement } from './KeycloakGroupManagement';
 import { KeycloakGroupManagement } from './KeycloakGroupManagement';
 import { LdapGroupManagement } from './LdapGroupManagement';
 import { LdapGroupManagement } from './LdapGroupManagement';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 
 export const ExternalGroupManagement: FC = () => {
 export const ExternalGroupManagement: FC = () => {
   const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
   const { data: externalUserGroupList, mutate: mutateExternalUserGroups } = useSWRxExternalUserGroupList();
@@ -93,7 +93,7 @@ export const ExternalGroupManagement: FC = () => {
     }
     }
   }, [t, mutateExternalUserGroups, hideUpdateModal]);
   }, [t, mutateExternalUserGroups, hideUpdateModal]);
 
 
-  const deleteExternalUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteExternalUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     try {
     try {
       await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
       await apiv3Delete(`/external-user-groups/${deleteGroupId}`, {
         actionName,
         actionName,

+ 6 - 3
apps/app/src/features/external-user-group/server/models/external-user-group.ts

@@ -1,7 +1,8 @@
-import { Schema, Model, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 
-import { IExternalUserGroup } from '~/features/external-user-group/interfaces/external-user-group';
+import type { IExternalUserGroup } from '~/features/external-user-group/interfaces/external-user-group';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 
@@ -12,7 +13,9 @@ export interface ExternalUserGroupModel extends Model<ExternalUserGroupDocument>
 
 
   PAGE_ITEMS: 10,
   PAGE_ITEMS: 10,
 
 
-  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
+  findGroupsWithDescendantsRecursively: (
+    groups: ExternalUserGroupDocument[], descendants?: ExternalUserGroupDocument[]
+  ) => Promise<ExternalUserGroupDocument[]>,
 }
 }
 
 
 const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({
 const schema = new Schema<ExternalUserGroupDocument, ExternalUserGroupModel>({

+ 8 - 5
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -1,6 +1,7 @@
 import { GroupType } from '@growi/core';
 import { GroupType } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { Router, Request } from 'express';
+import type { Request } from 'express';
+import { Router } from 'express';
 import {
 import {
   body, param, query, validationResult,
   body, param, query, validationResult,
 } from 'express-validator';
 } from 'express-validator';
@@ -8,13 +9,14 @@ import {
 import ExternalUserGroup from '~/features/external-user-group/server/models/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 ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
-import Crowi from '~/server/crowi';
+import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
+import type Crowi from '~/server/crowi';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
 import { serializeUserGroupRelationSecurely } from '~/server/models/serializers/user-group-relation-serializer';
-import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
-import UserGroupService from '~/server/service/user-group';
+import type UserGroupService from '~/server/service/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
 const logger = loggerFactory('growi:routes:apiv3:external-user-group');
@@ -148,7 +150,8 @@ module.exports = (crowi: Crowi): Router => {
   router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
   router.delete('/:id', loginRequiredStrictly, adminRequired, validators.delete, apiV3FormValidator, addActivity,
     async(req: AuthorizedRequest, res: ApiV3Response) => {
     async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id: deleteGroupId } = req.params;
       const { id: deleteGroupId } = req.params;
-      const { actionName, transferToUserGroupId } = req.query;
+      const { transferToUserGroupId } = req.query;
+      const actionName = req.query.actionName as PageActionOnGroupDelete;
 
 
       const transferGroupInfo = transferToUserGroupId != null ? {
       const transferGroupInfo = transferToUserGroupId != null ? {
         item: transferToUserGroupId as string,
         item: transferToUserGroupId as string,

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

@@ -5,3 +5,6 @@ export const SearchTypes = {
 } as const;
 } as const;
 
 
 export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];
 export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];
+
+export const PageActionOnGroupDelete = { publicize: 'publicize', delete: 'delete', transfer: 'transfer' } as const;
+export type PageActionOnGroupDelete = typeof PageActionOnGroupDelete[keyof typeof PageActionOnGroupDelete];

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

@@ -646,21 +646,6 @@ export const getPageSchema = (crowi) => {
     return await queryBuilder.query.exec();
     return await queryBuilder.query.exec();
   };
   };
 
 
-  pageSchema.statics.publicizePages = async function(pages) {
-    const operationsToPublicize = pages.map((page) => {
-      return {
-        updateOne: {
-          filter: { _id: page._id },
-          update: {
-            grantedGroups: [],
-            grant: this.GRANT_PUBLIC,
-          },
-        },
-      };
-    });
-    await this.bulkWrite(operationsToPublicize);
-  };
-
   /**
   /**
    * transfer pages grant to specified user group
    * transfer pages grant to specified user group
    * @param {Page[]} pages
    * @param {Page[]} pages

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

@@ -7,7 +7,7 @@ import {
   type IPage,
   type IPage,
   GroupType, type HasObjectId,
   GroupType, type HasObjectId,
 } from '@growi/core';
 } from '@growi/core';
-import { isPopulated } from '@growi/core/dist/interfaces';
+import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import escapeStringRegexp from 'escape-string-regexp';
@@ -18,6 +18,7 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
+import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import type { IOptionsForCreate } from '~/interfaces/page';
 import type { IOptionsForCreate } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
@@ -27,6 +28,7 @@ import { collectAncestorPaths } from '../util/collect-ancestor-paths';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
+import type { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 import UserGroupRelation from './user-group-relation';
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
@@ -82,6 +84,7 @@ export interface PageModel extends Model<PageDocument> {
     templateBody?: string,
     templateBody?: string,
     templateTags?: string[],
     templateTags?: string[],
   }>
   }>
+  removeGroupsToDeleteFromPages(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]): Promise<void>
 
 
   PageQueryBuilder: typeof PageQueryBuilder
   PageQueryBuilder: typeof PageQueryBuilder
 
 
@@ -1038,6 +1041,39 @@ schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promi
   return ancestors[0];
   return ancestors[0];
 };
 };
 
 
+schema.statics.removeGroupsToDeleteFromPages = async function(pages: PageDocument[], groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[]) {
+  const groupsToDeleteIds = groupsToDelete.map(group => group._id.toString());
+  const pageGroups = pages.reduce((acc: { canPublicize: PageDocument[], cannotPublicize: PageDocument[] }, page) => {
+    const canPublicize = page.grantedGroups.every(group => groupsToDeleteIds.includes(getIdForRef(group.item).toString()));
+    acc[canPublicize ? 'canPublicize' : 'cannotPublicize'].push(page);
+    return acc;
+  }, { canPublicize: [], cannotPublicize: [] });
+
+  // Only publicize pages that can only be accessed by the groups to be deleted
+  const publicizeQueries = pageGroups.canPublicize.map((page) => {
+    return {
+      updateOne: {
+        filter: { _id: page._id },
+        update: {
+          grantedGroups: [],
+          grant: this.GRANT_PUBLIC,
+        },
+      },
+    };
+  });
+  // Remove the groups to be deleted from the grantedGroups of the pages that can be accessed by other groups
+  const removeFromGrantedGroupsQueries = pageGroups.cannotPublicize.map((page) => {
+    return {
+      updateOne: {
+        filter: { _id: page._id },
+        update: { $set: { grantedGroups: page.grantedGroups.filter(group => !groupsToDeleteIds.includes(getIdForRef(group.item).toString())) } },
+      },
+    };
+  });
+
+  await this.bulkWrite([...publicizeQueries, ...removeFromGrantedGroupsQueries]);
+};
+
 /*
 /*
  * get latest revision body length
  * get latest revision body length
  */
  */

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

@@ -1,5 +1,6 @@
 import type { IUserGroup } from '@growi/core';
 import type { IUserGroup } from '@growi/core';
-import { Schema, Model, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -12,7 +13,7 @@ export interface UserGroupModel extends Model<UserGroupDocument> {
 
 
   PAGE_ITEMS: 10,
   PAGE_ITEMS: 10,
 
 
-  findGroupsWithDescendantsRecursively: (groups, descendants?) => any,
+  findGroupsWithDescendantsRecursively: (groups: UserGroupDocument[], descendants?: UserGroupDocument[]) => Promise<UserGroupDocument[]>,
 }
 }
 
 
 /*
 /*
@@ -109,7 +110,9 @@ schema.statics.findGroupsWithAncestorsRecursively = async function(group, ancest
  * @param descendants UserGroupDocument[]
  * @param descendants UserGroupDocument[]
  * @returns UserGroupDocument[]
  * @returns UserGroupDocument[]
  */
  */
-schema.statics.findGroupsWithDescendantsRecursively = async function(groups, descendants = groups) {
+schema.statics.findGroupsWithDescendantsRecursively = async function(
+    groups: UserGroupDocument[], descendants: UserGroupDocument[] = groups,
+): Promise<UserGroupDocument[]> {
   const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
   const nextGroups = await this.find({ parent: { $in: groups.map(g => g._id) } });
 
 
   if (nextGroups.length === 0) {
   if (nextGroups.length === 0) {

+ 12 - 13
apps/app/src/server/service/page/index.ts

@@ -18,6 +18,7 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 
 
 import { Comment } from '~/features/comment/server';
 import { Comment } from '~/features/comment/server';
+import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
@@ -30,6 +31,7 @@ import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import {
 import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
 } from '~/interfaces/page-operation';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { CreateMethod } from '~/server/models/page';
 import type { CreateMethod } from '~/server/models/page';
 import {
 import {
@@ -37,6 +39,7 @@ import {
 } from '~/server/models/page';
 } from '~/server/models/page';
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
+import type { UserGroupDocument } from '~/server/models/user-group';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -2504,23 +2507,19 @@ class PageService implements IPageService {
   }
   }
 
 
 
 
-  async handlePrivatePagesForGroupsToDelete(groupsToDelete, action, transferToUserGroup: IGrantedGroup, user) {
-    const Page = this.crowi.model('Page');
-    const pages = await Page.find({
-      grantedGroups: {
-        $elemMatch: {
-          item: { $in: groupsToDelete },
-        },
-      },
-    });
+  async handlePrivatePagesForGroupsToDelete(
+      groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[], action: PageActionOnGroupDelete, transferToUserGroup: IGrantedGroup, user,
+  ): Promise<void> {
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const pages = await Page.find({ grantedGroups: { $elemMatch: { item: { $in: groupsToDelete } } } });
 
 
     switch (action) {
     switch (action) {
-      case 'public':
-        await Page.publicizePages(pages);
+      case PageActionOnGroupDelete.publicize:
+        await Page.removeGroupsToDeleteFromPages(pages, groupsToDelete);
         break;
         break;
-      case 'delete':
+      case PageActionOnGroupDelete.delete:
         return this.deleteMultipleCompletely(pages, user);
         return this.deleteMultipleCompletely(pages, user);
-      case 'transfer':
+      case PageActionOnGroupDelete.transfer:
         await Page.transferPagesToGroup(pages, transferToUserGroup);
         await Page.transferPagesToGroup(pages, transferToUserGroup);
         break;
         break;
       default:
       default:

+ 20 - 10
apps/app/src/server/service/user-group.ts

@@ -1,21 +1,31 @@
 import type { IUser, IGrantedGroup } from '@growi/core';
 import type { IUser, IGrantedGroup } from '@growi/core';
-import { DeleteResult } from 'mongodb';
-import { Model } from 'mongoose';
+import type { DeleteResult } from 'mongodb';
+import type { Model } from 'mongoose';
 
 
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
-import UserGroup, { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { UserGroupDocument, UserGroupModel } from '~/server/models/user-group';
+import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds, includesObjectIds } from '~/server/util/compare-objectId';
 import { excludeTestIdsFromTargetIds, includesObjectIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import UserGroupRelation, { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
+import type { UserGroupRelationDocument, UserGroupRelationModel } from '../models/user-group-relation';
+import UserGroupRelation from '../models/user-group-relation';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 
 
 
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:service:UserGroupService'); // eslint-disable-line no-unused-vars
 
 
+export interface IUserGroupService {
+  init(): Promise<void>;
+  updateGroup(id: ObjectIdLike, name?: string, description?: string, parentId?: ObjectIdLike | null, forceUpdateParents?: boolean): Promise<UserGroupDocument>;
+  removeCompletelyByRootGroupId(deleteRootGroupId: ObjectIdLike, action: string, user: IUser, transferToUserGroup?: IGrantedGroup): Promise<DeleteResult>;
+  removeUserByUsername(userGroupId: ObjectIdLike, username: string): Promise<{user: IUser, deletedGroupsCount: number}>;
+}
+
 /**
 /**
  * the service class of UserGroupService
  * the service class of UserGroupService
  */
  */
-class UserGroupService {
+class UserGroupService implements IUserGroupService {
 
 
   crowi: any;
   crowi: any;
 
 
@@ -23,13 +33,13 @@ class UserGroupService {
     this.crowi = crowi;
     this.crowi = crowi;
   }
   }
 
 
-  async init() {
+  async init(): Promise<void> {
     logger.debug('removing all invalid relations');
     logger.debug('removing all invalid relations');
     return UserGroupRelation.removeAllInvalidRelations();
     return UserGroupRelation.removeAllInvalidRelations();
   }
   }
 
 
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
   // ref: https://dev.growi.org/61b2cdabaa330ce7d8152844
-  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false) {
+  async updateGroup(id, name?: string, description?: string, parentId?: string | null, forceUpdateParents = false): Promise<UserGroupDocument> {
     const userGroup = await UserGroup.findById(id);
     const userGroup = await UserGroup.findById(id);
     if (userGroup == null) {
     if (userGroup == null) {
       throw new Error('The group does not exist');
       throw new Error('The group does not exist');
@@ -115,7 +125,7 @@ class UserGroupService {
   }
   }
 
 
   async removeCompletelyByRootGroupId(
   async removeCompletelyByRootGroupId(
-      deleteRootGroupId, action, user, transferToUserGroup?: IGrantedGroup,
+      deleteRootGroupId, action: PageActionOnGroupDelete, user, transferToUserGroup?: IGrantedGroup,
       userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
       userGroupModel: Model<UserGroupDocument> & UserGroupModel = UserGroup,
       userGroupRelationModel: Model<UserGroupRelationDocument> & UserGroupRelationModel = UserGroupRelation,
       userGroupRelationModel: Model<UserGroupRelationDocument> & UserGroupRelationModel = UserGroupRelation,
   ): Promise<DeleteResult> {
   ): Promise<DeleteResult> {
@@ -144,7 +154,7 @@ class UserGroupService {
       User.findUserByUsername(username),
       User.findUserByUsername(username),
     ]);
     ]);
 
 
-    const groupsOfRelationsToDelete = await UserGroup.findGroupsWithDescendantsRecursively([userGroup]);
+    const groupsOfRelationsToDelete = userGroup != null ? await UserGroup.findGroupsWithDescendantsRecursively([userGroup]) : [];
     const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map(g => g._id);
     const relatedGroupIdsToDelete = groupsOfRelationsToDelete.map(g => g._id);
 
 
     const deleteManyRes = await UserGroupRelation.deleteMany({ relatedUser: user._id, relatedGroup: { $in: relatedGroupIdsToDelete } });
     const deleteManyRes = await UserGroupRelation.deleteMany({ relatedUser: user._id, relatedGroup: { $in: relatedGroupIdsToDelete } });

+ 2 - 0
apps/app/test/integration/models/v5.page.test.js

@@ -1531,6 +1531,7 @@ describe('Page', () => {
             // userB group remains, although options does not include it
             // userB group remains, although options does not include it
             { item: userGroupIdPModelB, type: GroupType.userGroup },
             { item: userGroupIdPModelB, type: GroupType.userGroup },
           ]));
           ]));
+          expect(normalizeGrantedGroups(page.grantedGroups).length).toBe(3);
         });
         });
       });
       });
     });
     });
@@ -1671,6 +1672,7 @@ describe('Page', () => {
         { item: upodUserGroupIdB, type: GroupType.userGroup },
         { item: upodUserGroupIdB, type: GroupType.userGroup },
         { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
         { item: upodExternalUserGroupIdB, type: GroupType.externalUserGroup },
       ]));
       ]));
+      expect(normalizeGrantedGroups(upodPagegAgBUpdated.grantedGroups).length).toBe(4);
 
 
       // Not changed
       // Not changed
       expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);
       expect(upodPagegBUpdated.grant).toBe(PageGrant.GRANT_USER_GROUP);

+ 240 - 116
apps/app/test/integration/service/user-groups.test.ts

@@ -1,13 +1,23 @@
 
 
+import type { IGrantedGroup } from '@growi/core';
+import {
+  PageGrant, type IPage, GroupType, getIdForRef,
+} from '@growi/core';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import type { PageDocument, PageModel } from '../../../src/server/models/page';
+import UserGroup from '../../../src/server/models/user-group';
+import UserGroupRelation from '../../../src/server/models/user-group-relation';
+import type { IUserGroupService } from '../../../src/server/service/user-group';
 import { getInstance } from '../setup-crowi';
 import { getInstance } from '../setup-crowi';
+import { PageActionOnGroupDelete } from '../../../src/interfaces/user-group';
 
 
 describe('UserGroupService', () => {
 describe('UserGroupService', () => {
   let crowi;
   let crowi;
   let User;
   let User;
-  let UserGroup;
-  let UserGroupRelation;
+  let Page: PageModel;
+
+  let userGroupService: IUserGroupService;
 
 
   const groupId1 = new mongoose.Types.ObjectId();
   const groupId1 = new mongoose.Types.ObjectId();
   const groupId2 = new mongoose.Types.ObjectId();
   const groupId2 = new mongoose.Types.ObjectId();
@@ -21,14 +31,33 @@ describe('UserGroupService', () => {
   const groupId10 = new mongoose.Types.ObjectId();
   const groupId10 = new mongoose.Types.ObjectId();
   const groupId11 = new mongoose.Types.ObjectId();
   const groupId11 = new mongoose.Types.ObjectId();
   const groupId12 = new mongoose.Types.ObjectId();
   const groupId12 = new mongoose.Types.ObjectId();
+  const groupId13 = new mongoose.Types.ObjectId();
+  const groupId14 = new mongoose.Types.ObjectId();
+  const groupId15 = new mongoose.Types.ObjectId();
 
 
   const userId1 = new mongoose.Types.ObjectId();
   const userId1 = new mongoose.Types.ObjectId();
+  let user1;
+
+  const pageId1 = new mongoose.Types.ObjectId();
+  const pageId2 = new mongoose.Types.ObjectId();
+
+  let rootPage: PageDocument | null;
+
+  // normalize for result comparison
+  const normalizeGrantedGroups = (grantedGroups: IGrantedGroup[] | undefined) => {
+    if (grantedGroups == null) { return null }
+    return grantedGroups.map((group) => {
+      return { item: getIdForRef(group.item), type: group.type };
+    });
+  };
 
 
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
     User = mongoose.model('User');
     User = mongoose.model('User');
-    UserGroup = mongoose.model('UserGroup');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
+    Page = mongoose.model<IPage, PageModel>('Page');
+
+    rootPage = await Page.findOne({ path: '/' });
+    userGroupService = crowi.userGroupService;
 
 
     await User.insertMany([
     await User.insertMany([
       // ug -> User Group
       // ug -> User Group
@@ -36,6 +65,7 @@ describe('UserGroupService', () => {
         _id: userId1, name: 'ug_test_user1', username: 'ug_test_user1', email: 'ug_test_user1@example.com',
         _id: userId1, name: 'ug_test_user1', username: 'ug_test_user1', email: 'ug_test_user1@example.com',
       },
       },
     ]);
     ]);
+    user1 = await User.findOne({ _id: userId1 });
 
 
 
 
     // Create Groups
     // Create Groups
@@ -111,6 +141,24 @@ describe('UserGroupService', () => {
         description: 'description12',
         description: 'description12',
         parent: groupId11,
         parent: groupId11,
       },
       },
+      // for removeCompletelyByRootGroupId test
+      {
+        _id: groupId13,
+        name: 'v5_group13',
+        description: 'description13',
+      },
+      {
+        _id: groupId14,
+        name: 'v5_group14',
+        description: 'description14',
+        parent: groupId13,
+      },
+      {
+        _id: groupId15,
+        name: 'v5_group15',
+        description: 'description15',
+        parent: groupId15,
+      },
     ]);
     ]);
 
 
     // Create UserGroupRelations
     // Create UserGroupRelations
@@ -145,139 +193,215 @@ describe('UserGroupService', () => {
       },
       },
     ]);
     ]);
 
 
+    await Page.insertMany([
+      {
+        _id: pageId1,
+        path: '/canBePublicized',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: userId1,
+        lastUpdateUser: userId1,
+        grantedGroups: [
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId14, type: GroupType.userGroup },
+        ],
+        parent: rootPage?._id,
+      },
+      {
+        _id: pageId2,
+        path: '/cannotBePublicized',
+        grant: PageGrant.GRANT_USER_GROUP,
+        creator: userId1,
+        lastUpdateUser: userId1,
+        grantedGroups: [
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId15, type: GroupType.userGroup },
+        ],
+        parent: rootPage?._id,
+      },
+    ]);
+
   });
   });
 
 
   /*
   /*
     * Update UserGroup
     * Update UserGroup
     */
     */
-  test('Updated values should be reflected. (name, description, parent)', async() => {
-    const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
+  describe('updateGroup', () => {
+    test('Updated values should be reflected. (name, description, parent)', async() => {
+      const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
 
-    const newGroupName = 'v5_group1_new';
-    const newGroupDescription = 'description1_new';
-    const newParentId = userGroup2._id;
+      const newGroupName = 'v5_group1_new';
+      const newGroupDescription = 'description1_new';
+      const newParentId = userGroup2?._id;
 
 
-    const updatedUserGroup = await crowi.userGroupService.updateGroup(groupId1, newGroupName, newGroupDescription, newParentId);
+      const updatedUserGroup = await userGroupService.updateGroup(groupId1, newGroupName, newGroupDescription, newParentId);
 
 
-    expect(updatedUserGroup.name).toBe(newGroupName);
-    expect(updatedUserGroup.description).toBe(newGroupDescription);
-    expect(updatedUserGroup.parent).toStrictEqual(newParentId);
-  });
+      expect(updatedUserGroup.name).toBe(newGroupName);
+      expect(updatedUserGroup.description).toBe(newGroupDescription);
+      expect(updatedUserGroup.parent).toStrictEqual(newParentId);
+    });
 
 
-  test('Should throw an error when trying to set existing group name', async() => {
+    test('Should throw an error when trying to set existing group name', async() => {
 
 
-    const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
+      const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
 
 
-    const result = crowi.userGroupService.updateGroup(groupId1, userGroup2.name);
+      const result = userGroupService.updateGroup(groupId1, userGroup2?.name);
 
 
-    await expect(result).rejects.toThrow('The group name is already taken');
-  });
+      await expect(result).rejects.toThrow('The group name is already taken');
+    });
 
 
-  test('Parent should be null when parent group is released', async() => {
-    const userGroup = await UserGroup.findOne({ _id: groupId3 });
-    const updatedUserGroup = await crowi.userGroupService.updateGroup(userGroup._id, userGroup.name, userGroup.description, null);
+    test('Parent should be null when parent group is released', async() => {
+      const userGroup = await UserGroup.findOne({ _id: groupId3 });
+      const updatedUserGroup = await userGroupService.updateGroup(userGroup?._id, userGroup?.name, userGroup?.description, null);
 
 
-    expect(updatedUserGroup.parent).toBeNull();
-  });
-
-  /*
-  * forceUpdateParents: false
-  */
-  test('Should throw an error when users in child group do not exist in parent group', async() => {
-    const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
-    const result = crowi.userGroupService.updateGroup(userGroup4._id, userGroup4.name, userGroup4.description, groupId5);
+      expect(updatedUserGroup.parent).toBeNull();
+    });
 
 
-    await expect(result).rejects.toThrow('The parent group does not contain the users in this group.');
-  });
+    /*
+    * forceUpdateParents: false
+    */
+    test('Should throw an error when users in child group do not exist in parent group', async() => {
+      const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
+      const result = userGroupService.updateGroup(userGroup4?._id, userGroup4?.name, userGroup4?.description, groupId5);
 
 
-  /*
-  * forceUpdateParents: true
-  */
-  test('User should be included to parent group (2 groups ver)', async() => {
-    const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
-    const userGroup5 = await UserGroup.findOne({ _id: groupId5, parent: null });
-    // userGroup4 has userId1
-    const userGroupRelation4BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup4, relatedUser: userId1 });
-    expect(userGroupRelation4BeforeUpdate).not.toBeNull();
-
-    // userGroup5 has not userId1
-    const userGroupRelation5BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup5, relatedUser: userId1 });
-    expect(userGroupRelation5BeforeUpdate).toBeNull();
-
-    // update userGroup4's parent with userGroup5 (forceUpdate: true)
-    const forceUpdateParents = true;
-    const updatedUserGroup = await crowi.userGroupService.updateGroup(
-      userGroup4._id, userGroup4.name, userGroup4.description, groupId5, forceUpdateParents,
-    );
-
-    expect(updatedUserGroup.parent).toStrictEqual(groupId5);
-    // userGroup5 should have userId1
-    const userGroupRelation5AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId5, relatedUser: userGroupRelation4BeforeUpdate.relatedUser });
-    expect(userGroupRelation5AfterUpdate).not.toBeNull();
-  });
+      await expect(result).rejects.toThrow('The parent group does not contain the users in this group.');
+    });
 
 
-  test('User should be included to parent group (3 groups ver)', async() => {
-    const userGroup8 = await UserGroup.findOne({ _id: groupId8, parent: null });
-
-    // userGroup7 has not userId1
-    const userGroupRelation6BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId6, relatedUser: userId1 });
-    const userGroupRelation7BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId7, relatedUser: userId1 });
-    const userGroupRelation8BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId8, relatedUser: userId1 });
-    expect(userGroupRelation6BeforeUpdate).not.toBeNull();
-    // userGroup7 does not have userId1
-    expect(userGroupRelation7BeforeUpdate).toBeNull();
-    expect(userGroupRelation8BeforeUpdate).not.toBeNull();
-
-    // update userGroup8's parent with userGroup7 (forceUpdate: true)
-    const forceUpdateParents = true;
-    await crowi.userGroupService.updateGroup(
-      userGroup8._id, userGroup8.name, userGroup8.description, groupId7, forceUpdateParents,
-    );
-
-    const userGroupRelation6AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId6, relatedUser: userId1 });
-    const userGroupRelation7AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId7, relatedUser: userId1 });
-    const userGroupRelation8AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId8, relatedUser: userId1 });
-    expect(userGroupRelation6AfterUpdate).not.toBeNull();
-    // userGroup7 should have userId1
-    expect(userGroupRelation7AfterUpdate).not.toBeNull();
-    expect(userGroupRelation8AfterUpdate).not.toBeNull();
+    /*
+    * forceUpdateParents: true
+    */
+    test('User should be included to parent group (2 groups ver)', async() => {
+      const userGroup4 = await UserGroup.findOne({ _id: groupId4, parent: null });
+      const userGroup5 = await UserGroup.findOne({ _id: groupId5, parent: null });
+      // userGroup4 has userId1
+      const userGroupRelation4BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup4, relatedUser: userId1 });
+      expect(userGroupRelation4BeforeUpdate).not.toBeNull();
+
+      // userGroup5 has not userId1
+      const userGroupRelation5BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup5, relatedUser: userId1 });
+      expect(userGroupRelation5BeforeUpdate).toBeNull();
+
+      // update userGroup4's parent with userGroup5 (forceUpdate: true)
+      const forceUpdateParents = true;
+      const updatedUserGroup = await userGroupService.updateGroup(
+        userGroup4?._id, userGroup4?.name, userGroup4?.description, groupId5, forceUpdateParents,
+      );
+
+      expect(updatedUserGroup.parent).toStrictEqual(groupId5);
+      // userGroup5 should have userId1
+      const userGroupRelation5AfterUpdate = await UserGroupRelation.findOne({
+        relatedGroup: groupId5, relatedUser: userGroupRelation4BeforeUpdate?.relatedUser,
+      });
+      expect(userGroupRelation5AfterUpdate).not.toBeNull();
+    });
+
+    test('User should be included to parent group (3 groups ver)', async() => {
+      const userGroup8 = await UserGroup.findOne({ _id: groupId8, parent: null });
+
+      // userGroup7 has not userId1
+      const userGroupRelation6BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId6, relatedUser: userId1 });
+      const userGroupRelation7BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId7, relatedUser: userId1 });
+      const userGroupRelation8BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  groupId8, relatedUser: userId1 });
+      expect(userGroupRelation6BeforeUpdate).not.toBeNull();
+      // userGroup7 does not have userId1
+      expect(userGroupRelation7BeforeUpdate).toBeNull();
+      expect(userGroupRelation8BeforeUpdate).not.toBeNull();
+
+      // update userGroup8's parent with userGroup7 (forceUpdate: true)
+      const forceUpdateParents = true;
+      await userGroupService.updateGroup(
+        userGroup8?._id, userGroup8?.name, userGroup8?.description, groupId7, forceUpdateParents,
+      );
+
+      const userGroupRelation6AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId6, relatedUser: userId1 });
+      const userGroupRelation7AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId7, relatedUser: userId1 });
+      const userGroupRelation8AfterUpdate = await UserGroupRelation.findOne({ relatedGroup: groupId8, relatedUser: userId1 });
+      expect(userGroupRelation6AfterUpdate).not.toBeNull();
+      // userGroup7 should have userId1
+      expect(userGroupRelation7AfterUpdate).not.toBeNull();
+      expect(userGroupRelation8AfterUpdate).not.toBeNull();
+    });
+
+    test('Should throw an error when trying to choose parent from descendant groups.', async() => {
+      const userGroup9 = await UserGroup.findOne({ _id: groupId9, parent: null });
+      const userGroup10 = await UserGroup.findOne({ _id: groupId10, parent: groupId9 });
+
+      const userGroupRelation9BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup9?._id, relatedUser: userId1 });
+      const userGroupRelation10BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup10?._id, relatedUser: userId1 });
+      expect(userGroupRelation9BeforeUpdate).not.toBeNull();
+      expect(userGroupRelation10BeforeUpdate).not.toBeNull();
+
+      const result = userGroupService.updateGroup(
+        userGroup9?._id, userGroup9?.name, userGroup9?.description, userGroup10?._id,
+      );
+      await expect(result).rejects.toThrow('It is not allowed to choose parent from descendant groups.');
+    });
   });
   });
 
 
-  test('Should throw an error when trying to choose parent from descendant groups.', async() => {
-    const userGroup9 = await UserGroup.findOne({ _id: groupId9, parent: null });
-    const userGroup10 = await UserGroup.findOne({ _id: groupId10, parent: groupId9 });
-
-    const userGroupRelation9BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup9._id, relatedUser: userId1 });
-    const userGroupRelation10BeforeUpdate = await UserGroupRelation.findOne({ relatedGroup:  userGroup10._id, relatedUser: userId1 });
-    expect(userGroupRelation9BeforeUpdate).not.toBeNull();
-    expect(userGroupRelation10BeforeUpdate).not.toBeNull();
-
-    const result = crowi.userGroupService.updateGroup(
-      userGroup9._id, userGroup9.name, userGroup9.description, userGroup10._id,
-    );
-    await expect(result).rejects.toThrow('It is not allowed to choose parent from descendant groups.');
+  describe('removeUserByUsername', () => {
+    test('User should be deleted from child groups when the user excluded from the parent group', async() => {
+      const userGroup11 = await UserGroup.findOne({ _id: groupId11, parent: null });
+      const userGroup12 = await UserGroup.findOne({ _id: groupId12, parent: groupId11 });
+
+      // Both groups have user1
+      const userGroupRelation11BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11?._id, relatedUser: userId1 });
+      const userGroupRelation12BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12?._id, relatedUser: userId1 });
+      expect(userGroupRelation11BeforeRemove).not.toBeNull();
+      expect(userGroupRelation12BeforeRemove).not.toBeNull();
+
+      // remove user1 from the parent group
+      await userGroupService.removeUserByUsername(
+        userGroup11?._id, 'ug_test_user1',
+      );
+
+      // Both groups have not user1
+      const userGroupRelation11AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11?._id, relatedUser: userId1 });
+      const userGroupRelation12AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12?._id, relatedUser: userId1 });
+      await expect(userGroupRelation11AfterRemove).toBeNull();
+      await expect(userGroupRelation12AfterRemove).toBeNull();
+    });
   });
   });
 
 
-  test('User should be deleted from child groups when the user excluded from the parent group', async() => {
-    const userGroup11 = await UserGroup.findOne({ _id: groupId11, parent: null });
-    const userGroup12 = await UserGroup.findOne({ _id: groupId12, parent: groupId11 });
-
-    // Both groups have user1
-    const userGroupRelation11BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11._id, relatedUser: userId1 });
-    const userGroupRelation12BeforeRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12._id, relatedUser: userId1 });
-    expect(userGroupRelation11BeforeRemove).not.toBeNull();
-    expect(userGroupRelation12BeforeRemove).not.toBeNull();
-
-    // remove user1 from the parent group
-    await crowi.userGroupService.removeUserByUsername(
-      userGroup11._id, 'ug_test_user1',
-    );
-
-    // Both groups have not user1
-    const userGroupRelation11AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup11._id, relatedUser: userId1 });
-    const userGroupRelation12AfterRemove = await UserGroupRelation.findOne({ relatedGroup:  userGroup12._id, relatedUser: userId1 });
-    await expect(userGroupRelation11AfterRemove).toBeNull();
-    await expect(userGroupRelation12AfterRemove).toBeNull();
+  describe('removeCompletelyByRootGroupId', () => {
+    describe('when action is public', () => {
+      test('Should remove the group and its descendants and publicize pages that are only visible to the groups to be removed', async() => {
+        const userGroup13 = await UserGroup.findOne({ _id: groupId13 });
+        const userGroup14 = await UserGroup.findOne({ _id: groupId14 });
+        expect(userGroup13).not.toBeNull();
+        expect(userGroup14).not.toBeNull();
+
+        const canBePublicized = await Page.findOne({ _id: pageId1 });
+        const cannotBePublicized = await Page.findOne({ _id: pageId2 });
+        expect(canBePublicized?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+        expect(normalizeGrantedGroups(canBePublicized?.grantedGroups)).toEqual(expect.arrayContaining([
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId14, type: GroupType.userGroup },
+        ]));
+        expect(normalizeGrantedGroups(canBePublicized?.grantedGroups)?.length).toBe(2);
+        expect(cannotBePublicized?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+        expect(normalizeGrantedGroups(cannotBePublicized?.grantedGroups)).toEqual(expect.arrayContaining([
+          { item: groupId13, type: GroupType.userGroup },
+          { item: groupId15, type: GroupType.userGroup },
+        ]));
+        expect(normalizeGrantedGroups(cannotBePublicized?.grantedGroups)?.length).toBe(2);
+
+        await userGroupService.removeCompletelyByRootGroupId(groupId13, PageActionOnGroupDelete.publicize, user1);
+
+        const userGroup13AfterDeleteProcess = await UserGroup.findOne({ _id: groupId13 });
+        const userGroup14AfterDeleteProcess = await UserGroup.findOne({ _id: groupId14 });
+        expect(userGroup13AfterDeleteProcess).toBeNull();
+        expect(userGroup14AfterDeleteProcess).toBeNull();
+
+        const canBePublicizedAfterDeleteProcess = await Page.findOne({ _id: pageId1 });
+        const cannotBePublicizedAfterDeleteProcess = await Page.findOne({ _id: pageId2 });
+        expect(canBePublicizedAfterDeleteProcess?.grant).toBe(PageGrant.GRANT_PUBLIC);
+        expect(normalizeGrantedGroups(canBePublicizedAfterDeleteProcess?.grantedGroups)).toEqual([]);
+        expect(cannotBePublicizedAfterDeleteProcess?.grant).toBe(PageGrant.GRANT_USER_GROUP);
+        expect(normalizeGrantedGroups(cannotBePublicizedAfterDeleteProcess?.grantedGroups)).toEqual(expect.arrayContaining([
+          { item: groupId15, type: GroupType.userGroup },
+        ]));
+        expect(normalizeGrantedGroups(cannotBePublicizedAfterDeleteProcess?.grantedGroups)?.length).toBe(1);
+      });
+    });
   });
   });
 
 
 });
 });