Răsfoiți Sursa

Merge branch 'dev/7.0.x' into feat/135182-use-drawio-modal-in-editor

soumaeda 2 ani în urmă
părinte
comite
b085826bda
64 a modificat fișierele cu 1252 adăugiri și 721 ștergeri
  1. 16 1
      CHANGELOG.md
  2. 2 1
      apps/app/docker/README.md
  3. 3 2
      apps/app/public/static/locales/en_US/admin.json
  4. 8 0
      apps/app/public/static/locales/en_US/translation.json
  5. 3 2
      apps/app/public/static/locales/ja_JP/admin.json
  6. 8 0
      apps/app/public/static/locales/ja_JP/translation.json
  7. 4 3
      apps/app/public/static/locales/zh_CN/admin.json
  8. 8 0
      apps/app/public/static/locales/zh_CN/translation.json
  9. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  10. 6 0
      apps/app/src/client/services/user-ui-settings.ts
  11. 14 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  12. 5 3
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  13. 6 95
      apps/app/src/components/Me/OtherSettings.tsx
  14. 106 0
      apps/app/src/components/Me/QuestionnaireSettings.tsx
  15. 2 2
      apps/app/src/components/Me/SidebarCollapsedIcon.jsx
  16. 0 0
      apps/app/src/components/Me/SidebarDockIcon.jsx
  17. 7 0
      apps/app/src/components/Me/UISettings.module.scss
  18. 114 0
      apps/app/src/components/Me/UISettings.tsx
  19. 30 62
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  20. 1 5
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  21. 6 21
      apps/app/src/components/PageEditor/PageEditor.tsx
  22. 12 34
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  23. 1 2
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  24. 6 4
      apps/app/src/components/TreeItem/SimpleItem.tsx
  25. 1 0
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  26. 6 6
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  27. 1 1
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  28. 0 2
      apps/app/src/interfaces/in-app-notification.ts
  29. 2 2
      apps/app/src/interfaces/page-grant.ts
  30. 14 21
      apps/app/src/pages/[[...path]].page.tsx
  31. 18 18
      apps/app/src/server/events/user.ts
  32. 2 2
      apps/app/src/server/models/activity.ts
  33. 2 1
      apps/app/src/server/models/config.ts
  34. 4 0
      apps/app/src/server/models/page.ts
  35. 246 1
      apps/app/src/server/routes/apiv3/attachment.js
  36. 3 1
      apps/app/src/server/routes/apiv3/bookmarks.js
  37. 9 1
      apps/app/src/server/routes/apiv3/page.js
  38. 16 10
      apps/app/src/server/routes/apiv3/pages.js
  39. 12 3
      apps/app/src/server/routes/apiv3/security-settings/index.js
  40. 5 10
      apps/app/src/server/routes/apiv3/users.js
  41. 0 162
      apps/app/src/server/routes/attachment/api.js
  42. 10 1
      apps/app/src/server/routes/comment.js
  43. 0 2
      apps/app/src/server/routes/index.js
  44. 9 2
      apps/app/src/server/routes/login.js
  45. 40 2
      apps/app/src/server/routes/page.js
  46. 18 5
      apps/app/src/server/service/activity.ts
  47. 13 14
      apps/app/src/server/service/file-uploader/aws.ts
  48. 45 52
      apps/app/src/server/service/file-uploader/azure.ts
  49. 10 8
      apps/app/src/server/service/file-uploader/gcs.ts
  50. 5 1
      apps/app/src/server/service/file-uploader/gridfs.ts
  51. 3 3
      apps/app/src/server/service/file-uploader/local.ts
  52. 20 36
      apps/app/src/server/service/in-app-notification.ts
  53. 19 0
      apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts
  54. 8 8
      apps/app/src/server/service/page-grant.ts
  55. 149 37
      apps/app/src/server/service/page.ts
  56. 66 0
      apps/app/src/server/service/pre-notify.ts
  57. 50 39
      apps/app/test/integration/service/external-user-group-sync.test.ts
  58. 16 16
      apps/app/test/integration/service/page-grant.test.js
  59. 1 1
      package.json
  60. 0 1
      packages/core/src/interfaces/page.ts
  61. 20 2
      packages/core/src/utils/page-path-utils/index.spec.ts
  62. 0 7
      packages/remark-drawio/src/components/DrawioViewer.module.scss
  63. 27 1
      packages/remark-drawio/src/components/DrawioViewer.tsx
  64. 4 4
      yarn.lock

+ 16 - 1
CHANGELOG.md

@@ -1,9 +1,24 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.4...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.2.4](https://github.com/weseek/growi/compare/v6.2.3...v6.2.4) - 2023-11-29
+
+### 💎 Features
+* feat: Show create date in Attachment Data list (#8229) @sakazuki
+
+### 🚀 Improvement
+
+* imprv: Add Marp preset template for ja_JP and zh_CN (#8179) @AikaHiyama
+* imprv: Allow deletion of user homepage when the user is deleted (#8224) @jam411
+
+### 🧰 Maintenance
+
+* support: Refactor deleteCompletelyUserHomeBySystem (#8262) @jam411
+
+
 ## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
 
 ### 🚀 Improvement

+ 2 - 1
apps/app/docker/README.md

@@ -11,7 +11,8 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
-* [`6.2.3`, `6.2`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.3/apps/app/docker/Dockerfile)
+* [`6.3.0`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.0/apps/app/docker/Dockerfile)
+* [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 
 

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

@@ -48,8 +48,9 @@
     "anyone": "Anyone",
     "user_homepage_deletion": {
       "user_homepage_deletion": "User homepage deletion",
-      "enable_user_homepage_deletion": "Complete deletion of user homepage, when user deletion",
-      "desc": "When deleting a user, the user homepage and its sub pages are also completely deleted."
+      "enable_user_homepage_deletion": "Enable user homepage deletion",
+      "enable_force_delete_user_homepage_on_user_deletion": "When you delete a user, the user's homepage and all its sub pages will be completely deleted",
+      "desc": "You will be able to delete a deleted user's homepage."
     },
     "session": "Session",
     "max_age": "Max age (msec)",

+ 8 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -249,6 +249,14 @@
       "page_create": "Subscribe to the page when you create it."
     }
   },
+  "ui_settings": {
+    "ui_settings": "UI Settings",
+    "side_bar_mode": {
+      "settings": "Sidebar mode settings",
+      "side_bar_mode_setting": "Set the sidebar mode",
+      "description": "You can set whether or not the sidebar will always be open when the screen width is large. If the screen width is small, the sidebar will always be closed."
+    }
+  },
   "editor_settings": {
     "editor_settings": "Editor Settings"
   },

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

@@ -57,8 +57,9 @@
     "anyone": "誰でも可能",
     "user_homepage_deletion": {
       "user_homepage_deletion": "ユーザーホームページの削除",
-      "enable_user_homepage_deletion": "ユーザー削除時にユーザーホームページを完全削除する",
-      "desc": "ユーザーを削除する際に、ユーザーホームページとその配下のページも完全削除されます。"
+      "enable_user_homepage_deletion": "ユーザーホームページの削除を有効化",
+      "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
+      "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
     },
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",

+ 8 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -250,6 +250,14 @@
       "page_create": "ページを作成した時にそのページをサブスクライブします。"
     }
   },
+  "ui_settings": {
+    "ui_settings": "UI設定",
+    "side_bar_mode": {
+      "settings": "サイドバーモードの設定",
+      "side_bar_mode_setting": "サイドバーのモードを設定する",
+      "description": "画面幅が大きい場合に、サイドバーを常時開いた状態にするかどうかを設定できます。画面幅が小さい場合はサイドバーは常に閉じた状態となります。"
+    }
+  },
   "editor_settings": {
     "editor_settings": "エディター設定",
     "common_settings": {

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

@@ -56,9 +56,10 @@
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
     "user_homepage_deletion": {
-      "user_homepage_deletion": "删除用户页面",
-      "enable_user_homepage_deletion": "用户删除时,完全删除用户主页",
-      "desc": "删除用户时,用户主页及其下属页面也会被完全删除。"
+      "user_homepage_deletion": "删除用户主页",
+      "enable_user_homepage_deletion": "启用用户主页删除功能",
+      "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除",
+      "desc": "您可以删除已删除用户的主页。"
     },
     "session": "会议",
     "max_age": "有效期间  (msec)",

+ 8 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -240,6 +240,14 @@
       "page_create": "创建页面时订阅页面。"
     }
   },
+  "ui_settings": {
+    "ui_settings": "用户界面设置",
+    "side_bar_mode": {
+      "settings": "侧边栏模式设置",
+      "side_bar_mode_setting": "设置侧边栏模式",
+      "description": "您可以设置当屏幕宽度较大时,侧边栏是否始终打开。 如果屏幕宽度较小,侧边栏将始终关闭。"
+    }
+  },
   "editor_settings": {
     "editor_settings": "编辑器设置"
   },

+ 10 - 0
apps/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -39,6 +39,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       isUsersHomepageDeletionEnabled: false,
+      isForceDeleteUserHomepageOnUserDeletion: false,
       isLocalEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,
@@ -75,6 +76,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -202,6 +204,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
   }
 
+  /**
+   * Switch isForceDeleteUserHomepageOnUserDeletion
+   */
+  switchIsForceDeleteUserHomepageOnUserDeletion() {
+    this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
+  }
+
   /**
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
@@ -219,6 +228,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
+      isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 6 - 0
apps/app/src/client/services/user-ui-settings.ts

@@ -25,3 +25,9 @@ export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
 
   _putUserUISettingsInBulkDebounced();
 };
+
+export const updateUserUISettings = async(settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+  const result = await apiv3Put<IUserUISettings>('/user-ui-settings', { settings });
+
+  return result;
+};

+ 14 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -468,8 +468,21 @@ class SecuritySetting extends React.Component {
                 {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
               </label>
             </div>
+            <div className="custom-control custom-switch custom-checkbox-success mt-2">
+              <input
+                type="checkbox"
+                className="form-check-input"
+                id="is-force-delete-user-homepage-on-user-deletion"
+                checked={adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion}
+                onChange={() => { adminGeneralSecurityContainer.switchIsForceDeleteUserHomepageOnUserDeletion() }}
+                disabled={!adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+              />
+              <label className="form-check-label" htmlFor="is-force-delete-user-homepage-on-user-deletion">
+                {t('security_settings.user_homepage_deletion.enable_force_delete_user_homepage_on_user_deletion')}
+              </label>
+            </div>
             <p
-              className="form-text text-muted small"
+              className="form-text text-muted small mt-2"
               dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
             />
           </div>

+ 5 - 3
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -85,7 +85,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
       return;
     }
-    if (!pageInfo?.isMovable) {
+
+    if (!pageInfo?.isDeletable) {
       logger.warn('This page could not be renamed.');
       return;
     }
@@ -176,9 +177,10 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && (
           <DropdownItem
             onClick={renameItemClickedHandler}
+            disabled={!pageInfo.isDeletable}
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
           >
@@ -230,7 +232,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 6 - 95
apps/app/src/components/Me/OtherSettings.tsx

@@ -1,105 +1,16 @@
-import {
-  useState, useEffect, useCallback,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
-import { useCurrentUser } from '~/stores/context';
+import { QuestionnaireSettings } from './QuestionnaireSettings';
+import { UISettings } from './UISettings';
 
 const OtherSettings = (): JSX.Element => {
-  const { t } = useTranslation();
-  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
-  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
-
-  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
-
-  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
-    setIsQuestionnaireEnabled(prev => !prev);
-  }, []);
-
-  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
-    try {
-      await apiv3Put('/personal-setting/questionnaire-settings', {
-        isQuestionnaireEnabled,
-      });
-      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [isQuestionnaireEnabled, t]);
-
-  // Sync currentUser and state
-  useEffect(() => {
-    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
-  }, [currentUser?.isQuestionnaireEnabled]);
-
-  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
 
   return (
     <>
-      <h2 className="border-bottom my-4">{t('questionnaire.settings')}</h2>
-
-      {isLoadingCurrentUser && (
-        <div className="text-muted text-center mb-5">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
-        </div>
-      )}
-
-      <div className="row">
-        <div className="offset-md-3 col-md-6 text-start">
-          {!isLoadingCurrentUser && (
-            <div className="form-check form-switch form-check-primary">
-              <span id="grw-questionnaire-settings-toggle-wrapper">
-                <input
-                  type="checkbox"
-                  className="form-check-input"
-                  id="isQuestionnaireEnabled"
-                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
-                  onChange={onChangeIsQuestionnaireEnabledHandler}
-                  disabled={!growiIsQuestionnaireEnabled}
-                />
-                <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
-                  {t('questionnaire.enable_questionnaire')}
-                </label>
-              </span>
-              <p className="form-text text-muted small">
-                {t('questionnaire.personal_settings_explanation')}
-              </p>
-              {!growiIsQuestionnaireEnabled && (
-                <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
-                  {t('questionnaire.disabled_by_admin')}
-                </UncontrolledTooltip>
-              ) }
-            </div>
-          )}
-        </div>
+      <div className="mt-4">
+        <QuestionnaireSettings />
       </div>
 
-      <div className="row my-3">
-        <div className="offset-4 col-5">
-          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
-            <button
-              data-testid="grw-questionnaire-settings-update-btn"
-              type="button"
-              className="btn btn-primary"
-              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
-              disabled={!growiIsQuestionnaireEnabled}
-              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
-            >
-              {t('Update')}
-            </button>
-          </span>
-          {!growiIsQuestionnaireEnabled && (
-            <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
-              {t('questionnaire.disabled_by_admin')}
-            </UncontrolledTooltip>
-          )}
-        </div>
+      <div className="mt-4">
+        <UISettings />
       </div>
     </>
   );

+ 106 - 0
apps/app/src/components/Me/QuestionnaireSettings.tsx

@@ -0,0 +1,106 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
+import { useCurrentUser } from '~/stores/context';
+
+
+export const QuestionnaireSettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentUser, error: errorCurrentUser } = useCurrentUser();
+  const { data: growiIsQuestionnaireEnabled } = useSWRxIsQuestionnaireEnabled();
+
+  const [isQuestionnaireEnabled, setIsQuestionnaireEnabled] = useState(currentUser?.isQuestionnaireEnabled);
+
+  const onChangeIsQuestionnaireEnabledHandler = useCallback(async() => {
+    setIsQuestionnaireEnabled(prev => !prev);
+  }, []);
+
+  const onClickUpdateIsQuestionnaireEnabledHandler = useCallback(async() => {
+    try {
+      await apiv3Put('/personal-setting/questionnaire-settings', {
+        isQuestionnaireEnabled,
+      });
+      toastSuccess(t('toaster.update_successed', { target: t('questionnaire.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isQuestionnaireEnabled, t]);
+
+  // Sync currentUser and state
+  useEffect(() => {
+    setIsQuestionnaireEnabled(currentUser?.isQuestionnaireEnabled);
+  }, [currentUser?.isQuestionnaireEnabled]);
+
+  const isLoadingCurrentUser = currentUser === undefined && errorCurrentUser === undefined;
+
+  return (
+    <>
+      <h2 className="border-bottom mb-4">{t('questionnaire.settings')}</h2>
+
+      {isLoadingCurrentUser && (
+        <div className="text-muted text-center mb-5">
+          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+        </div>
+      )}
+
+      <div className="row">
+        <div className="offset-md-3 col-md-6 text-start">
+          {!isLoadingCurrentUser && (
+            <div className="form-check form-switch">
+              <span id="grw-questionnaire-settings-toggle-wrapper">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isQuestionnaireEnabled"
+                  checked={growiIsQuestionnaireEnabled && isQuestionnaireEnabled}
+                  onChange={onChangeIsQuestionnaireEnabledHandler}
+                  disabled={!growiIsQuestionnaireEnabled}
+                />
+                <label className="form-label form-check-label" htmlFor="isQuestionnaireEnabled">
+                  {t('questionnaire.enable_questionnaire')}
+                </label>
+              </span>
+              <p className="form-text text-muted small">
+                {t('questionnaire.personal_settings_explanation')}
+              </p>
+              {!growiIsQuestionnaireEnabled && (
+                <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-toggle-wrapper">
+                  {t('questionnaire.disabled_by_admin')}
+                </UncontrolledTooltip>
+              ) }
+            </div>
+          )}
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <span className="d-inline-block" id="grw-questionnaire-settings-update-btn-wrapper">
+            <button
+              data-testid="grw-questionnaire-settings-update-btn"
+              type="button"
+              className="btn btn-primary"
+              onClick={onClickUpdateIsQuestionnaireEnabledHandler}
+              disabled={!growiIsQuestionnaireEnabled}
+              style={growiIsQuestionnaireEnabled ? {} : { pointerEvents: 'none' }}
+            >
+              {t('Update')}
+            </button>
+          </span>
+          {!growiIsQuestionnaireEnabled && (
+            <UncontrolledTooltip placement="bottom" target="grw-questionnaire-settings-update-btn-wrapper">
+              {t('questionnaire.disabled_by_admin')}
+            </UncontrolledTooltip>
+          )}
+        </div>
+      </div>
+    </>
+
+  );
+};

+ 2 - 2
apps/app/src/components/Icons/SidebarDrawerIcon.jsx → apps/app/src/components/Me/SidebarCollapsedIcon.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const SidebarDrawerIcon = () => (
+const SidebarCollapsedIcon = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 23 23"
@@ -22,4 +22,4 @@ const SidebarDrawerIcon = () => (
   </svg>
 );
 
-export default SidebarDrawerIcon;
+export default SidebarCollapsedIcon;

+ 0 - 0
apps/app/src/components/Icons/SidebarDockIcon.jsx → apps/app/src/components/Me/SidebarDockIcon.jsx


+ 7 - 0
apps/app/src/components/Me/UISettings.module.scss

@@ -0,0 +1,7 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+.grw-sidebar-mode-icon {
+  width: 20px;
+  height: 20px;
+  color: bs.$secondary;
+}

+ 114 - 0
apps/app/src/components/Me/UISettings.tsx

@@ -0,0 +1,114 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { updateUserUISettings } from '~/client/services/user-ui-settings';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useCollapsedContentsOpened, usePreferCollapsedMode, useSidebarMode } from '~/stores/ui';
+
+import SidebarCollapsedIcon from './SidebarCollapsedIcon';
+import SidebarDockIcon from './SidebarDockIcon';
+
+import styles from './UISettings.module.scss';
+
+const IconWithTooltip = ({
+  id, label, children, additionalClasses,
+}: {
+id: string,
+label: string,
+children: JSX.Element,
+additionalClasses: string
+}) => (
+  <>
+    <div id={id} className={`${additionalClasses != null ? additionalClasses : ''}`}>{children}</div>
+    <UncontrolledTooltip placement="bottom" fade={false} target={id}>{label}</UncontrolledTooltip>
+  </>
+);
+
+export const UISettings = (): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isDockMode, isCollapsedMode,
+  } = useSidebarMode();
+  const { mutate: mutatePreferCollapsedMode } = usePreferCollapsedMode();
+  const { mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
+
+  const toggleCollapsed = useCallback(() => {
+    mutatePreferCollapsedMode(!isCollapsedMode());
+    mutateCollapsedContentsOpened(false);
+  }, [mutatePreferCollapsedMode, isCollapsedMode, mutateCollapsedContentsOpened]);
+
+  const updateButtonHandler = useCallback(async() => {
+    try {
+      await updateUserUISettings({ preferCollapsedModeByUser: isCollapsedMode() });
+      toastSuccess(t('toaster.update_successed', { target: t('ui_settings.side_bar_mode.settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }, [isCollapsedMode, t]);
+
+  const renderSidebarModeSwitch = () => {
+    return (
+      <>
+        <div className="d-flex align-items-start">
+          <div className="d-flex align-items-center">
+            <IconWithTooltip
+              id="iwt-sidebar-collapsed"
+              label="Collapsed"
+              additionalClasses={styles['grw-sidebar-mode-icon']}
+            >
+              <SidebarCollapsedIcon />
+            </IconWithTooltip>
+            <div className="form-check form-switch ms-2">
+
+              <input
+                id="swSidebarMode"
+                className="form-check-input"
+                type="checkbox"
+                checked={isDockMode()}
+                onChange={toggleCollapsed}
+              />
+              <label className="form-label form-check-label" htmlFor="swSidebarMode"></label>
+            </div>
+            <IconWithTooltip id="iwt-sidebar-dock" label="Dock" additionalClasses={styles['grw-sidebar-mode-icon']}>
+              <SidebarDockIcon />
+            </IconWithTooltip>
+          </div>
+          <div className="ms-2">
+            <label className="form-label form-check-label" htmlFor="swSidebarMode">
+              {t('ui_settings.side_bar_mode.side_bar_mode_setting')}
+            </label>
+            <p className="form-text text-muted small">{t('ui_settings.side_bar_mode.description')}</p>
+          </div>
+        </div>
+      </>
+    );
+  };
+
+  return (
+    <>
+      <h2 className="border-bottom mb-4">{t('ui_settings.ui_settings')}</h2>
+
+      <div className="row justify-content-center">
+        <div className="col-md-6">
+
+          { renderSidebarModeSwitch() }
+
+          <div>
+          </div>
+        </div>
+      </div>
+
+      <div className="row my-3">
+        <div className="offset-4 col-5">
+          <button data-testid="" type="button" className="btn btn-primary" onClick={updateButtonHandler}>
+            {t('Update')}
+          </button>
+        </div>
+      </div>
+    </>
+  );
+};

+ 30 - 62
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { PageGrant, GroupType } from '@growi/core';
+import { PageGrant } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
-import { ApplicableGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
@@ -31,7 +31,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
 
   const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
-  const [selectedGroups, setSelectedGroups] = useState<ApplicableGroup[]>([]);
+  const [selectedGroup, setSelectedGroup] = useState<PopulatedGrantedGroup | undefined>(undefined);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -42,23 +42,14 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   useEffect(() => {
     if (isOpen) {
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
-      setSelectedGroups([]);
+      setSelectedGroup(undefined);
       setShowModalAlert(false);
     }
   }, [isOpen]);
 
-  const groupListItemClickHandler = (group: ApplicableGroup) => {
-    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
-      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
-    }
-    else {
-      setSelectedGroups([...selectedGroups, group]);
-    }
-  };
-
   const submit = async() => {
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
       setShowModalAlert(true);
       return;
     }
@@ -68,9 +59,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
-          return { item: g.item._id, type: g.type };
-        }) : null,
+        grantedGroups: selectedGroup?.item._id != null ? [{ item: selectedGroup?.item._id, type: selectedGroup.type }] : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -102,7 +91,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.grantedGroups.map(g => g.name).join(', ')})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroups[0].name})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -191,17 +180,31 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   <button
                     type="button"
                     className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                    data-toggle="dropdown"
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
-                    onClick={() => setIsGroupSelectModalShown(true)}
                   >
                     <span className="float-start ms-2">
                       {
-                        selectedGroups.length === 0
+                        selectedGroup == null
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroups.map(g => g.item.name).join(', ')
+                          : selectedGroup.item.name
                       }
                     </span>
                   </button>
+                  <div className="dropdown-menu">
+                    {
+                      applicableGroups != null && applicableGroups.map(g => (
+                        <button
+                          key={g.item._id}
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => setSelectedGroup(g)}
+                        >
+                          {g.item.name}
+                        </button>
+                      ))
+                    }
+                  </div>
                 </div>
               </div>
               {
@@ -224,47 +227,12 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   return (
-    <>
-      <Modal size="lg" isOpen={isOpen} toggle={close}>
-        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-          { t('fix_page_grant.modal.title') }
-        </ModalHeader>
-        {renderModalBodyAndFooter()}
-      </Modal>
-      {applicableGroups != null && (
-        <Modal
-          isOpen={isGroupSelectModalShown}
-          toggle={() => setIsGroupSelectModalShown(false)}
-        >
-          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
-            {t('user_group.select_group')}
-          </ModalHeader>
-          <ModalBody>
-            <>
-              { applicableGroups.map((group) => {
-                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
-                const activeClass = groupIsGranted ? 'active' : '';
-
-                return (
-                  <button
-                    className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
-                    type="button"
-                    key={group.item._id}
-                    onClick={() => groupListItemClickHandler(group)}
-                  >
-                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
-                    <h5 className="d-inline-block ml-3">{group.item.name}</h5>
-                    {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
-                    {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
-                  </button>
-                );
-              }) }
-              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
-            </>
-          </ModalBody>
-        </Modal>
-      )}
-    </>
+    <Modal size="lg" isOpen={isOpen} toggle={close}>
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        { t('fix_page_grant.modal.title') }
+      </ModalHeader>
+      {renderModalBodyAndFooter()}
+    </Modal>
   );
 };
 

+ 1 - 5
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -14,10 +14,6 @@ export const PageGrantAlert = (): JSX.Element => {
     return <></>;
   }
 
-  const populatedGrantedGroups = () => {
-    return pageData.grantedGroups.filter(group => isPopulated(group.item));
-  };
-
   const renderAlertContent = () => {
     const getGrantLabel = () => {
       if (pageData.grant === 2) {
@@ -39,7 +35,7 @@ export const PageGrantAlert = (): JSX.Element => {
           <>
             <i className="icon-fw icon-organization"></i>
             <strong>{
-              populatedGrantedGroups().map(g => g.item.name).join(', ')
+              isPopulated(pageData.grantedGroups[0].item) && pageData.grantedGroups[0].item.name
             }
             </strong>
           </>

+ 6 - 21
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -18,7 +18,7 @@ import { throttle, debounce } from 'throttle-debounce';
 
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
-import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
+import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -312,10 +312,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const uploadHandler = useCallback((files: File[]) => {
     files.forEach(async(file) => {
       try {
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        const resLimit: any = await apiGet('/attachments.limit', {
-          fileSize: file.size,
-        });
+        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
 
         if (!resLimit.isUploadable) {
           throw new Error(resLimit.errorMessage);
@@ -323,17 +320,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
         const formData = new FormData();
         formData.append('file', file);
-        if (currentPagePath != null) {
-          formData.append('path', currentPagePath);
-        }
         if (pageId != null) {
           formData.append('page_id', pageId);
         }
-        if (pageId == null) {
-          formData.append('page_body', codeMirrorEditor?.getDoc() ?? '');
-        }
 
-        const resAdd: any = await apiPostForm('/attachments.add', formData);
+        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
+
         const attachment = resAdd.attachment;
         const fileName = attachment.originalName;
 
@@ -343,23 +335,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
           // modify to "![fileName](url)" syntax
           insertText = `!${insertText}`;
         }
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.insertText(insertText);
+
         codeMirrorEditor?.insertText(insertText);
       }
       catch (e) {
         logger.error('failed to upload', e);
         toastError(e);
       }
-      finally {
-        // TODO: implement
-        // refs: https://redmine.weseek.co.jp/issues/126528
-        // editorRef.current.terminateUploadingState();
-      }
     });
 
-  }, [codeMirrorEditor, currentPagePath, pageId]);
+  }, [codeMirrorEditor, pageId]);
 
   const acceptedFileType = useMemo(() => {
     if (!isUploadEnabled) {

+ 12 - 34
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -86,17 +86,12 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
   const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
     if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      let grantedGroupsCopy = grantedGroups != null ? [...grantedGroups] : [];
-      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
-      if (grantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
-        grantedGroupsCopy.push(grantGroupInfo);
-      }
-      else {
-        grantedGroupsCopy = grantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
-      }
-      onUpdateGrant({ grant: 5, grantedGroups: grantedGroupsCopy });
+      onUpdateGrant({ grant: 5, grantedGroups: [{ id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type }] });
     }
-  }, [onUpdateGrant, grantedGroups]);
+
+    // hide modal
+    setIsSelectGroupModalShown(false);
+  }, [onUpdateGrant]);
 
   /**
    * Render grant selector DOM.
@@ -132,15 +127,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       const labelElm = (
         <span>
           <i className="icon icon-fw icon-organization"></i>
-          <span className="label">
-            {grantedGroups.length > 1
-              ? (
-                <span>
-                  {`${grantedGroups[0].name}... `}
-                  <span className="badge badge-purple">+{grantedGroups.length - 1}</span>
-                </span>
-              ) : grantedGroups[0].name}
-          </span>
+          <span className="label">{grantedGroups[0].name}</span>
         </span>
       );
 
@@ -193,30 +180,20 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <>
+      <div className="list-group">
         { myUserGroups.map((group) => {
-          const groupIsGranted = grantedGroups?.find(g => g.id === group.item._id) != null;
-          const activeClass = groupIsGranted ? 'active' : '';
-
           return (
-            <button
-              className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
-              type="button"
-              key={group.item._id}
-              onClick={() => groupListItemClickHandler(group)}
-            >
-              <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
-              <h5 className="d-inline-block ml-3">{group.item.name}</h5>
+            <button key={group.item._id} type="button" className="list-group-item list-group-item-action" onClick={() => groupListItemClickHandler(group)}>
+              <h5 className="d-inline-block">{group.item.name}</h5>
               {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
         }) }
-        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
-      </>
+      </div>
     );
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, grantedGroups]);
+  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t]);
 
   return (
     <>
@@ -225,6 +202,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       {/* render modal */}
       { !disabled && currentUser != null && (
         <Modal
+          className="select-grant-group"
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
         >

+ 1 - 2
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -23,7 +23,7 @@ import { Ellipsis } from './Ellipsis';
 const logger = loggerFactory('growi:cli:Item');
 
 type PageTreeItemPropsOptional = 'itemRef' | 'itemClass' | 'mainClassName';
-type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional> & {key};
+type PageTreeItemProps = Omit<SimpleItemProps, PageTreeItemPropsOptional>;
 
 export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
@@ -158,7 +158,6 @@ export const PageTreeItem: FC<PageTreeItemProps> = (props) => {
 
   return (
     <SimpleItem
-      key={props.key}
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
       isOpen

+ 6 - 4
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -252,13 +252,15 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
             </button>
           )}
         </div>
-        {SimpleItemContent.map(ItemContent => (
-          <ItemContent {...SimpleItemContentProps} />
+        {SimpleItemContent.map((ItemContent, index) => (
+          // eslint-disable-next-line react/no-array-index-key
+          <ItemContent key={index} {...SimpleItemContentProps} />
         ))}
       </li>
 
-      {CustomNextComponents?.map(UnderItemContent => (
-        <UnderItemContent {...SimpleItemContentProps} />
+      {CustomNextComponents?.map((UnderItemContent, index) => (
+        // eslint-disable-next-line react/no-array-index-key
+        <UnderItemContent key={index} {...SimpleItemContentProps} />
       ))}
 
       {

+ 1 - 0
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx

@@ -149,6 +149,7 @@ export const SyncExecution = ({
       </form>
 
       <Modal
+        className="select-grant-group"
         isOpen={isAlertModalOpen}
         toggle={() => setIsAlertModalOpen(false)}
       >

+ 6 - 6
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx

@@ -12,7 +12,7 @@ type Props = {
   id: string,
   name: string,
   url: string,
-  isEnalbed: boolean,
+  isEnabled: boolean,
   desc?: string,
   onDelete: () => void,
 }
@@ -20,27 +20,27 @@ type Props = {
 export const PluginCard = (props: Props): JSX.Element => {
 
   const {
-    id, name, url, isEnalbed, desc,
+    id, name, url, isEnabled, desc,
   } = props;
 
   const { t } = useTranslation('admin');
 
   const PluginCardButton = (): JSX.Element => {
-    const [isEnabled, setState] = useState<boolean>(isEnalbed);
+    const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
 
     const onChangeHandler = async() => {
       try {
-        if (isEnabled) {
+        if (_isEnabled) {
           const reqUrl = `/plugins/${id}/deactivate`;
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
         }
         else {
           const reqUrl = `/plugins/${id}/activate`;
           const res = await apiv3Put(reqUrl);
-          setState(!isEnabled);
+          setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
         }

+ 1 - 1
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -54,7 +54,7 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
                     id={plugin._id}
                     name={plugin.meta.name}
                     url={plugin.origin.url}
-                    isEnalbed={plugin.isEnabled}
+                    isEnabled={plugin.isEnabled}
                     desc={plugin.meta.desc}
                     onDelete={() => openPluginDeleteModal(plugin)}
                   />

+ 0 - 2
apps/app/src/interfaces/in-app-notification.ts

@@ -8,8 +8,6 @@ export enum InAppNotificationStatuses {
   STATUS_OPENED = 'OPENED',
 }
 
-// TODO: do not use any type
-// https://redmine.weseek.co.jp/issues/120632
 export interface IInAppNotification<T = unknown> {
   user: IUser
   targetModel: SupportedTargetModelType

+ 2 - 2
apps/app/src/interfaces/page-grant.ts

@@ -8,9 +8,9 @@ import { IPageGrantData } from './page';
 
 type UserGroupType = typeof GroupType.userGroup;
 type ExternalUserGroupType = typeof GroupType.externalUserGroup;
-export type ApplicableGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
+export type PopulatedGrantedGroup = {type: UserGroupType, item: UserGroupDocument } | {type: ExternalUserGroupType, item: ExternalUserGroupDocument }
 export type IDataApplicableGroup = {
-  applicableGroups?: ApplicableGroup[]
+  applicableGroups?: PopulatedGrantedGroup[]
 }
 
 export type IDataApplicableGrant = null | IDataApplicableGroup;

+ 14 - 21
apps/app/src/pages/[[...path]].page.tsx

@@ -22,7 +22,8 @@ import superjson from 'superjson';
 
 import { useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
-import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript'; import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -396,24 +397,6 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 }
 
-// apply parent page grant fot creating page
-async function applyGrantToPage(props: Props, ancestor: any) {
-  await ancestor.populate('grantedGroups.item');
-  const grant = {
-    grant: ancestor.grant,
-  };
-  const grantedGroups = ancestor.grantedGroups ? {
-    grantedGroups: ancestor.grantedGroups.map((group) => {
-      return {
-        id: group.item._id,
-        name: group.item.name,
-        type: group.type,
-      };
-    }),
-  } : {};
-  props.grantData = Object.assign(grant, grantedGroups);
-}
-
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const { model: mongooseModel } = await import('mongoose');
 
@@ -474,10 +457,20 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       props.templateBodyData = templateData.templateBody as string;
     }
 
-    // apply parent page grant
+    // apply parent page grant, without groups that user isn't related to
     const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
     if (ancestor != null) {
-      await applyGrantToPage(props, ancestor);
+      const userRelatedGrantedGroups = await pageService.getUserRelatedGrantedGroups(ancestor, user);
+      props.grantData = {
+        grant: ancestor.grant,
+        grantedGroups: userRelatedGrantedGroups.map((group) => {
+          return {
+            id: group.item._id,
+            name: group.item.name,
+            type: group.type,
+          };
+        }),
+      };
     }
   }
 

+ 18 - 18
apps/app/src/server/events/user.ts

@@ -1,8 +1,10 @@
 import EventEmitter from 'events';
 
-import type { IUserHasId } from '@growi/core';
+import type { IPage, IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose from 'mongoose';
 
+import type { PageModel } from '~/server/models/page';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:events:user');
@@ -18,31 +20,29 @@ class UserEvent extends EventEmitter {
   }
 
   async onActivated(user: IUserHasId): Promise<void> {
-    if (this.crowi.pageService === null) {
-      logger.warn('crowi pageService is null');
-      return;
-    }
-
-    const Page = this.crowi.model('Page');
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const userHomepagePath = pagePathUtils.userHomepagePath(user);
 
-    let page = await Page.findByPath(userHomepagePath, true);
+    try {
+      let page = await Page.findByPath(userHomepagePath, true);
 
-    if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
-      await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
-      page = null;
-    }
+      // TODO: Make it more type safe
+      // Since the type of page.creator is 'any', we resort to the following comparison,
+      // checking if page.creator.toString() is not equal to user._id.toString(). Our code covers null, string, or object types.
+      if (page != null && page.creator != null && page.creator.toString() !== user._id.toString()) {
+        await this.crowi.pageService.deleteCompletelyUserHomeBySystem(userHomepagePath);
+        page = null;
+      }
 
-    if (page == null) {
-      const body = `# ${user.username}\nThis is ${user.username}'s page`;
+      if (page == null) {
+        const body = `# ${user.username}\nThis is ${user.username}'s page`;
 
-      try {
         await this.crowi.pageService.create(userHomepagePath, body, user, {});
         logger.debug('User page created', page);
       }
-      catch (err) {
-        logger.error('Failed to create user page', err);
-      }
+    }
+    catch (err) {
+      logger.error('Failed to create user page', err);
     }
   }
 

+ 2 - 2
apps/app/src/server/models/activity.ts

@@ -112,8 +112,8 @@ activitySchema.statics.createByParameters = async function(parameters): Promise<
 };
 
 // When using this method, ensure that activity updates are allowed using ActivityService.shoudUpdateActivity
-activitySchema.statics.updateByParameters = async function(activityId: string, parameters): Promise<IActivity> {
-  const activity = await this.findOneAndUpdate({ _id: activityId }, parameters, { new: true }) as unknown as IActivity;
+activitySchema.statics.updateByParameters = async function(activityId: string, parameters): Promise<ActivityDocument | null> {
+  const activity = await this.findOneAndUpdate({ _id: activityId }, parameters, { new: true }).exec();
 
   return activity;
 };

+ 2 - 1
apps/app/src/server/models/config.ts

@@ -71,7 +71,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:disableLinkSharing' : false,
-  'security:isUsersHomepageDeletionEnabled': false,
+  'security:user-homepage-deletion:isEnabled': false,
+  'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,
 
   'security:passport-local:isEnabled' : true,
   'security:passport-ldap:isEnabled' : false,

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

@@ -18,13 +18,16 @@ import mongoose, {
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 
+import { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 import { getPageSchema, extractToAncestorsPaths, populateDataToShowRevision } from './obsolete-page';
+import { UserGroupDocument } from './user-group';
 import UserGroupRelation from './user-group-relation';
 
 const logger = loggerFactory('growi:models:page');
@@ -71,6 +74,7 @@ export interface PageModel extends Model<PageDocument> {
   generateGrantCondition(
     user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
+  removeLeafEmptyPagesRecursively(pageId: ObjectIdLike): Promise<void>
 
   PageQueryBuilder: typeof PageQueryBuilder
 

+ 246 - 1
apps/app/src/server/routes/apiv3/attachment.js

@@ -1,17 +1,28 @@
 import { ErrorV3 } from '@growi/core/dist/models';
+import multer from 'multer';
+import autoReap from 'multer-autoreap';
 
+import { SupportedAction } from '~/interfaces/activity';
+import { AttachmentType } from '~/server/interfaces/attachment';
 import { Attachment } from '~/server/models';
 import loggerFactory from '~/utils/logger';
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { certifySharedPageAttachmentMiddleware } from '../../middlewares/certify-shared-page-attachment';
+import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
+
 
 const logger = loggerFactory('growi:routes:apiv3:attachment'); // eslint-disable-line no-unused-vars
 const express = require('express');
 
 const router = express.Router();
-const { query, param } = require('express-validator');
+const {
+  query, param, body,
+} = require('express-validator');
 
+const { serializePageSecurely } = require('../../models/serializers/page-serializer');
+const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 /**
@@ -20,11 +31,75 @@ const { serializeUserSecurely } = require('../../models/serializers/user-seriali
  *    name: Attachment
  */
 
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      Attachment:
+ *        description: Attachment
+ *        type: object
+ *        properties:
+ *          _id:
+ *            type: string
+ *            description: attachment ID
+ *            example: 5e0734e072560e001761fa67
+ *          __v:
+ *            type: number
+ *            description: attachment version
+ *            example: 0
+ *          fileFormat:
+ *            type: string
+ *            description: file format in MIME
+ *            example: text/plain
+ *          fileName:
+ *            type: string
+ *            description: file name
+ *            example: 601b7c59d43a042c0117e08dd37aad0aimage.txt
+ *          originalName:
+ *            type: string
+ *            description: original file name
+ *            example: file.txt
+ *          creator:
+ *            $ref: '#/components/schemas/User'
+ *          page:
+ *            type: string
+ *            description: page ID attached at
+ *            example: 5e07345972560e001761fa63
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          fileSize:
+ *            type: number
+ *            description: file size
+ *            example: 3494332
+ *          url:
+ *            type: string
+ *            description: attachment URL
+ *            example: http://localhost/files/5e0734e072560e001761fa67
+ *          filePathProxied:
+ *            type: string
+ *            description: file path proxied
+ *            example: "/attachment/5e0734e072560e001761fa67"
+ *          downloadPathProxied:
+ *            type: string
+ *            description: download path proxied
+ *            example: "/download/5e0734e072560e001761fa67"
+ */
+
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const Page = crowi.model('Page');
   const User = crowi.model('User');
+  const { attachmentService } = crowi;
+  const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
   const validator = {
     retrieveAttachment: [
@@ -35,6 +110,12 @@ module.exports = (crowi) => {
       query('pageNumber').optional().isInt().withMessage('pageNumber must be a number'),
       query('limit').optional().isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     ],
+    retrieveFileLimit: [
+      query('fileSize').isNumeric().exists({ checkNull: true }).withMessage('fileSize is required'),
+    ],
+    retrieveAddAttachment: [
+      body('page_id').isString().exists({ checkNull: true }).withMessage('page_id is required'),
+    ],
   };
 
   /**
@@ -95,6 +176,170 @@ module.exports = (crowi) => {
     }
   });
 
+
+  /**
+   * @swagger
+   *
+   *    /attachment/limit:
+   *      get:
+   *        tags: [Attachment]
+   *        operationId: getAttachmentLimit
+   *        summary: /attachment/limit
+   *        description: Get available capacity of uploaded file with GridFS
+   *        parameters:
+   *          - in: query
+   *            name: fileSize
+   *            schema:
+   *              type: number
+   *              description: file size
+   *              example: 23175
+   *            required: true
+   *        responses:
+   *          200:
+   *            description: Succeeded to get available capacity of uploaded file with GridFS.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    isUploadable:
+   *                      type: boolean
+   *                      description: uploadable
+   *                      example: true
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {get} /attachment/limit get available capacity of uploaded file with GridFS
+   * @apiName AddAttachment
+   * @apiGroup Attachment
+   */
+  router.get('/limit', accessTokenParser, loginRequiredStrictly, validator.retrieveFileLimit, apiV3FormValidator, async(req, res) => {
+    const { fileUploadService } = crowi;
+    const fileSize = Number(req.query.fileSize);
+    try {
+      return res.apiv3(await fileUploadService.checkLimit(fileSize));
+    }
+    catch (err) {
+      logger.error('File limit retrieval failed', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /attachment:
+   *      post:
+   *        tags: [Attachment, CrowiCompatibles]
+   *        operationId: addAttachment
+   *        summary: /attachment
+   *        description: Add attachment to the page
+   *        requestBody:
+   *          content:
+   *            "multipart/form-data":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *            "*\/*":
+   *              schema:
+   *                properties:
+   *                  page_id:
+   *                    nullable: true
+   *                    type: string
+   *                  path:
+   *                    nullable: true
+   *                    type: string
+   *                  file:
+   *                    type: string
+   *                    format: binary
+   *                    description: attachment data
+   *              encoding:
+   *                path:
+   *                  contentType: application/x-www-form-urlencoded
+   *        responses:
+   *          200:
+   *            description: Succeeded to add attachment.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    page:
+   *                      $ref: '#/components/schemas/Page'
+   *                    attachment:
+   *                      $ref: '#/components/schemas/Attachment'
+   *                    url:
+   *                      $ref: '#/components/schemas/Attachment/properties/url'
+   *                    pageCreated:
+   *                      type: boolean
+   *                      description: whether the page was created
+   *                      example: false
+   *          403:
+   *            $ref: '#/components/responses/403'
+   *          500:
+   *            $ref: '#/components/responses/500'
+   */
+  /**
+   * @api {post} /attachment Add attachment to the page
+   * @apiName AddAttachment
+   * @apiGroup Attachment
+   *
+   * @apiParam {String} page_id
+   * @apiParam {String} path
+   * @apiParam {File} file
+   */
+  router.post('/', uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
+    validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
+    async(req, res) => {
+
+      const pageId = req.body.page_id;
+
+      // check params
+      const file = req.file || null;
+      if (file == null) {
+        return res.apiv3Err('File error.');
+      }
+
+      try {
+        const page = await Page.findById(pageId);
+
+        // check the user is accessible
+        const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
+        if (!isAccessible) {
+          return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
+        }
+
+        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
+
+        const result = {
+          page: serializePageSecurely(page),
+          revision: serializeRevisionSecurely(page.revision),
+          attachment: attachment.toObject({ virtuals: true }),
+        };
+
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
+
+        res.apiv3(result);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err.message);
+      }
+    });
+
   /**
    * @swagger
    *

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

@@ -1,6 +1,7 @@
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
+import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -301,7 +302,8 @@ module.exports = (crowi) => {
       target: page,
       action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
     return res.apiv3({ bookmark });
   });

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

@@ -14,6 +14,7 @@ import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
+import { preNotifyService } from '~/server/service/pre-notify';
 import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 
@@ -362,7 +363,9 @@ module.exports = (crowi) => {
       target: page,
       action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
+
 
     res.apiv3({ result });
 
@@ -564,6 +567,11 @@ module.exports = (crowi) => {
     const { pageId } = req.params;
     const { grant, grantedGroups } = req.body;
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136137
+    if (grantedGroups != null && grantedGroups.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     const Page = crowi.model('Page');
 
     const page = await Page.findByIdAndViewer(pageId, req.user, null, false);

+ 16 - 10
apps/app/src/server/routes/apiv3/pages.js

@@ -6,6 +6,7 @@ import { normalizePath, addHeadingSlash, attachTitleHeader } from '@growi/core/d
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
+import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -309,6 +310,11 @@ module.exports = (crowi) => {
       body, grant, grantUserGroupIds, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags, shouldGeneratePath,
     } = req.body;
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
+    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     let { path } = req.body;
 
     // check whether path starts slash
@@ -816,6 +822,11 @@ module.exports = (crowi) => {
 
       const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
+      // TODO: remove in https://redmine.weseek.co.jp/issues/136139
+      if (page.grantedGroups != null && page.grantedGroups.length > 1) {
+        return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+      }
+
       const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
       if (page == null || isEmptyAndNotRecursively) {
         res.code = 'Page is not found';
@@ -849,7 +860,8 @@ module.exports = (crowi) => {
         target: page,
         action: SupportedAction.ACTION_PAGE_DUPLICATE,
       };
-      activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+      activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify);
 
       return res.apiv3(result);
     });
@@ -933,18 +945,12 @@ module.exports = (crowi) => {
     }
 
     let pagesCanBeDeleted;
-    /*
-     * Delete Completely
-     */
     if (isCompletely) {
-      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
+      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDeleteCompletely(pagesToDelete, req.user, isRecursively);
     }
-    /*
-     * Trash
-     */
     else {
-      pagesCanBeDeleted = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
-      pagesCanBeDeleted = crowi.pageService.filterPagesByCanDelete(pagesToDelete, req.user, isRecursively);
+      const filteredPages = pagesToDelete.filter(p => p.isEmpty || p.isUpdatable(pageIdToRevisionIdMap[p._id].toString()));
+      pagesCanBeDeleted = await crowi.pageService.filterPagesByCanDelete(filteredPages, req.user, isRecursively);
     }
 
     if (pagesCanBeDeleted.length === 0) {

+ 12 - 3
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -31,6 +31,7 @@ const validator = {
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
     body('isUsersHomepageDeletionEnabled').if(value => value != null).isBoolean(),
+    body('isForceDeleteUserHomepageOnUserDeletion').if(value => value != null).isBoolean(),
   ],
   shareLinkSetting: [
     body('disableLinkSharing').if(value => value != null).isBoolean(),
@@ -358,7 +359,9 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
+        isForceDeleteUserHomepageOnUserDeletion:
+        await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
         wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
@@ -626,7 +629,11 @@ module.exports = (crowi) => {
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
-      'security:isUsersHomepageDeletionEnabled': req.body.isUsersHomepageDeletionEnabled,
+      'security:user-homepage-deletion:isEnabled': req.body.isUsersHomepageDeletionEnabled,
+      // Validate user-homepage-deletion config
+      'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': req.body.isUsersHomepageDeletionEnabled
+        ? req.body.isForceDeleteUserHomepageOnUserDeletion
+        : false,
     };
 
     // Validate delete config
@@ -655,7 +662,9 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
-        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
+        isForceDeleteUserHomepageOnUserDeletion:
+        await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };

+ 5 - 10
apps/app/src/server/routes/apiv3/users.js

@@ -779,7 +779,7 @@ module.exports = (crowi) => {
    *        tags: [Users]
    *        operationId: removeUser
    *        summary: /users/{id}/remove
-   *        description: Delete user and if isUsersHomepageDeletionEnabled delete user homepage and subpages
+   *        description: Delete user
    *        parameters:
    *          - name: id
    *            in: path
@@ -789,7 +789,7 @@ module.exports = (crowi) => {
    *              type: string
    *        responses:
    *          200:
-   *            description: Deleting user success and if isUsersHomepageDeletionEnabled delete user homepage and subpages success
+   *            description: Deleting user success
    *            content:
    *              application/json:
    *                schema:
@@ -797,16 +797,11 @@ module.exports = (crowi) => {
    *                    user:
    *                      type: object
    *                      description: data of deleted user
-   *                    userHomepagePath:
-   *                      type: string
-   *                      description: a user homepage path
-   *                    isUsersHomepageDeletionEnabled:
-   *                      type: boolean
-   *                      description: is users homepage deletion enabled
    */
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
     const { id } = req.params;
-    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled');
+    const isForceDeleteUserHomepageOnUserDeletion = configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion');
 
     try {
       const user = await User.findById(id);
@@ -823,7 +818,7 @@ module.exports = (crowi) => {
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
 
-      if (isUsersHomepageDeletionEnabled) {
+      if (isUsersHomepageDeletionEnabled && isForceDeleteUserHomepageOnUserDeletion) {
         crowi.pageService.deleteCompletelyUserHomeBySystem(homepagePath);
       }
 

+ 0 - 162
apps/app/src/server/routes/attachment/api.js

@@ -176,168 +176,6 @@ export const routesFactory = (crowi) => {
   //   return responseForAttachment(req, res, attachment, true);
   // };
 
-  /**
-   * @swagger
-   *
-   *    /attachments.limit:
-   *      get:
-   *        tags: [Attachments]
-   *        operationId: getAttachmentsLimit
-   *        summary: /attachments.limit
-   *        description: Get available capacity of uploaded file with GridFS
-   *        parameters:
-   *          - in: query
-   *            name: fileSize
-   *            schema:
-   *              type: number
-   *              description: file size
-   *              example: 23175
-   *            required: true
-   *        responses:
-   *          200:
-   *            description: Succeeded to get available capacity of uploaded file with GridFS.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    isUploadable:
-   *                      type: boolean
-   *                      description: uploadable
-   *                      example: true
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {get} /attachments.limit get available capacity of uploaded file with GridFS
-   * @apiName AddAttachments
-   * @apiGroup Attachment
-   */
-  api.limit = async function(req, res) {
-    const { fileUploadService } = crowi;
-    const fileSize = Number(req.query.fileSize);
-    return res.json(ApiResponse.success(await fileUploadService.checkLimit(fileSize)));
-  };
-
-  /**
-   * @swagger
-   *
-   *    /attachments.add:
-   *      post:
-   *        tags: [Attachments, CrowiCompatibles]
-   *        operationId: addAttachment
-   *        summary: /attachments.add
-   *        description: Add attachment to the page
-   *        requestBody:
-   *          content:
-   *            "multipart/form-data":
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    nullable: true
-   *                    type: string
-   *                  path:
-   *                    nullable: true
-   *                    type: string
-   *                  file:
-   *                    type: string
-   *                    format: binary
-   *                    description: attachment data
-   *              encoding:
-   *                path:
-   *                  contentType: application/x-www-form-urlencoded
-   *            "*\/*":
-   *              schema:
-   *                properties:
-   *                  page_id:
-   *                    nullable: true
-   *                    type: string
-   *                  path:
-   *                    nullable: true
-   *                    type: string
-   *                  file:
-   *                    type: string
-   *                    format: binary
-   *                    description: attachment data
-   *              encoding:
-   *                path:
-   *                  contentType: application/x-www-form-urlencoded
-   *        responses:
-   *          200:
-   *            description: Succeeded to add attachment.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    ok:
-   *                      $ref: '#/components/schemas/V1Response/properties/ok'
-   *                    page:
-   *                      $ref: '#/components/schemas/Page'
-   *                    attachment:
-   *                      $ref: '#/components/schemas/Attachment'
-   *                    url:
-   *                      $ref: '#/components/schemas/Attachment/properties/url'
-   *                    pageCreated:
-   *                      type: boolean
-   *                      description: whether the page was created
-   *                      example: false
-   *          403:
-   *            $ref: '#/components/responses/403'
-   *          500:
-   *            $ref: '#/components/responses/500'
-   */
-  /**
-   * @api {post} /attachments.add Add attachment to the page
-   * @apiName AddAttachments
-   * @apiGroup Attachment
-   *
-   * @apiParam {String} page_id
-   * @apiParam {File} file
-   */
-  api.add = async function(req, res) {
-    const pageId = req.body.page_id || null;
-    const pagePath = req.body.path || null;
-
-    // check params
-    if (pageId == null && pagePath == null) {
-      return res.json(ApiResponse.error('Either page_id or path is required.'));
-    }
-    if (req.file == null) {
-      return res.json(ApiResponse.error('File error.'));
-    }
-
-    const file = req.file;
-
-    try {
-      const page = await Page.findById(pageId);
-
-      // check the user is accessible
-      const isAccessible = await Page.isAccessiblePageByViewer(page.id, req.user);
-      if (!isAccessible) {
-        return res.json(ApiResponse.error(`Forbidden to access to the page '${page.id}'`));
-      }
-
-      const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
-
-      const result = {
-        page: serializePageSecurely(page),
-        revision: serializeRevisionSecurely(page.revision),
-        attachment: attachment.toObject({ virtuals: true }),
-      };
-
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
-
-      res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      logger.error(err);
-      return res.json(ApiResponse.error(err.message));
-    }
-  };
-
   /**
    * @swagger
    *

+ 10 - 1
apps/app/src/server/routes/comment.js

@@ -3,6 +3,8 @@ import { Comment, CommentEvent, commentEvent } from '~/features/comment/server';
 import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
+import { preNotifyService } from '../service/pre-notify';
+
 /**
  * @swagger
  *  tags:
@@ -266,7 +268,14 @@ module.exports = function(crowi, app) {
       event: createdComment,
       action: SupportedAction.ACTION_COMMENT_CREATE,
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
+    const getAdditionalTargetUsers = async(activity) => {
+      const mentionedUsers = await crowi.commentService.getMentionedUsers(activity.event);
+
+      return mentionedUsers;
+    };
+
+    activityEvent.emit('update', res.locals.activity._id, parameters, page, preNotifyService.generatePreNotify, getAdditionalTargetUsers);
 
     res.json(ApiResponse.success({ comment: createdComment }));
 

+ 0 - 2
apps/app/src/server/routes/index.js

@@ -139,11 +139,9 @@ module.exports = function(crowi, app) {
   apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
   apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
 
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, addActivity , attachmentApi.add);
   apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
   apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.removeProfileImage);
-  apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachmentApi.limit);
 
   // API v1
   app.use('/_api', unavailableWhenMaintenanceModeForApi, apiV1Router);

+ 9 - 2
apps/app/src/server/routes/login.js

@@ -44,13 +44,20 @@ module.exports = function(crowi, app) {
   }
 
   async function sendNotificationToAllAdmins(user) {
-    const adminUsers = await User.findAdmins();
+
     const activity = await activityService.createActivity({
       action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
       target: user,
       targetModel: SupportedTargetModel.MODEL_USER,
     });
-    await activityEvent.emit('updated', activity, user, adminUsers);
+
+    const preNotify = async(props) => {
+      const adminUsers = await User.findAdmins();
+
+      props.push(...adminUsers);
+    };
+
+    await activityEvent.emit('updated', activity, user, preNotify);
     return;
   }
 

+ 40 - 2
apps/app/src/server/routes/page.js

@@ -7,6 +7,7 @@ import loggerFactory from '~/utils/logger';
 
 import { PathAlreadyExistsError } from '../models/errors';
 import UpdatePost from '../models/update-post';
+import { preNotifyService } from '../service/pre-notify';
 
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
@@ -136,7 +137,7 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:page');
   const logger = loggerFactory('growi:routes:page');
 
-  const { pathUtils } = require('@growi/core/dist/utils');
+  const { pathUtils, pagePathUtils } = require('@growi/core/dist/utils');
 
   const Page = crowi.model('Page');
   const User = crowi.model('User');
@@ -329,6 +330,11 @@ module.exports = function(crowi, app) {
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136136
+    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
     }
@@ -448,6 +454,11 @@ module.exports = function(crowi, app) {
     const isSlackEnabled = !!req.body.isSlackEnabled; // cast to boolean
     const slackChannels = req.body.slackChannels || null;
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136140
+    if (grantUserGroupIds != null && grantUserGroupIds.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     if (pageId === null || pageBody === null || revisionId === null) {
       return res.json(ApiResponse.error('page_id, body and revision_id are required.'));
     }
@@ -522,7 +533,10 @@ module.exports = function(crowi, app) {
       target: page,
       action: SupportedAction.ACTION_PAGE_UPDATE,
     };
-    activityEvent.emit('update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() });
+
+    activityEvent.emit(
+      'update', res.locals.activity._id, parameters, { path: page.path, creator: page.creator._id.toString() }, preNotifyService.generatePreNotify,
+    );
   };
 
   /**
@@ -747,6 +761,16 @@ module.exports = function(crowi, app) {
         if (!crowi.pageService.canDeleteCompletely(page.path, creator, req.user, isRecursively)) {
           return res.json(ApiResponse.error('You can not delete this page completely', 'user_not_admin'));
         }
+
+        if (pagePathUtils.isUsersHomepage(page.path)) {
+          if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+        }
+
         await crowi.pageService.deleteCompletely(page, req.user, options, isRecursively, false, activityParameters);
       }
       else {
@@ -764,6 +788,15 @@ module.exports = function(crowi, app) {
           return res.json(ApiResponse.error('You can not delete this page', 'user_not_admin'));
         }
 
+        if (pagePathUtils.isUsersHomepage(page.path)) {
+          if (!crowi.pageService.canDeleteUserHomepageByConfig()) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+          if (!await crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+            return res.json(ApiResponse.error('Could not delete user homepage'));
+          }
+        }
+
         await crowi.pageService.deletePage(page, req.user, options, isRecursively, activityParameters);
       }
     }
@@ -893,6 +926,11 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
+    // TODO: remove in https://redmine.weseek.co.jp/issues/136139
+    if (page.grantedGroups != null && page.grantedGroups.length > 1) {
+      return res.apiv3Err('Cannot grant multiple groups to page at the moment');
+    }
+
     // check whether path starts slash
     newPagePath = pathUtils.addHeadingSlash(newPagePath);
 

+ 18 - 5
apps/app/src/server/service/activity.ts

@@ -1,16 +1,18 @@
-import type { Ref, IPage, IUser } from '@growi/core';
+import type { IPage } from '@growi/core';
 import mongoose from 'mongoose';
 
 import {
   IActivity, SupportedActionType, AllSupportedActions, ActionGroupSize,
   AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
 } from '~/interfaces/activity';
-import Activity from '~/server/models/activity';
+import Activity, { ActivityDocument } from '~/server/models/activity';
 
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 
 
+import type { GeneratePreNotify, GetAdditionalTargetUsers } from './pre-notify';
+
 const logger = loggerFactory('growi:service:ActivityService');
 
 const parseActionString = (actionsString: string): SupportedActionType[] => {
@@ -39,8 +41,10 @@ class ActivityService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
-      let activity: IActivity;
+    this.activityEvent.on('update', async(
+        activityId: string, parameters, target: IPage, generatePreNotify?: GeneratePreNotify, getAdditionalTargetUsers?: GetAdditionalTargetUsers,
+    ) => {
+      let activity: ActivityDocument;
       const shoudUpdate = this.shoudUpdateActivity(parameters.action);
 
       if (shoudUpdate) {
@@ -52,7 +56,16 @@ class ActivityService {
           return;
         }
 
-        this.activityEvent.emit('updated', activity, target, descendantsSubscribedUsers);
+        if (generatePreNotify != null) {
+          const preNotify = generatePreNotify(activity, getAdditionalTargetUsers);
+
+          this.activityEvent.emit('updated', activity, target, preNotify);
+
+          return;
+        }
+
+        this.activityEvent.emit('updated', activity, target);
+
       }
     });
   }

+ 13 - 14
apps/app/src/server/service/file-uploader/aws.ts

@@ -190,7 +190,6 @@ class AwsFileUploader extends AbstractFileUploader {
       throw new Error('AWS is not configured.');
     }
 
-
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
     const filePath = getFilePathOnStorage(attachment);
@@ -198,13 +197,13 @@ class AwsFileUploader extends AbstractFileUploader {
 
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     const params: GetObjectCommandInput = {
       Bucket: awsConfig.bucket,
       Key: filePath,
-      // ResponseContentType: contentHeaders.contentType?.value.toString(),
-      // ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      ResponseContentType: contentHeaders.contentType?.value.toString(),
+      ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
     };
     const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
       expiresIn: lifetimeSecForTemporaryUrl,
@@ -288,30 +287,30 @@ module.exports = (crowi) => {
     const awsConfig = getAwsConfig();
 
     const filePath = getFilePathOnStorage(attachment);
-    const params = {
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return s3.send(new PutObjectCommand({
       Bucket: awsConfig.bucket,
-      ContentType: attachment.fileFormat,
       Key: filePath,
       Body: fileStream,
       ACL: ObjectCannedACL.public_read,
-    };
-
-    return s3.send(new PutObjectCommand(params));
+      // put type and the file name for reference information when uploading
+      ContentType: contentHeaders.contentType?.value.toString(),
+      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+    }));
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {
     const s3 = S3Factory();
     const awsConfig = getAwsConfig();
 
-    const params = {
+    return s3.send(new PutObjectCommand({
       Bucket: awsConfig.bucket,
       ContentType: contentType,
       Key: filePath,
       Body: data,
       ACL: ObjectCannedACL.public_read,
-    };
-
-    return s3.send(new PutObjectCommand(params));
+    }));
   };
 
   (lib as any).checkLimit = async function(uploadFileSize) {

+ 45 - 52
apps/app/src/server/service/file-uploader/azure.ts

@@ -1,19 +1,16 @@
-import path from 'path';
-
 import { ClientSecretCredential, TokenCredential } from '@azure/identity';
 import {
+  generateBlobSASQueryParameters,
   BlobServiceClient,
   BlobClient,
   BlockBlobClient,
   BlobDeleteOptions,
   ContainerClient,
-  generateBlobSASQueryParameters,
   ContainerSASPermissions,
   SASProtocol,
   type BlobDeleteIfExistsResponse,
   type BlockBlobUploadResponse,
   type BlockBlobParallelUploadOptions,
-  type BlockBlobUploadStreamOptions,
 } from '@azure/storage-blob';
 
 import { ResponseMode, type RespondOptions } from '~/server/interfaces/attachment';
@@ -62,33 +59,6 @@ async function getContainerClient(): Promise<ContainerClient> {
   return blobServiceClient.getContainerClient(containerName);
 }
 
-// Server creates User Delegation SAS Token for container
-// https://learn.microsoft.com/ja-jp/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
-async function getSasToken(lifetimeSec) {
-  const { accountName, containerName } = getAzureConfig();
-  const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
-
-  const now = Date.now();
-  const startsOn = new Date(now - 30 * 1000);
-  const expiresOn = new Date(now + lifetimeSec * 1000);
-  const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
-
-  // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
-  // r:read, a:add, c:create, w:write, d:delete, l:list
-  const containerPermissionsForAnonymousUser = 'rl';
-  const sasOptions = {
-    containerName,
-    permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
-    protocol: SASProtocol.HttpsAndHttp,
-    startsOn,
-    expiresOn,
-  };
-
-  const sasToken = generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
-
-  return sasToken;
-}
-
 function getFilePathOnStorage(attachment) {
   const dirName = (attachment.page != null) ? 'attachment' : 'user';
   return urljoin(dirName, attachment.fileName);
@@ -165,27 +135,51 @@ class AzureFileUploader extends AbstractFileUploader {
 
   /**
    * @inheritDoc
+   * @see https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-create-user-delegation-sas-javascript
    */
   override async generateTemporaryUrl(attachment: IAttachmentDocument, opts?: RespondOptions): Promise<TemporaryUrl> {
     if (!this.getIsUploadable()) {
       throw new Error('Azure Blob is not configured.');
     }
 
-    const containerClient = await getContainerClient();
-    const filePath = getFilePathOnStorage(attachment);
-    const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
     const lifetimeSecForTemporaryUrl = configManager.getConfig('crowi', 'azure:lifetimeSecForTemporaryUrl');
 
-    const sasToken = await getSasToken(lifetimeSecForTemporaryUrl);
-    const signedUrl = `${blockBlobClient.url}?${sasToken}`;
-
-    // TODO: re-impl using generateSasUrl
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
-    // const signedUrl = blockBlobClient.generateSasUrl({
-    //   contentType: contentHeaders.contentType?.value.toString(),
-    //   contentDisposition: contentHeaders.contentDisposition?.value.toString(),
-    // });
+    const url = await (async() => {
+      const containerClient = await getContainerClient();
+      const filePath = getFilePathOnStorage(attachment);
+      const blockBlobClient = await containerClient.getBlockBlobClient(filePath);
+      return blockBlobClient.url;
+    })();
+
+    const sasToken = await (async() => {
+      const { accountName, containerName } = getAzureConfig();
+      const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+
+      const now = Date.now();
+      const startsOn = new Date(now - 30 * 1000);
+      const expiresOn = new Date(now + lifetimeSecForTemporaryUrl * 1000);
+      const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
+
+      const isDownload = opts?.download ?? false;
+      const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+
+      // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
+      // r:read, a:add, c:create, w:write, d:delete, l:list
+      const containerPermissionsForAnonymousUser = 'rl';
+      const sasOptions = {
+        containerName,
+        permissions: ContainerSASPermissions.parse(containerPermissionsForAnonymousUser),
+        protocol: SASProtocol.HttpsAndHttp,
+        startsOn,
+        expiresOn,
+        contentType: contentHeaders.contentType?.value.toString(),
+        contentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      };
+
+      return generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();
+    })();
+
+    const signedUrl = `${url}?${sasToken}`;
 
     return {
       url: signedUrl,
@@ -233,15 +227,15 @@ module.exports = (crowi) => {
     const filePath = getFilePathOnStorage(attachment);
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
-    const DEFAULT_BLOCK_BUFFER_SIZE_BYTES: number = 8 * 1024 * 1024; // 8MB
-    const DEFAULT_MAX_CONCURRENCY = 5;
-    const options: BlockBlobUploadStreamOptions = {
+    const contentHeaders = new ContentHeaders(attachment);
+
+    return blockBlobClient.uploadStream(readStream, undefined, undefined, {
       blobHTTPHeaders: {
-        blobContentType: attachment.fileFormat,
-        blobContentDisposition: `attachment;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+        // put type and the file name for reference information when uploading
+        blobContentType: contentHeaders.contentType?.value.toString(),
+        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
       },
-    };
-    return blockBlobClient.uploadStream(readStream, DEFAULT_BLOCK_BUFFER_SIZE_BYTES, DEFAULT_MAX_CONCURRENCY, options);
+    });
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {
@@ -250,7 +244,6 @@ module.exports = (crowi) => {
     const options: BlockBlobParallelUploadOptions = {
       blobHTTPHeaders: {
         blobContentType: contentType,
-        blobContentDisposition: `attachment;filename*=UTF-8''${encodeURIComponent(path.basename(filePath))}`,
       },
     };
     const blockBlobUploadResponse: BlockBlobUploadResponse = await blockBlobClient.upload(data, data.length, options);

+ 10 - 8
apps/app/src/server/service/file-uploader/gcs.ts

@@ -145,13 +145,13 @@ class GcsFileUploader extends AbstractFileUploader {
 
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
-      // responseType: contentHeaders.contentType?.value.toString(),
-      // responseDisposition: contentHeaders.contentDisposition?.value.toString(),
+      responseType: contentHeaders.contentType?.value.toString(),
+      responseDisposition: contentHeaders.contentDisposition?.value.toString(),
     });
 
     return {
@@ -211,11 +211,13 @@ module.exports = function(crowi: Crowi) {
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
-    const options = {
-      destination: filePath,
-    };
+    const contentHeaders = new ContentHeaders(attachment);
 
-    return myBucket.upload(fileStream.path, options);
+    return myBucket.upload(fileStream.path, {
+      destination: filePath,
+      // put type and the file name for reference information when uploading
+      contentType: contentHeaders.contentType?.value.toString(),
+    });
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {

+ 5 - 1
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -11,6 +11,7 @@ import loggerFactory from '~/utils/logger';
 import { configManager } from '../config-manager';
 
 import { AbstractFileUploader, type TemporaryUrl, type SaveFileParam } from './file-uploader';
+import { ContentHeaders } from './utils';
 
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
@@ -152,10 +153,13 @@ module.exports = function(crowi) {
   (lib as any).uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
+    const contentHeaders = new ContentHeaders(attachment);
+
     return AttachmentFile.promisifiedWrite(
       {
+        // put type and the file name for reference information when uploading
         filename: attachment.fileName,
-        contentType: attachment.fileFormat,
+        contentType: contentHeaders.contentType?.value.toString(),
       },
       fileStream,
     );

+ 3 - 3
apps/app/src/server/service/file-uploader/local.ts

@@ -218,10 +218,10 @@ module.exports = function(crowi) {
     const internalPathRoot = configManager.getConfig('crowi', 'fileUpload:local:internalRedirectPath');
     const internalPath = urljoin(internalPathRoot, relativePath);
 
-    // const isDownload = opts?.download ?? false;
-    // const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const isDownload = opts?.download ?? false;
+    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
     applyHeaders(res, [
-      // ...contentHeaders.toExpressHttpHeaders(),
+      ...contentHeaders.toExpressHttpHeaders(),
       { field: 'X-Accel-Redirect', value: internalPath },
       { field: 'X-Sendfile', value: storagePath },
     ]);

+ 20 - 36
apps/app/src/server/service/in-app-notification.ts

@@ -1,14 +1,12 @@
 import type {
-  HasObjectId, Ref, IUser,
+  HasObjectId, IUser, IPage,
 } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import { subDays } from 'date-fns';
 import { Types, FilterQuery, UpdateQuery } from 'mongoose';
 
-import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
+import { AllEssentialActions } from '~/interfaces/activity';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
-import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
-import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import { ActivityDocument } from '~/server/models/activity';
 import {
   InAppNotification,
@@ -21,12 +19,14 @@ import loggerFactory from '~/utils/logger';
 import Crowi from '../crowi';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
+import { generateSnapshot } from './in-app-notification/in-app-notification-utils';
+import { preNotifyService, type PreNotify } from './pre-notify';
+
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 const logger = loggerFactory('growi:service:inAppNotification');
 
-
 export default class InAppNotificationService {
 
   crowi!: Crowi;
@@ -49,13 +49,11 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    // TODO: do not use any type
-    // https://redmine.weseek.co.jp/issues/120632
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: any, users?: Ref<IUser>[]) => {
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IUser | IPage, preNotify: PreNotify) => {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target, users);
+          await this.createInAppNotification(activity, target, preNotify);
         }
       }
       catch (err) {
@@ -199,38 +197,24 @@ export default class InAppNotificationService {
     return;
   };
 
-  // TODO: do not use any type
-  // https://redmine.weseek.co.jp/issues/120632
-  createInAppNotification = async function(activity: ActivityDocument, target, users?: Ref<IUser>[]): Promise<void> {
-    if (activity.action === SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST) {
-      const snapshot = userSerializers.stringifySnapshot(target);
-      await this.upsertByActivity(users, activity, snapshot);
-      await this.emitSocketIo(users);
-      return;
-    }
+  createInAppNotification = async function(activity: ActivityDocument, target: IUser | IPage, preNotify: PreNotify): Promise<void> {
 
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
-    const snapshot = pageSerializers.stringifySnapshot(target);
+
+    const targetModel = activity.targetModel;
+
+    const snapshot = generateSnapshot(targetModel, target);
+
     if (shouldNotification) {
-      let mentionedUsers: IUser[] = [];
-      if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
-        mentionedUsers = await this.crowi.commentService.getMentionedUsers(activity.event);
-      }
-      const notificationTargetUsers = await activity?.getNotificationTargetUsers();
-      let notificationDescendantsUsers = [];
-      if (users != null) {
-        const User = this.crowi.model('User');
-        const descendantsUsers = users.filter(item => (item.toString() !== activity.user._id.toString()));
-        notificationDescendantsUsers = await User.find({
-          _id: { $in: descendantsUsers },
-          status: User.STATUS_ACTIVE,
-        }).distinct('_id');
-      }
-      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers, ...notificationDescendantsUsers], activity, snapshot);
-      await this.emitSocketIo([...notificationTargetUsers, notificationDescendantsUsers]);
+      const props = preNotifyService.generateInitialPreNotifyProps();
+
+      await preNotify(props);
+
+      await this.upsertByActivity(props.notificationTargetUsers, activity, snapshot);
+      await this.emitSocketIo(props.notificationTargetUsers);
     }
     else {
-      throw Error('No activity to notify');
+      throw Error('no activity to notify');
     }
     return;
   };

+ 19 - 0
apps/app/src/server/service/in-app-notification/in-app-notification-utils.ts

@@ -0,0 +1,19 @@
+import type { IUser, IPage } from '@growi/core';
+
+import { SupportedTargetModel } from '~/interfaces/activity';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+
+const isIPage = (targetModel: string, target: IUser | IPage): target is IPage => {
+  return targetModel === SupportedTargetModel.MODEL_PAGE;
+};
+
+export const generateSnapshot = (targetModel: string, target: IUser | IPage) => {
+
+  let snapshot;
+
+  if (isIPage(targetModel, target)) {
+    snapshot = pageSerializers.stringifySnapshot(target);
+  }
+
+  return snapshot;
+};

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

@@ -493,14 +493,14 @@ class PageGrantService {
       [PageGrant.GRANT_RESTRICTED]: null, // any page can be restricted
     };
 
-    const userPossessedGroups = await this.getUserPossessedGroups(user);
+    const userRelatedGroups = await this.getUserRelatedGroups(user);
 
     // -- Any grant is allowed if parent is null
     const isAnyGrantApplicable = page.parent == null;
     if (isAnyGrantApplicable) {
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_OWNER] = null;
-      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userPossessedGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userRelatedGroups };
       return data;
     }
 
@@ -516,7 +516,7 @@ class PageGrantService {
     if (grant === PageGrant.GRANT_PUBLIC) {
       data[PageGrant.GRANT_PUBLIC] = null;
       data[PageGrant.GRANT_OWNER] = null;
-      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userPossessedGroups };
+      data[PageGrant.GRANT_USER_GROUP] = { applicableGroups: userRelatedGroups };
     }
     else if (grant === PageGrant.GRANT_OWNER) {
       const grantedUser = grantedUsers[0];
@@ -568,14 +568,14 @@ class PageGrantService {
     return data;
   }
 
-  async getUserPossessedGroups(user) {
-    const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
-    const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
+  async getUserRelatedGroups(user) {
+    const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user);
+    const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user);
     return [
-      ...userPossessedUserGroups.map((group) => {
+      ...userRelatedUserGroups.map((group) => {
         return { type: GroupType.userGroup, item: group };
       }),
-      ...userPossessedExternalUserGroups.map((group) => {
+      ...userRelatedExternalUserGroups.map((group) => {
         return { type: GroupType.externalUserGroup, item: group };
       }),
     ];

+ 149 - 37
apps/app/src/server/service/page.ts

@@ -2,14 +2,13 @@ import pathlib from 'path';
 import { Readable, Writable } from 'stream';
 
 import type {
-  Ref, HasObjectId, IUserHasId,
+  Ref, HasObjectId, IUserHasId, IUser,
   IPage, IPageInfo, IPageInfoAll, IPageInfoForEntity, IPageWithMeta, IGrantedGroup,
 } from '@growi/core';
-import { PageGrant, PageStatus } from '@growi/core';
+import { PageGrant, PageStatus, getIdForRef } from '@growi/core';
 import {
   pagePathUtils, pathUtils,
 } from '@growi/core/dist/utils';
-import { collectAncestorPaths } from '@growi/core/dist/utils/page-path-utils';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, Cursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
@@ -21,6 +20,7 @@ import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
+import { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import {
   type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
 } from '~/interfaces/page-operation';
@@ -29,6 +29,7 @@ import {
   type CreateMethod, type PageCreateOptions, type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
+import { getModelSafely } from '~/server/util/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
@@ -49,8 +50,8 @@ const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
 const {
-  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages,
-  isMovablePage, canMoveByPath, isUsersProtectedPages, hasSlash, generateChildrenRegExp,
+  isTrashPage, isTopPage, omitDuplicateAreaPageFromPages, getUsernameByPath, collectAncestorPaths,
+  canMoveByPath, isUsersTopPage, isMovablePage, isUsersHomepage, hasSlash, generateChildrenRegExp,
 } = pagePathUtils;
 
 const { addTrailingSlash } = pathUtils;
@@ -157,6 +158,8 @@ class PageService {
 
     // init
     this.initPageEvent();
+    this.canDeleteCompletely = this.canDeleteCompletely.bind(this);
+    this.canDelete = this.canDelete.bind(this);
   }
 
   private initPageEvent() {
@@ -169,7 +172,7 @@ class PageService {
   }
 
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isTopPage(path) || isUsersProtectedPages(path)) return false;
+    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
     const pageCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageCompleteDeletionAuthority');
     const pageRecursiveCompleteDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority');
@@ -180,7 +183,7 @@ class PageService {
   }
 
   canDelete(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
-    if (operator == null || isUsersProtectedPages(path) || isTopPage(path)) return false;
+    if (operator == null || isTopPage(path) || isUsersTopPage(path)) return false;
 
     const pageDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageDeletionAuthority');
     const pageRecursiveDeletionAuthority = this.crowi.configManager.getConfig('crowi', 'security:pageRecursiveDeletionAuthority');
@@ -190,6 +193,20 @@ class PageService {
     return this.canDeleteLogic(creatorId, operator, isRecursively, singleAuthority, recursiveAuthority);
   }
 
+  canDeleteUserHomepageByConfig(): boolean {
+    return configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled') ?? false;
+  }
+
+  async isUsersHomepageOwnerAbsent(path: string): Promise<boolean> {
+    const User = mongoose.model('User');
+    const username = getUsernameByPath(path);
+    if (username == null) {
+      throw new Error('Cannot found username by path');
+    }
+    const ownerExists = await User.exists({ username });
+    return ownerExists === null;
+  }
+
   private canDeleteLogic(
       creatorId: ObjectIdLike,
       operator,
@@ -222,12 +239,58 @@ class PageService {
     return false;
   }
 
-  filterPagesByCanDeleteCompletely(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDeleteCompletely(p.path, p.creator, user, isRecursively));
+  private async getAbsenseUserHomeList(pages: PageDocument[]): Promise<string[]> {
+    const userHomepages = pages.filter(p => isUsersHomepage(p.path));
+
+    const User = mongoose.model<IUser>('User');
+    const usernames = userHomepages
+      .map(page => getUsernameByPath(page.path))
+      // see: https://zenn.dev/kimuson/articles/filter_safety_type_guard
+      .filter((username): username is Exclude<typeof username, null> => username !== null);
+    const existingUsernames = await User.distinct<string>('username', { username: { $in: usernames } });
+
+    return userHomepages.filter((page) => {
+      const username = getUsernameByPath(page.path);
+      if (username == null) {
+        throw new Error('Cannot found username by path');
+      }
+      return !existingUsernames.includes(username);
+    }).map(p => p.path);
   }
 
-  filterPagesByCanDelete(pages, user, isRecursively: boolean) {
-    return pages.filter(p => p.isEmpty || this.canDelete(p.path, p.creator, user, isRecursively));
+  private async filterPages(
+      pages: PageDocument[],
+      user: IUserHasId,
+      isRecursively: boolean,
+      canDeleteFunction: (path: string, creatorId: ObjectIdLike, operator: any, isRecursively: boolean) => boolean,
+  ): Promise<PageDocument[]> {
+    const filteredPages = pages.filter(p => p.isEmpty || canDeleteFunction(p.path, p.creator, user, isRecursively));
+
+    if (!this.canDeleteUserHomepageByConfig()) {
+      return filteredPages.filter(p => !isUsersHomepage(p.path));
+    }
+
+    // Confirmation of deletion of user homepages is an asynchronous process,
+    // so it is processed separately for performance optimization.
+    const absenseUserHomeList = await this.getAbsenseUserHomeList(filteredPages);
+
+    const excludeActiveUserHomepage = (path: string) => {
+      if (!isUsersHomepage(path)) {
+        return true;
+      }
+      return absenseUserHomeList.includes(path);
+    };
+
+    return filteredPages
+      .filter(p => excludeActiveUserHomepage(p.path));
+  }
+
+  async filterPagesByCanDeleteCompletely(pages: PageDocument[], user: IUserHasId, isRecursively: boolean): Promise<PageDocument[]> {
+    return this.filterPages(pages, user, isRecursively, this.canDeleteCompletely);
+  }
+
+  async filterPagesByCanDelete(pages: PageDocument[], user: IUserHasId, isRecursively: boolean): Promise<PageDocument[]> {
+    return this.filterPages(pages, user, isRecursively, this.canDelete);
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -255,7 +318,6 @@ class PageService {
         meta: {
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
-          isMovable: false,
           isDeletable: false,
           isAbleToDeleteCompletely: false,
           isRevertible: false,
@@ -443,7 +505,9 @@ class PageService {
       throw err;
     }
     if (page.descendantCount < 1) {
-      this.activityEvent.emit('updated', activity, page);
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     return renamedPage;
   }
@@ -548,8 +612,11 @@ class PageService {
     // update descendants first
       const descendantsSubscribedSets = new Set();
       await this.renameDescendantsWithStream(page, newPagePath, user, options, false, descendantsSubscribedSets);
-      const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-      this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+      const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+      const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     catch (err) {
       logger.warn(err);
@@ -1390,10 +1457,19 @@ class PageService {
       throw new Error('This method does NOT support deleting trashed pages.');
     }
 
-    if (!isMovablePage(page.path)) {
+    if (isTopPage(page.path) || isUsersTopPage(page.path)) {
       throw new Error('Page is not deletable.');
     }
 
+    if (pagePathUtils.isUsersHomepage(page.path)) {
+      if (!this.crowi.pageService.canDeleteUserHomepageByConfig()) {
+        throw new Error('User Homepage is not deletable.');
+      }
+      if (!await this.crowi.pageService.isUsersHomepageOwnerAbsent(page.path)) {
+        throw new Error('User Homepage is not deletable.');
+      }
+    }
+
     const newPath = Page.getDeletedPageName(page.path);
 
     const canOperate = await this.crowi.pageOperationService.canOperate(isRecursively, page.path, newPath);
@@ -1481,7 +1557,9 @@ class PageService {
       })();
     }
     else {
-      this.activityEvent.emit('updated', activity, page);
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
 
     return deletedPage;
@@ -1517,8 +1595,11 @@ class PageService {
     const descendantsSubscribedSets = new Set();
     await this.deleteDescendantsWithStream(page, user, false, descendantsSubscribedSets);
 
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
     await PageOperation.findByIdAndDelete(pageOpId);
 
@@ -1829,7 +1910,9 @@ class PageService {
       })();
     }
     else {
-      this.activityEvent.emit('updated', activity, page);
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
 
     return;
@@ -1838,8 +1921,11 @@ class PageService {
   async deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void> {
     const descendantsSubscribedSets = new Set();
     await this.deleteCompletelyDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
     await PageOperation.findByIdAndDelete(pageOpId);
 
@@ -1882,9 +1968,11 @@ class PageService {
 
     const descendantsSubscribedSets = new Set();
     const pages = await this.deleteCompletelyDescendantsWithStream(page, user, options, true, descendantsSubscribedSets);
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
 
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
     return pages;
   }
@@ -1975,24 +2063,32 @@ class PageService {
    * @throws {Error} - If an error occurs during the deletion process.
    */
   async deleteCompletelyUserHomeBySystem(userHomepagePath: string): Promise<void> {
-    const Page = this.crowi.model('Page');
+    if (!isUsersHomepage(userHomepagePath)) {
+      const msg = 'input value is not user homepage path.';
+      logger.error(msg);
+      throw new Error(msg);
+    }
+
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const userHomepage = await Page.findByPath(userHomepagePath, true);
 
     if (userHomepage == null) {
-      logger.error('user homepage is not found.');
-      return;
+      const msg = 'user homepage is not found.';
+      logger.error(msg);
+      throw new Error(msg);
     }
 
     const shouldUseV4Process = this.shouldUseV4Process(userHomepage);
 
     const ids = [userHomepage._id];
     const paths = [userHomepage.path];
+    const parentId = getIdForRef(userHomepage.parent);
 
     try {
       if (!shouldUseV4Process) {
         // Ensure consistency of ancestors
         const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
-        await this.updateDescendantCountOfAncestors(userHomepage.parent, inc, true);
+        await this.updateDescendantCountOfAncestors(parentId, inc, true);
       }
 
       // Delete the user's homepage
@@ -2000,7 +2096,7 @@ class PageService {
 
       if (!shouldUseV4Process) {
         // Remove leaf empty pages
-        await Page.removeLeafEmptyPagesRecursively(userHomepage.parent);
+        await Page.removeLeafEmptyPagesRecursively(parentId);
       }
 
       if (!userHomepage.isEmpty) {
@@ -2013,7 +2109,7 @@ class PageService {
       // Find descendant pages with system deletion condition
       const builder = new PageQueryBuilder(Page.find(), true)
         .addConditionForSystemDeletion()
-        .addConditionToListOnlyDescendants(userHomepage.path);
+        .addConditionToListOnlyDescendants(userHomepage.path, {});
 
       // Stream processing to delete descendant pages
       // ────────┤ start │─────────
@@ -2161,7 +2257,10 @@ class PageService {
 
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
-      this.activityEvent.emit('updated', activity, page);
+
+      const preNotify = preNotifyService.generatePreNotify(activity);
+
+      this.activityEvent.emit('updated', activity, page, preNotify);
     }
     else {
       let pageOp;
@@ -2207,8 +2306,11 @@ class PageService {
 
     const descendantsSubscribedSets = new Set();
     await this.revertDeletedDescendantsWithStream(page, user, options, false, descendantsSubscribedSets);
-    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets);
-    this.activityEvent.emit('updated', activity, page, descendantsSubscribedUsers);
+    const descendantsSubscribedUsers = Array.from(descendantsSubscribedSets) as Ref<IUser>[];
+
+    const preNotify = preNotifyService.generatePreNotify(activity, () => { return descendantsSubscribedUsers });
+
+    this.activityEvent.emit('updated', activity, page, preNotify);
 
     const newPath = Page.getRevertDeletedPageName(page.path);
     // normalize parent of descendant pages
@@ -2251,6 +2353,18 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
   }
 
+  /*
+ * get all groups of Page that user is related to
+ */
+  async getUserRelatedGrantedGroups(page: PageDocument, user): Promise<PopulatedGrantedGroup[]> {
+    const populatedPage = await page.populate<{grantedGroups: PopulatedGrantedGroup[] | null}>('grantedGroups.item');
+    const userRelatedGroupIds = [
+      ...(await UserGroupRelation.findAllGroupsForUser(user)).map(ugr => ugr._id.toString()),
+      ...(await ExternalUserGroupRelation.findAllGroupsForUser(user)).map(eugr => eugr._id.toString()),
+    ];
+    return populatedPage.grantedGroups?.filter(group => userRelatedGroupIds.includes(group.item._id.toString())) || [];
+  }
+
   private async revertDeletedPageV4(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
     const PageTagRelation = this.crowi.model('PageTagRelation');
@@ -2393,13 +2507,12 @@ class PageService {
   }
 
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
-    const isMovable = isGuestUser ? false : isMovablePage(page.path);
+    const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
     if (page.isEmpty) {
       return {
         isV5Compatible: true,
         isEmpty: true,
-        isMovable,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
         isRevertible: false,
@@ -2416,8 +2529,7 @@ class PageService {
       likerIds: this.extractStringIds(likers),
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
-      isMovable,
-      isDeletable: isMovable,
+      isDeletable,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),

+ 66 - 0
apps/app/src/server/service/pre-notify.ts

@@ -0,0 +1,66 @@
+import type {
+  IPage, IUser, Ref,
+} from '@growi/core';
+
+import { ActivityDocument } from '../models/activity';
+import Subscription from '../models/subscription';
+import { getModelSafely } from '../util/mongoose-utils';
+
+export type PreNotifyProps = {
+  notificationTargetUsers?: Ref<IUser>[],
+}
+
+export type PreNotify = (props: PreNotifyProps) => Promise<void>;
+export type GeneratePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: (activity?: ActivityDocument) => Ref<IUser>[]) => PreNotify;
+
+export type GetAdditionalTargetUsers = (activity: ActivityDocument) => Ref<IUser>[];
+
+interface IPreNotifyService {
+  generateInitialPreNotifyProps: (PreNotifyProps) => { notificationTargetUsers?: Ref<IUser>[] },
+  generatePreNotify: GeneratePreNotify
+}
+
+class PreNotifyService implements IPreNotifyService {
+
+  generateInitialPreNotifyProps = (): PreNotifyProps => {
+
+    const initialPreNotifyProps: Ref<IUser>[] = [];
+
+    return { notificationTargetUsers: initialPreNotifyProps };
+  };
+
+  generatePreNotify = (activity: ActivityDocument, getAdditionalTargetUsers?: GetAdditionalTargetUsers): PreNotify => {
+
+    const preNotify = async(props: PreNotifyProps) => {
+      const { notificationTargetUsers } = props;
+
+      const User = getModelSafely('User') || require('~/server/models/user')();
+      const actionUser = activity.user;
+      const target = activity.target;
+      const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
+      const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
+      const activeNotificationUsers = await User.find({
+        _id: { $in: notificationUsers },
+        status: User.STATUS_ACTIVE,
+      }).distinct('_id');
+
+      if (getAdditionalTargetUsers == null) {
+        notificationTargetUsers?.push(...activeNotificationUsers);
+      }
+      else {
+        const AdditionalTargetUsers = getAdditionalTargetUsers(activity);
+
+        notificationTargetUsers?.push(
+          ...activeNotificationUsers,
+          ...AdditionalTargetUsers,
+        );
+      }
+
+    };
+
+    return preNotify;
+  };
+
+}
+
+export const preNotifyService = new PreNotifyService();

+ 50 - 39
apps/app/test/integration/service/external-user-group-sync.test.ts

@@ -99,20 +99,6 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     parent: null,
   });
-  const grandParentGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: grandParentGroup._id });
-  if (autoGenerateUserOnGroupSync) {
-    expect(grandParentGroupRelations.length).toBe(3);
-    const grandParentGroupUser = (await grandParentGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(grandParentGroupUser?.username).toBe('grandParentGroupUser');
-    const parentGroupUser = (await grandParentGroupRelations[1].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(parentGroupUser?.username).toBe('parentGroupUser');
-    const childGroupUser = (await grandParentGroupRelations[2].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(childGroupUser?.username).toBe('childGroupUser');
-  }
-  else {
-    expect(grandParentGroupRelations.length).toBe(0);
-  }
 
   const parentGroup = await ExternalUserGroup.findOne({ name: 'parentGroup' });
   checkGroup(parentGroup, {
@@ -122,18 +108,6 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     parent: grandParentGroup._id,
   });
-  const parentGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: parentGroup._id });
-  if (autoGenerateUserOnGroupSync) {
-    expect(parentGroupRelations.length).toBe(2);
-    const parentGroupUser = (await parentGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(parentGroupUser?.username).toBe('parentGroupUser');
-    const childGroupUser = (await parentGroupRelations[1].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(childGroupUser?.username).toBe('childGroupUser');
-  }
-  else {
-    expect(parentGroupRelations.length).toBe(0);
-  }
 
   const childGroup = await ExternalUserGroup.findOne({ name: 'childGroup' });
   checkGroup(childGroup, {
@@ -143,16 +117,6 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     parent: parentGroup._id,
   });
-  const childGroupRelations = await ExternalUserGroupRelation
-    .find({ relatedGroup: childGroup._id });
-  if (autoGenerateUserOnGroupSync) {
-    expect(childGroupRelations.length).toBe(1);
-    const childGroupUser = (await childGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
-    expect(childGroupUser?.username).toBe('childGroupUser');
-  }
-  else {
-    expect(childGroupRelations.length).toBe(0);
-  }
 
   const previouslySyncedGroup = await ExternalUserGroup.findOne({ name: 'previouslySyncedGroup' });
   checkGroup(previouslySyncedGroup, {
@@ -162,14 +126,53 @@ const checkSync = async(autoGenerateUserOnGroupSync = true) => {
     provider: 'ldap',
     parent: null,
   });
+
+  const grandParentGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: grandParentGroup._id });
+  const parentGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: parentGroup._id });
+  const childGroupRelations = await ExternalUserGroupRelation
+    .find({ relatedGroup: childGroup._id });
   const previouslySyncedGroupRelations = await ExternalUserGroupRelation
     .find({ relatedGroup: previouslySyncedGroup._id });
+
   if (autoGenerateUserOnGroupSync) {
+    expect(grandParentGroupRelations.length).toBe(3);
+    const populatedGrandParentGroupRelations = await Promise.all(grandParentGroupRelations.map((relation) => {
+      return relation.populate<{relatedUser: IUserHasId}>('relatedUser');
+    }));
+    expect(populatedGrandParentGroupRelations[0].relatedUser.username).toBe('grandParentGroupUser');
+    expect(populatedGrandParentGroupRelations[1].relatedUser.username).toBe('parentGroupUser');
+    expect(populatedGrandParentGroupRelations[2].relatedUser.username).toBe('childGroupUser');
+
+    expect(parentGroupRelations.length).toBe(2);
+    const populatedParentGroupRelations = await Promise.all(parentGroupRelations.map((relation) => {
+      return relation.populate<{relatedUser: IUserHasId}>('relatedUser');
+    }));
+    expect(populatedParentGroupRelations[0].relatedUser.username).toBe('parentGroupUser');
+    expect(populatedParentGroupRelations[1].relatedUser.username).toBe('childGroupUser');
+
+    expect(childGroupRelations.length).toBe(1);
+    const childGroupUser = (await childGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
+    expect(childGroupUser?.username).toBe('childGroupUser');
+
     expect(previouslySyncedGroupRelations.length).toBe(1);
     const previouslySyncedGroupUser = (await previouslySyncedGroupRelations[0].populate<{relatedUser: IUserHasId}>('relatedUser'))?.relatedUser;
     expect(previouslySyncedGroupUser?.username).toBe('previouslySyncedGroupUser');
+
+    const userPages = await mongoose.model('Page').find({
+      path: {
+        $in: [
+          '/user/childGroupUser', '/user/parentGroupUser', '/user/grandParentGroupUser', '/user/previouslySyncedGroupUser',
+        ],
+      },
+    });
+    expect(userPages.length).toBe(4);
   }
   else {
+    expect(grandParentGroupRelations.length).toBe(0);
+    expect(parentGroupRelations.length).toBe(0);
+    expect(childGroupRelations.length).toBe(0);
     expect(previouslySyncedGroupRelations.length).toBe(0);
   }
 };
@@ -179,6 +182,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
 
   beforeAll(async() => {
     crowi = await getInstance();
+    await configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
     const passportService = new PassportService(crowi);
     instanciate(passportService);
   });
@@ -198,6 +202,13 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     await mongoose.model('User')
       .deleteMany({ username: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
     await ExternalAccount.deleteMany({ accountId: { $in: ['childGroupUser', 'parentGroupUser', 'grandParentGroupUser', 'previouslySyncedGroupUser'] } });
+    await mongoose.model('Page').deleteMany({
+      path: {
+        $in: [
+          '/user/childGroupUser', '/user/parentGroupUser', '/user/grandParentGroupUser', '/user/previouslySyncedGroupUser',
+        ],
+      },
+    });
   });
 
   describe('When autoGenerateUserOnGroupSync is true', () => {
@@ -207,7 +218,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     };
 
     beforeAll(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams);
     });
 
     // eslint-disable-next-line jest/expect-expect
@@ -224,7 +235,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     };
 
     beforeAll(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams);
     });
 
     // eslint-disable-next-line jest/expect-expect
@@ -241,7 +252,7 @@ describe('ExternalUserGroupSyncService.syncExternalUserGroups', () => {
     };
 
     beforeAll(async() => {
-      await configManager.updateConfigsInTheSameNamespace('crowi', configParams, true);
+      await configManager.updateConfigsInTheSameNamespace('crowi', configParams);
 
       const groupId = new Types.ObjectId();
       const userId = new Types.ObjectId();

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

@@ -532,13 +532,13 @@ describe('PageGrantService', () => {
 
     // parent property of all private pages is null
     test('Any grant is allowed if parent is null', async() => {
-      const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedGroups = [
-        ...userPossessedUserGroups.map((group) => {
+      const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedGroups = [
+        ...userRelatedUserGroups.map((group) => {
           return { type: GroupType.userGroup, item: group };
         }),
-        ...userPossessedExternalUserGroups.map((group) => {
+        ...userRelatedExternalUserGroups.map((group) => {
           return { type: GroupType.externalUserGroup, item: group };
         }),
       ];
@@ -551,7 +551,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
       );
 
@@ -563,7 +563,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
       );
 
@@ -575,20 +575,20 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
       );
     });
 
 
     test('Any grant is allowed if parent is public', async() => {
-      const userPossessedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
-      const userPossessedGroups = [
-        ...userPossessedUserGroups.map((group) => {
+      const userRelatedUserGroups = await UserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedExternalUserGroups = await ExternalUserGroupRelation.findAllGroupsForUser(user1);
+      const userRelatedGroups = [
+        ...userRelatedUserGroups.map((group) => {
           return { type: GroupType.userGroup, item: group };
         }),
-        ...userPossessedExternalUserGroups.map((group) => {
+        ...userRelatedExternalUserGroups.map((group) => {
           return { type: GroupType.externalUserGroup, item: group };
         }),
       ];
@@ -601,7 +601,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
       );
 
@@ -613,7 +613,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
       );
 
@@ -625,7 +625,7 @@ describe('PageGrantService', () => {
           [PageGrant.GRANT_PUBLIC]: null,
           [PageGrant.GRANT_RESTRICTED]: null,
           [PageGrant.GRANT_OWNER]: null,
-          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userPossessedGroups },
+          [PageGrant.GRANT_USER_GROUP]: { applicableGroups: userRelatedGroups },
         },
       );
     });

+ 1 - 1
package.json

@@ -89,7 +89,7 @@
     "ts-node-dev": "^2.0.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~5.0.0",
-    "vite": "^4.5.0",
+    "vite": "^4.5.1",
     "vite-plugin-dts": "^2.3.0",
     "vite-tsconfig-paths": "^4.2.0",
     "vitest": "^0.34.6",

+ 0 - 1
packages/core/src/interfaces/page.ts

@@ -80,7 +80,6 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageInfo = {
   isV5Compatible: boolean,
   isEmpty: boolean,
-  isMovable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
   isRevertible: boolean,

+ 20 - 2
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -1,9 +1,9 @@
 import {
-  isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
+  isMovablePage, isTopPage, isUsersProtectedPages, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
 } from './index';
 
 describe.concurrent('isMovablePage test', () => {
-  test('should decide deletable or not', () => {
+  test('should decide movable or not', () => {
     expect(isMovablePage('/')).toBeFalsy();
     expect(isMovablePage('/hoge')).toBeTruthy();
     expect(isMovablePage('/user')).toBeFalsy();
@@ -13,6 +13,24 @@ describe.concurrent('isMovablePage test', () => {
   });
 });
 
+describe.concurrent('isTopPage test', () => {
+  test('should decide deletable or not', () => {
+    expect(isTopPage('/')).toBeTruthy();
+    expect(isTopPage('/hoge')).toBeFalsy();
+    expect(isTopPage('/user/xxx/hoge')).toBeFalsy();
+  });
+});
+
+describe.concurrent('isUsersProtectedPages test', () => {
+  test('Should decide users protected pages or not', () => {
+    expect(isUsersProtectedPages('/hoge')).toBeFalsy();
+    expect(isUsersProtectedPages('/user')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx123')).toBeTruthy();
+    expect(isUsersProtectedPages('/user/xxx/hoge')).toBeFalsy();
+  });
+});
+
 describe.concurrent('convertToNewAffiliationPath test', () => {
   test.concurrent('Child path is not converted normally', () => {
     const result = convertToNewAffiliationPath('parent/', 'parent2/', 'parent/child');

+ 0 - 7
packages/remark-drawio/src/components/DrawioViewer.module.scss

@@ -2,11 +2,4 @@
   margin: 20px 0;
   border: 1px solid transparent;
   border-radius: 4px;
-
-  .geDiagramContainer {
-    // centering
-    margin-right: auto;
-    margin-left: auto;
-  }
-
 }

+ 27 - 1
packages/remark-drawio/src/components/DrawioViewer.tsx

@@ -56,16 +56,22 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
       return;
     }
 
-    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph');
+    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph') as HTMLCollectionOf<HTMLElement>;
     if (mxgraphs.length > 0) {
       // This component should have only one '.mxgraph' element
       const div = mxgraphs[0];
 
       if (div != null) {
         div.innerHTML = '';
+        div.style.width = '';
+        div.style.height = '';
 
         // render diagram with createViewerForElement
         try {
+          GraphViewer.useResizeSensor = false;
+          GraphViewer.prototype.checkVisibleState = false;
+          GraphViewer.prototype.lightboxZIndex = 1055; // set $zindex-modal
+          GraphViewer.prototype.toolbarZIndex = 1055; // set $zindex-modal
           GraphViewer.createViewerForElement(div);
         }
         catch (err) {
@@ -139,6 +145,26 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
   }, [onRenderingUpdated]);
   // *******************************  end  *******************************
 
+  // *******************  detect container is resized  *******************
+  useEffect(() => {
+    if (drawioContainerRef.current == null) {
+      return;
+    }
+
+    const observer = new ResizeObserver((entries) => {
+      entries.forEach(() => {
+        // setElementWidth(el.contentRect.width);
+        onRenderingStart?.();
+        renderDrawioWithDebounce();
+      });
+    });
+    observer.observe(drawioContainerRef.current);
+    return () => {
+      observer.disconnect();
+    };
+  }, [onRenderingStart, renderDrawioWithDebounce]);
+  // *******************************  end  *******************************
+
   return (
     <div
       key={`drawio-viewer-${diagramIndex}`}

+ 4 - 4
yarn.lock

@@ -16532,10 +16532,10 @@ vite-tsconfig-paths@^4.2.0:
     globrex "^0.1.2"
     tsconfck "^2.1.0"
 
-"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.0.tgz#ec406295b4167ac3bc23e26f9c8ff559287cff26"
-  integrity sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==
+"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.1:
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.1.tgz#3370986e1ed5dbabbf35a6c2e1fb1e18555b968a"
+  integrity sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==
   dependencies:
     esbuild "^0.18.10"
     postcss "^8.4.27"