Browse Source

Merge pull request #10708 from growilabs/feat/175627-admin-can-hide-user-pages

feat: New admin setting for hiding user pages
mergify[bot] 2 months ago
parent
commit
4008f0f8d7
33 changed files with 459 additions and 122 deletions
  1. 5 0
      apps/app/public/static/locales/en_US/admin.json
  2. 5 0
      apps/app/public/static/locales/fr_FR/admin.json
  3. 5 0
      apps/app/public/static/locales/ja_JP/admin.json
  4. 5 0
      apps/app/public/static/locales/ko_KR/admin.json
  5. 5 0
      apps/app/public/static/locales/zh_CN/admin.json
  6. 46 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/UserPageVisibilitySettings.tsx
  7. 7 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx
  8. 9 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  9. 6 1
      apps/app/src/features/search/client/components/PrivateLegacyPages.tsx
  10. 25 18
      apps/app/src/features/search/client/components/SearchPage/SearchControl.tsx
  11. 17 16
      apps/app/src/features/search/client/components/SearchPage/SearchOptionModal/SearchOptionModal.tsx
  12. 1 0
      apps/app/src/features/search/client/components/SearchPage/SearchOptionModal/dynamic.tsx
  13. 8 1
      apps/app/src/features/search/client/components/SearchPage/SearchPage.tsx
  14. 29 0
      apps/app/src/pages/[[...path]]/page-data-props.ts
  15. 3 0
      apps/app/src/pages/_search/get-server-side-props/index.ts
  16. 1 0
      apps/app/src/pages/_search/types.ts
  17. 2 0
      apps/app/src/pages/_search/use-hydrate-server-configurations.ts
  18. 3 1
      apps/app/src/pages/general-page/configuration-props.ts
  19. 2 0
      apps/app/src/pages/general-page/hydrate.ts
  20. 1 0
      apps/app/src/pages/general-page/types.ts
  21. 21 0
      apps/app/src/server/models/page.ts
  22. 15 1
      apps/app/src/server/routes/apiv3/page-listing.ts
  23. 21 1
      apps/app/src/server/routes/apiv3/page/index.ts
  24. 4 0
      apps/app/src/server/routes/apiv3/pages/index.js
  25. 13 0
      apps/app/src/server/routes/apiv3/security-settings/index.js
  26. 4 0
      apps/app/src/server/service/config-manager/config-definition.ts
  27. 10 0
      apps/app/src/server/service/page/index.ts
  28. 5 0
      apps/app/src/states/server-configurations/server-configurations.ts
  29. 6 0
      packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts
  30. 2 1
      packages/remark-lsx/src/server/index.ts
  31. 5 0
      packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts
  32. 91 13
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  33. 77 69
      packages/remark-lsx/src/server/routes/list-pages/index.ts

+ 5 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -56,6 +56,11 @@
       "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",
       "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."
       "desc": "You will be able to delete a deleted user's homepage."
     },
     },
+    "user_page_visibility": {
+      "user_page_visibility": "User page visibility",
+      "hide_user_pages": "Hide user pages",
+      "desc": "When enabled, all pages under /user will be hidden. Accessing hidden pages will return a 403 error, and they will not appear in page lists or search results."
+    },
     "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)",

+ 5 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -56,6 +56,11 @@
       "enable_force_delete_user_homepage_on_user_deletion": "Supprimer la page d'accueil et ses pages enfants",
       "enable_force_delete_user_homepage_on_user_deletion": "Supprimer la page d'accueil et ses pages enfants",
       "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
       "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
     },
     },
+    "user_page_visibility": {
+      "user_page_visibility": "Visibilité de la page utilisateur",
+      "hide_user_pages": "Masquer les pages utilisateur",
+      "desc": "Lorsque cette option est activée, toutes les pages sous /user seront masquées. L'accès aux pages masquées renverra une erreur 403, et elles n'apparaîtront pas dans les listes de pages ni dans les résultats de recherche."
+    },
     "session": "Session",
     "session": "Session",
     "max_age": "Âge maximal (ms)",
     "max_age": "Âge maximal (ms)",
     "max_age_desc": "Spécifie (en milliseconde) l'âge maximal d'une session <br>Par défaut: 2592000000 (30 jours)",
     "max_age_desc": "Spécifie (en milliseconde) l'âge maximal d'une session <br>Par défaut: 2592000000 (30 jours)",

+ 5 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -65,6 +65,11 @@
       "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
       "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
       "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
       "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
     },
     },
+    "user_page_visibility": {
+      "user_page_visibility": "ユーザーページの表示/非表示",
+      "hide_user_pages": "ユーザーページを非表示にする",
+      "desc": "有効にすると /user 配下のページがすべて非表示になります。非表示のページにアクセスした場合は403エラーを返し、ページリストや検索結果にも表示されなくなります。"
+    },
     "session": "セッション",
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",
     "max_age": "有効期間 (ミリ秒)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",
     "max_age_desc": "ユーザーのセッション情報の有効期間をミリ秒で指定できます。<br>デフォルト値: 2592000000 (30日間)",

+ 5 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -56,6 +56,11 @@
       "enable_force_delete_user_homepage_on_user_deletion": "사용자를 삭제할 때, 사용자의 홈페이지와 모든 하위 페이지가 완전히 삭제됩니다.",
       "enable_force_delete_user_homepage_on_user_deletion": "사용자를 삭제할 때, 사용자의 홈페이지와 모든 하위 페이지가 완전히 삭제됩니다.",
       "desc": "삭제된 사용자의 홈페이지를 삭제할 수 있습니다."
       "desc": "삭제된 사용자의 홈페이지를 삭제할 수 있습니다."
     },
     },
+    "user_page_visibility": {
+      "user_page_visibility": "사용자 페이지 가시성",
+      "hide_user_pages": "사용자 페이지 숨기기",
+      "desc": "활성화하면 /user 하위의 모든 페이지가 숨겨집니다. 숨겨진 페이지에 접근하면 403 오류가 반환되며, 페이지 목록이나 검색 결과에도 표시되지 않습니다."
+    },
     "session": "세션",
     "session": "세션",
     "max_age": "최대 수명 (밀리초)",
     "max_age": "최대 수명 (밀리초)",
     "max_age_desc": "사용자 세션이 만료되는 시간(밀리초)을 지정합니다.<br>기본값: 2592000000 (30일)",
     "max_age_desc": "사용자 세션이 만료되는 시간(밀리초)을 지정합니다.<br>기본값: 2592000000 (30일)",

+ 5 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -65,6 +65,11 @@
       "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除",
       "enable_force_delete_user_homepage_on_user_deletion": "删除用户时,该用户的主页及其所有子页面将被完全删除",
       "desc": "您可以删除已删除用户的主页。"
       "desc": "您可以删除已删除用户的主页。"
     },
     },
+    "user_page_visibility": {
+      "user_page_visibility": "用户页面可见性",
+      "hide_user_pages": "隐藏用户页面",
+      "desc": "启用后,/user 下的所有页面都将被隐藏。访问隐藏页面时将返回403错误,并且这些页面不会出现在页面列表或搜索结果中。"
+    },
     "session": "会议",
     "session": "会议",
     "max_age": "有效期间  (msec)",
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",

+ 46 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/UserPageVisibilitySettings.tsx

@@ -0,0 +1,46 @@
+/* eslint-disable react/no-danger */
+import type React from 'react';
+
+import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const UserPageVisibilitySettings: React.FC<Props> = ({
+  adminGeneralSecurityContainer,
+  t,
+}) => {
+  return (
+    <>
+      <h4 className="mb-3">
+        {t('security_settings.user_page_visibility.user_page_visibility')}
+      </h4>
+      <div className="row mb-4">
+        <div className="col-md-10 offset-md-2">
+          <div className="form-check form-switch form-check-success">
+            <input
+              type="checkbox"
+              className="form-check-input"
+              id="is-user-pages-visible"
+              checked={adminGeneralSecurityContainer.state.isHidingUserPages}
+              onChange={() => {
+                adminGeneralSecurityContainer.changeUserPageVisibility();
+              }}
+            />
+            <label
+              className="form-label form-check-label"
+              htmlFor="is-user-pages-visible"
+            >
+              {t('security_settings.user_page_visibility.hide_user_pages')}
+            </label>
+          </div>
+          <p className="form-text text-muted small mt-2">
+            {t('security_settings.user_page_visibility.desc')}
+          </p>
+        </div>
+      </div>
+    </>
+  );
+};

+ 7 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx

@@ -13,6 +13,7 @@ import { PageDeleteRightsSettings } from './PageDeleteRightsSettings';
 import { PageListDisplaySettings } from './PageListDisplaySettings';
 import { PageListDisplaySettings } from './PageListDisplaySettings';
 import { SessionMaxAgeSettings } from './SessionMaxAgeSettings';
 import { SessionMaxAgeSettings } from './SessionMaxAgeSettings';
 import { UserHomepageDeletionSettings } from './UserHomepageDeletionSettings';
 import { UserHomepageDeletionSettings } from './UserHomepageDeletionSettings';
+import { UserPageVisibilitySettings } from './UserPageVisibilitySettings';
 
 
 type FormData = {
 type FormData = {
   sessionMaxAge: string;
   sessionMaxAge: string;
@@ -63,6 +64,8 @@ const SecuritySettingComponent: React.FC<Props> = ({
           hideRestrictedByOwner:
           hideRestrictedByOwner:
             adminGeneralSecurityContainer.state
             adminGeneralSecurityContainer.state
               .currentOwnerRestrictionDisplayMode === 'Hidden',
               .currentOwnerRestrictionDisplayMode === 'Hidden',
+          isHidingUserPages:
+            adminGeneralSecurityContainer.state.isHidingUserPages,
           isUsersHomepageDeletionEnabled:
           isUsersHomepageDeletionEnabled:
             adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled,
             adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled,
           isForceDeleteUserHomepageOnUserDeletion:
           isForceDeleteUserHomepageOnUserDeletion:
@@ -114,6 +117,10 @@ const SecuritySettingComponent: React.FC<Props> = ({
             adminGeneralSecurityContainer={adminGeneralSecurityContainer}
             adminGeneralSecurityContainer={adminGeneralSecurityContainer}
             t={t}
             t={t}
           />
           />
+          <UserPageVisibilitySettings
+            adminGeneralSecurityContainer={adminGeneralSecurityContainer}
+            t={t}
+          />
           <CommentManageRightsSettings
           <CommentManageRightsSettings
             adminGeneralSecurityContainer={adminGeneralSecurityContainer}
             adminGeneralSecurityContainer={adminGeneralSecurityContainer}
             t={t}
             t={t}

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

@@ -45,6 +45,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       expandOtherOptionsForCompleteDeletion: false,
       expandOtherOptionsForCompleteDeletion: false,
       isShowRestrictedByOwner: false,
       isShowRestrictedByOwner: false,
       isUsersHomepageDeletionEnabled: false,
       isUsersHomepageDeletionEnabled: false,
+      isHidingUserPages: false,
       isForceDeleteUserHomepageOnUserDeletion: false,
       isForceDeleteUserHomepageOnUserDeletion: false,
       isRomUserAllowedToComment: false,
       isRomUserAllowedToComment: false,
       isLocalEnabled: false,
       isLocalEnabled: false,
@@ -67,6 +68,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       this.changeGroupRestrictionDisplayMode.bind(this);
       this.changeGroupRestrictionDisplayMode.bind(this);
     this.changePageDeletionAuthority =
     this.changePageDeletionAuthority =
       this.changePageDeletionAuthority.bind(this);
       this.changePageDeletionAuthority.bind(this);
+    this.changeUserPageVisibility = this.changeUserPageVisibility.bind(this);
     this.changePageCompleteDeletionAuthority =
     this.changePageCompleteDeletionAuthority =
       this.changePageCompleteDeletionAuthority.bind(this);
       this.changePageCompleteDeletionAuthority.bind(this);
     this.changePageRecursiveDeletionAuthority =
     this.changePageRecursiveDeletionAuthority =
@@ -105,6 +107,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isForceDeleteUserHomepageOnUserDeletion:
       isForceDeleteUserHomepageOnUserDeletion:
         generalSetting.isForceDeleteUserHomepageOnUserDeletion,
         generalSetting.isForceDeleteUserHomepageOnUserDeletion,
       isRomUserAllowedToComment: generalSetting.isRomUserAllowedToComment,
       isRomUserAllowedToComment: generalSetting.isRomUserAllowedToComment,
+      isHidingUserPages: generalSetting.isHidingUserPages,
       sessionMaxAge: generalSetting.sessionMaxAge,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -176,6 +179,10 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ currentPageDeletionAuthority: val });
     this.setState({ currentPageDeletionAuthority: val });
   }
   }
 
 
+  changeUserPageVisibility() {
+    this.setState({ isHidingUserPages: !this.state.isHidingUserPages });
+  }
+
   /**
   /**
    * Change pageCompleteDeletionAuthority
    * Change pageCompleteDeletionAuthority
    */
    */
@@ -284,6 +291,7 @@ export default class AdminGeneralSecurityContainer extends Container {
               formData.isAllGroupMembershipRequiredForPageCompleteDeletion,
               formData.isAllGroupMembershipRequiredForPageCompleteDeletion,
             hideRestrictedByGroup: formData.hideRestrictedByGroup,
             hideRestrictedByGroup: formData.hideRestrictedByGroup,
             hideRestrictedByOwner: formData.hideRestrictedByOwner,
             hideRestrictedByOwner: formData.hideRestrictedByOwner,
+            isHidingUserPages: formData.isHidingUserPages,
             isUsersHomepageDeletionEnabled:
             isUsersHomepageDeletionEnabled:
               formData.isUsersHomepageDeletionEnabled,
               formData.isUsersHomepageDeletionEnabled,
             isForceDeleteUserHomepageOnUserDeletion:
             isForceDeleteUserHomepageOnUserDeletion:
@@ -306,6 +314,7 @@ export default class AdminGeneralSecurityContainer extends Container {
               this.state.currentGroupRestrictionDisplayMode === 'Hidden',
               this.state.currentGroupRestrictionDisplayMode === 'Hidden',
             hideRestrictedByOwner:
             hideRestrictedByOwner:
               this.state.currentOwnerRestrictionDisplayMode === 'Hidden',
               this.state.currentOwnerRestrictionDisplayMode === 'Hidden',
+            isHidingUserPages: this.state.isHidingUserPages,
             isUsersHomepageDeletionEnabled:
             isUsersHomepageDeletionEnabled:
               this.state.isUsersHomepageDeletionEnabled,
               this.state.isUsersHomepageDeletionEnabled,
             isForceDeleteUserHomepageOnUserDeletion:
             isForceDeleteUserHomepageOnUserDeletion:

+ 6 - 1
apps/app/src/features/search/client/components/PrivateLegacyPages.tsx

@@ -7,6 +7,7 @@ import React, {
   useState,
   useState,
 } from 'react';
 } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   DropdownItem,
   DropdownItem,
@@ -35,6 +36,7 @@ import type { PageMigrationErrorData } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import { useIsAdmin } from '~/states/context';
 import { useIsAdmin } from '~/states/context';
 import { useSearchKeyword, useSetSearchKeyword } from '~/states/search';
 import { useSearchKeyword, useSetSearchKeyword } from '~/states/search';
+import { isHidingUserPagesAtom } from '~/states/server-configurations';
 import { useGlobalSocket } from '~/states/socket-io';
 import { useGlobalSocket } from '~/states/socket-io';
 import type { ILegacyPrivatePage } from '~/states/ui/modal/private-legacy-pages-migration';
 import type { ILegacyPrivatePage } from '~/states/ui/modal/private-legacy-pages-migration';
 import { usePrivateLegacyPagesMigrationModalActions } from '~/states/ui/modal/private-legacy-pages-migration';
 import { usePrivateLegacyPagesMigrationModalActions } from '~/states/ui/modal/private-legacy-pages-migration';
@@ -268,6 +270,8 @@ const PrivateLegacyPages = (): JSX.Element => {
   const keyword = useSearchKeyword();
   const keyword = useSearchKeyword();
   const setSearchKeyword = useSetSearchKeyword('/_private-legacy-pages');
   const setSearchKeyword = useSetSearchKeyword('/_private-legacy-pages');
 
 
+  const isHidingUserPages = useAtomValue(isHidingUserPagesAtom);
+
   const [offset, setOffset] = useState<number>(0);
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
   const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
   const [isOpenConvertModal, setOpenConvertModal] = useState<boolean>(false);
   const [isOpenConvertModal, setOpenConvertModal] = useState<boolean>(false);
@@ -515,6 +519,7 @@ const PrivateLegacyPages = (): JSX.Element => {
   const searchControl = useMemo(() => {
   const searchControl = useMemo(() => {
     return (
     return (
       <SearchControl
       <SearchControl
+        isHidingUserPages={isHidingUserPages}
         isEnableSort={false}
         isEnableSort={false}
         isEnableFilter={false}
         isEnableFilter={false}
         initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
         initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
@@ -522,7 +527,7 @@ const PrivateLegacyPages = (): JSX.Element => {
         extraControls={extraControls}
         extraControls={extraControls}
       />
       />
     );
     );
-  }, [searchInvokedHandler, extraControls]);
+  }, [searchInvokedHandler, extraControls, isHidingUserPages]);
 
 
   const searchResultListHead = useMemo(() => {
   const searchResultListHead = useMemo(() => {
     if (data == null) {
     if (data == null) {

+ 25 - 18
apps/app/src/features/search/client/components/SearchPage/SearchControl.tsx

@@ -12,6 +12,7 @@ import SortControl from './SortControl';
 type Props = {
 type Props = {
   isEnableSort: boolean;
   isEnableSort: boolean;
   isEnableFilter: boolean;
   isEnableFilter: boolean;
+  isHidingUserPages: boolean;
   initialSearchConditions: Partial<ISearchConditions>;
   initialSearchConditions: Partial<ISearchConditions>;
 
 
   onSearchInvoked?: (
   onSearchInvoked?: (
@@ -29,6 +30,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
   const {
   const {
     isEnableSort,
     isEnableSort,
     isEnableFilter,
     isEnableFilter,
+    isHidingUserPages,
     initialSearchConditions,
     initialSearchConditions,
     onSearchInvoked,
     onSearchInvoked,
     extraControls,
     extraControls,
@@ -152,25 +154,29 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
               </button>
               </button>
             </div>
             </div>
             <div className="d-none d-lg-flex align-items-center search-control-include-options">
             <div className="d-none d-lg-flex align-items-center search-control-include-options">
-              <div className="px-2 py-1">
-                <div className="form-check form-check-succsess">
-                  <input
-                    className="form-check-input me-2"
-                    type="checkbox"
-                    id="flexCheckDefault"
-                    defaultChecked={includeUserPages}
-                    onChange={(e) =>
-                      changeIncludeUserPagesHandler(e.target.checked)
-                    }
-                  />
-                  <label
-                    className="form-label form-check-label mb-0 d-flex align-items-center text-secondary with-no-font-weight"
-                    htmlFor="flexCheckDefault"
-                  >
-                    {t('Include Subordinated Target Page', { target: '/user' })}
-                  </label>
+              {isHidingUserPages === false && (
+                <div className="px-2 py-1">
+                  <div className="form-check form-check-succsess">
+                    <input
+                      className="form-check-input me-2"
+                      type="checkbox"
+                      id="flexCheckDefault"
+                      defaultChecked={includeUserPages}
+                      onChange={(e) =>
+                        changeIncludeUserPagesHandler(e.target.checked)
+                      }
+                    />
+                    <label
+                      className="form-label form-check-label mb-0 d-flex align-items-center text-secondary with-no-font-weight"
+                      htmlFor="flexCheckDefault"
+                    >
+                      {t('Include Subordinated Target Page', {
+                        target: '/user',
+                      })}
+                    </label>
+                  </div>
                 </div>
                 </div>
-              </div>
+              )}
               <div className="px-2 py-1">
               <div className="px-2 py-1">
                 <div className="form-check form-check-succsess">
                 <div className="form-check form-check-succsess">
                   <input
                   <input
@@ -206,6 +212,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
       <SearchOptionModalLazyLoaded
       <SearchOptionModalLazyLoaded
         isOpen={isFileterOptionModalShown || false}
         isOpen={isFileterOptionModalShown || false}
         onClose={() => setIsFileterOptionModalShown(false)}
         onClose={() => setIsFileterOptionModalShown(false)}
+        isHidingUserPages={isHidingUserPages}
         includeUserPages={includeUserPages}
         includeUserPages={includeUserPages}
         includeTrashPages={includeTrashPages}
         includeTrashPages={includeTrashPages}
         onIncludeUserPagesSwitched={setIncludeUserPages}
         onIncludeUserPagesSwitched={setIncludeUserPages}

+ 17 - 16
apps/app/src/features/search/client/components/SearchPage/SearchOptionModal/SearchOptionModal.tsx

@@ -6,6 +6,7 @@ type Props = {
   isOpen: boolean;
   isOpen: boolean;
   includeUserPages: boolean;
   includeUserPages: boolean;
   includeTrashPages: boolean;
   includeTrashPages: boolean;
+  isHidingUserPages: boolean;
   onClose?: () => void;
   onClose?: () => void;
   onIncludeUserPagesSwitched?: (isChecked: boolean) => void;
   onIncludeUserPagesSwitched?: (isChecked: boolean) => void;
   onIncludeTrashPagesSwitched?: (isChecked: boolean) => void;
   onIncludeTrashPagesSwitched?: (isChecked: boolean) => void;
@@ -18,6 +19,7 @@ export const SearchOptionModal: FC<Props> = (props: Props) => {
     isOpen,
     isOpen,
     includeUserPages,
     includeUserPages,
     includeTrashPages,
     includeTrashPages,
+    isHidingUserPages,
     onClose,
     onClose,
     onIncludeUserPagesSwitched,
     onIncludeUserPagesSwitched,
     onIncludeTrashPagesSwitched,
     onIncludeTrashPagesSwitched,
@@ -31,9 +33,9 @@ export const SearchOptionModal: FC<Props> = (props: Props) => {
   }, [onClose]);
   }, [onClose]);
 
 
   const includeUserPagesChangeHandler = useCallback(
   const includeUserPagesChangeHandler = useCallback(
-    (isChecked: boolean) => {
+    (e: React.ChangeEvent<HTMLInputElement>) => {
       if (onIncludeUserPagesSwitched != null) {
       if (onIncludeUserPagesSwitched != null) {
-        onIncludeUserPagesSwitched(isChecked);
+        onIncludeUserPagesSwitched(e.target.checked);
       }
       }
     },
     },
     [onIncludeUserPagesSwitched],
     [onIncludeUserPagesSwitched],
@@ -55,20 +57,19 @@ export const SearchOptionModal: FC<Props> = (props: Props) => {
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <div className="d-flex p-2">
         <div className="d-flex p-2">
-          <div className="me-3">
-            <label className="form-label px-3 py-2 mb-0 d-flex align-items-center">
-              <input
-                className="me-2"
-                type="checkbox"
-                onChange={useCallback(
-                  (e) => includeUserPagesChangeHandler(e.target.checked),
-                  [includeUserPagesChangeHandler],
-                )}
-                checked={includeUserPages}
-              />
-              {t('Include Subordinated Target Page', { target: '/user' })}
-            </label>
-          </div>
+          {!isHidingUserPages && (
+            <div className="me-3">
+              <label className="form-label px-3 py-2 mb-0 d-flex align-items-center">
+                <input
+                  className="me-2"
+                  type="checkbox"
+                  onChange={includeUserPagesChangeHandler}
+                  checked={includeUserPages}
+                />
+                {t('Include Subordinated Target Page', { target: '/user' })}
+              </label>
+            </div>
+          )}
           <div className="">
           <div className="">
             <label className="form-label px-3 py-2 mb-0 d-flex align-items-center">
             <label className="form-label px-3 py-2 mb-0 d-flex align-items-center">
               <input
               <input

+ 1 - 0
apps/app/src/features/search/client/components/SearchPage/SearchOptionModal/dynamic.tsx

@@ -6,6 +6,7 @@ type SearchOptionModalProps = {
   isOpen: boolean;
   isOpen: boolean;
   includeUserPages: boolean;
   includeUserPages: boolean;
   includeTrashPages: boolean;
   includeTrashPages: boolean;
+  isHidingUserPages: boolean;
   onClose?: () => void;
   onClose?: () => void;
   onIncludeUserPagesSwitched?: (isChecked: boolean) => void;
   onIncludeUserPagesSwitched?: (isChecked: boolean) => void;
   onIncludeTrashPagesSwitched?: (isChecked: boolean) => void;
   onIncludeTrashPagesSwitched?: (isChecked: boolean) => void;

+ 8 - 1
apps/app/src/features/search/client/components/SearchPage/SearchPage.tsx

@@ -11,7 +11,10 @@ import type {
 } from '~/client/interfaces/selectable-all';
 } from '~/client/interfaces/selectable-all';
 import type { IFormattedSearchResult } from '~/interfaces/search';
 import type { IFormattedSearchResult } from '~/interfaces/search';
 import { useSearchKeyword, useSetSearchKeyword } from '~/states/search';
 import { useSearchKeyword, useSetSearchKeyword } from '~/states/search';
-import { showPageLimitationLAtom } from '~/states/server-configurations';
+import {
+  isHidingUserPagesAtom,
+  showPageLimitationLAtom,
+} from '~/states/server-configurations';
 import {
 import {
   type ISearchConditions,
   type ISearchConditions,
   type ISearchConfigurations,
   type ISearchConfigurations,
@@ -106,6 +109,8 @@ export const SearchPage = (): JSX.Element => {
   const keyword = useSearchKeyword();
   const keyword = useSearchKeyword();
   const setSearchKeyword = useSetSearchKeyword();
   const setSearchKeyword = useSetSearchKeyword();
 
 
+  const isHidingUserPages = useAtomValue(isHidingUserPagesAtom);
+
   const [offset, setOffset] = useState<number>(0);
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(
   const [limit, setLimit] = useState<number>(
     showPageLimitationL ?? INITIAL_PAGIONG_SIZE,
     showPageLimitationL ?? INITIAL_PAGIONG_SIZE,
@@ -286,6 +291,7 @@ export const SearchPage = (): JSX.Element => {
       <SearchControl
       <SearchControl
         isEnableSort
         isEnableSort
         isEnableFilter
         isEnableFilter
+        isHidingUserPages={isHidingUserPages}
         initialSearchConditions={initialSearchConditions}
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
         onSearchInvoked={searchInvokedHandler}
         extraControls={extraControls}
         extraControls={extraControls}
@@ -298,6 +304,7 @@ export const SearchPage = (): JSX.Element => {
     collapseContents,
     collapseContents,
     initialSearchConditions,
     initialSearchConditions,
     isCollapsed,
     isCollapsed,
+    isHidingUserPages,
     searchInvokedHandler,
     searchInvokedHandler,
   ]);
   ]);
 
 

+ 29 - 0
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -10,6 +10,8 @@ import { isIPageInfo, isIPageNotFoundInfo } from '@growi/core';
 import {
 import {
   isPermalink as _isPermalink,
   isPermalink as _isPermalink,
   isTopPage,
   isTopPage,
+  isUserPage,
+  isUsersTopPage,
 } from '@growi/core/dist/utils/page-path-utils';
 } from '@growi/core/dist/utils/page-path-utils';
 import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import assert from 'assert';
 import assert from 'assert';
@@ -162,6 +164,33 @@ export async function getPageDataForInitial(
     { pageId, path: resolvedPagePath, user },
     { pageId, path: resolvedPagePath, user },
   );
   );
 
 
+  const isHidingUserPages = configManager.getConfig(
+    'security:isHidingUserPages',
+  );
+
+  if (isHidingUserPages && pageWithMeta.data != null) {
+    const pagePath = pageWithMeta.data.path;
+    const isTargetUserPage = isUserPage(pagePath) || isUsersTopPage(pagePath);
+
+    if (isTargetUserPage) {
+      return {
+        props: {
+          currentPathname: resolvedPagePath,
+          isIdenticalPathPage: false,
+          pageWithMeta: {
+            data: null,
+            meta: {
+              isNotFound: true,
+              isForbidden: true,
+            },
+          } satisfies IDataWithRequiredMeta<null, IPageNotFoundInfo>,
+          skipSSR: false,
+          redirectFrom,
+        },
+      };
+    }
+  }
+
   // Handle URL conversion
   // Handle URL conversion
   const currentPathname = resolveFinalizedPathname(
   const currentPathname = resolveFinalizedPathname(
     resolvedPagePath,
     resolvedPagePath,

+ 3 - 0
apps/app/src/pages/_search/get-server-side-props/index.ts

@@ -31,6 +31,9 @@ const getServerSideConfigurationProps: GetServerSideProps<
         showPageLimitationL: configManager.getConfig(
         showPageLimitationL: configManager.getConfig(
           'customize:showPageLimitationL',
           'customize:showPageLimitationL',
         ),
         ),
+        isHidingUserPages: configManager.getConfig(
+          'security:isHidingUserPages',
+        ),
       },
       },
     },
     },
   };
   };

+ 1 - 0
apps/app/src/pages/_search/types.ts

@@ -2,5 +2,6 @@ export type ServerConfigurationProps = {
   serverConfig: {
   serverConfig: {
     isContainerFluid: boolean;
     isContainerFluid: boolean;
     showPageLimitationL: number;
     showPageLimitationL: number;
+    isHidingUserPages: boolean;
   };
   };
 };
 };

+ 2 - 0
apps/app/src/pages/_search/use-hydrate-server-configurations.ts

@@ -3,6 +3,7 @@ import { useHydrateAtoms } from 'jotai/utils';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
 import {
   isContainerFluidAtom,
   isContainerFluidAtom,
+  isHidingUserPagesAtom,
   rendererConfigAtom,
   rendererConfigAtom,
   showPageLimitationLAtom,
   showPageLimitationLAtom,
 } from '~/states/server-configurations';
 } from '~/states/server-configurations';
@@ -25,6 +26,7 @@ export const useHydrateServerConfigurationAtoms = (
           [isContainerFluidAtom, serverConfig.isContainerFluid],
           [isContainerFluidAtom, serverConfig.isContainerFluid],
           [showPageLimitationLAtom, serverConfig.showPageLimitationL],
           [showPageLimitationLAtom, serverConfig.showPageLimitationL],
           [rendererConfigAtom, rendererConfigs],
           [rendererConfigAtom, rendererConfigs],
+          [isHidingUserPagesAtom, serverConfig.isHidingUserPages],
         ],
         ],
   );
   );
 };
 };

+ 3 - 1
apps/app/src/pages/general-page/configuration-props.ts

@@ -28,7 +28,6 @@ export const getServerSideRendererConfigProps: GetServerSideProps<
         isIndentSizeForced: configManager.getConfig(
         isIndentSizeForced: configManager.getConfig(
           'markdown:isIndentSizeForced',
           'markdown:isIndentSizeForced',
         ),
         ),
-
         drawioUri: configManager.getConfig('app:drawioUri'),
         drawioUri: configManager.getConfig('app:drawioUri'),
         plantumlUri: configManager.getConfig('app:plantumlUri'),
         plantumlUri: configManager.getConfig('app:plantumlUri'),
 
 
@@ -121,6 +120,9 @@ export const getServerSideGeneralPageProps: GetServerSideProps<
         isEnabledAttachTitleHeader: configManager.getConfig(
         isEnabledAttachTitleHeader: configManager.getConfig(
           'customize:isEnabledAttachTitleHeader',
           'customize:isEnabledAttachTitleHeader',
         ),
         ),
+        isHidingUserPages: configManager.getConfig(
+          'security:isHidingUserPages',
+        ),
       },
       },
     },
     },
   };
   };

+ 2 - 0
apps/app/src/pages/general-page/hydrate.ts

@@ -13,6 +13,7 @@ import {
   isContainerFluidAtom,
   isContainerFluidAtom,
   isEnabledAttachTitleHeaderAtom,
   isEnabledAttachTitleHeaderAtom,
   isEnabledStaleNotificationAtom,
   isEnabledStaleNotificationAtom,
+  isHidingUserPagesAtom,
   isIndentSizeForcedAtom,
   isIndentSizeForcedAtom,
   isLocalAccountRegistrationEnabledAtom,
   isLocalAccountRegistrationEnabledAtom,
   isPdfBulkExportEnabledAtom,
   isPdfBulkExportEnabledAtom,
@@ -84,6 +85,7 @@ export const useHydrateGeneralPageConfigurationAtoms = (
             serverConfig.isLocalAccountRegistrationEnabled,
             serverConfig.isLocalAccountRegistrationEnabled,
           ],
           ],
           [rendererConfigAtom, rendererConfigs],
           [rendererConfigAtom, rendererConfigs],
+          [isHidingUserPagesAtom, serverConfig.isHidingUserPages],
         ],
         ],
   );
   );
 };
 };

+ 1 - 0
apps/app/src/pages/general-page/types.ts

@@ -32,6 +32,7 @@ export type ServerConfigurationProps = {
     isEnabledStaleNotification: boolean;
     isEnabledStaleNotification: boolean;
     disableLinkSharing: boolean;
     disableLinkSharing: boolean;
     isIndentSizeForced: boolean;
     isIndentSizeForced: boolean;
+    isHidingUserPages: boolean;
     isEnabledAttachTitleHeader: boolean;
     isEnabledAttachTitleHeader: boolean;
     isSlackConfigured: boolean;
     isSlackConfigured: boolean;
     isAclEnabled: boolean;
     isAclEnabled: boolean;

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

@@ -89,6 +89,7 @@ export type FindRecentUpdatedPagesOption = {
   desc: number;
   desc: number;
   hideRestrictedByOwner: boolean;
   hideRestrictedByOwner: boolean;
   hideRestrictedByGroup: boolean;
   hideRestrictedByGroup: boolean;
+  hideUserPages: boolean;
 };
 };
 
 
 export type CreateMethod = (
 export type CreateMethod = (
@@ -430,6 +431,22 @@ export class PageQueryBuilder {
     return this;
     return this;
   }
   }
 
 
+  addConditionToListByNotMatchPathAndChildren(str: string): PageQueryBuilder {
+    const path = normalizePath(str);
+
+    if (isTopPage(path)) {
+      return this;
+    }
+
+    const startsPattern = escapeStringRegexp(path);
+
+    this.query = this.query.and({
+      path: { $not: new RegExp(`^${startsPattern}(/|$)`) },
+    });
+
+    return this;
+  }
+
   addConditionToListByMatch(str: string): PageQueryBuilder {
   addConditionToListByMatch(str: string): PageQueryBuilder {
     // No request is set for "/"
     // No request is set for "/"
     if (str === '/') {
     if (str === '/') {
@@ -920,6 +937,10 @@ schema.statics.findRecentUpdatedPages = async function (
   const baseQuery = this.find({});
   const baseQuery = this.find({});
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
 
 
+  if (options.hideUserPages) {
+    queryBuilder.addConditionToListByNotMatchPathAndChildren('/user');
+  }
+
   if (!options.includeTrashed) {
   if (!options.includeTrashed) {
     queryBuilder.addConditionToExcludeTrashed();
     queryBuilder.addConditionToExcludeTrashed();
   }
   }

+ 15 - 1
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -2,6 +2,10 @@ import type { IPageInfoForListing, IUserHasId } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { type IPageInfoForEmpty, SCOPE } from '@growi/core/dist/interfaces';
 import { type IPageInfoForEmpty, SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
+import {
+  isUserPage,
+  isUsersTopPage,
+} from '@growi/core/dist/utils/page-path-utils';
 import type { Request, Router } from 'express';
 import type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
 import { oneOf, query } from 'express-validator';
 import { oneOf, query } from 'express-validator';
@@ -155,15 +159,25 @@ const routerFactory = (crowi: Crowi): Router => {
       const hideRestrictedByGroup = await configManager.getConfig(
       const hideRestrictedByGroup = await configManager.getConfig(
         'security:list-policy:hideRestrictedByGroup',
         'security:list-policy:hideRestrictedByGroup',
       );
       );
+      const hideUserPages = await configManager.getConfig(
+        'security:isHidingUserPages',
+      );
 
 
       try {
       try {
-        const pages =
+        let pages =
           await pageListingService.findChildrenByParentPathOrIdAndViewer(
           await pageListingService.findChildrenByParentPathOrIdAndViewer(
             (id || path) as string,
             (id || path) as string,
             req.user,
             req.user,
             !hideRestrictedByOwner,
             !hideRestrictedByOwner,
             !hideRestrictedByGroup,
             !hideRestrictedByGroup,
           );
           );
+
+        if (hideUserPages === true) {
+          pages = pages.filter(
+            (page) => !isUserPage(page.path) && !isUsersTopPage(page.path),
+          );
+        }
+
         return res.apiv3({ children: pages });
         return res.apiv3({ children: pages });
       } catch (err) {
       } catch (err) {
         logger.error('Error occurred while finding children.', err);
         logger.error('Error occurred while finding children.', err);

+ 21 - 1
apps/app/src/server/routes/apiv3/page/index.ts

@@ -17,7 +17,11 @@ import {
   SubscriptionStatusType,
   SubscriptionStatusType,
 } from '@growi/core';
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
+import {
+  convertToNewAffiliationPath,
+  isUserPage,
+  isUsersTopPage,
+} from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
@@ -193,6 +197,10 @@ module.exports = (crowi: Crowi) => {
       const { pageId, path, findAll, revisionId, shareLinkId, includeEmpty } =
       const { pageId, path, findAll, revisionId, shareLinkId, includeEmpty } =
         req.query;
         req.query;
 
 
+      const isHidingUserPages = crowi.configManager.getConfig(
+        'security:isHidingUserPages',
+      );
+
       const respondWithSinglePage = async (
       const respondWithSinglePage = async (
         pageWithMeta:
         pageWithMeta:
           | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
           | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
@@ -219,6 +227,18 @@ module.exports = (crowi: Crowi) => {
           );
           );
         }
         }
 
 
+        if (isHidingUserPages && page != null) {
+          const isTargetUserPage =
+            isUserPage(page.path) || isUsersTopPage(page.path);
+
+          if (isTargetUserPage) {
+            return res.apiv3Err(
+              new ErrorV3('Page is forbidden', 'page-is-forbidden'),
+              403,
+            );
+          }
+        }
+
         if (page != null) {
         if (page != null) {
           try {
           try {
             page.initLatestRevisionField(revisionId);
             page.initLatestRevisionField(revisionId);

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

@@ -193,6 +193,9 @@ module.exports = (crowi) => {
       const hideRestrictedByGroup = configManager.getConfig(
       const hideRestrictedByGroup = configManager.getConfig(
         'security:list-policy:hideRestrictedByGroup',
         'security:list-policy:hideRestrictedByGroup',
       );
       );
+      const hideUserPages = configManager.getConfig(
+        'security:isHidingUserPages',
+      );
 
 
       /**
       /**
        * @type {import('~/server/models/page').FindRecentUpdatedPagesOption}
        * @type {import('~/server/models/page').FindRecentUpdatedPagesOption}
@@ -207,6 +210,7 @@ module.exports = (crowi) => {
         desc: -1,
         desc: -1,
         hideRestrictedByOwner,
         hideRestrictedByOwner,
         hideRestrictedByGroup,
         hideRestrictedByGroup,
+        hideUserPages,
       };
       };
 
 
       try {
       try {

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

@@ -47,6 +47,9 @@ const validator = {
     body('hideRestrictedByGroup')
     body('hideRestrictedByGroup')
       .if((value) => value != null)
       .if((value) => value != null)
       .isBoolean(),
       .isBoolean(),
+    body('isHidingUserPages')
+      .if((value) => value != null)
+      .isBoolean(),
     body('isUsersHomepageDeletionEnabled')
     body('isUsersHomepageDeletionEnabled')
       .if((value) => value != null)
       .if((value) => value != null)
       .isBoolean(),
       .isBoolean(),
@@ -217,6 +220,9 @@ const validator = {
  *          pageCompleteDeletionAuthority:
  *          pageCompleteDeletionAuthority:
  *            type: string
  *            type: string
  *            description: type of pageDeletionAuthority
  *            description: type of pageDeletionAuthority
+ *          isHidingUserPages:
+ *            type: boolean
+ *            description: hide all user pages from general users
  *          hideRestrictedByOwner:
  *          hideRestrictedByOwner:
  *            type: boolean
  *            type: boolean
  *            description: enable hide by owner
  *            description: enable hide by owner
@@ -505,6 +511,9 @@ module.exports = (crowi) => {
           hideRestrictedByGroup: await configManager.getConfig(
           hideRestrictedByGroup: await configManager.getConfig(
             'security:list-policy:hideRestrictedByGroup',
             'security:list-policy:hideRestrictedByGroup',
           ),
           ),
+          isHidingUserPages: await configManager.getConfig(
+            'security:isHidingUserPages',
+          ),
           isUsersHomepageDeletionEnabled: await configManager.getConfig(
           isUsersHomepageDeletionEnabled: await configManager.getConfig(
             'security:user-homepage-deletion:isEnabled',
             'security:user-homepage-deletion:isEnabled',
           ),
           ),
@@ -995,6 +1004,7 @@ module.exports = (crowi) => {
           req.body.hideRestrictedByOwner,
           req.body.hideRestrictedByOwner,
         'security:list-policy:hideRestrictedByGroup':
         'security:list-policy:hideRestrictedByGroup':
           req.body.hideRestrictedByGroup,
           req.body.hideRestrictedByGroup,
+        'security:isHidingUserPages': req.body.isHidingUserPages,
         'security:user-homepage-deletion:isEnabled':
         'security:user-homepage-deletion:isEnabled':
           req.body.isUsersHomepageDeletionEnabled,
           req.body.isUsersHomepageDeletionEnabled,
         // Validate user-homepage-deletion config
         // Validate user-homepage-deletion config
@@ -1067,6 +1077,9 @@ module.exports = (crowi) => {
           hideRestrictedByGroup: await configManager.getConfig(
           hideRestrictedByGroup: await configManager.getConfig(
             'security:list-policy:hideRestrictedByGroup',
             'security:list-policy:hideRestrictedByGroup',
           ),
           ),
+          isHidingUserPages: await configManager.getConfig(
+            'security:isHidingUserPages',
+          ),
           isUsersHomepageDeletionEnabled: await configManager.getConfig(
           isUsersHomepageDeletionEnabled: await configManager.getConfig(
             'security:user-homepage-deletion:isEnabled',
             'security:user-homepage-deletion:isEnabled',
           ),
           ),

+ 4 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -115,6 +115,7 @@ export const CONFIG_KEYS = [
   'security:pageRecursiveDeletionAuthority',
   'security:pageRecursiveDeletionAuthority',
   'security:pageRecursiveCompleteDeletionAuthority',
   'security:pageRecursiveCompleteDeletionAuthority',
   'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
   'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
+  'security:isHidingUserPages',
   'security:user-homepage-deletion:isEnabled',
   'security:user-homepage-deletion:isEnabled',
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion',
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion',
   'security:isRomUserAllowedToComment',
   'security:isRomUserAllowedToComment',
@@ -678,6 +679,9 @@ export const CONFIG_DEFINITIONS = {
     defineConfig<boolean>({
     defineConfig<boolean>({
       defaultValue: true,
       defaultValue: true,
     }),
     }),
+  'security:isHidingUserPages': defineConfig<boolean>({
+    defaultValue: false,
+  }),
   'security:user-homepage-deletion:isEnabled': defineConfig<boolean>({
   'security:user-homepage-deletion:isEnabled': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),

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

@@ -796,6 +796,16 @@ class PageService implements IPageService {
     return renamedPage;
     return renamedPage;
   }
   }
 
 
+  getExcludedPathsBySystem(): string[] {
+    const excludedPaths: string[] = [];
+
+    if (configManager.getConfig('security:isHidingUserPages')) {
+      excludedPaths.push('/user');
+    }
+
+    return excludedPaths;
+  }
+
   async renameSubOperation(
   async renameSubOperation(
     page,
     page,
     newPagePathSanitized: string,
     newPagePathSanitized: string,

+ 5 - 0
apps/app/src/states/server-configurations/server-configurations.ts

@@ -143,6 +143,11 @@ export const isBulkExportPagesEnabledAtom = atom<boolean>(false);
  */
  */
 export const isPdfBulkExportEnabledAtom = atom<boolean>(false);
 export const isPdfBulkExportEnabledAtom = atom<boolean>(false);
 
 
+/**
+ * Atom for hiding user pages setting enabled
+ */
+export const isHidingUserPagesAtom = atom<boolean>(false);
+
 /**
 /**
  * Atom for local account registration enabled
  * Atom for local account registration enabled
  */
  */

+ 6 - 0
packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts

@@ -84,6 +84,12 @@ describe('useSWRxLsx integration tests', () => {
     const mockCrowi = {
     const mockCrowi = {
       require: () => () => (req: any, res: any, next: any) => next(),
       require: () => () => (req: any, res: any, next: any) => next(),
       accessTokenParser: () => (req: any, res: any, next: any) => next(),
       accessTokenParser: () => (req: any, res: any, next: any) => next(),
+      pageService: {
+        getExcludedPathsBySystem: vi.fn().mockReturnValue(['/user']),
+      },
+      configManager: {
+        getConfig: vi.fn().mockReturnValue(false),
+      },
     };
     };
 
 
     // Import and setup the LSX middleware
     // Import and setup the LSX middleware

+ 2 - 1
packages/remark-lsx/src/server/index.ts

@@ -57,6 +57,7 @@ const middleware = (crowi: any, app: any): void => {
     loginRequiredFallback,
     loginRequiredFallback,
   );
   );
   const accessTokenParser = crowi.accessTokenParser;
   const accessTokenParser = crowi.accessTokenParser;
+  const excludedPaths = crowi.pageService.getExcludedPathsBySystem();
 
 
   app.get(
   app.get(
     '/_api/lsx',
     '/_api/lsx',
@@ -64,7 +65,7 @@ const middleware = (crowi: any, app: any): void => {
     loginRequired,
     loginRequired,
     lsxValidator,
     lsxValidator,
     paramValidator,
     paramValidator,
-    listPages,
+    listPages({ excludedPaths }),
   );
   );
 };
 };
 
 

+ 5 - 0
packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts

@@ -1,4 +1,5 @@
 import type { IPageHasId, IUser } from '@growi/core';
 import type { IPageHasId, IUser } from '@growi/core';
+import createError from 'http-errors';
 import type { Document, Query } from 'mongoose';
 import type { Document, Query } from 'mongoose';
 import { model } from 'mongoose';
 import { model } from 'mongoose';
 
 
@@ -17,6 +18,10 @@ export const generateBaseQuery = async (
   pagePath: string,
   pagePath: string,
   user: IUser,
   user: IUser,
 ): Promise<PageQueryBuilder> => {
 ): Promise<PageQueryBuilder> => {
+  if (pagePath === '') {
+    throw createError(400, 'pagePath must not be empty');
+  }
+
   const Page = model<IPageHasId>('Page');
   const Page = model<IPageHasId>('Page');
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   const PageAny = Page as any;
   const PageAny = Page as any;

+ 91 - 13
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -36,20 +36,27 @@ vi.mock('./get-toppage-viewers-count', () => ({
 }));
 }));
 
 
 describe('listPages', () => {
 describe('listPages', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
   it("returns 400 HTTP response when the query 'pagePath' is undefined", async () => {
   it("returns 400 HTTP response when the query 'pagePath' is undefined", async () => {
     // setup
     // setup
     const reqMock = mock<IListPagesRequest>();
     const reqMock = mock<IListPagesRequest>();
+    reqMock.query = { pagePath: '' };
+
     const resMock = mock<Response>();
     const resMock = mock<Response>();
     const resStatusMock = mock<Response>();
     const resStatusMock = mock<Response>();
-    resMock.status.calledWith(400).mockReturnValue(resStatusMock);
+    resMock.status.mockReturnValue(resStatusMock);
+
+    mocks.generateBaseQueryMock.mockRejectedValue(
+      createError(400, 'pagePath is required'),
+    );
 
 
-    // when
-    await listPages(reqMock, resMock);
+    const handler = listPages({ excludedPaths: [] });
+    await handler(reqMock, resMock);
 
 
-    // then
-    expect(resMock.status).toHaveBeenCalledOnce();
-    expect(resStatusMock.send).toHaveBeenCalledOnce();
-    expect(mocks.generateBaseQueryMock).not.toHaveBeenCalled();
+    expect(resMock.status).toHaveBeenCalledWith(400);
   });
   });
 
 
   describe('with num option', () => {
   describe('with num option', () => {
@@ -58,12 +65,16 @@ describe('listPages', () => {
 
 
     const builderMock = mock<PageQueryBuilder>();
     const builderMock = mock<PageQueryBuilder>();
 
 
-    mocks.generateBaseQueryMock.mockResolvedValue(builderMock);
-    mocks.getToppageViewersCountMock.mockImplementation(() => 99);
-
     const queryMock = mock<PageQuery>();
     const queryMock = mock<PageQuery>();
     builderMock.query = queryMock;
     builderMock.query = queryMock;
 
 
+    beforeEach(() => {
+      mocks.generateBaseQueryMock.mockResolvedValue(builderMock);
+      mocks.getToppageViewersCountMock.mockImplementation(() => 99);
+
+      queryMock.and.mockReturnValue(queryMock);
+    });
+
     it('returns 200 HTTP response', async () => {
     it('returns 200 HTTP response', async () => {
       // setup query.clone().count()
       // setup query.clone().count()
       const queryClonedMock = mock<PageQuery>();
       const queryClonedMock = mock<PageQuery>();
@@ -85,7 +96,8 @@ describe('listPages', () => {
       resMock.status.calledWith(200).mockReturnValue(resStatusMock);
       resMock.status.calledWith(200).mockReturnValue(resStatusMock);
 
 
       // when
       // when
-      await listPages(reqMock, resMock);
+      const handler = listPages({ excludedPaths: [] });
+      await handler(reqMock, resMock);
 
 
       // then
       // then
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
@@ -118,7 +130,8 @@ describe('listPages', () => {
       resMock.status.calledWith(500).mockReturnValue(resStatusMock);
       resMock.status.calledWith(500).mockReturnValue(resStatusMock);
 
 
       // when
       // when
-      await listPages(reqMock, resMock);
+      const handler = listPages({ excludedPaths: [] });
+      await handler(reqMock, resMock);
 
 
       // then
       // then
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
@@ -147,7 +160,8 @@ describe('listPages', () => {
       resMock.status.calledWith(400).mockReturnValue(resStatusMock);
       resMock.status.calledWith(400).mockReturnValue(resStatusMock);
 
 
       // when
       // when
-      await listPages(reqMock, resMock);
+      const handler = listPages({ excludedPaths: [] });
+      await handler(reqMock, resMock);
 
 
       // then
       // then
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
@@ -243,3 +257,67 @@ describe('listPages', () => {
     });
     });
   });
   });
 });
 });
+
+describe('when excludedPaths is handled', () => {
+  const pagePath = '/Sandbox';
+  const builderMock = mock<PageQueryBuilder>();
+  const queryMock = mock<PageQuery>();
+  builderMock.query = queryMock;
+
+  beforeEach(() => {
+    mocks.generateBaseQueryMock.mockResolvedValue(builderMock);
+    queryMock.and.mockReturnValue(queryMock);
+
+    // Setup successful flow for count and exec
+    const queryClonedMock = mock<PageQuery>();
+    queryMock.clone.mockReturnValue(queryClonedMock);
+    queryClonedMock.count.mockResolvedValue(0);
+    queryMock.exec.mockResolvedValue([]);
+
+    mocks.addNumConditionMock.mockReturnValue(queryMock);
+    mocks.addSortConditionMock.mockReturnValue(queryMock);
+    mocks.getToppageViewersCountMock.mockResolvedValue(0);
+  });
+
+  it('does not add path exclusion conditions when excludedPaths is empty', async () => {
+    // setup
+    const reqMock = mock<IListPagesRequest>();
+    reqMock.query = { pagePath };
+    const resMock = mock<Response>();
+    resMock.status.mockReturnValue(mock<Response>());
+
+    // excludedPaths is empty
+    const handler = listPages({ excludedPaths: [] });
+    await handler(reqMock, resMock);
+
+    // query.and should NOT be called with a $not regex for paths
+    expect(queryMock.and).not.toHaveBeenCalledWith(
+      expect.arrayContaining([
+        expect.objectContaining({
+          path: expect.objectContaining({ $not: expect.any(RegExp) }),
+        }),
+      ]),
+    );
+  });
+
+  it('adds a regex exclusion condition when excludedPaths is specified', async () => {
+    // setup
+    const reqMock = mock<IListPagesRequest>();
+    reqMock.query = { pagePath };
+    const resMock = mock<Response>();
+    resMock.status.mockReturnValue(mock<Response>());
+
+    // excludedPaths provided
+    const excludedPaths = ['/user', '/tmp'];
+    const handler = listPages({ excludedPaths });
+    await handler(reqMock, resMock);
+
+    // check if the logic generates the correct regex: ^\/(user|tmp)(\/|$)
+    const expectedRegex = /^\/(user|tmp)(\/|$)/;
+    expect(queryMock.and).toHaveBeenCalledWith([
+      {
+        path: { $not: expectedRegex },
+      },
+    ]);
+  });
+});

+ 77 - 69
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -66,78 +66,86 @@ interface IListPagesRequest
   user: IUser;
   user: IUser;
 }
 }
 
 
-export const listPages = async (
-  req: IListPagesRequest,
-  res: Response,
-): Promise<Response> => {
-  const user = req.user;
-
-  if (req.query.pagePath == null) {
-    return res.status(400).send("the 'pagepath' query must not be null.");
-  }
-
-  const params: LsxApiParams = {
-    pagePath: removeTrailingSlash(req.query.pagePath),
-    offset: req.query?.offset,
-    limit: req.query?.limit,
-    options: req.query?.options ?? {},
-  };
+export const listPages = ({ excludedPaths }: { excludedPaths: string[] }) => {
+  return async (req: IListPagesRequest, res: Response): Promise<Response> => {
+    const params: LsxApiParams = {
+      pagePath: removeTrailingSlash(req.query.pagePath),
+      offset: req.query?.offset,
+      limit: req.query?.limit,
+      options: req.query?.options ?? {},
+    };
 
 
-  const { pagePath, offset, limit, options } = params;
-  const builder = await generateBaseQuery(params.pagePath, user);
+    const { pagePath, offset, limit, options } = params;
 
 
-  // count viewers of `/`
-  let toppageViewersCount: number;
-  try {
-    toppageViewersCount = await getToppageViewersCount();
-  } catch (error) {
-    // biome-ignore lint/suspicious/noConsole: Allow to use console.error here
-    console.error('Error occurred in getToppageViewersCount:', error);
-    return res.status(500).send('An internal server error occurred.');
-  }
-
-  let query = builder.query;
-  try {
-    // depth
-    if (options?.depth != null) {
-      query = addDepthCondition(
-        query,
-        params.pagePath,
-        OptionParser.parseRange(options.depth),
-      );
+    // count viewers of `/`
+    let toppageViewersCount: number;
+    try {
+      toppageViewersCount = await getToppageViewersCount();
+    } catch (error) {
+      // biome-ignore lint/suspicious/noConsole: Allow to use console.error here
+      console.error('Error occurred in getToppageViewersCount:', error);
+      return res.status(500).send('An internal server error occurred.');
     }
     }
-    // filter
-    if (options?.filter != null) {
-      query = addFilterCondition(query, pagePath, options.filter);
-    }
-    if (options?.except != null) {
-      query = addExceptCondition(query, pagePath, options.except);
-    }
-
-    // get total num before adding num/sort conditions
-    const total = await query.clone().count();
-
-    // num
-    query = addNumCondition(query, offset, limit);
-    // sort
-    query = addSortCondition(query, options?.sort, options?.reverse);
-
-    const pages = await query.exec();
-    const cursor = (offset ?? 0) + pages.length;
 
 
-    const responseData: LsxApiResponseData = {
-      pages,
-      cursor,
-      total,
-      toppageViewersCount,
-    };
-    return res.status(200).send(responseData);
-  } catch (error) {
-    // biome-ignore lint/suspicious/noConsole: Allow to use console.error here
-    console.error('Error occurred while processing listPages request:', error);
-    if (isHttpError(error)) {
-      return res.status(error.status).send(error.message);
+    try {
+      const user = req.user;
+      const builder = await generateBaseQuery(params.pagePath, user);
+      let query = builder.query;
+
+      if (excludedPaths.length > 0) {
+        const escapedPaths = excludedPaths.map((p) => {
+          const cleanPath = p.startsWith('/') ? p.substring(1) : p;
+          return escapeStringRegexp(cleanPath);
+        });
+
+        const regex = new RegExp(`^\\/(${escapedPaths.join('|')})(\\/|$)`);
+        query = query.and([{ path: { $not: regex } }]);
+      }
+
+      // depth
+      if (options?.depth != null) {
+        query = addDepthCondition(
+          query,
+          params.pagePath,
+          OptionParser.parseRange(options.depth),
+        );
+      }
+      // filter
+      if (options?.filter != null) {
+        query = addFilterCondition(query, pagePath, options.filter);
+      }
+      if (options?.except != null) {
+        query = addExceptCondition(query, pagePath, options.except);
+      }
+
+      // get total num before adding num/sort conditions
+      const total = await query.clone().count();
+
+      // num
+      query = addNumCondition(query, offset, limit);
+      // sort
+      query = addSortCondition(query, options?.sort, options?.reverse);
+
+      const pages = await query.exec();
+      const cursor = (offset ?? 0) + pages.length;
+
+      const responseData: LsxApiResponseData = {
+        pages,
+        cursor,
+        total,
+        toppageViewersCount,
+      };
+      return res.status(200).send(responseData);
+    } catch (error) {
+      // biome-ignore lint/suspicious/noConsole: Allow to use console.error here
+      console.error(
+        'Error occurred while processing listPages request:',
+        error,
+      );
+      if (isHttpError(error)) {
+        return res.status(error.status).send(error.message);
+      }
+      return res.status(500).send('An internal server error occurred.');
     }
     }
-    return res.status(500).send('An internal server error occurred.');
-  }
+  };
 };
 };