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

Merge branch 'master' into feat/search-plpages

Haku Mizuki 4 лет назад
Родитель
Сommit
42c44788e2

+ 2 - 1
.github/workflows/draft-release.yml

@@ -50,8 +50,9 @@ jobs:
           RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
           RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
           echo ::set-output name=RELEASE_VERSION::$RELEASE_VERSION
           echo ::set-output name=RELEASE_VERSION::$RELEASE_VERSION
 
 
+      # See: https://github.com/bakunyo/git-pr-release-action/issues/15, https://github.com/samunohito/SimpleVolumeMixer/commit/2059044c71236509466cf9b1bb2d56d515274938
       - name: Create/Update Pull Request
       - name: Create/Update Pull Request
-        uses: bakunyo/git-pr-release-action@master
+        uses: bakunyo/git-pr-release-action@281e1fe424fac01f3992542266805e4202a22fe0
         env:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current
           GIT_PR_RELEASE_BRANCH_PRODUCTION: release/current

+ 14 - 0
CHANGELOG.md

@@ -102,6 +102,20 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 
 
+## [v4.5.18](https://github.com/weseek/growi/compare/v4.5.17...v4.5.18) - 2022-04-15
+
+### 🐛 Bug Fixes
+
+- fix: One Time Token is not available for v4.5.x (#5713) @miya
+- fix: Prevent auto completing email with username stored by browser in /me page for v4.5.x (#5703) @Yohei-Shiina
+- fix: Page view count stops at 15 (#5705) @miya
+
+## [v4.5.17](https://github.com/weseek/growi/compare/v4.5.16...v4.5.17) - 2022-04-07
+
+### 🐛 Bug Fixes
+
+* fix: Elasticsearch doesn't work properly on production (#5676) @Yohei-Shiina  
+
 ## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
 ## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
 
 
 ### 💎 Features
 ### 💎 Features

+ 35 - 36
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -1,24 +1,23 @@
 import React, { useState, useCallback, useEffect } from 'react';
 import React, { useState, useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { useTranslation } from 'react-i18next';
-
-import loggerFactory from '~/utils/logger';
-
 import {
 import {
   IPageInfoAll, isIPageInfoForOperation,
   IPageInfoAll, isIPageInfoForOperation,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
 
 
 export const MenuItemType = {
 export const MenuItemType = {
   BOOKMARK: 'bookmark',
   BOOKMARK: 'bookmark',
-  DUPLICATE: 'duplicate',
   RENAME: 'rename',
   RENAME: 'rename',
+  DUPLICATE: 'duplicate',
   DELETE: 'delete',
   DELETE: 'delete',
   REVERT: 'revert',
   REVERT: 'revert',
 } as const;
 } as const;
@@ -34,8 +33,8 @@ type CommonProps = {
   forceHideMenuItems?: ForceHideMenuItems,
   forceHideMenuItems?: ForceHideMenuItems,
 
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
 
 
@@ -55,7 +54,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const {
   const {
     pageId, isLoading,
     pageId, isLoading,
     pageInfo, isEnableActions, forceHideMenuItems,
     pageInfo, isEnableActions, forceHideMenuItems,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
     additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename,
   } = props;
   } = props;
 
 
@@ -68,14 +67,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
 
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const duplicateItemClickedHandler = useCallback(async() => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-    await onClickDuplicateMenuItem(pageId);
-  }, [onClickDuplicateMenuItem, pageId]);
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
   const renameItemClickedHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
@@ -88,6 +79,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickRenameMenuItem(pageId, pageInfo);
     await onClickRenameMenuItem(pageId, pageInfo);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
 
 
+  // eslint-disable-next-line react-hooks/rules-of-hooks
+  const duplicateItemClickedHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
   const revertItemClickedHandler = useCallback(async() => {
   const revertItemClickedHandler = useCallback(async() => {
     if (onClickRevertMenuItem == null) {
     if (onClickRevertMenuItem == null) {
       return;
       return;
@@ -143,18 +142,6 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
-        {/* Duplicate */}
-        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem
-            onClick={duplicateItemClickedHandler}
-            data-testid="open-page-duplicate-modal-btn"
-            className="grw-page-control-dropdown-item"
-          >
-            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
-            {t('Duplicate')}
-          </DropdownItem>
-        ) }
-
         {/* Move/Rename */}
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
           <DropdownItem
           <DropdownItem
@@ -167,6 +154,18 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
           </DropdownItem>
         ) }
         ) }
 
 
+        {/* Duplicate */}
+        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
+          <DropdownItem
+            onClick={duplicateItemClickedHandler}
+            data-testid="open-page-duplicate-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
+            {t('Duplicate')}
+          </DropdownItem>
+        ) }
+
         {/* Revert */}
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
           <DropdownItem
           <DropdownItem
@@ -224,7 +223,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const {
   const {
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     pageId, pageInfo: presetPageInfo, fetchOnInit,
     children,
     children,
-    onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
   } = props;
   } = props;
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
@@ -255,13 +254,6 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 
 
   const isLoading = shouldFetch && fetchedPageInfo == null;
   const isLoading = shouldFetch && fetchedPageInfo == null;
 
 
-  const duplicateMenuItemClickHandler = useCallback(async() => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-    await onClickDuplicateMenuItem(pageId);
-  }, [onClickDuplicateMenuItem, pageId]);
-
   const renameMenuItemClickHandler = useCallback(async() => {
   const renameMenuItemClickHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
@@ -269,6 +261,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
     await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
   }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
 
+  const duplicateMenuItemClickHandler = useCallback(async() => {
+    if (onClickDuplicateMenuItem == null) {
+      return;
+    }
+    await onClickDuplicateMenuItem(pageId);
+  }, [onClickDuplicateMenuItem, pageId]);
+
   const deleteMenuItemClickHandler = useCallback(async() => {
   const deleteMenuItemClickHandler = useCallback(async() => {
     if (onClickDeleteMenuItem == null) {
     if (onClickDeleteMenuItem == null) {
       return;
       return;
@@ -289,8 +288,8 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
         isLoading={isLoading}
         isLoading={isLoading}
         pageInfo={fetchedPageInfo ?? presetPageInfo}
         pageInfo={fetchedPageInfo ?? presetPageInfo}
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
         onClickRenameMenuItem={renameMenuItemClickHandler}
+        onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
         onClickDeleteMenuItem={deleteMenuItemClickHandler}
       />
       />
     </Dropdown>
     </Dropdown>

+ 5 - 3
packages/app/src/components/PageAttachment.jsx

@@ -1,14 +1,16 @@
 /* eslint-disable react/no-access-state-in-setstate */
 /* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
-import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import AppContainer from '~/client/services/AppContainer';
+import PageContainer from '~/client/services/PageContainer';
+
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
 
 
 class PageAttachment extends React.Component {
 class PageAttachment extends React.Component {
 
 

+ 2 - 2
packages/app/src/components/TagsList.tsx

@@ -43,7 +43,7 @@ const TagsList: FC<Props> = (props: Props) => {
   }
   }
 
 
   return (
   return (
-    <>
+    <div data-testid="grw-tags-list">
       <header className="py-0">
       <header className="py-0">
         <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
         <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
       </header>
       </header>
@@ -78,7 +78,7 @@ const TagsList: FC<Props> = (props: Props) => {
           />
           />
         </div>
         </div>
       </div>
       </div>
-    </>
+    </div>
   );
   );
 };
 };
 
 

+ 2 - 2
packages/app/src/server/crowi/index.js

@@ -130,7 +130,7 @@ Crowi.prototype.init = async function() {
     this.setUpAcl(),
     this.setUpAcl(),
     this.setUpCustomize(),
     this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
     this.setUpRestQiitaAPI(),
-    this.setupUserGroup(),
+    this.setupUserGroupService(),
     this.setupExport(),
     this.setupExport(),
     this.setupImport(),
     this.setupImport(),
     this.setupPageService(),
     this.setupPageService(),
@@ -644,7 +644,7 @@ Crowi.prototype.setUpRestQiitaAPI = async function() {
   }
   }
 };
 };
 
 
-Crowi.prototype.setupUserGroup = async function() {
+Crowi.prototype.setupUserGroupService = async function() {
   const UserGroupService = require('../service/user-group');
   const UserGroupService = require('../service/user-group');
   if (this.userGroupService == null) {
   if (this.userGroupService == null) {
     this.userGroupService = new UserGroupService(this);
     this.userGroupService = new UserGroupService(this);

+ 23 - 14
packages/app/src/server/models/page.ts

@@ -1,27 +1,29 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
 
+import nodePath from 'path';
+
+import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
+import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, {
 import mongoose, {
   Schema, Model, Document, AnyObject,
   Schema, Model, Document, AnyObject,
 } from 'mongoose';
 } from '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 escapeStringRegexp from 'escape-string-regexp';
-import nodePath from 'path';
-import { getOrCreateModel, pagePathUtils, pathUtils } from '@growi/core';
 
 
+import { IUserHasId } from '~/interfaces/user';
+import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+import { IPage, IPageHasId } from '../../interfaces/page';
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
-import { IPage } from '../../interfaces/page';
+
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import { PageRedirectModel } from './page-redirect';
 import { PageRedirectModel } from './page-redirect';
 
 
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { addTrailingSlash, normalizePath } = pathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 const { isTopPage, collectAncestorPaths } = pagePathUtils;
 
 
 const logger = loggerFactory('growi:models:page');
 const logger = loggerFactory('growi:models:page');
-
-
 /*
 /*
  * define schema
  * define schema
  */
  */
@@ -34,7 +36,7 @@ const PAGE_GRANT_ERROR = 1;
 const STATUS_PUBLISHED = 'published';
 const STATUS_PUBLISHED = 'published';
 const STATUS_DELETED = 'deleted';
 const STATUS_DELETED = 'deleted';
 
 
-export interface PageDocument extends IPage, Document {}
+export interface PageDocument extends IPage, Document { }
 
 
 
 
 type TargetAndAncestorsResult = {
 type TargetAndAncestorsResult = {
@@ -649,8 +651,11 @@ schema.statics.getParentAndFillAncestors = async function(path: string, user): P
   });
   });
   await this.bulkWrite(operations);
   await this.bulkWrite(operations);
 
 
-  const createdParent = ancestorsMap.get(parentPath);
-
+  const parentId = ancestorsMap.get(parentPath)._id; // get parent page id to fetch updated parent parent
+  const createdParent = await this.findOne({ _id: parentId });
+  if (createdParent == null) {
+    throw Error('updated parent not Found');
+  }
   return createdParent;
   return createdParent;
 };
 };
 
 
@@ -775,7 +780,7 @@ schema.statics.findAncestorsChildrenByPathAndViewer = async function(path: strin
     .lean()
     .lean()
     .exec();
     .exec();
   // mark target
   // mark target
-  const pages = _pages.map((page: PageDocument & {isTarget?: boolean}) => {
+  const pages = _pages.map((page: PageDocument & { isTarget?: boolean }) => {
     if (page.path === path) {
     if (page.path === path) {
       page.isTarget = true;
       page.isTarget = true;
     }
     }
@@ -823,7 +828,7 @@ schema.statics.incrementDescendantCountOfPageIds = async function(pageIds: Objec
 /**
 /**
  * recount descendantCount of a page with the provided id and return it
  * recount descendantCount of a page with the provided id and return it
  */
  */
-schema.statics.recountDescendantCount = async function(id: ObjectIdLike):Promise<number> {
+schema.statics.recountDescendantCount = async function(id: ObjectIdLike): Promise<number> {
   const res = await this.aggregate(
   const res = await this.aggregate(
     [
     [
       {
       {
@@ -1132,11 +1137,15 @@ export default (crowi: Crowi): any => {
     return savedPage;
     return savedPage;
   };
   };
 
 
-  const shouldUseUpdatePageV4 = (grant:number, isV5Compatible:boolean, isOnTree:boolean): boolean => {
+  const shouldUseUpdatePageV4 = (grant: number, isV5Compatible: boolean, isOnTree: boolean): boolean => {
     const isRestricted = grant === GRANT_RESTRICTED;
     const isRestricted = grant === GRANT_RESTRICTED;
     return !isRestricted && (!isV5Compatible || !isOnTree);
     return !isRestricted && (!isV5Compatible || !isOnTree);
   };
   };
 
 
+  schema.statics.emitPageEventUpdate = (page: IPageHasId, user: IUserHasId): void => {
+    pageEvent.emit('update', page, user);
+  };
+
   schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
   schema.statics.updatePage = async function(pageData, body, previousBody, user, options = {}) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
     if (crowi.configManager == null || crowi.pageGrantService == null || crowi.pageService == null) {
       throw Error('Crowi is not set up');
       throw Error('Crowi is not set up');
@@ -1206,7 +1215,7 @@ export default (crowi: Crowi): any => {
       savedPage = await this.syncRevisionToHackmd(savedPage);
       savedPage = await this.syncRevisionToHackmd(savedPage);
     }
     }
 
 
-    pageEvent.emit('update', savedPage, user);
+    this.emitPageEventUpdate(savedPage, user);
 
 
     // Update ex children's parent
     // Update ex children's parent
     if (!wasOnTree && shouldBeOnTree) {
     if (!wasOnTree && shouldBeOnTree) {

+ 13 - 4
packages/app/src/server/models/user.js

@@ -1,14 +1,15 @@
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+const crypto = require('crypto');
+
 const debug = require('debug')('growi:models:user');
 const debug = require('debug')('growi:models:user');
+const md5 = require('md5');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
-const md5 = require('md5');
 
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
-const crypto = require('crypto');
 
 
 const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
 const { listLocaleIds, migrateDeprecatedLocaleId } = require('~/utils/locale-utils');
 
 
@@ -393,8 +394,16 @@ module.exports = function(crowi) {
       .sort(sort);
       .sort(sort);
   };
   };
 
 
-  userSchema.statics.findAdmins = async function() {
-    return this.find({ admin: true });
+  userSchema.statics.findAdmins = async function(option) {
+    const sort = option?.sort ?? { createdAt: -1 };
+
+    let status = option?.status ?? [STATUS_ACTIVE];
+    if (!Array.isArray(status)) {
+      status = [status];
+    }
+
+    return this.find({ admin: true, status: { $in: status } })
+      .sort(sort);
   };
   };
 
 
   userSchema.statics.findUserByUsername = function(username) {
   userSchema.statics.findUserByUsername = function(username) {

+ 4 - 3
packages/app/src/server/routes/apiv3/attachment.js

@@ -8,8 +8,8 @@ const express = require('express');
 
 
 const router = express.Router();
 const router = express.Router();
 const { query } = require('express-validator');
 const { query } = require('express-validator');
-const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 
+const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 /**
 /**
@@ -28,7 +28,8 @@ module.exports = (crowi) => {
   const validator = {
   const validator = {
     retrieveAttachments: [
     retrieveAttachments: [
       query('pageId').isMongoId().withMessage('pageId is required'),
       query('pageId').isMongoId().withMessage('pageId is required'),
-      query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
+      query('page').optional().isInt().withMessage('page must be a number'),
+      query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
     ],
   };
   };
   /**
   /**
@@ -52,7 +53,7 @@ module.exports = (crowi) => {
   router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
   router.get('/list', accessTokenParser, loginRequired, validator.retrieveAttachments, apiV3FormValidator, async(req, res) => {
 
 
     const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
     const limit = req.query.limit || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;
-    const page = req.query.page;
+    const page = req.query.page || 1;
     const offset = (page - 1) * limit;
     const offset = (page - 1) * limit;
 
 
     try {
     try {

+ 0 - 1
packages/app/src/server/service/user-group.ts

@@ -25,7 +25,6 @@ class UserGroupService {
     return UserGroupRelation.removeAllInvalidRelations();
     return UserGroupRelation.removeAllInvalidRelations();
   }
   }
 
 
-  // TODO 85062: write test code
   // 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) {
     const userGroup = await UserGroup.findById(id);
     const userGroup = await UserGroup.findById(id);

+ 12 - 0
packages/app/src/styles/_page_list.scss

@@ -48,6 +48,18 @@ body .page-list {
       i {
       i {
         margin-right: 2px;
         margin-right: 2px;
       }
       }
+
+      .seen-users-count {
+        &.strength-1 {
+          font-weight: bold;
+        }
+        &.strength-2 {
+          font-weight: normal;
+        }
+        &.strength-3 {
+          opacity: 0.6;
+        }
+      }
     }
     }
 
 
     // after second level indent
     // after second level indent

+ 19 - 7
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -277,19 +277,30 @@ ul.pagination {
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
     @include override-list-group-item-for-pagetree(
-      $color-list,
+      $gray-200,
       $bgcolor-sidebar-list-group,
       $bgcolor-sidebar-list-group,
-      $color-list-hover,
-      $bgcolor-list-hover,
-      $color-list-active,
-      lighten($bgcolor-list-hover, 5%)
+      $gray-200,
+      lighten($bgcolor-sidebar-context, 8%),
+      $gray-200,
+      lighten($bgcolor-sidebar-context, 15%)
     );
     );
     .grw-pagetree-triangle-btn {
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($secondary, $gray-200);
       @include button-outline-svg-icon-variant($secondary, $gray-200);
     }
     }
     .grw-pagetree-count {
     .grw-pagetree-count {
       color: $gray-400;
       color: $gray-400;
-      background: $gray-700;
+      background: lighten($bgcolor-sidebar-context, 15%);
+    }
+    .btn-page-item-control {
+      @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
+      @include hover() {
+        background-color: lighten($bgcolor-sidebar-context, 20%);
+      }
+      &:not(:disabled):not(.disabled):active,
+      &:not(:disabled):not(.disabled).active {
+        background-color: lighten($bgcolor-sidebar-context, 34%);
+      }
+      box-shadow: none !important;
     }
     }
   }
   }
   .private-legacy-pages-link {
   .private-legacy-pages-link {
@@ -302,11 +313,12 @@ ul.pagination {
 .btn.btn-page-item-control {
 .btn.btn-page-item-control {
   @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
   @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
   @include hover() {
   @include hover() {
-    background-color: $gray-600;
+    background-color: $gray-700;
   }
   }
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled):active,
   &:not(:disabled):not(.disabled).active {
   &:not(:disabled):not(.disabled).active {
     color: $gray-200;
     color: $gray-200;
+    background-color: $gray-600;
   }
   }
   box-shadow: none !important;
   box-shadow: none !important;
 }
 }

+ 2 - 0
packages/app/test/cypress/integration/2-basic-features/access-to-page.spec.ts

@@ -95,6 +95,8 @@ context('Access to special pages', () => {
     // select tags
     // select tags
     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
     cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
     cy.getByTestid('grw-sidebar-content-tags').should('be.visible');
+    cy.getByTestid('grw-tags-list').should('be.visible');
+    cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
 
 
     cy.getByTestid('tags-page').should('be.visible');
     cy.getByTestid('tags-page').should('be.visible');
     cy.screenshot(`${ssPrefix}-tags`);
     cy.screenshot(`${ssPrefix}-tags`);

+ 84 - 7
packages/app/test/integration/models/user.test.js

@@ -8,17 +8,53 @@ describe('User', () => {
   let crowi;
   let crowi;
   let User;
   let User;
 
 
+  let adminusertestToBeRemovedId;
+
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
     User = mongoose.model('User');
     User = mongoose.model('User');
 
 
-    await User.create({
-      name: 'Example for User Test',
-      username: 'usertest',
-      email: 'usertest@example.com',
-      password: 'usertestpass',
-      lang: 'en_US',
-    });
+    await User.insertMany([
+      {
+        name: 'Example for User Test',
+        username: 'usertest',
+        email: 'usertest@example.com',
+        password: 'usertestpass',
+        lang: 'en_US',
+      },
+      {
+        name: 'Admin Example Active',
+        username: 'adminusertest1',
+        email: 'adminusertest1@example.com',
+        password: 'adminusertestpass',
+        admin: true,
+        status: User.STATUS_ACTIVE,
+        lang: 'en_US',
+      },
+      {
+        name: 'Admin Example Suspended',
+        username: 'adminusertest2',
+        email: 'adminusertes2@example.com',
+        password: 'adminusertestpass',
+        admin: true,
+        status: User.STATUS_SUSPENDED,
+        lang: 'en_US',
+      },
+      {
+        name: 'Admin Example to delete',
+        username: 'adminusertestToBeRemoved',
+        email: 'adminusertestToBeRemoved@example.com',
+        password: 'adminusertestpass',
+        admin: true,
+        status: User.STATUS_ACTIVE,
+        lang: 'en_US',
+      },
+    ]);
+
+    // delete adminusertestToBeRemoved
+    const adminusertestToBeRemoved = await User.findOne({ username: 'adminusertestToBeRemoved' });
+    adminusertestToBeRemovedId = adminusertestToBeRemoved._id;
+    await adminusertestToBeRemoved.statusDelete();
   });
   });
 
 
   describe('Create and Find.', () => {
   describe('Create and Find.', () => {
@@ -39,6 +75,47 @@ describe('User', () => {
       });
       });
 
 
     });
     });
+
+  });
+
+  describe('Delete.', () => {
+
+    describe('Deleted users', () => {
+      test('should have correct attributes', async() => {
+        const adminusertestToBeRemoved = await User.findOne({ _id: adminusertestToBeRemovedId });
+
+        expect(adminusertestToBeRemoved).toBeInstanceOf(User);
+        expect(adminusertestToBeRemoved.name).toBe('');
+        expect(adminusertestToBeRemoved.password).toBe('');
+        expect(adminusertestToBeRemoved.googleId).toBeNull();
+        expect(adminusertestToBeRemoved.isGravatarEnabled).toBeFalsy();
+        expect(adminusertestToBeRemoved.image).toBeNull();
+      });
+    });
+  });
+
+  describe('User.findAdmins', () => {
+    test('should retrieves only active users', async() => {
+      const users = await User.findAdmins();
+      const adminusertestActive = users.find(user => user.username === 'adminusertest1');
+      const adminusertestSuspended = users.find(user => user.username === 'adminusertest2');
+      const adminusertestToBeRemoved = users.find(user => user._id.toString() === adminusertestToBeRemovedId.toString());
+
+      expect(adminusertestActive).toBeInstanceOf(User);
+      expect(adminusertestSuspended).toBeUndefined();
+      expect(adminusertestToBeRemoved).toBeUndefined();
+    });
+
+    test('with \'includesInactive\' option should retrieves suspended users', async() => {
+      const users = await User.findAdmins({ status: [User.STATUS_ACTIVE, User.STATUS_SUSPENDED] });
+      const adminusertestActive = users.find(user => user.username === 'adminusertest1');
+      const adminusertestSuspended = users.find(user => user.username === 'adminusertest2');
+      const adminusertestToBeRemoved = users.find(user => user._id.toString() === adminusertestToBeRemovedId.toString());
+
+      expect(adminusertestActive).toBeInstanceOf(User);
+      expect(adminusertestSuspended).toBeInstanceOf(User);
+      expect(adminusertestToBeRemoved).toBeUndefined();
+    });
   });
   });
 
 
   describe('User Utilities', () => {
   describe('User Utilities', () => {

+ 376 - 0
packages/app/test/integration/models/v5.page.test.js

@@ -13,10 +13,19 @@ describe('Page', () => {
   let Comment;
   let Comment;
   let ShareLink;
   let ShareLink;
   let PageRedirect;
   let PageRedirect;
+  let UserGroup;
+  let UserGroupRelation;
   let xssSpy;
   let xssSpy;
 
 
   let rootPage;
   let rootPage;
   let dummyUser1;
   let dummyUser1;
+  let pModelUser1;
+  let pModelUser2;
+  let pModelUser3;
+  let groupIdIsolate;
+  let groupIdA;
+  let groupIdB;
+  let groupIdC;
 
 
   beforeAll(async() => {
   beforeAll(async() => {
     crowi = await getInstance();
     crowi = await getInstance();
@@ -32,11 +41,100 @@ describe('Page', () => {
     Comment = mongoose.model('Comment');
     Comment = mongoose.model('Comment');
     ShareLink = mongoose.model('ShareLink');
     ShareLink = mongoose.model('ShareLink');
     PageRedirect = mongoose.model('PageRedirect');
     PageRedirect = mongoose.model('PageRedirect');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
 
 
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
     dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
 
 
     rootPage = await Page.findOne({ path: '/' });
     rootPage = await Page.findOne({ path: '/' });
 
 
+    const pModelUserId1 = new mongoose.Types.ObjectId();
+    const pModelUserId2 = new mongoose.Types.ObjectId();
+    const pModelUserId3 = new mongoose.Types.ObjectId();
+    await User.insertMany([
+      {
+        _id: pModelUserId1, name: 'pmodelUser1', username: 'pmodelUser1', email: 'pmodelUser1@example.com',
+      },
+      {
+        _id: pModelUserId2, name: 'pmodelUser2', username: 'pmodelUser2', email: 'pmodelUser2@example.com',
+      },
+      {
+        _id: pModelUserId3, name: 'pModelUser3', username: 'pModelUser3', email: 'pModelUser3@example.com',
+      },
+    ]);
+    pModelUser1 = await User.findOne({ _id: pModelUserId1 });
+    pModelUser2 = await User.findOne({ _id: pModelUserId2 });
+    pModelUser3 = await User.findOne({ _id: pModelUserId3 });
+
+
+    groupIdIsolate = new mongoose.Types.ObjectId();
+    groupIdA = new mongoose.Types.ObjectId();
+    groupIdB = new mongoose.Types.ObjectId();
+    groupIdC = new mongoose.Types.ObjectId();
+    await UserGroup.insertMany([
+      {
+        _id: groupIdIsolate,
+        name: 'pModel_groupIsolate',
+      },
+      {
+        _id: groupIdA,
+        name: 'pModel_groupA',
+      },
+      {
+        _id: groupIdB,
+        name: 'pModel_groupB',
+        parent: groupIdA,
+      },
+      {
+        _id: groupIdC,
+        name: 'pModel_groupC',
+        parent: groupIdB,
+      },
+    ]);
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupIdIsolate,
+        relatedUser: pModelUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdIsolate,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: pModelUserId1,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdA,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdB,
+        relatedUser: pModelUserId2,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdB,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+      {
+        relatedGroup: groupIdC,
+        relatedUser: pModelUserId3,
+        createdAt: new Date(),
+      },
+    ]);
+
     const pageIdCreate1 = new mongoose.Types.ObjectId();
     const pageIdCreate1 = new mongoose.Types.ObjectId();
     const pageIdCreate2 = new mongoose.Types.ObjectId();
     const pageIdCreate2 = new mongoose.Types.ObjectId();
     const pageIdCreate3 = new mongoose.Types.ObjectId();
     const pageIdCreate3 = new mongoose.Types.ObjectId();
@@ -129,6 +227,7 @@ describe('Page', () => {
     const pageIdUpd10 = new mongoose.Types.ObjectId();
     const pageIdUpd10 = new mongoose.Types.ObjectId();
     const pageIdUpd11 = new mongoose.Types.ObjectId();
     const pageIdUpd11 = new mongoose.Types.ObjectId();
     const pageIdUpd12 = new mongoose.Types.ObjectId();
     const pageIdUpd12 = new mongoose.Types.ObjectId();
+    const pageIdUpd13 = new mongoose.Types.ObjectId();
 
 
     await Page.insertMany([
     await Page.insertMany([
       {
       {
@@ -255,6 +354,125 @@ describe('Page', () => {
         parent: rootPage._id,
         parent: rootPage._id,
         descendantCount: 1,
         descendantCount: 1,
       },
       },
+      {
+        path: '/mup19',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup20',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup21',
+        grant: Page.GRANT_RESTRICTED,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdUpd13,
+        path: '/mup22',
+        grant: Page.GRANT_PUBLIC,
+        creator: pModelUser1,
+        lastUpdateUser: pModelUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 1,
+      },
+      {
+        path: '/mup22/mup23',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        creator: pModelUserId1,
+        lastUpdateUser: pModelUserId1,
+        isEmpty: false,
+        parent: pageIdUpd13,
+        descendantCount: 0,
+      },
+      {
+        path: '/mup24',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [dummyUser1._id],
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+    ]);
+
+    /**
+     * getParentAndFillAncestors
+     */
+    const pageIdPAF1 = new mongoose.Types.ObjectId();
+    const pageIdPAF2 = new mongoose.Types.ObjectId();
+    const pageIdPAF3 = new mongoose.Types.ObjectId();
+
+    await Page.insertMany([
+      {
+        _id: pageIdPAF1,
+        path: '/PAF1',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        parent: rootPage._id,
+        descendantCount: 0,
+      },
+      {
+        _id: pageIdPAF2,
+        path: '/emp_anc3',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc3/PAF3',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF2,
+      },
+      {
+        _id: pageIdPAF3,
+        path: '/emp_anc4',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+        descendantCount: 1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/emp_anc4/PAF4',
+        grant: Page.GRANT_PUBLIC,
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+        descendantCount: 0,
+        parent: pageIdPAF3,
+      },
+      {
+        path: '/emp_anc4',
+        grant: Page.GRANT_OWNER,
+        grantedUsers: [dummyUser1._id],
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1._id,
+        isEmpty: false,
+      },
     ]);
     ]);
 
 
   });
   });
@@ -351,6 +569,13 @@ describe('Page', () => {
 
 
   describe('update', () => {
   describe('update', () => {
 
 
+    const updatePage = async(page, newRevisionBody, oldRevisionBody, user, options = {}) => {
+      const mockedRenameSubOperation = jest.spyOn(Page, 'emitPageEventUpdate').mockReturnValue(null);
+      const savedPage = await Page.updatePage(page, newRevisionBody, oldRevisionBody, user, options);
+      mockedRenameSubOperation.mockRestore();
+      return savedPage;
+    };
+
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
     describe('Changing grant from PUBLIC to RESTRICTED of', () => {
       test('an only-child page will delete its empty parent page', async() => {
       test('an only-child page will delete its empty parent page', async() => {
         const pathT = '/mup13_top';
         const pathT = '/mup13_top';
@@ -423,6 +648,22 @@ describe('Page', () => {
         expect(_pageT.descendantCount).toBe(0);
         expect(_pageT.descendantCount).toBe(0);
       });
       });
     });
     });
+
+    describe('Changing grant to GRANT_RESTRICTED', () => {
+      test('successfully change to GRANT_RESTRICTED from GRANT_OWNER', async() => {
+        const path = '/mup24';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_OWNER, grantedUsers: [dummyUser1._id] });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_RESTRICTED });
+
+        const page = await Page.findOne({ path });
+        expect(page).toBeTruthy();
+        expect(page.grant).toBe(Page.GRANT_RESTRICTED);
+        expect(page.grantedUsers).toStrictEqual([]);
+      });
+    });
+
     describe('Changing grant from RESTRICTED to PUBLIC of', () => {
     describe('Changing grant from RESTRICTED to PUBLIC of', () => {
       test('a page will create ancestors if they do not exist', async() => {
       test('a page will create ancestors if they do not exist', async() => {
         const pathT = '/mup16_top';
         const pathT = '/mup16_top';
@@ -482,5 +723,140 @@ describe('Page', () => {
       });
       });
     });
     });
 
 
+    describe('Changing grant to GRANT_OWNER(onlyme)', () => {
+      test('successfully change to GRANT_OWNER from GRANT_PUBLIC', async() => {
+        const path = '/mup19';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_PUBLIC });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_OWNER });
+
+        const page = await Page.findOne({ path });
+        expect(page.grant).toBe(Page.GRANT_OWNER);
+        expect(page.grantedUsers).toStrictEqual([dummyUser1._id]);
+
+      });
+      test('successfully change to GRANT_OWNER from GRANT_USER_GROUP', async() => {
+        const path = '/mup20';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', pModelUser1, { grant: Page.GRANT_OWNER });
+
+        const page = await Page.findOne({ path });
+        expect(page.grant).toBe(Page.GRANT_OWNER);
+        expect(page.grantedUsers).toStrictEqual([pModelUser1._id]);
+        expect(page.grantedGroup).toBeNull();
+      });
+      test('successfully change to GRANT_OWNER from GRANT_RESTRICTED', async() => {
+        const path = '/mup21';
+        const _page = await Page.findOne({ path, grant: Page.GRANT_RESTRICTED });
+        expect(_page).toBeTruthy();
+
+        await updatePage(_page, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_OWNER });
+
+        const page = await Page.findOne({ path });
+        expect(page.grant).toBe(Page.GRANT_OWNER);
+        expect(page.grantedUsers).toStrictEqual([dummyUser1._id]);
+      });
+      test('Failed to change to GRANT_OWNER if one of the ancestors is GRANT_USER_GROUP page', async() => {
+        const path1 = '/mup22';
+        const path2 = '/mup22/mup23';
+        const _page1 = await Page.findOne({ path: path1, grant: Page.GRANT_PUBLIC });
+        const _page2 = await Page.findOne({ path: path2, grant: Page.GRANT_USER_GROUP, grantedGroup: groupIdA });
+        expect(_page1).toBeTruthy();
+        expect(_page2).toBeTruthy();
+
+        await expect(updatePage(_page1, 'newRevisionBody', 'oldRevisionBody', dummyUser1, { grant: Page.GRANT_OWNER }))
+          .rejects.toThrow(new Error('The selected grant or grantedGroup is not assignable to this page.'));
+
+        const page1 = await Page.findOne({ path1 });
+        expect(page1).toBeTruthy();
+        expect(page1.grant).toBe(Page.GRANT_PUBLIC);
+        expect(page1.grantedUsers).not.toStrictEqual([dummyUser1._id]);
+      });
+    });
+
+  });
+
+  describe('getParentAndFillAncestors', () => {
+    test('return parent if exist', async() => {
+      const page1 = await Page.findOne({ path: '/PAF1' });
+      const parent = await Page.getParentAndFillAncestors(page1.path, dummyUser1);
+      expect(parent).toBeTruthy();
+      expect(page1.parent).toStrictEqual(parent._id);
+    });
+    test('create parent and ancestors when they do not exist, and return the new parent', async() => {
+      const path1 = '/emp_anc1';
+      const path2 = '/emp_anc1/emp_anc2';
+      const path3 = '/emp_anc1/emp_anc2/PAF2';
+      const _page1 = await Page.findOne({ path: path1 }); // not exist
+      const _page2 = await Page.findOne({ path: path2 }); // not exist
+      const _page3 = await Page.findOne({ path: path3 }); // not exist
+      expect(_page1).toBeNull();
+      expect(_page2).toBeNull();
+      expect(_page3).toBeNull();
+
+      const parent = await Page.getParentAndFillAncestors(path3, dummyUser1);
+      const page1 = await Page.findOne({ path: path1 });
+      const page2 = await Page.findOne({ path: path2 });
+      const page3 = await Page.findOne({ path: path3 });
+
+      expect(parent._id).toStrictEqual(page2._id);
+      expect(parent.path).toStrictEqual(page2.path);
+      expect(parent.parent).toStrictEqual(page2.parent);
+
+      expect(parent).toBeTruthy();
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeNull();
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test('return parent even if the parent page is empty', async() => {
+      const path1 = '/emp_anc3';
+      const path2 = '/emp_anc3/PAF3';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+
+      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true }); // parent
+      const page2 = await Page.findOne({ path: path2, isEmpty: false });
+
+      // check for the parent (should be the same as page1)
+      expect(parent._id).toStrictEqual(page1._id);
+      expect(parent.path).toStrictEqual(page1.path);
+      expect(parent.parent).toStrictEqual(page1.parent);
+
+      expect(page1.parent).toStrictEqual(rootPage._id);
+      expect(page2.parent).toStrictEqual(page1._id);
+    });
+    test("should find parent while NOT updating private legacy page's parent", async() => {
+      const path1 = '/emp_anc4';
+      const path2 = '/emp_anc4/PAF4';
+      const _page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const _page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const _page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(_page1).toBeTruthy();
+      expect(_page2).toBeTruthy();
+      expect(_page3).toBeTruthy();
+      expect(_page3.parent).toBeNull();
+
+      const parent = await Page.getParentAndFillAncestors(_page2.path, dummyUser1);
+      const page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
+      const page2 = await Page.findOne({ path: path2, isEmpty: false, grant: Page.GRANT_PUBLIC });
+      const page3 = await Page.findOne({ path: path1, isEmpty: false, grant: Page.GRANT_OWNER });
+      expect(page1).toBeTruthy();
+      expect(page2).toBeTruthy();
+      expect(page3).toBeTruthy();
+      expect(page3.parent).toBeNull(); // parent property of page in private legacy pages should be null
+
+      expect(page1._id).toStrictEqual(parent._id);
+      expect(page2.parent).toStrictEqual(parent._id);
+
+    });
   });
   });
 });
 });

+ 77 - 0
packages/app/test/integration/service/user-groups.test.ts

@@ -0,0 +1,77 @@
+
+import mongoose from 'mongoose';
+
+import { getInstance } from '../setup-crowi';
+
+describe('UserGroupService', () => {
+  let crowi;
+  let UserGroup;
+
+  const groupId1 = new mongoose.Types.ObjectId();
+  const groupId2 = new mongoose.Types.ObjectId();
+  const groupId3 = new mongoose.Types.ObjectId();
+
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    UserGroup = mongoose.model('UserGroup');
+
+
+    // Create Groups
+    await UserGroup.insertMany([
+      // No parent
+      {
+        _id: groupId1,
+        name: 'v5_group1',
+        description: 'description1',
+      },
+      // No parent
+      {
+        _id: groupId2,
+        name: 'v5_group2',
+        description: 'description2',
+      },
+      {
+        _id: groupId3,
+        name: 'v5_group3',
+        parent: groupId1,
+        description: 'description3',
+      },
+    ]);
+  });
+
+  /*
+    * Update UserGroup
+    */
+  test('Updated values should be reflected. (name, description, parent)', async() => {
+    const userGroup = await UserGroup.findOne({ _id: groupId1 });
+
+    const newGroupName = 'v5_group1_new';
+    const newGroupDescription = 'description1_new';
+    const newParentId = groupId2;
+
+    const updatedUserGroup = await crowi.userGroupService.updateGroup(userGroup._id, newGroupName, newGroupDescription, 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() => {
+    const userGroup1 = await UserGroup.findOne({ _id: groupId1 });
+    const userGroup2 = await UserGroup.findOne({ _id: groupId2 });
+
+    const result = crowi.userGroupService.updateGroup(userGroup1._id, userGroup2.name);
+
+    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);
+
+    expect(updatedUserGroup.parent).toBeNull();
+  });
+
+});

+ 1 - 0
packages/app/test/integration/setup-crowi.js

@@ -23,6 +23,7 @@ const initCrowi = async(crowi) => {
     crowi.setupPageService(),
     crowi.setupPageService(),
     crowi.setupInAppNotificationService(),
     crowi.setupInAppNotificationService(),
     crowi.setupActivityService(),
     crowi.setupActivityService(),
+    crowi.setupUserGroupService(),
   ]);
   ]);
 };
 };
 
 

+ 4 - 1
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -1,9 +1,10 @@
 
 
+import React from 'react';
+
 import * as url from 'url';
 import * as url from 'url';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import React from 'react';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 import styles from '../../css/index.css';
 import styles from '../../css/index.css';
@@ -59,6 +60,8 @@ export class Lsx extends React.Component {
     try {
     try {
       const res = await this.props.appContainer.apiGet('/plugins/lsx', { pagePath, options: lsxContext.options });
       const res = await this.props.appContainer.apiGet('/plugins/lsx', { pagePath, options: lsxContext.options });
 
 
+      lsxContext.activeUsersCount = res.activeUsersCount;
+
       if (res.ok) {
       if (res.ok) {
         const nodeTree = this.generatePageNodeTree(pagePath, res.pages);
         const nodeTree = this.generatePageNodeTree(pagePath, res.pages);
         this.setState({ nodeTree });
         this.setState({ nodeTree });

+ 3 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx

@@ -1,8 +1,8 @@
+import React from 'react';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
 import { PageListMeta } from '@growi/ui';
 import { PageListMeta } from '@growi/ui';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import React from 'react';
 
 
 import { LsxContext } from '../../util/LsxContext';
 import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
 import { PageNode } from '../PageNode';
@@ -79,6 +79,7 @@ export class LsxPage extends React.Component {
 
 
   render() {
   render() {
     const pageNode = this.props.pageNode;
     const pageNode = this.props.pageNode;
+    const { activeUsersCount } = this.props.lsxContext;
 
 
     // create PagePath element
     // create PagePath element
     let pagePathNode = <PagePathWrapper pagePath={pageNode.pagePath} isExists={this.state.isExists} />;
     let pagePathNode = <PagePathWrapper pagePath={pageNode.pagePath} isExists={this.state.isExists} />;
@@ -87,7 +88,7 @@ export class LsxPage extends React.Component {
     }
     }
 
 
     // create PageListMeta element
     // create PageListMeta element
-    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} /> : '';
+    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} activeUsersCount={activeUsersCount} /> : '';
 
 
     return (
     return (
       <li className="page-list-li">
       <li className="page-list-li">

+ 11 - 1
packages/plugin-lsx/src/server/routes/lsx.js

@@ -162,6 +162,7 @@ class Lsx {
 
 
 module.exports = (crowi, app) => {
 module.exports = (crowi, app) => {
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const ApiResponse = crowi.require('../util/apiResponse');
   const ApiResponse = crowi.require('../util/apiResponse');
   const actions = {};
   const actions = {};
 
 
@@ -207,6 +208,15 @@ module.exports = (crowi, app) => {
 
 
     const builder = await generateBaseQueryBuilder(pagePath, user);
     const builder = await generateBaseQueryBuilder(pagePath, user);
 
 
+    // count active users
+    let activeUsersCount;
+    try {
+      activeUsersCount = await User.countListByStatus(User.STATUS_ACTIVE);
+    }
+    catch (error) {
+      return res.json(ApiResponse.error(error));
+    }
+
     let query = builder.query;
     let query = builder.query;
     try {
     try {
       // depth
       // depth
@@ -227,7 +237,7 @@ module.exports = (crowi, app) => {
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
 
 
       const pages = await query.exec();
       const pages = await query.exec();
-      res.json(ApiResponse.success({ pages }));
+      res.json(ApiResponse.success({ pages, activeUsersCount }));
     }
     }
     catch (error) {
     catch (error) {
       return res.json(ApiResponse.error(error));
       return res.json(ApiResponse.error(error));

+ 50 - 12
packages/ui/src/components/PagePath/PageListMeta.tsx

@@ -1,5 +1,7 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
+import assert from 'assert';
+
 import { IPageHasId } from '@growi/app/src/interfaces/page';
 import { IPageHasId } from '@growi/app/src/interfaces/page';
 import { templateChecker, pagePathUtils } from '@growi/core';
 import { templateChecker, pagePathUtils } from '@growi/core';
 
 
@@ -8,16 +10,62 @@ import { FootstampIcon } from '../SearchPage/FootstampIcon';
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 const { checkTemplatePath } = templateChecker;
 
 
+
+const SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT = 5;
+const MIN_STRENGTH_LEVEL = -3;
+
+type SeenUsersCountProps = {
+  count: number,
+  activeUsersCount?: number,
+  shouldSpaceOutIcon?: boolean,
+}
+
+const SeenUsersCount = (props: SeenUsersCountProps): JSX.Element => {
+
+  const { count, shouldSpaceOutIcon, activeUsersCount } = props;
+
+  if (count === 0) {
+    return <></>;
+  }
+
+  if (activeUsersCount != null && activeUsersCount <= SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT) {
+    return <></>;
+  }
+
+  const strengthLevel = Math.log(count / (activeUsersCount ?? count)); // Max: 0
+
+  if (strengthLevel <= MIN_STRENGTH_LEVEL) {
+    return <></>;
+  }
+
+  assert(strengthLevel > MIN_STRENGTH_LEVEL); // [0, MIN_STRENGTH_LEVEL)
+
+  let strengthClass = '';
+  if (strengthLevel < 0) {
+    strengthClass = `strength-${Math.ceil(strengthLevel * -1)}`; // strength-{0, 1, 2, 3}
+  }
+
+  return (
+    <span className={`seen-users-count ${shouldSpaceOutIcon ? 'mr-3' : ''} ${strengthClass}`}>
+      <i className="footstamp-icon"><FootstampIcon /></i>
+      {count}
+    </span>
+  );
+
+};
+
+
 type PageListMetaProps = {
 type PageListMetaProps = {
   page: IPageHasId,
   page: IPageHasId,
   likerCount?: number,
   likerCount?: number,
   bookmarkCount?: number,
   bookmarkCount?: number,
   shouldSpaceOutIcon?: boolean,
   shouldSpaceOutIcon?: boolean,
+  activeUsersCount?: number,
 }
 }
 
 
 export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) => {
 export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) => {
 
 
-  const { page, shouldSpaceOutIcon } = props;
+  const { page, shouldSpaceOutIcon, activeUsersCount } = props;
 
 
   // top check
   // top check
   let topLabel;
   let topLabel;
@@ -46,16 +94,6 @@ export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) =>
     locked = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-lock" /></span>;
     locked = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="icon-lock" /></span>;
   }
   }
 
 
-  let seenUserCount;
-  if (page.seenUsers.length > 0) {
-    seenUserCount = (
-      <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}>
-        <i className="footstamp-icon"><FootstampIcon /></i>
-        {page.seenUsers.length}
-      </span>
-    );
-  }
-
   let bookmarkCount;
   let bookmarkCount;
   if (props.bookmarkCount != null && props.bookmarkCount > 0) {
   if (props.bookmarkCount != null && props.bookmarkCount > 0) {
     bookmarkCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-bookmark-o" />{props.bookmarkCount}</span>;
     bookmarkCount = <span className={`${shouldSpaceOutIcon ? 'mr-3' : ''}`}><i className="fa fa-bookmark-o" />{props.bookmarkCount}</span>;
@@ -65,7 +103,7 @@ export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) =>
     <span className="page-list-meta">
     <span className="page-list-meta">
       {topLabel}
       {topLabel}
       {templateLabel}
       {templateLabel}
-      {seenUserCount}
+      <SeenUsersCount count={page.seenUsers.length} activeUsersCount={activeUsersCount} />
       {commentCount}
       {commentCount}
       {likerCount}
       {likerCount}
       {locked}
       {locked}