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

Merge pull request #5914 from weseek/master

Release v5.0.7
Yuki Takei 3 лет назад
Родитель
Сommit
84f6f4730e
53 измененных файлов с 549 добавлено и 188 удалено
  1. 8 2
      .github/workflows/reusable-app-prod.yml
  2. 1 12
      THIRD-PARTY-NOTICES.md
  3. 1 1
      lerna.json
  4. 1 1
      package.json
  5. 0 1
      packages/app/.eslintrc.js
  6. 1 0
      packages/app/config/ci/.env.local.for-auto-install-with-allowing-guest
  7. 3 3
      packages/app/config/webpack.common.js
  8. 7 7
      packages/app/package.json
  9. 2 1
      packages/app/resource/locales/en_US/translation.json
  10. 2 1
      packages/app/resource/locales/ja_JP/translation.json
  11. 1 0
      packages/app/resource/locales/zh_CN/translation.json
  12. 1 22
      packages/app/src/client/services/PageContainer.js
  13. 16 16
      packages/app/src/client/util/markdown-it/emoji-mart-data.ts
  14. 4 7
      packages/app/src/client/util/markdown-it/emoji.js
  15. 13 7
      packages/app/src/client/util/markdown-it/toc-and-anchor.js
  16. 11 3
      packages/app/src/components/DescendantsPageList.tsx
  17. 5 6
      packages/app/src/components/Me/PasswordSettings.jsx
  18. 7 5
      packages/app/src/components/Page/FixPageGrantAlert.tsx
  19. 2 6
      packages/app/src/components/Page/RevisionRenderer.jsx
  20. 5 1
      packages/app/src/components/PageDeleteModal.tsx
  21. 5 5
      packages/app/src/components/PageEditor/CommentMentionHelper.ts
  22. 11 2
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  23. 1 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  24. 1 1
      packages/app/src/components/Sidebar/Tag.tsx
  25. 5 1
      packages/app/src/server/crowi/index.js
  26. 16 2
      packages/app/src/server/models/user.js
  27. 5 2
      packages/app/src/server/routes/apiv3/forgot-password.js
  28. 6 4
      packages/app/src/server/routes/apiv3/personal-setting.js
  29. 43 6
      packages/app/src/server/routes/apiv3/users.js
  30. 13 1
      packages/app/src/server/service/config-loader.ts
  31. 20 8
      packages/app/src/server/service/installer.ts
  32. 2 2
      packages/app/src/server/service/page.ts
  33. 7 2
      packages/app/src/server/util/middlewares.js
  34. 4 2
      packages/app/src/server/views/layout/layout.html
  35. 3 1
      packages/app/src/server/views/private-legacy-pages.html
  36. 3 1
      packages/app/src/server/views/search.html
  37. 0 0
      packages/app/test/cypress/integration/10-install/install.spec.ts
  38. 0 1
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  39. 0 0
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  40. 81 0
      packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts
  41. 0 0
      packages/app/test/cypress/integration/30-search/search.spec.ts
  42. 0 0
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  43. 0 0
      packages/app/test/cypress/integration/50-switch-sidebar-mode/switching-sidebar-mode.spec.ts
  44. 0 0
      packages/app/test/cypress/integration/60-home/home.spec.ts
  45. 223 35
      packages/app/test/integration/service/v5.migration.test.js
  46. 1 1
      packages/codemirror-textlint/package.json
  47. 1 1
      packages/core/package.json
  48. 1 1
      packages/plugin-attachment-refs/package.json
  49. 1 1
      packages/plugin-lsx/package.json
  50. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  51. 1 1
      packages/slack/package.json
  52. 2 2
      packages/slackbot-proxy/package.json
  53. 1 1
      packages/ui/package.json

+ 8 - 2
.github/workflows/reusable-app-prod.yml

@@ -180,7 +180,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['1', '2', '3', '4', '6']
+        spec-group: ['10', '20', '21', '30', '40', '60']
 
     services:
       mongodb:
@@ -239,11 +239,17 @@ jobs:
         cat config/ci/.env.local.for-ci >> .env.production.local
 
     - name: Copy dotenv file for automatic installation
-      if: ${{ matrix.spec-group != '1' }}
+      if: ${{ matrix.spec-group != '10' }}
       working-directory: ./packages/app
       run: |
         cat config/ci/.env.local.for-auto-install >> .env.production.local
 
+    - name: Copy dotenv file for automatic installation with allowing guest mode
+      if: ${{ matrix.spec-group == '21' }}
+      working-directory: ./packages/app
+      run: |
+        cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
+
     - name: Cypress Run
       uses: cypress-io/github-action@v3
       with:

+ 1 - 12
THIRD-PARTY-NOTICES.md

@@ -16,8 +16,7 @@ https://github.com/weseek/growi.
 2. crowi/crowi (https://github.com/crowi/crowi)
 3. Microsoft/vscode (https://github.com/Microsoft/vscode)
 4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
-5. EmojiOne Version 3 (https://github.com/joypixels/emojione/tree/v3.1.1)
-6. Kuromoji.js (https://github.com/takuyaa/kuromoji.js)
+5. Kuromoji.js (https://github.com/takuyaa/kuromoji.js)
 
 
 License Notice for Apache License, Version 2.0 Derivative Works
@@ -101,16 +100,6 @@ Copyright (c) 2018 Stephen Hutchings
 ```
 
 
-License Notice for EmojiOne
-------------------------
-
-https://creativecommons.org/licenses/by/4.0/
-
-```
-author: "EmojiOne <ryan@emojione.com> (http://emojione.com)"
-```
-
-
 License Notice for Kuromoji.js
 ------------------------
 

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 0 - 1
packages/app/.eslintrc.js

@@ -12,7 +12,6 @@ module.exports = {
   globals: {
     $: true,
     jquery: true,
-    emojione: true,
     hljs: true,
     ScrollPosStyler: true,
     window: true,

+ 1 - 0
packages/app/config/ci/.env.local.for-auto-install-with-allowing-guest

@@ -0,0 +1 @@
+AUTO_INSTALL_ALLOW_GUEST_MODE=true

+ 3 - 3
packages/app/config/webpack.common.js

@@ -2,14 +2,15 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
 const path = require('path');
+
+const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 const webpack = require('webpack');
 
 /*
   * Webpack Plugins
   */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
-const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
-const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
 
 /*
   * Webpack configuration
@@ -60,7 +61,6 @@ module.exports = (options) => {
       // require("jquery") is external and available
       //  on the global var jQuery
       jquery: 'jQuery',
-      emojione: 'emojione',
       hljs: 'hljs',
       'dtrace-provider': 'dtrace-provider',
     },

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.6",
-    "@growi/plugin-attachment-refs": "^5.0.6",
-    "@growi/plugin-lsx": "^5.0.6",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.6",
-    "@growi/slack": "^5.0.6",
+    "@growi/codemirror-textlint": "^5.0.7-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.7-RC.0",
+    "@growi/plugin-lsx": "^5.0.7-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.7-RC.0",
+    "@growi/slack": "^5.0.7-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.6",
+    "@growi/ui": "^5.0.7-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 2 - 1
packages/app/resource/locales/en_US/translation.json

@@ -212,7 +212,7 @@
     },
     "form_help": {
       "email": "You must have email address which listed below to sign up to this wiki.",
-      "password": "Your password must be at least 8 characters long.",
+      "password": "Your password must be at least {{target}} characters long.",
       "user_id": "The URL of pages you create will contain your User ID. Your User ID can consist of letters, numbers, and some symbols."
     }
   },
@@ -437,6 +437,7 @@
     "recursively": "Delete pages under this path recursively.",
     "completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_page": "Moved to the trash",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",

+ 2 - 1
packages/app/resource/locales/ja_JP/translation.json

@@ -214,7 +214,7 @@
     },
     "form_help": {
       "email": "この Wiki では以下のメールアドレスのみ登録可能です。",
-      "password": "パスワードには、8文字以上の半角英数字または記号等を設定してください。",
+      "password": "パスワードには、{{target}}文字以上の半角英数字または記号等を設定してください。",
       "user_id": "ユーザーIDは、ユーザーページのURLなどに利用されます。半角英数字と一部の記号のみ利用できます。"
     }
   },
@@ -437,6 +437,7 @@
     "recursively": "配下のページも削除します",
     "completely": "ゴミ箱を経由せず、完全に削除します"
   },
+  "deleted_page": "ゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -416,6 +416,7 @@
 		"recursively": "Delete children of <code>%s</code> recursively.",
 		"completely": "Delete completely instead of putting it into trash."
   },
+  "deleted_page": "移到了垃圾箱。",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",

+ 1 - 22
packages/app/src/client/services/PageContainer.js

@@ -17,7 +17,6 @@ import {
 import {
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
-import { emojiMartData } from '../util/markdown-it/emoji-mart-data';
 
 const { isTrashPage } = pagePathUtils;
 
@@ -197,30 +196,10 @@ export default class PageContainer extends Container {
 
   async setTocHtml(tocHtml) {
     if (this.state.tocHtml !== tocHtml) {
-      const tocHtmlWithEmoji = await this.colonsToEmoji(tocHtml);
-      this.setState({ tocHtml: tocHtmlWithEmoji });
+      this.setState({ tocHtml });
     }
   }
 
-  /**
-   *
-   * @param {*} html TOC html string
-   * @returns TOC html with emoji (emoji-mart) in URL
-   */
-  async colonsToEmoji(html) {
-    // Emoji colons matching
-    const colons = ':[a-zA-Z0-9-_+]+:';
-    // Emoji with skin tone matching
-    const skin = ':skin-tone-[2-6]:';
-    const colonsRegex = new RegExp(`(${colons}${skin}|${colons})`, 'g');
-    const emojiData = await emojiMartData();
-    return html.replace(colonsRegex, (index, match) => {
-      const emojiName = match.slice(1, -1);
-      return emojiData[emojiName];
-    });
-
-  }
-
   /**
    * save success handler
    * @param {object} page Page instance

+ 16 - 16
packages/app/src/client/util/markdown-it/emoji-mart-data.ts

@@ -3,24 +3,28 @@ import data from 'emoji-mart/data/apple.json';
 
 const DEFAULT_EMOJI_SIZE = 24;
 
+
+type EmojiMap = {
+  [key: string]: string,
+};
+
 /**
  *
  * Get native emoji with skin tone
- * @param emoji Emoji object
  * @param skin number
  * @returns emoji data with skin tone
  */
-const getEmojiSkinTone = async(emoji) => {
+const getEmojiSkinTone = (emojiName: string): EmojiMap => {
   const emojiData = {};
   [...Array(6).keys()].forEach((index) => {
     if (index > 0) {
       const elem = Emoji({
-        emoji,
+        emoji: emojiName,
         skin: index + 1,
         size: DEFAULT_EMOJI_SIZE,
       });
       if (elem) {
-        emojiData[`${emoji}::skin-tone-${index + 1}`] = elem.props['aria-label'].split(',')[0];
+        emojiData[`${emojiName}::skin-tone-${index + 1}`] = elem.props['aria-label'].split(',')[0];
       }
     }
   });
@@ -29,27 +33,29 @@ const getEmojiSkinTone = async(emoji) => {
 
 /**
  * Get native emoji from emoji array
- * @param emojis array of emoji
  * @returns emoji data
  */
 
-const getNativeEmoji = async(emojis) => {
+const getNativeEmoji = (): EmojiMap => {
   const emojiData = {};
-  emojis.forEach(async(emoji) => {
+  Object.entries(data.emojis).forEach((emoji) => {
     const emojiName = emoji[0];
-    const hasSkinVariation = emoji[1].skin_variations;
+    const hasSkinVariation = 'skin_variations' in emoji[1];
+
     const elem = Emoji({
       emoji: emojiName,
       size: DEFAULT_EMOJI_SIZE,
     });
+
     if (elem != null) {
       emojiData[emojiName] = elem.props['aria-label'].split(',')[0];
       if (hasSkinVariation) {
-        const emojiWithSkinTone = await getEmojiSkinTone(emojiName);
+        const emojiWithSkinTone = getEmojiSkinTone(emojiName);
         Object.assign(emojiData, emojiWithSkinTone);
       }
     }
   });
+
   return emojiData;
 };
 
@@ -57,10 +63,4 @@ const getNativeEmoji = async(emojis) => {
  * Get native emoji mart data
  * @returns native emoji mart data
  */
-
-export const emojiMartData = () => {
-  const emojis = Object.entries(data.emojis).map((emoji) => {
-    return emoji;
-  });
-  return getNativeEmoji(emojis);
-};
+export const emojiMartData = getNativeEmoji();

+ 4 - 7
packages/app/src/client/util/markdown-it/emoji.js

@@ -1,15 +1,12 @@
+import markdownItEmojiMart from 'markdown-it-emoji-mart';
+
 import { emojiMartData } from './emoji-mart-data';
 
-export default class EmojiConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
+export default class EmojiConfigurer {
 
   configure(md) {
-    emojiMartData().then((data) => {
-      md.use(require('markdown-it-emoji-mart'), { defs: data });
-    });
+    md.use(markdownItEmojiMart, { defs: emojiMartData });
   }
 
 }

+ 13 - 7
packages/app/src/client/util/markdown-it/toc-and-anchor.js

@@ -1,3 +1,8 @@
+import markdownItEmojiMart from 'markdown-it-emoji-mart';
+import markdownItToc from 'markdown-it-toc-and-anchor-with-slugid';
+
+import { emojiMartData } from './emoji-mart-data';
+
 export default class TocAndAnchorConfigurer {
 
   constructor(crowi, setHtml) {
@@ -6,13 +11,14 @@ export default class TocAndAnchorConfigurer {
   }
 
   configure(md) {
-    md.use(require('markdown-it-toc-and-anchor-with-slugid').default, {
-      tocLastLevel: 3,
-      anchorLinkBefore: false,
-      anchorLinkSymbol: '',
-      anchorLinkSymbolClassName: 'icon-link',
-      anchorClassName: 'revision-head-link',
-    });
+    md.use(markdownItEmojiMart, { defs: emojiMartData })
+      .use(markdownItToc, {
+        tocLastLevel: 3,
+        anchorLinkBefore: false,
+        anchorLinkSymbol: '',
+        anchorLinkSymbolClassName: 'icon-link',
+        anchorClassName: 'revision-head-link',
+      });
 
     // set toc render function
     if (this.setHtml != null) {

+ 11 - 3
packages/app/src/components/DescendantsPageList.tsx

@@ -1,5 +1,7 @@
 import React, { useCallback, useState } from 'react';
+
 import { useTranslation } from 'react-i18next';
+
 import { toastSuccess } from '~/client/util/apiNotification';
 import {
   IDataWithMeta,
@@ -9,13 +11,12 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSharedUser, useIsTrashPage } from '~/stores/context';
-
 import {
   useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList, useSWRxPageList, useDescendantsPageListForCurrentPathTermManager,
 } from '~/stores/page';
 import { usePageTreeTermManager } from '~/stores/page-listing';
-import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 
+import { ForceHideMenuItems, MenuItemType } from './Common/Dropdown/PageItemControl';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
@@ -61,7 +62,14 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   }
 
   const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
-    toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+    const path = args[0];
+    const isCompletely = args[2];
+    if (path == null || isCompletely == null) {
+      toastSuccess(t('deleted_page'));
+    }
+    else {
+      toastSuccess(t('deleted_pages_completely', { path }));
+    }
 
     advancePt();
 

+ 5 - 6
packages/app/src/components/Me/PasswordSettings.jsx

@@ -10,7 +10,6 @@ import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-
 class PasswordSettings extends React.Component {
 
   constructor() {
@@ -22,6 +21,7 @@ class PasswordSettings extends React.Component {
       newPassword: '',
       newPasswordConfirm: '',
       isPasswordSet: false,
+      minPasswordLength: null,
     };
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
@@ -32,8 +32,8 @@ class PasswordSettings extends React.Component {
   async componentDidMount() {
     try {
       const res = await apiv3Get('/personal-setting/is-password-set');
-      const { isPasswordSet } = res.data;
-      this.setState({ isPasswordSet });
+      const { isPasswordSet, minPasswordLength } = res.data;
+      this.setState({ isPasswordSet, minPasswordLength });
     }
     catch (err) {
       toastError(err);
@@ -74,9 +74,8 @@ class PasswordSettings extends React.Component {
 
   render() {
     const { t } = this.props;
-    const { newPassword, newPasswordConfirm } = this.state;
+    const { newPassword, newPasswordConfirm, minPasswordLength } = this.state;
     const isIncorrectConfirmPassword = (newPassword !== newPasswordConfirm);
-
     if (this.state.retrieveError != null) {
       throw new Error(this.state.retrieveError.message);
     }
@@ -131,7 +130,7 @@ class PasswordSettings extends React.Component {
               onChange={(e) => { this.onChangeNewPasswordConfirm(e.target.value) }}
             />
 
-            <p className="form-text text-muted">{t('page_register.form_help.password') }</p>
+            <p className="form-text text-muted">{t('page_register.form_help.password', { target: minPasswordLength }) }</p>
           </div>
         </div>
 

+ 7 - 5
packages/app/src/components/Page/FixPageGrantAlert.tsx

@@ -9,7 +9,7 @@ import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { PageGrant, IPageGrantData } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
-import { useCurrentPageId, useHasParent } from '~/stores/context';
+import { useCurrentPageId, useCurrentUser, useHasParent } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized } from '~/stores/page';
 
 type ModalProps = {
@@ -231,12 +231,14 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
 const FixPageGrantAlert = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const [isOpen, setOpen] = useState<boolean>(false);
-
+  const { data: currentUser } = useCurrentUser();
   const { data: pageId } = useCurrentPageId();
   const { data: hasParent } = useHasParent();
-  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(pageId);
-  const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(currentUser != null ? pageId : null);
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
 
   // Dependencies
   if (!hasParent) {

+ 2 - 6
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -33,7 +33,7 @@ class LegacyRevisionRenderer extends React.PureComponent {
     this.currentRenderingContext = {
       markdown: this.props.markdown,
       pagePath: this.props.pagePath,
-      renderDrawioInRealtime: this.props.editorSettings.renderDrawioInRealtime,
+      renderDrawioInRealtime: this.props.editorSettings?.renderDrawioInRealtime,
       currentPathname: decodeURIComponent(window.location.pathname),
     };
   }
@@ -178,7 +178,7 @@ LegacyRevisionRenderer.propTypes = {
   pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
   additionalClassName: PropTypes.string,
-  editorSettings: PropTypes.any.isRequired,
+  editorSettings: PropTypes.any,
 };
 
 /**
@@ -191,10 +191,6 @@ const LegacyRevisionRendererWrapper = withUnstatedContainers(LegacyRevisionRende
 const RevisionRenderer = (props) => {
   const { data: editorSettings } = useEditorSettings();
 
-  if (editorSettings == null) {
-    return <></>;
-  }
-
   return <LegacyRevisionRendererWrapper {...props} editorSettings={editorSettings} />;
 };
 

+ 5 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, FC, useMemo,
+  useState, FC, useMemo, useEffect,
 } from 'react';
 
 import { useTranslation } from 'react-i18next';
@@ -83,6 +83,10 @@ const PageDeleteModal: FC = () => {
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
 
+  useEffect(() => {
+    setIsDeleteCompletely(forceDeleteCompletelyMode);
+  }, [forceDeleteCompletelyMode]);
+
   function changeIsDeleteRecursivelyHandler() {
     setIsDeleteRecursively(!isDeleteRecursively);
   }

+ 5 - 5
packages/app/src/components/PageEditor/CommentMentionHelper.ts

@@ -48,12 +48,12 @@ export default class CommentMentionHelper {
     });
   }
 
-  getUsersList = async(username) => {
+  getUsersList = async(q: string) => {
     const limit = 20;
-    const { data } = await apiv3Get('/users/list', { username, limit });
-    return data.users.map(user => ({
-      text: `@${user.username} `,
-      displayText: user.username,
+    const { data } = await apiv3Get('/users/usernames', { q, limit });
+    return data.activeUser.usernames.map(username => ({
+      text: `@${username} `,
+      displayText: username,
     }));
   }
 

+ 11 - 2
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -1,7 +1,9 @@
 import React, {
   forwardRef, ForwardRefRenderFunction, useEffect, useImperativeHandle, useRef, useState,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
+
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess } from '~/client/util/apiNotification';
@@ -11,8 +13,8 @@ import { OnDeletedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { usePageTreeTermManager } from '~/stores/page-listing';
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 
+import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
@@ -253,7 +255,14 @@ export const usePageDeleteModalForBulkDeletion = (
 
     openDeleteModal(selectedPages, {
       onDeleted: (...args) => {
-        toastSuccess(args[2] ? t('deleted_pages_completely') : t('deleted_pages'));
+        const path = args[0];
+        const isCompletely = args[2];
+        if (path == null || isCompletely == null) {
+          toastSuccess(t('deleted_page'));
+        }
+        else {
+          toastSuccess(t('deleted_pages_completely', { path }));
+        }
         advancePt();
 
         if (onDeleted != null) {

+ 1 - 1
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -43,7 +43,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
           Custom Sidebar
           <a className="h6 ml-2" href="/Sidebar"><i className="icon-pencil"></i></a>
         </h3>
-        <button type="button" className="btn btn-sm btn-outline-secondary ml-auto" onClick={() => mutate()}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
           <i className="icon icon-reload"></i>
         </button>
       </div>

+ 1 - 1
packages/app/src/components/Sidebar/Tag.tsx

@@ -38,7 +38,7 @@ const Tag: FC = () => {
         <h3 className="mb-0">{t('Tags')}</h3>
         <button
           type="button"
-          className="btn btn-sm ml-auto grw-btn-reload-rc"
+          className="btn btn-sm ml-auto grw-btn-reload"
           onClick={onReload}
         >
           <i className="icon icon-reload"></i>

+ 5 - 1
packages/app/src/server/crowi/index.js

@@ -400,12 +400,16 @@ Crowi.prototype.autoInstall = function() {
     admin: true,
   };
   const globalLang = this.configManager.getConfig('crowi', 'autoInstall:globalLang');
+  const allowGuestMode = this.configManager.getConfig('crowi', 'autoInstall:allowGuestMode');
   const serverDate = this.configManager.getConfig('crowi', 'autoInstall:serverDate');
 
   const installerService = new InstallerService(this);
 
   try {
-    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', serverDate);
+    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', {
+      allowGuestMode,
+      serverDate,
+    });
   }
   catch (err) {
     logger.warn('Automatic installation failed.', err);

+ 16 - 2
packages/app/src/server/models/user.js

@@ -715,8 +715,22 @@ module.exports = function(crowi) {
     return users;
   };
 
-  userSchema.statics.findUserByUsernameRegex = async function(username, limit) {
-    return this.find({ username: { $regex: username, $options: 'i' } }).limit(limit);
+  userSchema.statics.findUserByUsernameRegexWithTotalCount = async function(username, status, option) {
+    const opt = option || {};
+    const sortOpt = opt.sortOpt || { username: 1 };
+    const offset = opt.offset || 0;
+    const limit = opt.limit || 10;
+
+    const conditions = { username: { $regex: username, $options: 'i' }, status: { $in: status } };
+
+    const users = await this.find(conditions)
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find(conditions).distinct('username')).length;
+
+    return { users, totalCount };
   };
 
   class UserUpperLimitException {

+ 5 - 2
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -10,6 +10,7 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import httpErrorHandler from '../../middlewares/http-error-handler';
 import { checkForgotPasswordEnabledMiddlewareFactory } from '../forgot-password';
 
+
 const logger = loggerFactory('growi:routes:apiv3:forgotPassword'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -25,11 +26,13 @@ module.exports = (crowi) => {
   const path = require('path');
   const csrf = require('../../middlewares/csrf')(crowi);
 
+  const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+
   const validator = {
     password: [
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 8 })
-        .withMessage('password must be at least 8 characters long'),
+        .isLength({ min: minPasswordLength })
+        .withMessage(`password must be at least ${minPasswordLength} characters long`),
       // checking if password confirmation matches password
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {

+ 6 - 4
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -72,6 +72,8 @@ module.exports = (crowi) => {
 
   const { User, ExternalAccount } = crowi.models;
 
+  const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+
   const validator = {
     personal: [
       body('name').isString().not().isEmpty(),
@@ -91,8 +93,8 @@ module.exports = (crowi) => {
     password: [
       body('oldPassword').isString(),
       body('newPassword').isString().not().isEmpty()
-        .isLength({ min: 8 })
-        .withMessage('password must be at least 8 characters long'),
+        .isLength({ min: minPasswordLength })
+        .withMessage(`password must be at least ${minPasswordLength} characters long`),
       body('newPasswordConfirm').isString().not().isEmpty()
         .custom((value, { req }) => {
           return (value === req.body.newPassword);
@@ -146,7 +148,6 @@ module.exports = (crowi) => {
    */
   router.get('/', accessTokenParser, loginRequiredStrictly, async(req, res) => {
     const { username } = req.user;
-
     try {
       const user = await User.findUserByUsername(username);
 
@@ -189,7 +190,8 @@ module.exports = (crowi) => {
     try {
       const user = await User.findUserByUsername(username);
       const isPasswordSet = user.isPasswordSet();
-      return res.apiv3({ isPasswordSet });
+      const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
+      return res.apiv3({ isPasswordSet, minPasswordLength });
     }
     catch (err) {
       logger.error(err);

+ 43 - 6
packages/app/src/server/routes/apiv3/users.js

@@ -120,6 +120,13 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 300 }).withMessage('You should set less than 300 or not to set limit.'),
   ];
 
+  validator.usernames = [
+    query('q').isString().withMessage('q is required'),
+    query('offset').optional().isInt().withMessage('offset must be a number'),
+    query('limit').optional().isInt({ max: 20 }).withMessage('You should set less than 20 or not to set limit.'),
+    query('options').optional().isString().withMessage('options must be string'),
+  ];
+
   const sendEmailByUserList = async(userList) => {
     const { appService, mailService } = crowi;
     const appTitle = appService.getAppTitle();
@@ -899,17 +906,11 @@ module.exports = (crowi) => {
    */
   router.get('/list', accessTokenParser, loginRequired, async(req, res) => {
     const userIds = req.query.userIds || null;
-    const username = req.query.username || null;
-    const limit = req.query.limit || 20;
 
     let userFetcher;
     if (userIds !== null && userIds.split(',').length > 0) {
       userFetcher = User.findUsersByIds(userIds.split(','));
     }
-    // Get username list by matching pattern from username mention
-    else if (username !== null) {
-      userFetcher = User.findUserByUsernameRegex(username, limit);
-    }
     else {
       userFetcher = User.findAllUsers();
     }
@@ -932,5 +933,41 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
+  router.get('/usernames', accessTokenParser, loginRequired, validator.usernames, apiV3FormValidator, async(req, res) => {
+    const q = req.query.q;
+    const offset = +req.query.offset || 0;
+    const limit = +req.query.limit || 10;
+
+    try {
+      const options = JSON.parse(req.query.options || '{}');
+      const data = {};
+
+      if (options.isIncludeActiveUser == null || options.isIncludeActiveUser) {
+        const activeUserData = await User.findUserByUsernameRegexWithTotalCount(q, [User.STATUS_ACTIVE], { offset, limit });
+        const activeUsernames = activeUserData.users.map(user => user.username);
+        Object.assign(data, { activeUser: { usernames: activeUsernames, totalCount: activeUserData.totalCount } });
+      }
+
+      if (options.isIncludeInactiveUser) {
+        const inactiveUserStates = [User.STATUS_REGISTERED, User.STATUS_SUSPENDED, User.STATUS_DELETED, User.STATUS_INVITED];
+        const inactiveUserData = await User.findUserByUsernameRegexWithTotalCount(q, inactiveUserStates, { offset, limit });
+        const inactiveUsernames = inactiveUserData.users.map(user => user.username);
+        Object.assign(data, { inactiveUser: { usernames: inactiveUsernames, totalCount: inactiveUserData.totalCount } });
+      }
+
+      if (options.isIncludeMixedUsername) {
+        const allUsernames = [...data.activeUser?.usernames || [], ...data.inactiveUser?.usernames || []];
+        const distinctUsernames = Array.from(new Set(allUsernames));
+        Object.assign(data, { mixedUsernames: distinctUsernames });
+      }
+
+      return res.apiv3(data);
+    }
+    catch (err) {
+      logger.error('Failed to get usernames', err);
+      return res.apiv3Err(err);
+    }
+  });
+
   return router;
 };

+ 13 - 1
packages/app/src/server/service/config-loader.ts

@@ -1,6 +1,6 @@
+import { envUtils } from '@growi/core';
 import { parseISO } from 'date-fns';
 
-import { envUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 
@@ -217,6 +217,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  AUTO_INSTALL_ALLOW_GUEST_MODE: {
+    ns:      'crowi',
+    key:     'autoInstall:allowGuestMode',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   AUTO_INSTALL_SERVER_DATE: {
     ns:      'crowi',
     key:     'autoInstall:serverDate',
@@ -610,6 +616,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  MIN_PASSWORD_LENGTH: {
+    ns: 'crowi',
+    key: 'app:minPasswordLength',
+    type: ValueType.NUMBER,
+    default: 8,
+  },
 };
 
 

+ 20 - 8
packages/app/src/server/service/installer.ts

@@ -1,23 +1,30 @@
-import mongoose from 'mongoose';
-import fs from 'graceful-fs';
 import path from 'path';
+
 import ExtensibleCustomError from 'extensible-custom-error';
+import fs from 'graceful-fs';
+import mongoose from 'mongoose';
+
 
+import { Lang } from '~/interfaces/lang';
 import { IPage } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
-import { Lang } from '~/interfaces/lang';
 import loggerFactory from '~/utils/logger';
 
 import { generateConfigsForInstalling } from '../models/config';
 
-import SearchService from './search';
 import ConfigManager from './config-manager';
+import SearchService from './search';
 
 const logger = loggerFactory('growi:service:installer');
 
 export class FailedToCreateAdminUserError extends ExtensibleCustomError {
 }
 
+export type AutoInstallOptions = {
+  allowGuestMode?: boolean,
+  serverDate?: Date,
+}
+
 export class InstallerService {
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -90,16 +97,21 @@ export class InstallerService {
   /**
    * Execute only once for installing application
    */
-  private async initDB(globalLang: Lang): Promise<void> {
+  private async initDB(globalLang: Lang, options?: AutoInstallOptions): Promise<void> {
     const configManager: ConfigManager = this.crowi.configManager;
 
     const initialConfig = generateConfigsForInstalling();
     initialConfig['app:globalLang'] = globalLang;
+
+    if (options?.allowGuestMode) {
+      initialConfig['security:restrictGuestMode'] = 'Readonly';
+    }
+
     return configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
 
-  async install(firstAdminUserToSave: IUser, globalLang: Lang, initialPagesCreatedAt?: Date): Promise<IUser> {
-    await this.initDB(globalLang);
+  async install(firstAdminUserToSave: IUser, globalLang: Lang, options?: AutoInstallOptions): Promise<IUser> {
+    await this.initDB(globalLang, options);
 
     // TODO typescriptize models/user.js and remove eslint-disable-next-line
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -137,7 +149,7 @@ export class InstallerService {
     await Promise.all([rootPage.save(), rootRevision.save()]);
 
     // create initial pages
-    await this.createInitialPages(adminUser, globalLang, initialPagesCreatedAt);
+    await this.createInitialPages(adminUser, globalLang, options?.serverDate);
 
     return adminUser;
   }

+ 2 - 2
packages/app/src/server/service/page.ts

@@ -262,7 +262,7 @@ class PageService {
       authority: IPageDeleteConfigValueToProcessValidation | null,
       recursiveAuthority: IPageDeleteConfigValueToProcessValidation | null,
   ): boolean {
-    const isAdmin = operator.admin;
+    const isAdmin = operator?.admin ?? false;
     const isOperator = operator?._id == null ? false : operator._id.equals(creatorId);
 
     if (isRecursively) {
@@ -3418,7 +3418,7 @@ class PageService {
     }
 
     // Prepare a page document
-    const shouldNew = !isGrantRestricted;
+    const shouldNew = isGrantRestricted;
     const page = await this.preparePageDocumentToCreate(path, shouldNew);
 
     // Set field

+ 7 - 2
packages/app/src/server/util/middlewares.js

@@ -4,10 +4,10 @@ import loggerFactory from '~/utils/logger';
 // all new middlewares should be an independent file under /server/middlewares
 // eslint-disable-next-line no-unused-vars
 
-const { formatDistanceStrict } = require('date-fns');
 const { pathUtils } = require('@growi/core');
-const md5 = require('md5');
+const { formatDistanceStrict } = require('date-fns');
 const entities = require('entities');
+const md5 = require('md5');
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:lib:middlewares');
@@ -153,6 +153,11 @@ module.exports = (crowi) => {
         return list.slice(start, end);
       });
 
+      swig.setFilter('push', (list, element) => {
+        list.push(element);
+        return list;
+      });
+
       next();
     };
   };

+ 4 - 2
packages/app/src/server/views/layout/layout.html

@@ -59,11 +59,13 @@
 {% endblock %}
 
 {% block html_body %}
+{% set additionalBodyClasses = []; %}
+{% block html_additional_body_classes %}{% endblock %}
 {% if getConfig('crowi', 'customize:isContainerFluid') %}
-  {% set additionalBodyClass = 'growi-layout-fluid' %}
+  {% set additionalBodyClasses = additionalBodyClasses|push('growi-layout-fluid') %}
 {% endif %}
 <body
-  class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClass }}"
+  class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClasses|join(' ') }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"
   {% block html_base_attr %}{% endblock %}
   data-csrftoken="{{ csrf() }}"

+ 3 - 1
packages/app/src/server/views/private-legacy-pages.html

@@ -12,7 +12,9 @@
 {% endblock %}
 
 <!-- add .on-search to body tag class in layout -->
-{% set additionalBodyClass = 'on-search' %}
+{% block html_additional_body_classes %}
+  {% set additionalBodyClasses = additionalBodyClasses|push('on-search') %}
+{% endblock %}
 
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>

+ 3 - 1
packages/app/src/server/views/search.html

@@ -12,7 +12,9 @@
 {% endblock %}
 
 <!-- add .on-search to body tag class in layout -->
-{% set additionalBodyClass = 'on-search' %}
+{% block html_additional_body_classes %}
+  {% set additionalBodyClasses = additionalBodyClasses|push('on-search') %}
+{% endblock %}
 
 {% block layout_main %}
 <div id="grw-fav-sticky-trigger" class="sticky-top"></div>

+ 0 - 0
packages/app/test/cypress/integration/1-install/install.spec.ts → packages/app/test/cypress/integration/10-install/install.spec.ts


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

@@ -1,4 +1,3 @@
-
 context('Access to page', () => {
   const ssPrefix = 'access-to-page-';
 

+ 0 - 0
packages/app/test/cypress/integration/2-basic-features/use-tools.spec.ts → packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts


+ 81 - 0
packages/app/test/cypress/integration/21-basic-features-for-guest/access-to-page.spec.ts

@@ -0,0 +1,81 @@
+context('Access to page by guest', () => {
+  const ssPrefix = 'access-to-page-by-guest-';
+
+  beforeEach(() => {
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('/Sandbox is successfully loaded', () => {
+    cy.visit('/Sandbox', {  });
+    cy.screenshot(`${ssPrefix}-sandbox`);
+  });
+
+  it('/Sandbox with anchor hash is successfully loaded', () => {
+    cy.visit('/Sandbox#Headers');
+
+    // hide fab
+    cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+
+    cy.screenshot(`${ssPrefix}-sandbox-headers`);
+  });
+
+  it('/Sandbox/Math is successfully loaded', () => {
+    cy.visit('/Sandbox/Math');
+    cy.screenshot(`${ssPrefix}-sandbox-math`);
+  });
+
+  it('/Sandbox with edit is successfully loaded', () => {
+    cy.visit('/Sandbox#edit');
+    cy.screenshot(`${ssPrefix}-sandbox-edit-page`);
+  })
+
+});
+
+
+context('Access to /me page', () => {
+  const ssPrefix = 'access-to-me-page-by-guest-';
+
+  beforeEach(() => {
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('/me should be redirected to /login', () => {
+    cy.visit('/me', {  });
+    cy.screenshot(`${ssPrefix}-me`);
+  });
+
+});
+
+
+context('Access to special pages by guest', () => {
+  const ssPrefix = 'access-to-special-pages-by-guest-';
+
+  beforeEach(() => {
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('/trash is successfully loaded', () => {
+    cy.visit('/trash', {  });
+    cy.getByTestid('trash-page-list').should('be.visible');
+    cy.screenshot(`${ssPrefix}-trash`);
+  });
+
+  it('/tags is successfully loaded', () => {
+    cy.visit('/tags');
+
+    // open sidebar
+    cy.collapseSidebar(false);
+    // select tags
+    cy.getByTestid('grw-sidebar-nav-primary-tags').click();
+    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.screenshot(`${ssPrefix}-tags`);
+  });
+
+});

+ 0 - 0
packages/app/test/cypress/integration/3-search/search.spec.ts → packages/app/test/cypress/integration/30-search/search.spec.ts


+ 0 - 0
packages/app/test/cypress/integration/4-admin/access-to-admin-page.spec.ts → packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts


+ 0 - 0
packages/app/test/cypress/integration/5-switch-sidebar-mode/switching-sidebar-mode.spec.ts → packages/app/test/cypress/integration/50-switch-sidebar-mode/switching-sidebar-mode.spec.ts


+ 0 - 0
packages/app/test/cypress/integration/6-home/home.spec.ts → packages/app/test/cypress/integration/60-home/home.spec.ts


+ 223 - 35
packages/app/test/integration/service/v5.migration.test.js

@@ -34,6 +34,16 @@ describe('V5 page migration', () => {
   const pageId10 = new mongoose.Types.ObjectId();
   const pageId11 = new mongoose.Types.ObjectId();
 
+  const public = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
+  const ownedByTestUser1 = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
+  const root = filter => ({ grantedUsers: [rootUser._id], ...filter });
+  const rootUserGroup = filter => ({ grantedGroup: rootUserGroupId, ...filter });
+  const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
+
+  const normalized = { parent: { $ne: null } };
+  const notNormalized = { parent: null };
+  const empty = { isEmpty: true };
+
   beforeAll(async() => {
     jest.restoreAllMocks();
 
@@ -392,14 +402,6 @@ describe('V5 page migration', () => {
      *     - /normalize_g/normalize_i/normalize_k (only me) is normalized
      */
 
-    const public = filter => ({ grant: Page.GRANT_PUBLIC, ...filter });
-    const owned = filter => ({ grant: Page.GRANT_OWNER, grantedUsers: [testUser1._id], ...filter });
-    const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
-
-    const normalized = { parent: { $ne: null } };
-    const notNormalized = { parent: null };
-    const empty = { isEmpty: true };
-
     beforeAll(async() => {
       // Prepare data
       const id1 = new mongoose.Types.ObjectId();
@@ -555,10 +557,10 @@ describe('V5 page migration', () => {
 
     test('should replace all unnecessary empty pages and normalization succeeds', async() => {
       const _pageG = await Page.findOne(public({ path: '/normalize_g', ...normalized }));
-      const _pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h', ...notNormalized }));
-      const _pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i', ...notNormalized }));
-      const _pageGHJ = await Page.findOne(owned({ path: '/normalize_g/normalize_h/normalize_j', ...notNormalized }));
-      const _pageGIK = await Page.findOne(owned({ path: '/normalize_g/normalize_i/normalize_k', ...notNormalized }));
+      const _pageGH = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h', ...notNormalized }));
+      const _pageGI = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i', ...notNormalized }));
+      const _pageGHJ = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h/normalize_j', ...notNormalized }));
+      const _pageGIK = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i/normalize_k', ...notNormalized }));
 
       expect(_pageG).not.toBeNull();
       expect(_pageGH).not.toBeNull();
@@ -608,8 +610,8 @@ describe('V5 page migration', () => {
       expect(pageGIK.descendantCount).toStrictEqual(0);
 
       // -- not normalized pages
-      const pageGH = await Page.findOne(owned({ path: '/normalize_g/normalize_h' }));
-      const pageGI = await Page.findOne(owned({ path: '/normalize_g/normalize_i' }));
+      const pageGH = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_h' }));
+      const pageGI = await Page.findOne(ownedByTestUser1({ path: '/normalize_g/normalize_i' }));
       // Check existence
       expect(pageGH).not.toBeNull();
       expect(pageGI).not.toBeNull();
@@ -655,15 +657,6 @@ describe('V5 page migration', () => {
      *     - E and F are NOT normalized
      */
 
-    const owned = filter => ({ grantedUsers: [testUser1._id], ...filter });
-    const root = filter => ({ grantedUsers: [rootUser._id], ...filter });
-    const rootUserGroup = filter => ({ grantedGroup: rootUserGroupId, ...filter });
-    const testUser1Group = filter => ({ grantedGroup: testUser1GroupId, ...filter });
-
-    const normalized = { parent: { $ne: null } };
-    const notNormalized = { parent: null };
-    const empty = { isEmpty: true };
-
     beforeAll(async() => {
       // Prepare data
       const id17 = new mongoose.Types.ObjectId();
@@ -787,10 +780,10 @@ describe('V5 page migration', () => {
 
 
     test('Should normalize a single page without including other pages', async() => {
-      const _owned13 = await Page.findOne(owned({ path: '/normalize_13_owned', ...notNormalized }));
-      const _owned14 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
-      const _owned15 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));
-      const _owned16 = await Page.findOne(owned({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned', ...notNormalized }));
+      const _owned13 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned', ...notNormalized }));
+      const _owned14 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned', ...notNormalized }));
+      const _owned15 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned', ...notNormalized }));
+      const _owned16 = await Page.findOne(ownedByTestUser1({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_owned', ...notNormalized }));
       const _root16 = await Page.findOne(root({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_root', ...notNormalized }));
       const _group16 = await Page.findOne(testUser1Group({ path: '/normalize_13_owned/normalize_14_owned/normalize_15_owned/normalize_16_group', ...notNormalized }));
 
@@ -836,10 +829,10 @@ describe('V5 page migration', () => {
     });
 
     test('Should normalize pages recursively excluding the pages not selected', async() => {
-      const _owned17 = await Page.findOne(owned({ path: '/normalize_17_owned', ...normalized }));
-      const _owned18 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned', ...normalized }));
-      const _owned19 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...notNormalized }));
-      const _owned20 = await Page.findOne(owned({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned', ...notNormalized }));
+      const _owned17 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned', ...normalized }));
+      const _owned18 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned', ...normalized }));
+      const _owned19 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned', ...notNormalized }));
+      const _owned20 = await Page.findOne(ownedByTestUser1({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_owned', ...notNormalized }));
       const _root20 = await Page.findOne(root({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_root', ...notNormalized }));
       const _group20 = await Page.findOne(rootUserGroup({ path: '/normalize_17_owned/normalize_18_owned/normalize_19_owned/normalize_20_group', ...notNormalized }));
 
@@ -884,11 +877,11 @@ describe('V5 page migration', () => {
     });
 
     test('Should normalize pages recursively excluding the pages of not user\'s & Should delete unnecessary empty pages', async() => {
-      const _owned21 = await Page.findOne(owned({ path: '/normalize_21_owned', ...normalized }));
-      const _owned22 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned', ...normalized }));
-      const _owned23 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...notNormalized }));
+      const _owned21 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned', ...normalized }));
+      const _owned22 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned', ...normalized }));
+      const _owned23 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...notNormalized }));
       const _empty23 = await Page.findOne({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned', ...normalized, ...empty });
-      const _owned24 = await Page.findOne(owned({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned', ...normalized }));
+      const _owned24 = await Page.findOne(ownedByTestUser1({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_owned', ...normalized }));
       const _root24 = await Page.findOne(root({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_root', ...notNormalized }));
       const _rootGroup24 = await Page.findOne(rootUserGroup({ path: '/normalize_21_owned/normalize_22_owned/normalize_23_owned/normalize_24_rootGroup', ...notNormalized }));
 
@@ -1116,4 +1109,199 @@ describe('V5 page migration', () => {
     expect(privatePage).toStrictEqual(expectedPrivatePage);
   });
 
+  describe('normalizeParentByPath', () => {
+    const normalizeParentByPath = async(path, user) => {
+      const mock = jest.spyOn(crowi.pageService, 'normalizeParentRecursivelyMainOperation').mockReturnValue(null);
+      const result = await crowi.pageService.normalizeParentByPath(path, user);
+      const args = mock.mock.calls[0];
+
+      mock.mockRestore();
+
+      await crowi.pageService.normalizeParentRecursivelyMainOperation(...args);
+
+      return result;
+    };
+
+    beforeAll(async() => {
+      const pageIdD = new mongoose.Types.ObjectId();
+      const pageIdG = new mongoose.Types.ObjectId();
+
+      await Page.insertMany([
+        {
+          path: '/norm_parent_by_path_A',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [testUser1._id],
+          creator: testUser1._id,
+          lastUpdateUser: testUser1._id,
+          parent: rootPage._id,
+        },
+        {
+          path: '/norm_parent_by_path_B/norm_parent_by_path_C',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+        },
+        {
+          _id: pageIdD,
+          path: '/norm_parent_by_path_D',
+          isEmpty: true,
+          parent: rootPage._id,
+          descendantCount: 1,
+        },
+        {
+          path: '/norm_parent_by_path_D/norm_parent_by_path_E',
+          grant: Page.GRANT_PUBLIC,
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+          parent: pageIdD,
+        },
+        {
+          path: '/norm_parent_by_path_D/norm_parent_by_path_F',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+        },
+        {
+          _id: pageIdG,
+          path: '/norm_parent_by_path_G',
+          grant: Page.GRANT_PUBLIC,
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+          parent: rootPage._id,
+          descendantCount: 1,
+        },
+        {
+          path: '/norm_parent_by_path_G/norm_parent_by_path_H',
+          grant: Page.GRANT_PUBLIC,
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+          parent: pageIdG,
+        },
+        {
+          path: '/norm_parent_by_path_G/norm_parent_by_path_I',
+          grant: Page.GRANT_OWNER,
+          grantedUsers: [rootUser._id],
+          creator: rootUser._id,
+          lastUpdateUser: rootUser._id,
+        },
+      ]);
+    });
+
+    test('should fail when the user is not allowed to edit the target page found by path', async() => {
+      const pageTestUser1 = await Page.findOne(ownedByTestUser1({ path: '/norm_parent_by_path_A' }));
+
+      expect(pageTestUser1).not.toBeNull();
+
+      await expect(normalizeParentByPath('/norm_parent_by_path_A', rootUser)).rejects.toThrowError();
+    });
+
+    test('should normalize all granted pages under the path when no page exists at the path', async() => {
+      const _pageB = await Page.findOne({ path: '/norm_parent_by_path_B' });
+      const _pageBC = await Page.findOne(root({ path: '/norm_parent_by_path_B/norm_parent_by_path_C' }));
+
+      expect(_pageB).toBeNull();
+      expect(_pageBC).not.toBeNull();
+
+      await normalizeParentByPath('/norm_parent_by_path_B', rootUser);
+
+      const pagesB = await Page.find({ path: '/norm_parent_by_path_B' }); // did not exist before running normalizeParentByPath
+      const pageBC = await Page.findById(_pageBC._id);
+
+      // -- check count
+      expect(pagesB.length).toBe(1);
+
+      const pageB = pagesB[0];
+
+      // -- check existance
+      expect(pageB.path).toBe('/norm_parent_by_path_B');
+      expect(pageBC.path).toBe('/norm_parent_by_path_B/norm_parent_by_path_C');
+
+      // -- check parent
+      expect(pageB.parent).toStrictEqual(rootPage._id);
+      expect(pageBC.parent).toStrictEqual(pageB._id);
+
+      // -- check descendantCount
+      expect(pageB.descendantCount).toBe(1);
+      expect(pageBC.descendantCount).toBe(0);
+    });
+
+    test('should normalize all granted pages under the path when an empty page exists at the path', async() => {
+      const _emptyD = await Page.findOne({ path: '/norm_parent_by_path_D', ...empty, ...normalized });
+      const _pageDE = await Page.findOne(public({ path: '/norm_parent_by_path_D/norm_parent_by_path_E', ...normalized }));
+      const _pageDF = await Page.findOne(root({ path: '/norm_parent_by_path_D/norm_parent_by_path_F', ...notNormalized }));
+
+      expect(_emptyD).not.toBeNull();
+      expect(_pageDE).not.toBeNull();
+      expect(_pageDF).not.toBeNull();
+
+      await normalizeParentByPath('/norm_parent_by_path_D', rootUser);
+
+      const countD = await Page.count({ path: '/norm_parent_by_path_D' });
+
+      // -- check count
+      expect(countD).toBe(1);
+
+      const pageD = await Page.findById(_emptyD._id);
+      const pageDE = await Page.findById(_pageDE._id);
+      const pageDF = await Page.findById(_pageDF._id);
+
+      // -- check existance
+      expect(pageD.path).toBe('/norm_parent_by_path_D');
+      expect(pageDE.path).toBe('/norm_parent_by_path_D/norm_parent_by_path_E');
+      expect(pageDF.path).toBe('/norm_parent_by_path_D/norm_parent_by_path_F');
+
+      // -- check isEmpty of pageD
+      // pageD should not be empty because growi system will create a non-empty page while running normalizeParentByPath
+      expect(pageD.isEmpty).toBe(false);
+
+      // -- check parent
+      expect(pageD.parent).toStrictEqual(rootPage._id);
+      expect(pageDE.parent).toStrictEqual(pageD._id);
+      expect(pageDF.parent).toStrictEqual(pageD._id);
+
+      // -- check descendantCount
+      expect(pageD.descendantCount).toBe(2);
+      expect(pageDE.descendantCount).toBe(0);
+      expect(pageDF.descendantCount).toBe(0);
+    });
+
+    test('should normalize all granted pages under the path when a non-empty page exists at the path', async() => {
+      const _pageG = await Page.findOne(public({ path: '/norm_parent_by_path_G', ...normalized }));
+      const _pageGH = await Page.findOne(public({ path: '/norm_parent_by_path_G/norm_parent_by_path_H', ...normalized }));
+      const _pageGI = await Page.findOne(root({ path: '/norm_parent_by_path_G/norm_parent_by_path_I', ...notNormalized }));
+
+      expect(_pageG).not.toBeNull();
+      expect(_pageGH).not.toBeNull();
+      expect(_pageGI).not.toBeNull();
+
+      await normalizeParentByPath('/norm_parent_by_path_G', rootUser);
+
+      const countG = await Page.count({ path: '/norm_parent_by_path_G' });
+
+      // -- check count
+      expect(countG).toBe(1);
+
+      const pageG = await Page.findById(_pageG._id);
+      const pageGH = await Page.findById(_pageGH._id);
+      const pageGI = await Page.findById(_pageGI._id);
+
+      // -- check existance
+      expect(pageG.path).toBe('/norm_parent_by_path_G');
+      expect(pageGH.path).toBe('/norm_parent_by_path_G/norm_parent_by_path_H');
+      expect(pageGI.path).toBe('/norm_parent_by_path_G/norm_parent_by_path_I');
+
+      // -- check parent
+      expect(pageG.parent).toStrictEqual(rootPage._id);
+      expect(pageGH.parent).toStrictEqual(pageG._id);
+      expect(pageGI.parent).toStrictEqual(pageG._id);
+
+      // -- check descendantCount
+      expect(pageG.descendantCount).toBe(2);
+      expect(pageGH.descendantCount).toBe(0);
+      expect(pageGI.descendantCount).toBe(0);
+    });
+  });
+
 });

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.6",
+  "version": "5.0.7-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.6",
+    "@growi/slack": "^5.0.7-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.6",
+  "version": "5.0.7-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [