Procházet zdrojové kódy

Merge branch 'master' into support/typescript-go

Yuki Takei před 3 měsíci
rodič
revize
66e370c0c4
44 změnil soubory, kde provedl 652 přidání a 202 odebrání
  1. 27 1
      CHANGELOG.md
  2. 1 1
      apps/app/docker/README.md
  3. 3 3
      apps/app/package.json
  4. 5 0
      apps/app/public/static/locales/en_US/admin.json
  5. 5 0
      apps/app/public/static/locales/fr_FR/admin.json
  6. 5 0
      apps/app/public/static/locales/ja_JP/admin.json
  7. 5 0
      apps/app/public/static/locales/ko_KR/admin.json
  8. 5 0
      apps/app/public/static/locales/zh_CN/admin.json
  9. 52 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/UserPageVisibilitySettings.tsx
  10. 7 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx
  11. 59 60
      apps/app/src/client/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  12. 9 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  13. 6 1
      apps/app/src/features/search/client/components/PrivateLegacyPages.tsx
  14. 25 18
      apps/app/src/features/search/client/components/SearchPage/SearchControl.tsx
  15. 17 16
      apps/app/src/features/search/client/components/SearchPage/SearchOptionModal/SearchOptionModal.tsx
  16. 1 0
      apps/app/src/features/search/client/components/SearchPage/SearchOptionModal/dynamic.tsx
  17. 8 1
      apps/app/src/features/search/client/components/SearchPage/SearchPage.tsx
  18. 27 0
      apps/app/src/pages/[[...path]]/page-data-props.ts
  19. 1 0
      apps/app/src/pages/_search/get-server-side-props/index.ts
  20. 1 0
      apps/app/src/pages/_search/types.ts
  21. 2 0
      apps/app/src/pages/_search/use-hydrate-server-configurations.ts
  22. 1 1
      apps/app/src/pages/general-page/configuration-props.ts
  23. 2 0
      apps/app/src/pages/general-page/hydrate.ts
  24. 1 0
      apps/app/src/pages/general-page/types.ts
  25. 26 0
      apps/app/src/pages/share/[[...path]]/page-data-props.ts
  26. 21 0
      apps/app/src/server/models/page.ts
  27. 21 2
      apps/app/src/server/routes/apiv3/bookmarks.ts
  28. 15 1
      apps/app/src/server/routes/apiv3/page-listing.ts
  29. 11 0
      apps/app/src/server/routes/apiv3/page/create-page.ts
  30. 21 1
      apps/app/src/server/routes/apiv3/page/index.ts
  31. 13 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  32. 18 0
      apps/app/src/server/routes/apiv3/pages/index.js
  33. 13 0
      apps/app/src/server/routes/apiv3/security-settings/index.js
  34. 4 0
      apps/app/src/server/service/config-manager/config-definition.ts
  35. 10 0
      apps/app/src/server/service/page/index.ts
  36. 5 0
      apps/app/src/states/server-configurations/server-configurations.ts
  37. 1 1
      apps/slackbot-proxy/package.json
  38. 1 1
      package.json
  39. 3 0
      packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts
  40. 5 1
      packages/remark-lsx/src/server/index.ts
  41. 5 0
      packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts
  42. 91 13
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  43. 82 69
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  44. 11 11
      pnpm-lock.yaml

+ 27 - 1
CHANGELOG.md

@@ -1,9 +1,35 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.4.2...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.3...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.3](https://github.com/growilabs/compare/v7.4.2...v7.4.3) - 2026-01-21
+
+### 💎 Features
+
+* feat: Disable user page (#10735) @miya
+* feat: New admin setting for hiding user pages (#10708) @arvid-e
+* feat: Block other user's user pages (#10725) @arvid-e
+
+### 🚀 Improvement
+
+* imprv: New sidebar tool icon appearance (#10672) @satof3
+* imprv: Admin Home (#10692) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Vim keymap insert mode exiting after single keystroke  (#10714) @miya
+* fix: Cannot create `/Sidebar` page from custom sidebar (#10690) @miya
+* fix: PageTree does not auto-scroll to target page path on initial render (#10699) @miya
+
+### 🧰 Maintenance
+
+* support: Upgrade headless-tree (#10733) @miya
+* support: Integrate Lefthook for pre-commit Biome formatting (#10694) @[copilot-swe-agent[bot]](https://github.com/apps/copilot-swe-agent)
+* support: Stop pushing docker image to weseek repository (#10681) @miya
+* support: Migrate the rest of files to Biome from Eslint (#10683) @yuki-takei
+
 ## [v7.4.2](https://github.com/growilabs/compare/v7.4.1...v7.4.2) - 2026-01-08
 
 ### 🚀 Improvement

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

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.4.2`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.2/apps/app/docker/Dockerfile)
+* [`7.4.3`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.3/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 

+ 3 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.3-RC.0",
+  "version": "7.4.4-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -268,8 +268,8 @@
     "@growi/editor": "workspace:^",
     "@growi/ui": "workspace:^",
     "@handsontable/react": "=2.1.0",
-    "@headless-tree/core": "^1.5.1",
-    "@headless-tree/react": "^1.5.1",
+    "@headless-tree/core": "^1.5.3",
+    "@headless-tree/react": "^1.5.3",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
     "@tanstack/react-virtual": "^3.13.12",

+ 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",
       "desc": "You will be able to delete a deleted user's homepage."
     },
+    "disable_user_pages": {
+      "disable_user_pages": "Disable user pages",
+      "disable_user_pages_label": "Disable user pages",
+      "desc": "By disabling user pages, creating, viewing, editing, and duplicating all user pages will be disabled.</br>Additionally, user pages will not appear in page trees, recent changes, or search results."
+    },
     "session": "Session",
     "max_age": "Max age (msec)",
     "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",
       "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
     },
+    "disable_user_pages": {
+      "disable_user_pages": "Désactiver les pages utilisateur",
+      "disable_user_pages_label": "Désactiver les pages utilisateur",
+      "desc": "En désactivant les pages utilisateur, la création, la consultation, la modification et la duplication de toutes les pages utilisateur seront désactivées.</br>De plus, les pages utilisateur n'apparaîtront pas dans l'arborescence des pages, les modifications récentes ou les résultats de recherche."
+    },
     "session": "Session",
     "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)",

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

@@ -65,6 +65,11 @@
       "enable_force_delete_user_homepage_on_user_deletion": "ユーザーを削除したとき、ユーザーホームページとその配下のページを完全削除する",
       "desc": "削除済みユーザーのユーザーホームページを削除できるようになります。"
     },
+    "disable_user_pages": {
+      "disable_user_pages": "ユーザーページの無効化",
+      "disable_user_pages_label": "ユーザーページを無効にする",
+      "desc": "ユーザーページを無効にすることで、すべてのユーザーページに対する作成・閲覧・編集・複製ができなくなります。</br>また、ページツリーや最近の変更、検索結果などでもユーザーページが表示されなくなります。"
+    },
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",
     "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": "사용자를 삭제할 때, 사용자의 홈페이지와 모든 하위 페이지가 완전히 삭제됩니다.",
       "desc": "삭제된 사용자의 홈페이지를 삭제할 수 있습니다."
     },
+    "disable_user_pages": {
+      "disable_user_pages": "사용자 페이지 비활성화",
+      "disable_user_pages_label": "사용자 페이지 비활성화",
+      "desc": "사용자 페이지를 비활성화하면 모든 사용자 페이지의 생성, 조회, 편집 및 복제가 비활성화됩니다.</br>또한, 사용자 페이지는 페이지 트리, 최근 변경 사항 또는 검색 결과에도 표시되지 않습니다."
+    },
     "session": "세션",
     "max_age": "최대 수명 (밀리초)",
     "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": "删除用户时,该用户的主页及其所有子页面将被完全删除",
       "desc": "您可以删除已删除用户的主页。"
     },
+    "disable_user_pages": {
+      "disable_user_pages": "禁用用户页面",
+      "disable_user_pages_label": "禁用用户页面",
+      "desc": "通过禁用用户页面,将无法创建、查看、编辑和复制所有用户页面。</br>此外,用户页面也不会出现在页面树、最近更改或搜索结果中。"
+    },
     "session": "会议",
     "max_age": "有效期间  (msec)",
     "max_age_desc": "指定使用户会话过期的数量(以毫秒为单位)。<br>默认值: 2592000000 (30天)",

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

@@ -0,0 +1,52 @@
+/* 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.disable_user_pages.disable_user_pages')}
+      </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.disableUserPages}
+              onChange={() => {
+                adminGeneralSecurityContainer.changeUserPageVisibility();
+              }}
+            />
+            <label
+              className="form-label form-check-label"
+              htmlFor="is-user-pages-visible"
+            >
+              {t(
+                'security_settings.disable_user_pages.disable_user_pages_label',
+              )}
+            </label>
+          </div>
+          <p
+            className="form-text text-muted small mt-2"
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: includes <br> and <code> from i18n strings
+            dangerouslySetInnerHTML={{
+              __html: t('security_settings.disable_user_pages.desc'),
+            }}
+          />
+        </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 { SessionMaxAgeSettings } from './SessionMaxAgeSettings';
 import { UserHomepageDeletionSettings } from './UserHomepageDeletionSettings';
+import { UserPageVisibilitySettings } from './UserPageVisibilitySettings';
 
 type FormData = {
   sessionMaxAge: string;
@@ -63,6 +64,8 @@ const SecuritySettingComponent: React.FC<Props> = ({
           hideRestrictedByOwner:
             adminGeneralSecurityContainer.state
               .currentOwnerRestrictionDisplayMode === 'Hidden',
+          disableUserPages:
+            adminGeneralSecurityContainer.state.disableUserPages,
           isUsersHomepageDeletionEnabled:
             adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled,
           isForceDeleteUserHomepageOnUserDeletion:
@@ -114,6 +117,10 @@ const SecuritySettingComponent: React.FC<Props> = ({
             adminGeneralSecurityContainer={adminGeneralSecurityContainer}
             t={t}
           />
+          <UserPageVisibilitySettings
+            adminGeneralSecurityContainer={adminGeneralSecurityContainer}
+            t={t}
+          />
           <CommentManageRightsSettings
             adminGeneralSecurityContainer={adminGeneralSecurityContainer}
             t={t}

+ 59 - 60
apps/app/src/client/components/Sidebar/SidebarNav/PersonalDropdown.tsx

@@ -2,6 +2,7 @@ import type { JSX } from 'react';
 import Link from 'next/link';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import {
   DropdownItem,
@@ -13,6 +14,7 @@ import {
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
+import { disableUserPagesAtom } from '~/states/server-configurations';
 
 import { SkeletonItem } from './SkeletonItem';
 
@@ -22,6 +24,8 @@ export const PersonalDropdown = (): JSX.Element => {
   const { t } = useTranslation('commons');
   const currentUser = useCurrentUser();
 
+  const disableUserPages = useAtomValue(disableUserPagesAtom);
+
   if (currentUser == null) {
     return <SkeletonItem />;
   }
@@ -36,41 +40,41 @@ export const PersonalDropdown = (): JSX.Element => {
   };
 
   return (
-    <>
-      <UncontrolledDropdown direction="end">
-        <DropdownToggle
-          className={`btn btn-primary ${styles['btn-personal-dropdown']} opacity-100`}
-          data-testid="personal-dropdown-button"
-        >
-          <UserPicture user={currentUser} noLink noTooltip />
-        </DropdownToggle>
+    <UncontrolledDropdown direction="end">
+      <DropdownToggle
+        className={`btn btn-primary ${styles['btn-personal-dropdown']} opacity-100`}
+        data-testid="personal-dropdown-button"
+      >
+        <UserPicture user={currentUser} noLink noTooltip />
+      </DropdownToggle>
 
-        <DropdownMenu
-          container="body"
-          data-testid="personal-dropdown-menu"
-          className={styles['personal-dropdown-menu']}
-        >
-          <DropdownItem className={styles['personal-dropdown-header']} header>
-            <div className="mt-2 mb-3">
-              <UserPicture user={currentUser} size="lg" noLink noTooltip />
-            </div>
-            <div className="ms-1 fs-6">{currentUser.name}</div>
-            <div className="d-flex align-items-center my-2">
-              <small className="material-symbols-outlined me-1 pb-0 fs-6">
-                person
-              </small>
-              <span>{currentUser.username}</span>
-            </div>
-            <div className="d-flex align-items-center">
-              <span className="material-symbols-outlined me-1 pb-0 fs-6">
-                mail
-              </span>
-              <span className="item-text-email">{currentUser.email}</span>
-            </div>
-          </DropdownItem>
+      <DropdownMenu
+        container="body"
+        data-testid="personal-dropdown-menu"
+        className={styles['personal-dropdown-menu']}
+      >
+        <DropdownItem className={styles['personal-dropdown-header']} header>
+          <div className="mt-2 mb-3">
+            <UserPicture user={currentUser} size="lg" noLink noTooltip />
+          </div>
+          <div className="ms-1 fs-6">{currentUser.name}</div>
+          <div className="d-flex align-items-center my-2">
+            <small className="material-symbols-outlined me-1 pb-0 fs-6">
+              person
+            </small>
+            <span>{currentUser.username}</span>
+          </div>
+          <div className="d-flex align-items-center">
+            <span className="material-symbols-outlined me-1 pb-0 fs-6">
+              mail
+            </span>
+            <span className="item-text-email">{currentUser.email}</span>
+          </div>
+        </DropdownItem>
 
-          <DropdownItem className="my-3" divider />
+        <DropdownItem className="my-3" divider />
 
+        {!disableUserPages && (
           <Link
             href={pagePathUtils.userHomepagePath(currentUser)}
             data-testid="grw-personal-dropdown-menu-user-home"
@@ -86,39 +90,34 @@ export const PersonalDropdown = (): JSX.Element => {
               </span>
             </DropdownItem>
           </Link>
+        )}
 
-          <Link
-            href="/me"
-            data-testid="grw-personal-dropdown-menu-user-settings"
-          >
-            <DropdownItem
-              className={`my-1 ${styles['personal-dropdown-item']}`}
-            >
-              <span className="d-flex align-items-center">
-                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
-                  discover_tune
-                </span>
-                <span className="item-text">
-                  {t('personal_dropdown.settings')}
-                </span>
-              </span>
-            </DropdownItem>
-          </Link>
-
-          <DropdownItem
-            data-testid="logout-button"
-            onClick={logoutHandler}
-            className={`my-1 ${styles['personal-dropdown-item']}`}
-          >
+        <Link href="/me" data-testid="grw-personal-dropdown-menu-user-settings">
+          <DropdownItem className={`my-1 ${styles['personal-dropdown-item']}`}>
             <span className="d-flex align-items-center">
               <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
-                logout
+                discover_tune
+              </span>
+              <span className="item-text">
+                {t('personal_dropdown.settings')}
               </span>
-              <span className="item-text">{t('Sign out')}</span>
             </span>
           </DropdownItem>
-        </DropdownMenu>
-      </UncontrolledDropdown>
-    </>
+        </Link>
+
+        <DropdownItem
+          data-testid="logout-button"
+          onClick={logoutHandler}
+          className={`my-1 ${styles['personal-dropdown-item']}`}
+        >
+          <span className="d-flex align-items-center">
+            <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
+              logout
+            </span>
+            <span className="item-text">{t('Sign out')}</span>
+          </span>
+        </DropdownItem>
+      </DropdownMenu>
+    </UncontrolledDropdown>
   );
 };

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

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

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

@@ -7,6 +7,7 @@ import React, {
   useState,
 } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import {
   DropdownItem,
@@ -35,6 +36,7 @@ import type { PageMigrationErrorData } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
 import { useIsAdmin } from '~/states/context';
 import { useSearchKeyword, useSetSearchKeyword } from '~/states/search';
+import { disableUserPagesAtom } from '~/states/server-configurations';
 import { useGlobalSocket } from '~/states/socket-io';
 import type { ILegacyPrivatePage } 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 setSearchKeyword = useSetSearchKeyword('/_private-legacy-pages');
 
+  const disableUserPages = useAtomValue(disableUserPagesAtom);
+
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
   const [isOpenConvertModal, setOpenConvertModal] = useState<boolean>(false);
@@ -515,6 +519,7 @@ const PrivateLegacyPages = (): JSX.Element => {
   const searchControl = useMemo(() => {
     return (
       <SearchControl
+        disableUserPages={disableUserPages}
         isEnableSort={false}
         isEnableFilter={false}
         initialSearchConditions={{ keyword: initQ, limit: INITIAL_PAGING_SIZE }}
@@ -522,7 +527,7 @@ const PrivateLegacyPages = (): JSX.Element => {
         extraControls={extraControls}
       />
     );
-  }, [searchInvokedHandler, extraControls]);
+  }, [searchInvokedHandler, extraControls, disableUserPages]);
 
   const searchResultListHead = useMemo(() => {
     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 = {
   isEnableSort: boolean;
   isEnableFilter: boolean;
+  disableUserPages: boolean;
   initialSearchConditions: Partial<ISearchConditions>;
 
   onSearchInvoked?: (
@@ -29,6 +30,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
   const {
     isEnableSort,
     isEnableFilter,
+    disableUserPages,
     initialSearchConditions,
     onSearchInvoked,
     extraControls,
@@ -152,25 +154,29 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
               </button>
             </div>
             <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>
+              {disableUserPages === 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 className="px-2 py-1">
                 <div className="form-check form-check-succsess">
                   <input
@@ -206,6 +212,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
       <SearchOptionModalLazyLoaded
         isOpen={isFileterOptionModalShown || false}
         onClose={() => setIsFileterOptionModalShown(false)}
+        disableUserPages={disableUserPages}
         includeUserPages={includeUserPages}
         includeTrashPages={includeTrashPages}
         onIncludeUserPagesSwitched={setIncludeUserPages}

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

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

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

@@ -6,6 +6,7 @@ type SearchOptionModalProps = {
   isOpen: boolean;
   includeUserPages: boolean;
   includeTrashPages: boolean;
+  disableUserPages: boolean;
   onClose?: () => void;
   onIncludeUserPagesSwitched?: (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';
 import type { IFormattedSearchResult } from '~/interfaces/search';
 import { useSearchKeyword, useSetSearchKeyword } from '~/states/search';
-import { showPageLimitationLAtom } from '~/states/server-configurations';
+import {
+  disableUserPagesAtom,
+  showPageLimitationLAtom,
+} from '~/states/server-configurations';
 import {
   type ISearchConditions,
   type ISearchConfigurations,
@@ -106,6 +109,8 @@ export const SearchPage = (): JSX.Element => {
   const keyword = useSearchKeyword();
   const setSearchKeyword = useSetSearchKeyword();
 
+  const disableUserPages = useAtomValue(disableUserPagesAtom);
+
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(
     showPageLimitationL ?? INITIAL_PAGIONG_SIZE,
@@ -286,6 +291,7 @@ export const SearchPage = (): JSX.Element => {
       <SearchControl
         isEnableSort
         isEnableFilter
+        disableUserPages={disableUserPages}
         initialSearchConditions={initialSearchConditions}
         onSearchInvoked={searchInvokedHandler}
         extraControls={extraControls}
@@ -298,6 +304,7 @@ export const SearchPage = (): JSX.Element => {
     collapseContents,
     initialSearchConditions,
     isCollapsed,
+    disableUserPages,
     searchInvokedHandler,
   ]);
 

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

@@ -10,6 +10,8 @@ import { isIPageInfo, isIPageNotFoundInfo } from '@growi/core';
 import {
   isPermalink as _isPermalink,
   isTopPage,
+  isUserPage,
+  isUsersTopPage,
 } from '@growi/core/dist/utils/page-path-utils';
 import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import assert from 'assert';
@@ -162,6 +164,31 @@ export async function getPageDataForInitial(
     { pageId, path: resolvedPagePath, user },
   );
 
+  const disableUserPages = configManager.getConfig('security:disableUserPages');
+
+  if (disableUserPages && 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
   const currentPathname = resolveFinalizedPathname(
     resolvedPagePath,

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

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

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

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

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

@@ -2,6 +2,7 @@ import { useHydrateAtoms } from 'jotai/utils';
 
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
+  disableUserPagesAtom,
   isContainerFluidAtom,
   rendererConfigAtom,
   showPageLimitationLAtom,
@@ -28,6 +29,7 @@ export const useHydrateServerConfigurationAtoms = (
             serverConfig.showPageLimitationL,
           ),
           createAtomTuple(rendererConfigAtom, rendererConfigs),
+          createAtomTuple(disableUserPagesAtom, serverConfig.disableUserPages),
         ];
 
   useHydrateAtoms(tuples);

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

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

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

@@ -5,6 +5,7 @@ import {
   aiEnabledAtom,
   defaultIndentSizeAtom,
   disableLinkSharingAtom,
+  disableUserPagesAtom,
   drawioUriAtom,
   elasticsearchMaxBodyLengthToIndexAtom,
   isAclEnabledAtom,
@@ -108,6 +109,7 @@ export const useHydrateGeneralPageConfigurationAtoms = (
             serverConfig.isLocalAccountRegistrationEnabled,
           ),
           createAtomTuple(rendererConfigAtom, rendererConfigs),
+          createAtomTuple(disableUserPagesAtom, serverConfig.disableUserPages),
         ];
 
   useHydrateAtoms(tuples);

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

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

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

@@ -1,6 +1,10 @@
 import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 import type { IPage } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
+import {
+  isUserPage,
+  isUsersTopPage,
+} from '@growi/core/dist/utils/page-path-utils';
 import type { model } from 'mongoose';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
@@ -68,6 +72,28 @@ export const getPageDataForInitial = async (
     return notFoundProps;
   }
 
+  const disableUserPages = configManager.getConfig('security:disableUserPages');
+  if (
+    disableUserPages &&
+    (isUserPage(pageWithMeta.data.path) ||
+      isUsersTopPage(pageWithMeta.data.path))
+  ) {
+    return {
+      props: {
+        isNotFound: true,
+        pageWithMeta: {
+          data: null,
+          meta: {
+            isNotFound: true,
+            isForbidden: true,
+          },
+        },
+        isExpired: undefined,
+        shareLink: undefined,
+      },
+    };
+  }
+
   // expired
   if (shareLink.isExpired()) {
     const populatedPage =

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

@@ -91,6 +91,7 @@ export type FindRecentUpdatedPagesOption = {
   desc: number;
   hideRestrictedByOwner: boolean;
   hideRestrictedByGroup: boolean;
+  disableUserPages: boolean;
 };
 
 export type CreateMethod = (
@@ -432,6 +433,22 @@ export class PageQueryBuilder {
     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 {
     // No request is set for "/"
     if (str === '/') {
@@ -921,6 +938,10 @@ schema.statics.findRecentUpdatedPages = async function (
   const baseQuery = this.find({});
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
 
+  if (options.disableUserPages) {
+    queryBuilder.addConditionToListByNotMatchPathAndChildren('/user');
+  }
+
   if (!options.includeTrashed) {
     queryBuilder.addConditionToExcludeTrashed();
   }

+ 21 - 2
apps/app/src/server/routes/apiv3/bookmarks.ts

@@ -1,6 +1,10 @@
 import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import {
+  isUserPage,
+  isUsersTopPage,
+} from '@growi/core/dist/utils/page-path-utils';
 import mongoose, { type HydratedDocument } from 'mongoose';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
@@ -12,6 +16,7 @@ import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { BookmarkDocument, BookmarkModel } from '~/server/models/bookmark';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
+import { configManager } from '~/server/service/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
 
@@ -233,11 +238,12 @@ module.exports = (crowi: Crowi) => {
           'bookmarks',
           { owner: userId },
         );
+
         const userRootBookmarks = await Bookmark.find({
           _id: { $nin: bookmarkIdsInFolders },
           user: userId,
         })
-          .populate({
+          .populate<{ page: PageDocument | null }>({
             path: 'page',
             model: 'Page',
             populate: {
@@ -247,8 +253,21 @@ module.exports = (crowi: Crowi) => {
           })
           .exec();
 
+        const disabledUserPage = configManager.getConfig(
+          'security:disableUserPages',
+        );
+
+        const filteredBookmarks = disabledUserPage
+          ? userRootBookmarks.filter(
+              (bookmark) =>
+                bookmark.page != null &&
+                !isUserPage(bookmark.page.path) &&
+                !isUsersTopPage(bookmark.page.path),
+            )
+          : userRootBookmarks;
+
         // serialize Bookmark
-        const serializedUserRootBookmarks = userRootBookmarks.map((bookmark) =>
+        const serializedUserRootBookmarks = filteredBookmarks.map((bookmark) =>
           serializeBookmarkSecurely(bookmark),
         );
 

+ 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 { type IPageInfoForEmpty, SCOPE } from '@growi/core/dist/interfaces';
 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 express from 'express';
 import { oneOf, query } from 'express-validator';
@@ -153,15 +157,25 @@ const routerFactory = (crowi: Crowi): Router => {
       const hideRestrictedByGroup = await configManager.getConfig(
         'security:list-policy:hideRestrictedByGroup',
       );
+      const disableUserPages = await configManager.getConfig(
+        'security:disableUserPages',
+      );
 
       try {
-        const pages =
+        let pages =
           await pageListingService.findChildrenByParentPathOrIdAndViewer(
             (id || path) as string,
             req.user,
             !hideRestrictedByOwner,
             !hideRestrictedByGroup,
           );
+
+        if (disableUserPages) {
+          pages = pages.filter(
+            (page) => !isUserPage(page.path) && !isUsersTopPage(page.path),
+          );
+        }
+
         return res.apiv3({ children: pages });
       } catch (err) {
         logger.error('Error occurred while finding children.', err);

+ 11 - 0
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -6,6 +6,7 @@ import {
   isCreatablePage,
   isUserPage,
   isUsersHomepage,
+  isUsersTopPage,
 } from '@growi/core/dist/utils/page-path-utils';
 import {
   attachTitleHeader,
@@ -307,6 +308,16 @@ export const createPageHandlersFactory = (crowi: Crowi) => {
         );
       }
 
+      const disableUserPages = configManager.getConfig(
+        'security:disableUserPages',
+      );
+      if (
+        disableUserPages &&
+        (isUsersTopPage(pathToCreate) || isUserPage(pathToCreate))
+      ) {
+        return res.apiv3Err('User pages are disabled');
+      }
+
       if (isUserPage(pathToCreate)) {
         const isExistUser = await User.isExistUserByUserPagePath(pathToCreate);
         if (!isExistUser) {

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

@@ -17,7 +17,11 @@ import {
   SubscriptionStatusType,
 } from '@growi/core';
 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 type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
@@ -189,6 +193,10 @@ module.exports = (crowi: Crowi) => {
       const { pageId, path, findAll, revisionId, shareLinkId, includeEmpty } =
         req.query;
 
+      const disableUserPages = crowi.configManager.getConfig(
+        'security:disableUserPages',
+      );
+
       const respondWithSinglePage = async (
         pageWithMeta:
           | IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoExt>
@@ -215,6 +223,18 @@ module.exports = (crowi: Crowi) => {
           );
         }
 
+        if (disableUserPages && 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) {
           try {
             page.initLatestRevisionField(revisionId);

+ 13 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -5,7 +5,9 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import {
   isTopPage,
+  isUserPage,
   isUsersProtectedPages,
+  isUsersTopPage,
 } from '@growi/core/dist/utils/page-path-utils';
 import type { Request, RequestHandler } from 'express';
 import type { ValidationChain } from 'express-validator';
@@ -30,6 +32,7 @@ import {
   serializePageSecurely,
   serializeRevisionSecurely,
 } from '~/server/models/serializers';
+import { configManager } from '~/server/service/config-manager/config-manager';
 import { preNotifyService } from '~/server/service/pre-notify';
 import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
 import { getYjsService } from '~/server/service/yjs';
@@ -222,6 +225,16 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
         );
       }
 
+      const disableUserPages = configManager.getConfig(
+        'security:disableUserPages',
+      );
+      if (
+        disableUserPages &&
+        (isUsersTopPage(currentPage.path) || isUserPage(currentPage.path))
+      ) {
+        return res.apiv3Err('User pages are disabled');
+      }
+
       const isGrantImmutable =
         isTopPage(currentPage.path) || isUsersProtectedPages(currentPage.path);
 

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

@@ -6,6 +6,7 @@ import {
   isCreatablePage,
   isTrashPage,
   isUserPage,
+  isUsersTopPage,
 } from '@growi/core/dist/utils/page-path-utils';
 import {
   addHeadingSlash,
@@ -189,6 +190,9 @@ module.exports = (crowi) => {
       const hideRestrictedByGroup = configManager.getConfig(
         'security:list-policy:hideRestrictedByGroup',
       );
+      const disableUserPages = configManager.getConfig(
+        'security:disableUserPages',
+      );
 
       /**
        * @type {import('~/server/models/page').FindRecentUpdatedPagesOption}
@@ -203,6 +207,7 @@ module.exports = (crowi) => {
         desc: -1,
         hideRestrictedByOwner,
         hideRestrictedByGroup,
+        disableUserPages,
       };
 
       try {
@@ -774,6 +779,19 @@ module.exports = (crowi) => {
       }
 
       const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
+      const disableUserPages = configManager.getConfig(
+        'security:disableUserPages',
+      );
+      if (disableUserPages) {
+        if (
+          isUsersTopPage(newPagePath) ||
+          isUserPage(newPagePath) ||
+          isUsersTopPage(page.path) ||
+          isUserPage(page.path)
+        ) {
+          return res.apiv3Err('User pages are disabled');
+        }
+      }
 
       const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
       if (page == null || isEmptyAndNotRecursively) {

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

@@ -49,6 +49,9 @@ const validator = {
     body('hideRestrictedByGroup')
       .if((value) => value != null)
       .isBoolean(),
+    body('disableUserPages')
+      .if((value) => value != null)
+      .isBoolean(),
     body('isUsersHomepageDeletionEnabled')
       .if((value) => value != null)
       .isBoolean(),
@@ -219,6 +222,9 @@ const validator = {
  *          pageCompleteDeletionAuthority:
  *            type: string
  *            description: type of pageDeletionAuthority
+ *          disableUserPages:
+ *            type: boolean
+ *            description: hide all user pages from general users
  *          hideRestrictedByOwner:
  *            type: boolean
  *            description: enable hide by owner
@@ -505,6 +511,9 @@ module.exports = (crowi) => {
           hideRestrictedByGroup: await configManager.getConfig(
             'security:list-policy:hideRestrictedByGroup',
           ),
+          disableUserPages: await configManager.getConfig(
+            'security:disableUserPages',
+          ),
           isUsersHomepageDeletionEnabled: await configManager.getConfig(
             'security:user-homepage-deletion:isEnabled',
           ),
@@ -995,6 +1004,7 @@ module.exports = (crowi) => {
           req.body.hideRestrictedByOwner,
         'security:list-policy:hideRestrictedByGroup':
           req.body.hideRestrictedByGroup,
+        'security:disableUserPages': req.body.disableUserPages,
         'security:user-homepage-deletion:isEnabled':
           req.body.isUsersHomepageDeletionEnabled,
         // Validate user-homepage-deletion config
@@ -1067,6 +1077,9 @@ module.exports = (crowi) => {
           hideRestrictedByGroup: await configManager.getConfig(
             'security:list-policy:hideRestrictedByGroup',
           ),
+          disableUserPages: await configManager.getConfig(
+            'security:disableUserPages',
+          ),
           isUsersHomepageDeletionEnabled: await configManager.getConfig(
             '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:pageRecursiveCompleteDeletionAuthority',
   'security:isAllGroupMembershipRequiredForPageCompleteDeletion',
+  'security:disableUserPages',
   'security:user-homepage-deletion:isEnabled',
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion',
   'security:isRomUserAllowedToComment',
@@ -678,6 +679,9 @@ export const CONFIG_DEFINITIONS = {
     defineConfig<boolean>({
       defaultValue: true,
     }),
+  'security:disableUserPages': defineConfig<boolean>({
+    defaultValue: false,
+  }),
   'security:user-homepage-deletion:isEnabled': defineConfig<boolean>({
     defaultValue: false,
   }),

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

@@ -796,6 +796,16 @@ class PageService implements IPageService {
     return renamedPage;
   }
 
+  getExcludedPathsBySystem(): string[] {
+    const excludedPaths: string[] = [];
+
+    if (configManager.getConfig('security:disableUserPages')) {
+      excludedPaths.push('/user');
+    }
+
+    return excludedPaths;
+  }
+
   async renameSubOperation(
     page,
     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);
 
+/**
+ * Atom for hiding user pages setting enabled
+ */
+export const disableUserPagesAtom = atom<boolean>(false);
+
 /**
  * Atom for local account registration enabled
  */

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.4.3-slackbot-proxy.0",
+  "version": "7.4.4-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.4.3-RC.0",
+  "version": "7.4.4-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

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

@@ -98,6 +98,9 @@ describe('useSWRxLsx integration tests', () => {
         req.user = { _id: '507f1f77bcf86cd799439012', username: 'testuser' };
         next();
       },
+      pageService: {
+        getExcludedPathsBySystem: vi.fn().mockReturnValue(['/user']),
+      },
     };
 
     // Import and setup the LSX middleware

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

@@ -58,13 +58,17 @@ const middleware = (crowi: any, app: any): void => {
   );
   const accessTokenParser: AccessTokenParser = crowi.accessTokenParser;
 
+  // Use a callback to get excludedPaths at request time, not at server startup.
+  // This ensures config changes are reflected without server restart.
+  const getExcludedPaths = () => crowi.pageService.getExcludedPathsBySystem();
+
   app.get(
     '/_api/lsx',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
     loginRequired,
     lsxValidator,
     paramValidator,
-    listPages,
+    listPages({ getExcludedPaths }),
   );
 };
 

+ 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 createError from 'http-errors';
 import type { Document, Query } from 'mongoose';
 import { model } from 'mongoose';
 
@@ -17,6 +18,10 @@ export const generateBaseQuery = async (
   pagePath: string,
   user: IUser,
 ): Promise<PageQueryBuilder> => {
+  if (pagePath === '') {
+    throw createError(400, 'pagePath must not be empty');
+  }
+
   const Page = model<IPageHasId>('Page');
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   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', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
   it("returns 400 HTTP response when the query 'pagePath' is undefined", async () => {
     // setup
     const reqMock = mock<IListPagesRequest>();
+    reqMock.query = { pagePath: '' };
+
     const resMock = 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({ getExcludedPaths: () => [] });
+    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', () => {
@@ -58,12 +65,16 @@ describe('listPages', () => {
 
     const builderMock = mock<PageQueryBuilder>();
 
-    mocks.generateBaseQueryMock.mockResolvedValue(builderMock);
-    mocks.getToppageViewersCountMock.mockImplementation(() => 99);
-
     const queryMock = mock<PageQuery>();
     builderMock.query = queryMock;
 
+    beforeEach(() => {
+      mocks.generateBaseQueryMock.mockResolvedValue(builderMock);
+      mocks.getToppageViewersCountMock.mockImplementation(() => 99);
+
+      queryMock.and.mockReturnValue(queryMock);
+    });
+
     it('returns 200 HTTP response', async () => {
       // setup query.clone().count()
       const queryClonedMock = mock<PageQuery>();
@@ -85,7 +96,8 @@ describe('listPages', () => {
       resMock.status.calledWith(200).mockReturnValue(resStatusMock);
 
       // when
-      await listPages(reqMock, resMock);
+      const handler = listPages({ getExcludedPaths: () => [] });
+      await handler(reqMock, resMock);
 
       // then
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
@@ -118,7 +130,8 @@ describe('listPages', () => {
       resMock.status.calledWith(500).mockReturnValue(resStatusMock);
 
       // when
-      await listPages(reqMock, resMock);
+      const handler = listPages({ getExcludedPaths: () => [] });
+      await handler(reqMock, resMock);
 
       // then
       expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
@@ -147,7 +160,8 @@ describe('listPages', () => {
       resMock.status.calledWith(400).mockReturnValue(resStatusMock);
 
       // when
-      await listPages(reqMock, resMock);
+      const handler = listPages({ getExcludedPaths: () => [] });
+      await handler(reqMock, resMock);
 
       // then
       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>());
+
+    // getExcludedPaths returns empty array
+    const handler = listPages({ getExcludedPaths: () => [] });
+    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>());
+
+    // getExcludedPaths returns paths to exclude
+    const excludedPaths = ['/user', '/tmp'];
+    const handler = listPages({ getExcludedPaths: () => 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 },
+      },
+    ]);
+  });
+});

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

@@ -66,78 +66,91 @@ interface IListPagesRequest
   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 = ({
+  getExcludedPaths,
+}: {
+  getExcludedPaths: () => 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;
+
+      const excludedPaths = getExcludedPaths();
+      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.');
-  }
+  };
 };

+ 11 - 11
pnpm-lock.yaml

@@ -768,11 +768,11 @@ importers:
         specifier: '=2.1.0'
         version: 2.1.0(handsontable@6.2.2)
       '@headless-tree/core':
-        specifier: ^1.5.1
-        version: 1.5.1
+        specifier: ^1.5.3
+        version: 1.6.3
       '@headless-tree/react':
-        specifier: ^1.5.1
-        version: 1.5.1(@headless-tree/core@1.5.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+        specifier: ^1.5.3
+        version: 1.6.3(@headless-tree/core@1.6.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       '@next/bundle-analyzer':
         specifier: ^14.1.3
         version: 14.2.4
@@ -3085,11 +3085,11 @@ packages:
     peerDependencies:
       handsontable: '>=6.0.0'
 
-  '@headless-tree/core@1.5.1':
-    resolution: {integrity: sha512-uPoFcjPYdnXwuEDJd2oCMY8a4nnsMKyx6P0G1+is6dIGFpUsoV0qjtJN6ykJtOgTHVhBRR11zRmletER6Qgj/Q==}
+  '@headless-tree/core@1.6.3':
+    resolution: {integrity: sha512-en0EOaZfiCRF2B8DEnhGhSaUf3hVr9Bauye8G8aswPbHOKSyhJiN4bsczz1GvqF4Xb7Ga3LP0vLA6Zih7YLoyw==}
 
-  '@headless-tree/react@1.5.1':
-    resolution: {integrity: sha512-8r34ug5g25peTDgyGoCZf5Ohy4O0FMhdhUNyiaXzGn/1nwUDpmmwsrqh64DVNPereSA9uhY0s39uRibfvdmTqw==}
+  '@headless-tree/react@1.6.3':
+    resolution: {integrity: sha512-aiRwG6e2EPBSec9uLLy9GlTvAuCtSTouU30Nwcr5ZTsYjG/i7B/ouC8f8Zu4unzo/v1h5ztbemp+EH2TPTKh+g==}
     peerDependencies:
       '@headless-tree/core': '*'
       react: '*'
@@ -16695,11 +16695,11 @@ snapshots:
     dependencies:
       handsontable: 6.2.2
 
-  '@headless-tree/core@1.5.1': {}
+  '@headless-tree/core@1.6.3': {}
 
-  '@headless-tree/react@1.5.1(@headless-tree/core@1.5.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+  '@headless-tree/react@1.6.3(@headless-tree/core@1.6.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
     dependencies:
-      '@headless-tree/core': 1.5.1
+      '@headless-tree/core': 1.6.3
       react: 18.2.0
       react-dom: 18.2.0(react@18.2.0)