Przeglądaj źródła

Merge pull request #7921 from weseek/imprv/integrate-customize-user-page-delete

imprv: Able to customize users homepage deletion
Ryoji Shimizu 2 lat temu
rodzic
commit
7fef4e4052
34 zmienionych plików z 324 dodań i 136 usunięć
  1. 6 1
      apps/app/public/static/locales/en_US/admin.json
  2. 6 1
      apps/app/public/static/locales/ja_JP/admin.json
  3. 6 1
      apps/app/public/static/locales/zh_CN/admin.json
  4. 10 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  5. 1 1
      apps/app/src/client/services/AdminHomeContainer.js
  6. 6 1
      apps/app/src/components/Admin/AuditLog/ActivityTable.tsx
  7. 1 1
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  8. 22 0
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  9. 3 3
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  10. 3 3
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  11. 1 2
      apps/app/src/components/Navbar/AuthorInfo.tsx
  12. 1 2
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  13. 2 1
      apps/app/src/components/Navbar/PersonalDropdown.jsx
  14. 8 9
      apps/app/src/components/Page/PageView.tsx
  15. 14 14
      apps/app/src/components/PageCreateModal.jsx
  16. 10 10
      apps/app/src/components/PageRenameModal.tsx
  17. 3 3
      apps/app/src/components/PageSideContents.tsx
  18. 3 5
      apps/app/src/components/SavePageControls.tsx
  19. 2 1
      apps/app/src/components/User/Username.tsx
  20. 0 0
      apps/app/src/components/UsersHomepageFooter.module.scss
  21. 4 4
      apps/app/src/components/UsersHomepageFooter.tsx
  22. 3 3
      apps/app/src/pages/admin/index.page.tsx
  23. 2 3
      apps/app/src/server/crowi/index.js
  24. 0 35
      apps/app/src/server/events/user.js
  25. 51 0
      apps/app/src/server/events/user.ts
  26. 1 0
      apps/app/src/server/models/config.ts
  27. 0 11
      apps/app/src/server/models/obsolete-page.js
  28. 23 0
      apps/app/src/server/models/page.ts
  29. 4 0
      apps/app/src/server/routes/apiv3/security-settings/index.js
  30. 29 12
      apps/app/src/server/routes/apiv3/users.js
  31. 1 1
      apps/app/src/server/service/config-loader.ts
  32. 92 0
      apps/app/src/server/service/page.ts
  33. 5 5
      packages/core/src/utils/page-path-utils/index.ts
  34. 1 3
      packages/ui/src/components/UserPicture.tsx

+ 6 - 1
apps/app/public/static/locales/en_US/admin.json

@@ -3,7 +3,7 @@
     "display_name": "English"
     "display_name": "English"
   },
   },
   "last_login": "Last login",
   "last_login": "Last login",
-  "wiki_management_home_page": "Wiki Management Home Page",
+  "wiki_management_homepage": "Wiki Management Homepage",
   "public": "Public",
   "public": "Public",
   "anyone_with_the_link": "Anyone with the link",
   "anyone_with_the_link": "Anyone with the link",
   "specified_users": "Specified users",
   "specified_users": "Specified users",
@@ -45,6 +45,11 @@
     "admin_only": "Admin only",
     "admin_only": "Admin only",
     "admin_and_author": "Admin and author",
     "admin_and_author": "Admin and author",
     "anyone": "Anyone",
     "anyone": "Anyone",
+    "user_homepage_deletion": {
+      "user_homepage_deletion": "User homepage deletion",
+      "enable_user_homepage_deletion": "Enable user homepage deletion",
+      "when_deleting_a_user_the_user_homepage_is_also_deleted": "When deleting a user, the user homepage is also deleted."
+    },
     "session": "Session",
     "session": "Session",
     "max_age": "Max age (msec)",
     "max_age": "Max age (msec)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",
     "max_age_desc": "Specifies the number (in milliseconds) to expire users session.<br>Default: 2592000000 (30days)",

+ 6 - 1
apps/app/public/static/locales/ja_JP/admin.json

@@ -11,7 +11,7 @@
   "Edit": "編集",
   "Edit": "編集",
   "Description": "説明",
   "Description": "説明",
   "last_login": "最終ログイン",
   "last_login": "最終ログイン",
-  "wiki_management_home_page": "Wiki管理トップ",
+  "wiki_management_homepage": "Wiki管理トップ",
   "public": "公開",
   "public": "公開",
   "anyone_with_the_link": "リンクを知っている人のみ",
   "anyone_with_the_link": "リンクを知っている人のみ",
   "specified_users": "特定ユーザーのみ",
   "specified_users": "特定ユーザーのみ",
@@ -53,6 +53,11 @@
     "admin_only": "管理者のみ可能",
     "admin_only": "管理者のみ可能",
     "admin_and_author": "管理者とページ作者が可能",
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
     "anyone": "誰でも可能",
+    "user_homepage_deletion": {
+      "user_homepage_deletion": "ユーザーページの削除",
+      "enable_user_homepage_deletion": "ユーザーページの削除を有効化",
+      "when_deleting_a_user_the_user_homepage_is_also_deleted": "ユーザー削除時にユーザーページも削除します。"
+    },
     "session": "セッション",
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",
     "max_age": "有効期間 (ミリ秒)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",

+ 6 - 1
apps/app/public/static/locales/zh_CN/admin.json

@@ -11,7 +11,7 @@
   "Edit": "编辑",
   "Edit": "编辑",
   "Description": "描述",
   "Description": "描述",
   "last_login": "上次登录",
   "last_login": "上次登录",
-  "wiki_management_home_page": "Wiki管理首页",
+  "wiki_management_homepage": "Wiki管理首页",
   "public": "公共",
   "public": "公共",
   "anyone_with_the_link": "任何人",
   "anyone_with_the_link": "任何人",
   "specified_users": "仅指定用户",
   "specified_users": "仅指定用户",
@@ -53,6 +53,11 @@
 		"admin_only": "仅管理员",
 		"admin_only": "仅管理员",
 		"admin_and_author": "管理员|作者",
 		"admin_and_author": "管理员|作者",
 		"anyone": "任何人",
 		"anyone": "任何人",
+    "user_homepage_deletion": {
+      "user_homepage_deletion": "删除用户页面",
+      "enable_user_homepage_deletion": "启用删除用户页面",
+      "when_deleting_a_user_the_user_homepage_is_also_deleted": "当一个用户被删除时,用户页面也会被删除。"
+    },
     "session": "会议",
     "session": "会议",
     "max_age": "有效期间  (msec)",
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",

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

@@ -38,6 +38,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       expandOtherOptionsForCompleteDeletion: false,
       expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByGroup: false,
       isShowRestrictedByGroup: false,
+      isUsersHomepageDeletionEnabled: false,
       isLocalEnabled: false,
       isLocalEnabled: false,
       isLdapEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,
       isSamlEnabled: false,
@@ -73,6 +74,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
       currentPageRecursiveCompleteDeletionAuthority: generalSetting.pageRecursiveCompleteDeletionAuthority,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByOwner: !generalSetting.hideRestrictedByOwner,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
+      isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
       sessionMaxAge: generalSetting.sessionMaxAge,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -193,6 +195,13 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isShowRestrictedByGroup:  !this.state.isShowRestrictedByGroup });
     this.setState({ isShowRestrictedByGroup:  !this.state.isShowRestrictedByGroup });
   }
   }
 
 
+  /**
+   * Switch isUsersHomepageDeletionEnabled
+   */
+  switchIsUsersHomepageDeletionEnabled() {
+    this.setState({ isUsersHomepageDeletionEnabled: !this.state.isUsersHomepageDeletionEnabled });
+  }
+
   /**
   /**
    * Update restrictGuestMode
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
    * @memberOf AdminGeneralSecuritySContainer
@@ -209,6 +218,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
       pageRecursiveCompleteDeletionAuthority: this.state.currentPageRecursiveCompleteDeletionAuthority,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByGroup: !this.state.isShowRestrictedByGroup,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
+      isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
     };
     };
 
 
     requestParams = await removeNullPropertyFromObject(requestParams);
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 1 - 1
apps/app/src/client/services/AdminHomeContainer.js

@@ -9,7 +9,7 @@ import { apiv3Get } from '../util/apiv3-client';
 const logger = loggerFactory('growi:services:AdminHomeContainer');
 const logger = loggerFactory('growi:services:AdminHomeContainer');
 
 
 /**
 /**
- * Service container for admin home page (AdminHome.jsx)
+ * Service container for admin homepage (AdminHome.jsx)
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
  */
  */
 export default class AdminHomeContainer extends Container {
 export default class AdminHomeContainer extends Container {

+ 6 - 1
apps/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -48,7 +48,12 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                   { activity.user != null && (
                   { activity.user != null && (
                     <>
                     <>
                       <UserPicture user={activity.user} />
                       <UserPicture user={activity.user} />
-                      <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
+                      <a
+                        className="ml-2"
+                        href={pagePathUtils.userHomepagePath(activity.user)}
+                      >
+                        {activity.snapshot?.username}
+                      </a>
                     </>
                     </>
                   )}
                   )}
                 </td>
                 </td>

+ 1 - 1
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -29,7 +29,7 @@ const MenuLabel = ({ menu }: { menu: string }) => {
     case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
     case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
     case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
     case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
     case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
     case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
-    default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
+    default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_homepage') }</>;
       /* eslint-enable no-multi-spaces, max-len */
       /* eslint-enable no-multi-spaces, max-len */
   }
   }
 };
 };

+ 22 - 0
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -453,6 +453,28 @@ class SecuritySetting extends React.Component {
           ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
           ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
         }
         }
 
 
+        <h4>{t('security_settings.user_homepage_deletion.user_homepage_deletion')}</h4>
+        <div className="row mb-4">
+          <div className="col-6 offset-3">
+            <div className="custom-control custom-switch custom-checkbox-success">
+              <input
+                type="checkbox"
+                className="custom-control-input"
+                id="is-user-page-deletion-enabled"
+                checked={adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+                onChange={() => { adminGeneralSecurityContainer.switchIsUsersHomepageDeletionEnabled() }}
+              />
+              <label className="custom-control-label" htmlFor="is-user-page-deletion-enabled">
+                {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
+              </label>
+            </div>
+            <p
+              className="form-text text-muted small"
+              dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.when_deleting_a_user_the_user_homepage_is_also_deleted') }}
+            />
+          </div>
+        </div>
+
         <h4>{t('security_settings.session')}</h4>
         <h4>{t('security_settings.session')}</h4>
         <div className="form-group row">
         <div className="form-group row">
           <label className="text-left text-md-right col-md-3 col-form-label">{t('security_settings.max_age')}</label>
           <label className="text-left text-md-right col-md-3 col-form-label">{t('security_settings.max_age')}</label>

+ 3 - 3
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -29,7 +29,7 @@ type BookmarkFolderItemProps = {
   isOperable: boolean,
   isOperable: boolean,
   level: number
   level: number
   root: string
   root: string
-  isUserHomePage?: boolean
+  isUserHomepage?: boolean
   onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
   onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
   bookmarkFolderTreeMutation: () => void
   bookmarkFolderTreeMutation: () => void
 }
 }
@@ -38,7 +38,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const BASE_FOLDER_PADDING = 15;
   const BASE_FOLDER_PADDING = 15;
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
+    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomepage,
     onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
     onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
   } = props;
   } = props;
 
 
@@ -155,7 +155,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             bookmarkFolder={childFolder}
             bookmarkFolder={childFolder}
             level={level + 1}
             level={level + 1}
             root={root}
             root={root}
-            isUserHomePage={isUserHomePage}
+            isUserHomepage={isUserHomepage}
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
           />

+ 3 - 3
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -27,13 +27,13 @@ import styles from './BookmarkFolderTree.module.scss';
 //  } & IPageHasId
 //  } & IPageHasId
 
 
 type Props = {
 type Props = {
-  isUserHomePage?: boolean,
+  isUserHomepage?: boolean,
   userId?: string,
   userId?: string,
   isOperable: boolean,
   isOperable: boolean,
 }
 }
 
 
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
-  const { isUserHomePage, userId } = props;
+  const { isUserHomepage, userId } = props;
 
 
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -114,7 +114,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               isOpen={false}
               isOpen={false}
               level={0}
               level={0}
               root={bookmarkFolder._id}
               root={bookmarkFolder._id}
-              isUserHomePage={isUserHomePage}
+              isUserHomepage={isUserHomepage}
               onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
             />

+ 1 - 2
apps/app/src/components/Navbar/AuthorInfo.tsx

@@ -18,7 +18,6 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     date, user, mode = 'create', locate = 'subnav',
     date, user, mode = 'create', locate = 'subnav',
   } = props;
   } = props;
 
 
-  const { userPageRoot } = pagePathUtils;
   const formatType = 'yyyy/MM/dd HH:mm';
   const formatType = 'yyyy/MM/dd HH:mm';
 
 
   const infoLabelForSubNav = mode === 'create'
   const infoLabelForSubNav = mode === 'create'
@@ -32,7 +31,7 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     : 'Last revision posted at';
     : 'Last revision posted at';
   const userLabel = user != null
   const userLabel = user != null
     ? (
     ? (
-      <Link href={userPageRoot(user)} prefetch={false}>
+      <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>
         {user.name}
         {user.name}
       </Link>
       </Link>
     )
     )

+ 1 - 2
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -48,7 +48,6 @@ import type { SubNavButtonsProps } from './SubNavButtons';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
-
 const AuthorInfoSkeleton = () => <Skeleton additionalClass={`${AuthorInfoStyles['grw-author-info-skeleton']} py-1`} />;
 const AuthorInfoSkeleton = () => <Skeleton additionalClass={`${AuthorInfoStyles['grw-author-info-skeleton']} py-1`} />;
 
 
 
 
@@ -396,7 +395,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               />
               />
             )}
             )}
           </div>
           </div>
-          {(isAbleToShowPageAuthors && !isCompactMode && !pagePathUtils.isUsersHomePage(path ?? '')) && (
+          {(isAbleToShowPageAuthors && !isCompactMode && !pagePathUtils.isUsersHomepage(path ?? '')) && (
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
               <li className="pb-1">
                 {currentPage != null
                 {currentPage != null

+ 2 - 1
apps/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,5 +1,6 @@
 import { useRef, useState } from 'react';
 import { useRef, useState } from 'react';
 
 
+import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
@@ -64,7 +65,7 @@ const PersonalDropdown = () => {
 
 
           <div className="btn-group btn-block mt-2" role="group">
           <div className="btn-group btn-block mt-2" role="group">
             <Link
             <Link
-              href={`/user/${currentUser.username}`}
+              href={pagePathUtils.userHomepagePath(currentUser)}
               className="btn btn-sm btn-outline-secondary col"
               className="btn btn-sm btn-outline-secondary col"
               data-testid="grw-personal-dropdown-menu-user-home"
               data-testid="grw-personal-dropdown-menu-user-home"
             >
             >

+ 8 - 9
apps/app/src/components/Page/PageView.tsx

@@ -2,9 +2,8 @@ import React, {
   useEffect, useMemo, useRef, useState,
   useEffect, useMemo, useRef, useState,
 } from 'react';
 } from 'react';
 
 
-
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
-import { isUsersHomePage } from '@growi/core/dist/utils/page-path-utils';
+import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -23,7 +22,7 @@ import { PageAlerts } from '../PageAlert/PageAlerts';
 import { PageContentFooter } from '../PageContentFooter';
 import { PageContentFooter } from '../PageContentFooter';
 import type { PageSideContentsProps } from '../PageSideContents';
 import type { PageSideContentsProps } from '../PageSideContents';
 import { UserInfo } from '../User/UserInfo';
 import { UserInfo } from '../User/UserInfo';
-import type { UsersHomePageFooterProps } from '../UsersHomePageFooter';
+import type { UsersHomepageFooterProps } from '../UsersHomepageFooter';
 
 
 import RevisionRenderer from './RevisionRenderer';
 import RevisionRenderer from './RevisionRenderer';
 
 
@@ -36,8 +35,8 @@ const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('../PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
 const PageContentsUtilities = dynamic(() => import('./PageContentsUtilities').then(mod => mod.PageContentsUtilities), { ssr: false });
 const PageContentsUtilities = dynamic(() => import('./PageContentsUtilities').then(mod => mod.PageContentsUtilities), { ssr: false });
 const Comments = dynamic<CommentsProps>(() => import('../Comments').then(mod => mod.Comments), { ssr: false });
 const Comments = dynamic<CommentsProps>(() => import('../Comments').then(mod => mod.Comments), { ssr: false });
-const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../UsersHomePageFooter')
-  .then(mod => mod.UsersHomePageFooter), { ssr: false });
+const UsersHomepageFooter = dynamic<UsersHomepageFooterProps>(() => import('../UsersHomepageFooter')
+  .then(mod => mod.UsersHomepageFooter), { ssr: false });
 const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
 const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
 
 
 
 
@@ -68,7 +67,7 @@ export const PageView = (props: Props): JSX.Element => {
 
 
   const page = pageBySWR ?? initialPage;
   const page = pageBySWR ?? initialPage;
   const isNotFound = isNotFoundMeta || page?.revision == null;
   const isNotFound = isNotFoundMeta || page?.revision == null;
-  const isUsersHomePagePath = isUsersHomePage(pagePath);
+  const isUsersHomepagePath = isUsersHomepage(pagePath);
 
 
 
 
   // ***************************  Auto Scroll  ***************************
   // ***************************  Auto Scroll  ***************************
@@ -116,8 +115,8 @@ export const PageView = (props: Props): JSX.Element => {
             onLoaded={() => setCommentsLoaded(true)}
             onLoaded={() => setCommentsLoaded(true)}
           />
           />
         </div>
         </div>
-        {(isUsersHomePagePath && page.creator != null) && (
-          <UsersHomePageFooter creatorId={page.creator._id} />
+        {(isUsersHomepagePath && page.creator != null) && (
+          <UsersHomepageFooter creatorId={page.creator._id} />
         )}
         )}
         <PageContentFooter page={page} />
         <PageContentFooter page={page} />
       </>
       </>
@@ -150,7 +149,7 @@ export const PageView = (props: Props): JSX.Element => {
       {specialContents}
       {specialContents}
       {specialContents == null && (
       {specialContents == null && (
         <>
         <>
-          {(isUsersHomePagePath && page?.creator != null) && <UserInfo author={page.creator} />}
+          {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
           <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
           <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
             <Contents />
             <Contents />
           </div>
           </div>

+ 14 - 14
apps/app/src/components/PageCreateModal.jsx

@@ -21,7 +21,7 @@ import PagePathAutoComplete from './PagePathAutoComplete';
 import styles from './PageCreateModal.module.scss';
 import styles from './PageCreateModal.module.scss';
 
 
 const {
 const {
-  userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
+  isCreatablePage, generateEditorPath, isUsersHomepage,
 } = pagePathUtils;
 } = pagePathUtils;
 
 
 const PageCreateModal = () => {
 const PageCreateModal = () => {
@@ -35,8 +35,8 @@ const PageCreateModal = () => {
 
 
   const { data: isReachable } = useIsSearchServiceReachable();
   const { data: isReachable } = useIsSearchServiceReachable();
   const pathname = path || '';
   const pathname = path || '';
-  const userPageRootPath = userPageRoot(currentUser);
-  const isCreatable = isCreatablePage(pathname) || isUsersHomePage(pathname);
+  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
+  const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
   const now = format(new Date(), 'yyyy/MM/dd');
 
 
@@ -46,7 +46,7 @@ const PageCreateModal = () => {
   const [todayInput2, setTodayInput2] = useState('');
   const [todayInput2, setTodayInput2] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
   const [template, setTemplate] = useState(null);
-  const [isMatchedWithUserHomePagePath, setIsMatchedWithUserHomePagePath] = useState(false);
+  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
 
 
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   // ensure pageNameInput is synced with selectedPagePath || currentPagePath
   useEffect(() => {
   useEffect(() => {
@@ -59,19 +59,19 @@ const PageCreateModal = () => {
     setTodayInput1(t('Memo'));
     setTodayInput1(t('Memo'));
   }, [t]);
   }, [t]);
 
 
-  const checkIsUsersHomePageDebounce = useMemo(() => {
-    const checkIsUsersHomePage = () => {
-      setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
+  const checkIsUsersHomepageDebounce = useMemo(() => {
+    const checkIsUsersHomepage = () => {
+      setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
     };
     };
 
 
-    return debounce(1000, checkIsUsersHomePage);
+    return debounce(1000, checkIsUsersHomepage);
   }, [pageNameInput]);
   }, [pageNameInput]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpened) {
     if (isOpened) {
-      checkIsUsersHomePageDebounce(pageNameInput);
+      checkIsUsersHomepageDebounce(pageNameInput);
     }
     }
-  }, [isOpened, checkIsUsersHomePageDebounce, pageNameInput]);
+  }, [isOpened, checkIsUsersHomepageDebounce, pageNameInput]);
 
 
   function transitBySubmitEvent(e, transitHandler) {
   function transitBySubmitEvent(e, transitHandler) {
     // prevent page transition by submit
     // prevent page transition by submit
@@ -129,7 +129,7 @@ const PageCreateModal = () => {
     if (tmpTodayInput1 === '') {
     if (tmpTodayInput1 === '') {
       tmpTodayInput1 = t('Memo');
       tmpTodayInput1 = t('Memo');
     }
     }
-    redirectToEditor(userPageRootPath, tmpTodayInput1, now, todayInput2);
+    redirectToEditor(userHomepagePath, tmpTodayInput1, now, todayInput2);
   }
   }
 
 
   /**
   /**
@@ -164,7 +164,7 @@ const PageCreateModal = () => {
 
 
             <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
             <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
               <div className="d-flex align-items-center">
               <div className="d-flex align-items-center">
-                <span>{userPageRootPath}/</span>
+                <span>{userHomepagePath}/</span>
                 <form onSubmit={e => transitBySubmitEvent(e, createTodayPage)}>
                 <form onSubmit={e => transitBySubmitEvent(e, createTodayPage)}>
                   <input
                   <input
                     type="text"
                     type="text"
@@ -246,14 +246,14 @@ const PageCreateModal = () => {
                 data-testid="btn-create-page-under-below"
                 data-testid="btn-create-page-under-below"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ml-3"
                 onClick={createInputPage}
                 onClick={createInputPage}
-                disabled={isMatchedWithUserHomePagePath}
+                disabled={isMatchedWithUserHomepagePath}
               >
               >
                 <i className="icon-fw icon-doc"></i>{t('Create')}
                 <i className="icon-fw icon-doc"></i>{t('Create')}
               </button>
               </button>
             </div>
             </div>
 
 
           </div>
           </div>
-          { isMatchedWithUserHomePagePath && (
+          { isMatchedWithUserHomepagePath && (
             <p className="text-danger mt-2">Error: Cannot create page under /user page directory.</p>
             <p className="text-danger mt-2">Error: Cannot create page under /user page directory.</p>
           ) }
           ) }
 
 

+ 10 - 10
apps/app/src/components/PageRenameModal.tsx

@@ -28,7 +28,7 @@ const isV5Compatible = (meta: unknown): boolean => {
 const PageRenameModal = (): JSX.Element => {
 const PageRenameModal = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { isUsersHomePage } = pagePathUtils;
+  const { isUsersHomepage } = pagePathUtils;
   const { data: siteUrl } = useSiteUrl();
   const { data: siteUrl } = useSiteUrl();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
   const { data: isReachable } = useIsSearchServiceReachable();
   const { data: isReachable } = useIsSearchServiceReachable();
@@ -54,7 +54,7 @@ const PageRenameModal = (): JSX.Element => {
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
   const [expandOtherOptions, setExpandOtherOptions] = useState(false);
   const [expandOtherOptions, setExpandOtherOptions] = useState(false);
   const [subordinatedError] = useState(null);
   const [subordinatedError] = useState(null);
-  const [isMatchedWithUserHomePagePath, setIsMatchedWithUserHomePagePath] = useState(false);
+  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
 
 
   const updateSubordinatedList = useCallback(async() => {
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
     if (page == null) {
@@ -80,14 +80,14 @@ const PageRenameModal = (): JSX.Element => {
   }, [isOpened, page, updateSubordinatedList]);
   }, [isOpened, page, updateSubordinatedList]);
 
 
   const canRename = useMemo(() => {
   const canRename = useMemo(() => {
-    if (page == null || isMatchedWithUserHomePagePath || page.data.path === pageNameInput) {
+    if (page == null || isMatchedWithUserHomepagePath || page.data.path === pageNameInput) {
       return false;
       return false;
     }
     }
     if (isV5Compatible(page.meta)) {
     if (isV5Compatible(page.meta)) {
       return existingPaths.length === 0; // v5 data
       return existingPaths.length === 0; // v5 data
     }
     }
     return isRenameRecursively; // v4 data
     return isRenameRecursively; // v4 data
-  }, [existingPaths.length, isMatchedWithUserHomePagePath, isRenameRecursively, page, pageNameInput]);
+  }, [existingPaths.length, isMatchedWithUserHomepagePath, isRenameRecursively, page, pageNameInput]);
 
 
   const rename = useCallback(async() => {
   const rename = useCallback(async() => {
     if (page == null || !canRename) {
     if (page == null || !canRename) {
@@ -151,20 +151,20 @@ const PageRenameModal = (): JSX.Element => {
     return debounce(1000, checkExistPaths);
     return debounce(1000, checkExistPaths);
   }, [checkExistPaths]);
   }, [checkExistPaths]);
 
 
-  const checkIsUsersHomePageDebounce = useMemo(() => {
+  const checkIsUsersHomepageDebounce = useMemo(() => {
     const checkIsPagePathRenameable = () => {
     const checkIsPagePathRenameable = () => {
-      setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
+      setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
     };
     };
 
 
     return debounce(1000, checkIsPagePathRenameable);
     return debounce(1000, checkIsPagePathRenameable);
-  }, [isUsersHomePage, pageNameInput]);
+  }, [isUsersHomepage, pageNameInput]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpened && page != null && pageNameInput !== page.data.path) {
     if (isOpened && page != null && pageNameInput !== page.data.path) {
       checkExistPathsDebounce(page.data.path, pageNameInput);
       checkExistPathsDebounce(page.data.path, pageNameInput);
-      checkIsUsersHomePageDebounce(pageNameInput);
+      checkIsUsersHomepageDebounce(pageNameInput);
     }
     }
-  }, [isOpened, pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
+  }, [isOpened, pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomepageDebounce]);
 
 
   function ppacInputChangeHandler(value) {
   function ppacInputChangeHandler(value) {
     setErrs(null);
     setErrs(null);
@@ -246,7 +246,7 @@ const PageRenameModal = (): JSX.Element => {
         { isTargetPageDuplicate && (
         { isTargetPageDuplicate && (
           <p className="text-danger">Error: Target path is duplicated.</p>
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
         ) }
-        { isMatchedWithUserHomePagePath && (
+        { isMatchedWithUserHomepagePath && (
           <p className="text-danger">Error: Cannot move to directory under /user page.</p>
           <p className="text-danger">Error: Cannot move to directory under /user page.</p>
         ) }
         ) }
 
 

+ 3 - 3
apps/app/src/components/PageSideContents.tsx

@@ -16,7 +16,7 @@ import TableOfContents from './TableOfContents';
 import styles from './PageSideContents.module.scss';
 import styles from './PageSideContents.module.scss';
 
 
 
 
-const { isTopPage, isUsersHomePage, isTrashPage } = pagePathUtils;
+const { isTopPage, isUsersHomepage, isTrashPage } = pagePathUtils;
 
 
 
 
 export type PageSideContentsProps = {
 export type PageSideContentsProps = {
@@ -35,7 +35,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
 
   const pagePath = page.path;
   const pagePath = page.path;
   const isTopPagePath = isTopPage(pagePath);
   const isTopPagePath = isTopPage(pagePath);
-  const isUsersHomePagePath = isUsersHomePage(pagePath);
+  const isUsersHomepagePath = isUsersHomepage(pagePath);
   const isTrash = isTrashPage(pagePath);
   const isTrash = isTrashPage(pagePath);
 
 
   return (
   return (
@@ -83,7 +83,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
 
       <div className="d-none d-lg-block">
       <div className="d-none d-lg-block">
         <TableOfContents />
         <TableOfContents />
-        {isUsersHomePagePath && <ContentLinkButtons author={page?.creator} />}
+        {isUsersHomepagePath && <ContentLinkButtons author={page?.creator} />}
       </div>
       </div>
     </>
     </>
   );
   );

+ 3 - 5
apps/app/src/components/SavePageControls.tsx

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
 
 
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
-import { pagePathUtils } from '@growi/core/dist/utils';
+import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   UncontrolledButtonDropdown, Button,
   UncontrolledButtonDropdown, Button,
@@ -29,8 +29,6 @@ declare global {
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
-const { isTopPage } = pagePathUtils;
-
 export type SavePageControlsProps = {
 export type SavePageControlsProps = {
   slackChannels: string
   slackChannels: string
 }
 }
@@ -71,7 +69,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
 
 
   const { grant, grantedGroup } = grantData;
   const { grant, grantedGroup } = grantData;
 
 
-  const isRootPage = isTopPage(currentPage?.path ?? '');
+  const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = (currentPage != null && !currentPage.isEmpty) ? t('Update') : t('Create');
   const labelSubmitButton = (currentPage != null && !currentPage.isEmpty) ? t('Update') : t('Create');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
 
@@ -83,7 +81,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           <div className="mr-2">
           <div className="mr-2">
             <GrantSelector
             <GrantSelector
               grant={grant}
               grant={grant}
-              disabled={isRootPage}
+              disabled={isGrantSelectorDisabledPage}
               grantGroupId={grantedGroup?.id}
               grantGroupId={grantedGroup?.id}
               grantGroupName={grantedGroup?.name}
               grantGroupName={grantedGroup?.name}
               onUpdateGrant={updateGrantHandler}
               onUpdateGrant={updateGrantHandler}

+ 2 - 1
apps/app/src/components/User/Username.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
 import Link from 'next/link';
 import Link from 'next/link';
 
 
 export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
 export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
@@ -11,7 +12,7 @@ export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
 
 
   const name = user.name || '(no name)';
   const name = user.name || '(no name)';
   const username = user.username;
   const username = user.username;
-  const href = `/user/${user.username}`;
+  const href = pagePathUtils.userHomepagePath(user);
 
 
   return (
   return (
     <Link href={href} prefetch={false}>
     <Link href={href} prefetch={false}>

+ 0 - 0
apps/app/src/components/UsersHomePageFooter.module.scss → apps/app/src/components/UsersHomepageFooter.module.scss


+ 4 - 4
apps/app/src/components/UsersHomePageFooter.tsx → apps/app/src/components/UsersHomepageFooter.tsx

@@ -5,18 +5,18 @@ import { useTranslation } from 'next-i18next';
 
 
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
 import { RecentCreated } from '~/components/RecentCreated/RecentCreated';
-import styles from '~/components/UsersHomePageFooter.module.scss';
+import styles from '~/components/UsersHomepageFooter.module.scss';
 import { useCurrentUser } from '~/stores/context';
 import { useCurrentUser } from '~/stores/context';
 
 
 import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
 import { BookmarkFolderTree } from './Bookmarks/BookmarkFolderTree';
 import { CompressIcon } from './Icons/CompressIcon';
 import { CompressIcon } from './Icons/CompressIcon';
 import { ExpandIcon } from './Icons/ExpandIcon';
 import { ExpandIcon } from './Icons/ExpandIcon';
 
 
-export type UsersHomePageFooterProps = {
+export type UsersHomepageFooterProps = {
   creatorId: string,
   creatorId: string,
 }
 }
 
 
-export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Element => {
+export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { creatorId } = props;
   const { creatorId } = props;
   const [isExpanded, setIsExpanded] = useState<boolean>(false);
   const [isExpanded, setIsExpanded] = useState<boolean>(false);
@@ -43,7 +43,7 @@ export const UsersHomePageFooter = (props: UsersHomePageFooterProps): JSX.Elemen
         </h2>
         </h2>
         {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
         {/* TODO: In bookmark folders v1, the button to create a new folder does not exist. The button should be included in the bookmark component. */}
         <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
         <div className={`${isExpanded ? `${styles['grw-bookarks-contents-expanded']}` : `${styles['grw-bookarks-contents-compressed']}`}`}>
-          <BookmarkFolderTree isUserHomePage={true} isOperable={isOperable} userId={creatorId} />
+          <BookmarkFolderTree isUserHomepage={true} isOperable={isOperable} userId={creatorId} />
         </div>
         </div>
       </div>
       </div>
       <div className="grw-user-page-list-m mt-5 d-edit-none">
       <div className="grw-user-page-list-m mt-5 d-edit-none">

+ 3 - 3
apps/app/src/pages/admin/index.page.tsx

@@ -28,14 +28,14 @@ type Props = CommonProps & {
 };
 };
 
 
 
 
-const AdminHomePage: NextPage<Props> = (props) => {
+const AdminHomepage: NextPage<Props> = (props) => {
   useCurrentUser(props.currentUser ?? null);
   useCurrentUser(props.currentUser ?? null);
   useGrowiCloudUri(props.growiCloudUri);
   useGrowiCloudUri(props.growiCloudUri);
   useGrowiAppIdForGrowiCloud(props.growiAppIdForGrowiCloud);
   useGrowiAppIdForGrowiCloud(props.growiAppIdForGrowiCloud);
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const title = generateCustomTitle(props, t('wiki_management_home_page'));
+  const title = generateCustomTitle(props, t('wiki_management_homepage'));
 
 
   const injectableContainers: Container<any>[] = [];
   const injectableContainers: Container<any>[] = [];
 
 
@@ -82,4 +82,4 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 };
 };
 
 
 
 
-export default AdminHomePage;
+export default AdminHomepage;

+ 2 - 3
apps/app/src/server/crowi/index.js

@@ -17,7 +17,7 @@ import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 
-
+import UserEvent from '../events/user';
 import Activity from '../models/activity';
 import Activity from '../models/activity';
 import PageRedirect from '../models/page-redirect';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import Tag from '../models/tag';
@@ -36,7 +36,6 @@ import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
-
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const models = require('../models');
 const models = require('../models');
@@ -97,7 +96,7 @@ function Crowi() {
   this.port = this.env.PORT || 3000;
   this.port = this.env.PORT || 3000;
 
 
   this.events = {
   this.events = {
-    user: new (require('../events/user'))(this),
+    user: new UserEvent(this),
     page: new (require('../events/page'))(this),
     page: new (require('../events/page'))(this),
     activity: new (require('../events/activity'))(this),
     activity: new (require('../events/activity'))(this),
     bookmark: new (require('../events/bookmark'))(this),
     bookmark: new (require('../events/bookmark'))(this),

+ 0 - 35
apps/app/src/server/events/user.js

@@ -1,35 +0,0 @@
-const debug = require('debug')('growi:events:user');
-const util = require('util');
-const events = require('events');
-
-function UserEvent(crowi) {
-  this.crowi = crowi;
-
-  events.EventEmitter.call(this);
-}
-util.inherits(UserEvent, events.EventEmitter);
-
-UserEvent.prototype.onActivated = async function(user) {
-  const Page = this.crowi.model('Page');
-
-  const userPagePath = Page.getUserPagePath(user);
-
-  const page = await Page.findByPath(userPagePath, user);
-
-  if (page == null) {
-    const body = `# ${user.username}\nThis is ${user.username}'s page`;
-
-    // create user page
-    try {
-      await this.crowi.pageService.create(userPagePath, body, user, {});
-
-      // page created
-      debug('User page created', page);
-    }
-    catch (err) {
-      debug('Failed to create user page', err);
-    }
-  }
-};
-
-module.exports = UserEvent;

+ 51 - 0
apps/app/src/server/events/user.ts

@@ -0,0 +1,51 @@
+import EventEmitter from 'events';
+
+import type { IUserHasId } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:events:user');
+
+class UserEvent extends EventEmitter {
+
+  crowi: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(crowi: any) {
+    super();
+    this.crowi = crowi;
+  }
+
+  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 userHomepagePath = pagePathUtils.userHomepagePath(user);
+
+    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;
+    }
+
+    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);
+      }
+    }
+  }
+
+}
+
+export default UserEvent;

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

@@ -70,6 +70,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:pageRecursiveCompleteDeletionAuthority' : undefined,
   'security:disableLinkSharing' : false,
   'security:disableLinkSharing' : false,
+  'security:isUsersHomepageDeletionEnabled': false,
 
 
   'security:passport-local:isEnabled' : true,
   'security:passport-local:isEnabled' : true,
   'security:passport-ldap:isEnabled' : false,
   'security:passport-ldap:isEnabled' : false,

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

@@ -288,10 +288,6 @@ export const getPageSchema = (crowi) => {
       });
       });
   };
   };
 
 
-  pageSchema.statics.getUserPagePath = function(user) {
-    return `/user/${user.username}`;
-  };
-
   pageSchema.statics.getDeletedPageName = function(path) {
   pageSchema.statics.getDeletedPageName = function(path) {
     if (path.match('/')) {
     if (path.match('/')) {
       // eslint-disable-next-line no-param-reassign
       // eslint-disable-next-line no-param-reassign
@@ -673,13 +669,6 @@ export const getPageSchema = (crowi) => {
 
 
   };
   };
 
 
-  pageSchema.statics.removeByPath = function(path) {
-    if (path == null) {
-      throw new Error('path is required');
-    }
-    return this.findOneAndRemove({ path }).exec();
-  };
-
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
   pageSchema.statics.findListByPathsArray = async function(paths, includeEmpty = false) {
     const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     const queryBuilder = new this.PageQueryBuilder(this.find(), includeEmpty);
     queryBuilder.addConditionToListByPathsArray(paths);
     queryBuilder.addConditionToListByPathsArray(paths);

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

@@ -374,6 +374,12 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionForSystemDeletion(): PageQueryBuilder {
+    const condition = generateGrantConditionForSystemDeletion();
+    this.query = this.query.and(condition);
+    return this;
+  }
+
   addConditionToPagenate(offset, limit, sortOpt?): PageQueryBuilder {
   addConditionToPagenate(offset, limit, sortOpt?): PageQueryBuilder {
     this.query = this.query
     this.query = this.query
       .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
       .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
@@ -940,6 +946,23 @@ export function generateGrantCondition(
 
 
 schema.statics.generateGrantCondition = generateGrantCondition;
 schema.statics.generateGrantCondition = generateGrantCondition;
 
 
+function generateGrantConditionForSystemDeletion(): { $or: any[] } {
+  const grantCondition: AnyObject[] = [
+    { grant: null },
+    { grant: GRANT_PUBLIC },
+    { grant: GRANT_RESTRICTED },
+    { grant: GRANT_SPECIFIED },
+    { grant: GRANT_OWNER },
+    { grant: GRANT_USER_GROUP },
+  ];
+
+  return {
+    $or: grantCondition,
+  };
+}
+
+schema.statics.generateGrantConditionForSystemDeletion = generateGrantConditionForSystemDeletion;
+
 // find ancestor page with isEmpty: false. If parameter path is '/', return undefined
 // find ancestor page with isEmpty: false. If parameter path is '/', return undefined
 schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | undefined> {
 schema.statics.findNonEmptyClosestAncestor = async function(path: string): Promise<PageDocument | undefined> {
   if (path === '/') {
   if (path === '/') {

+ 4 - 0
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -28,6 +28,7 @@ const validator = {
     body('pageCompleteDeletionAuthority').if(value => value != null).isString().isIn(Object.values(PageDeleteConfigValue)),
     body('pageCompleteDeletionAuthority').if(value => value != null).isString().isIn(Object.values(PageDeleteConfigValue)),
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByOwner').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
     body('hideRestrictedByGroup').if(value => value != null).isBoolean(),
+    body('isUsersHomepageDeletionEnabled').if(value => value != null).isBoolean(),
   ],
   ],
   shareLinkSetting: [
   shareLinkSetting: [
     body('disableLinkSharing').if(value => value != null).isBoolean(),
     body('disableLinkSharing').if(value => value != null).isBoolean(),
@@ -355,6 +356,7 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
         wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
         wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
         sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
       },
@@ -622,6 +624,7 @@ module.exports = (crowi) => {
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:pageRecursiveCompleteDeletionAuthority': req.body.pageRecursiveCompleteDeletionAuthority,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByOwner': req.body.hideRestrictedByOwner,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
       'security:list-policy:hideRestrictedByGroup': req.body.hideRestrictedByGroup,
+      'security:isUsersHomepageDeletionEnabled': req.body.isUsersHomepageDeletionEnabled,
     };
     };
 
 
     // Validate delete config
     // Validate delete config
@@ -650,6 +653,7 @@ module.exports = (crowi) => {
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         pageRecursiveCompleteDeletionAuthority: await configManager.getConfig('crowi', 'security:pageRecursiveCompleteDeletionAuthority'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByOwner: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         hideRestrictedByGroup: await configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
+        isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled'),
       };
       };
 
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };

+ 29 - 12
apps/app/src/server/routes/apiv3/users.js

@@ -1,4 +1,6 @@
+
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
+import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 
 
 import { SupportedAction } from '~/interfaces/activity';
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
 import Activity from '~/server/models/activity';
@@ -369,7 +371,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3('find-user-is-not-found'));
       return res.apiv3Err(new ErrorV3('find-user-is-not-found'));
     }
     }
 
 
-    const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationM') || 30;
+    const limit = parseInt(req.query.limit) || await configManager.getConfig('crowi', 'customize:showPageLimitationM') || 30;
     const page = req.query.page;
     const page = req.query.page;
     const offset = (page - 1) * limit;
     const offset = (page - 1) * limit;
     const queryOptions = { offset, limit };
     const queryOptions = { offset, limit };
@@ -765,6 +767,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(err));
       return res.apiv3Err(new ErrorV3(err));
     }
     }
   });
   });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -774,7 +777,7 @@ module.exports = (crowi) => {
    *        tags: [Users]
    *        tags: [Users]
    *        operationId: removeUser
    *        operationId: removeUser
    *        summary: /users/{id}/remove
    *        summary: /users/{id}/remove
-   *        description: Delete user
+   *        description: Delete user and if isUsersHomepageDeletionEnabled delete user homepage and subpages
    *        parameters:
    *        parameters:
    *          - name: id
    *          - name: id
    *            in: path
    *            in: path
@@ -784,30 +787,44 @@ module.exports = (crowi) => {
    *              type: string
    *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
-   *            description: Deleting user success
+   *            description: Deleting user success and if isUsersHomepageDeletionEnabled delete user homepage and subpages success
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
-   *                    userData:
+   *                    user:
    *                      type: object
    *                      type: object
-   *                      description: data of delete user
+   *                      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) => {
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
+    const isUsersHomepageDeletionEnabled = configManager.getConfig('crowi', 'security:isUsersHomepageDeletionEnabled');
 
 
     try {
     try {
-      const userData = await User.findById(id);
-      await UserGroupRelation.remove({ relatedUser: userData });
-      await userData.statusDelete();
-      await ExternalAccount.remove({ user: userData });
-      await Page.removeByPath(`/user/${userData.username}`);
+      const user = await User.findById(id);
+      // !! DO NOT MOVE homepagePath FROM THIS POSITION !! -- 05.31.2023
+      // catch username before delete user because username will be change to deleted_at_*
+      const homepagePath = userHomepagePath(user);
 
 
-      const serializedUserData = serializeUserSecurely(userData);
+      await UserGroupRelation.remove({ relatedUser: user });
+      await user.statusDelete();
+      await ExternalAccount.remove({ user });
+
+      const serializedUser = serializeUserSecurely(user);
 
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE });
 
 
-      return res.apiv3({ userData: serializedUserData });
+      if (isUsersHomepageDeletionEnabled) {
+        crowi.pageService.deleteCompletelyUserHomeBySystem(homepagePath);
+      }
+
+      return res.apiv3({ user: serializedUser });
     }
     }
     catch (err) {
     catch (err) {
       logger.error('Error', err);
       logger.error('Error', err);

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

@@ -782,7 +782,7 @@ export default class ConfigLoader {
   /**
   /**
    * get config from the environment variables for display admin page
    * get config from the environment variables for display admin page
    *
    *
-   * **use this only admin home page.**
+   * **use this only admin homepage.**
    */
    */
   static getEnvVarsForDisplay(avoidSecurity = false): any {
   static getEnvVarsForDisplay(avoidSecurity = false): any {
     const config = {};
     const config = {};

+ 92 - 0
apps/app/src/server/service/page.ts

@@ -1963,6 +1963,98 @@ class PageService {
     }
     }
   }
   }
 
 
+  /**
+   * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
+   * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
+   *
+   * @param {string} userHomepagePath - The path of the user's homepage.
+   * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
+   * @throws {Error} - If an error occurs during the deletion process.
+   */
+  async deleteCompletelyUserHomeBySystem(userHomepagePath: string): Promise<void> {
+    const Page = this.crowi.model('Page');
+    const userHomepage = await Page.findByPath(userHomepagePath, true);
+
+    if (userHomepage == null) {
+      logger.error('user homepage is not found.');
+      return;
+    }
+
+    const shouldUseV4Process = this.shouldUseV4Process(userHomepage);
+
+    const ids = [userHomepage._id];
+    const paths = [userHomepage.path];
+
+    try {
+      if (!shouldUseV4Process) {
+        // Ensure consistency of ancestors
+        const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
+        await this.updateDescendantCountOfAncestors(userHomepage.parent, inc, true);
+      }
+
+      // Delete the user's homepage
+      await this.deleteCompletelyOperation(ids, paths);
+
+      if (!shouldUseV4Process) {
+        // Remove leaf empty pages
+        await Page.removeLeafEmptyPagesRecursively(userHomepage.parent);
+      }
+
+      if (!userHomepage.isEmpty) {
+        // Emit an event for the search service
+        this.pageEvent.emit('deleteCompletely', userHomepage);
+      }
+
+      const { PageQueryBuilder } = Page;
+
+      // Find descendant pages with system deletion condition
+      const builder = new PageQueryBuilder(Page.find(), true)
+        .addConditionForSystemDeletion()
+        .addConditionToListOnlyDescendants(userHomepage.path);
+
+      // Stream processing to delete descendant pages
+      // ────────┤ start │─────────
+      const readStream = await builder
+        .query
+        .lean()
+        .cursor({ batchSize: BULK_REINDEX_SIZE });
+
+      let count = 0;
+
+      const deleteMultipleCompletely = this.deleteMultipleCompletely.bind(this);
+      const writeStream = new Writable({
+        objectMode: true,
+        async write(batch, encoding, callback) {
+          try {
+            count += batch.length;
+            // Delete multiple pages completely
+            await deleteMultipleCompletely(batch, null, {});
+            logger.debug(`Adding pages progressing: (count=${count})`);
+          }
+          catch (err) {
+            logger.error('addAllPages error on add anyway: ', err);
+          }
+          callback();
+        },
+        final(callback) {
+          logger.debug(`Adding pages has completed: (totalCount=${count})`);
+          callback();
+        },
+      });
+
+      readStream
+        .pipe(createBatchStream(BULK_REINDEX_SIZE))
+        .pipe(writeStream);
+
+      await streamToPromise(writeStream);
+      // ────────┤ end │─────────
+    }
+    catch (err) {
+      logger.error('Error occurred while deleting user homepage and subpages.', err);
+      throw err;
+    }
+  }
+
   // use the same process in both v4 and v5
   // use the same process in both v4 and v5
   private async revertDeletedDescendants(pages, user) {
   private async revertDeletedDescendants(pages, user) {
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');

+ 5 - 5
packages/core/src/utils/page-path-utils/index.ts

@@ -25,10 +25,10 @@ export const isPermalink = (path: string): boolean => {
 };
 };
 
 
 /**
 /**
- * Whether path is user's home page
+ * Whether path is user's homepage
  * @param path
  * @param path
  */
  */
-export const isUsersHomePage = (path: string): boolean => {
+export const isUsersHomepage = (path: string): boolean => {
   // https://regex101.com/r/utVQct/1
   // https://regex101.com/r/utVQct/1
   if (path.match(/^\/user\/[^/]+$/)) {
   if (path.match(/^\/user\/[^/]+$/)) {
     return true;
     return true;
@@ -41,7 +41,7 @@ export const isUsersHomePage = (path: string): boolean => {
  * @param path
  * @param path
  */
  */
 export const isUsersProtectedPages = (path: string): boolean => {
 export const isUsersProtectedPages = (path: string): boolean => {
-  return isUsersTopPage(path) || isUsersHomePage(path);
+  return isUsersTopPage(path) || isUsersHomepage(path);
 };
 };
 
 
 /**
 /**
@@ -121,11 +121,11 @@ export const isCreatablePage = (path: string): boolean => {
 };
 };
 
 
 /**
 /**
- * return user path
+ * return user's homepage path
  * @param user
  * @param user
  */
  */
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const userPageRoot = (user: any): string => {
+export const userHomepagePath = (user: any): string => {
   if (!user || !user.username) {
   if (!user || !user.username) {
     return '';
     return '';
   }
   }

+ 1 - 3
packages/ui/src/components/UserPicture.tsx

@@ -11,8 +11,6 @@ import type { UncontrolledTooltipProps } from 'reactstrap';
 
 
 const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
 const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
 
 
-const { userPageRoot } = pagePathUtils;
-
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
 
 
 
@@ -30,7 +28,7 @@ const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootProps
   const router = useRouter();
   const router = useRouter();
 
 
   const { user } = props;
   const { user } = props;
-  const href = userPageRoot(user);
+  const href = pagePathUtils.userHomepagePath(user);
 
 
   const clickHandler = useCallback(() => {
   const clickHandler = useCallback(() => {
     router.push(href);
     router.push(href);