فهرست منبع

Merge branch 'master' into fix/144087-144091-use-drawio-in-slide-view

reiji-h 2 سال پیش
والد
کامیت
9a2652d919
63فایلهای تغییر یافته به همراه1062 افزوده شده و 742 حذف شده
  1. 3 2
      apps/app/package.json
  2. 4 2
      apps/app/public/static/locales/en_US/commons.json
  3. 8 1
      apps/app/public/static/locales/en_US/translation.json
  4. 4 2
      apps/app/public/static/locales/ja_JP/commons.json
  5. 9 2
      apps/app/public/static/locales/ja_JP/translation.json
  6. 4 2
      apps/app/public/static/locales/zh_CN/commons.json
  7. 8 1
      apps/app/public/static/locales/zh_CN/translation.json
  8. 10 2
      apps/app/src/client/services/page-operation.ts
  9. 8 12
      apps/app/src/client/util/bookmark-utils.ts
  10. 4 2
      apps/app/src/components/AuthorInfo/AuthorInfo.tsx
  11. 31 29
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  12. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  13. 4 4
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  14. 2 2
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  15. 3 3
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  16. 2 1
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx
  17. 1 9
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  18. 62 15
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  19. 2 2
      apps/app/src/components/ContentLinkButtons.tsx
  20. 2 2
      apps/app/src/components/Icons/FolderIcon.tsx
  21. 8 6
      apps/app/src/components/LoginForm/LoginForm.tsx
  22. 9 0
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.module.scss
  23. 68 46
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  24. 8 2
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  25. 15 6
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  26. 68 55
      apps/app/src/components/PageControls/PageControls.tsx
  27. 1 1
      apps/app/src/components/PageControls/SearchButton.tsx
  28. 10 9
      apps/app/src/components/PageEditor/PageEditor.tsx
  29. 1 1
      apps/app/src/components/PageHistory/Revision.tsx
  30. 2 2
      apps/app/src/components/PageList/PageListItemS.tsx
  31. 1 1
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  32. 2 19
      apps/app/src/components/SavePageControls.tsx
  33. 90 52
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  34. 0 38
      apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts
  35. 2 6
      apps/app/src/components/SearchPage/SearchControl.tsx
  36. 31 0
      apps/app/src/components/SearchPage/SearchModalTriggerinput.tsx
  37. 4 1
      apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss
  38. 1 1
      apps/app/src/components/Sidebar/Bookmarks.tsx
  39. 2 2
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  40. 2 1
      apps/app/src/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  41. 2 2
      apps/app/src/components/UsersHomepageFooter.tsx
  42. 4 12
      apps/app/src/features/external-user-group/client/stores/external-user-group.ts
  43. 7 1
      apps/app/src/features/search/client/components/SearchModal.tsx
  44. 9 4
      apps/app/src/features/search/client/stores/search.ts
  45. 1 1
      apps/app/src/interfaces/bookmark-info.ts
  46. 30 4
      apps/app/src/interfaces/page.ts
  47. 2 1
      apps/app/src/interfaces/user-ui-settings.ts
  48. 5 47
      apps/app/src/pages/[[...path]].page.tsx
  49. 3 3
      apps/app/src/pages/share/[[...path]].page.tsx
  50. 8 9
      apps/app/src/server/models/bookmark-folder.ts
  51. 1 1
      apps/app/src/server/models/user-group-relation.ts
  52. 5 1
      apps/app/src/server/models/user-ui-settings.ts
  53. 8 8
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  54. 0 2
      apps/app/src/server/routes/apiv3/index.js
  55. 0 54
      apps/app/src/server/routes/apiv3/me.ts
  56. 24 43
      apps/app/src/server/routes/apiv3/page/index.ts
  57. 127 43
      apps/app/src/server/service/page-grant.ts
  58. 3 3
      apps/app/src/stores/page.tsx
  59. 18 3
      apps/app/src/stores/ui.tsx
  60. 2 10
      apps/app/src/stores/user-group.tsx
  61. 164 18
      apps/app/test/integration/service/page-grant.test.ts
  62. 1 1
      packages/remark-lsx/package.json
  63. 141 126
      yarn.lock

+ 3 - 2
apps/app/package.json

@@ -113,7 +113,7 @@
     "escape-string-regexp": "^4.0.0",
     "eslint-plugin-regex": "^1.8.0",
     "expose-gc": "^1.0.0",
-    "express": "^4.16.1",
+    "express": "^4.19.2",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
     "express-session": "^1.16.1",
@@ -177,7 +177,7 @@
     "react-markdown": "^8.0.7",
     "react-multiline-clamp": "^2.0.0",
     "react-scroll": "^1.8.7",
-    "react-stickynode": "^4.1.0",
+    "react-stickynode": "^4.1.1",
     "react-syntax-highlighter": "^15.5.0",
     "react-toastify": "^9.1.3",
     "react-use-ripple": "^1.5.2",
@@ -234,6 +234,7 @@
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
+    "@types/react-stickynode": "^4.0.3",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",

+ 4 - 2
apps/app/public/static/locales/en_US/commons.json

@@ -61,7 +61,8 @@
     "no_notification": "You don't have any notificatios.",
     "all": "All",
     "unopend": "Unread",
-    "mark_all_as_read": "Mark all as read"
+    "mark_all_as_read": "Mark all as read",
+    "only_unread": "Only unread"
   },
 
   "personal_dropdown": {
@@ -93,7 +94,8 @@
     "Page URL": "Page URL",
     "Permanent link": "Permanent link",
     "Page path and permanent link": "Page path and permanent link",
-    "Markdown link": "Markdown link"
+    "Markdown link": "Markdown link",
+    "Append params": "Append params"
   },
 
   "crop_image_modal": {

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

@@ -87,6 +87,7 @@
   "Go to this version": "View this version",
   "View diff": "View diff",
   "No diff": "No diff",
+  "Latest": "Latest",
   "User ID": "User ID",
   "User Information": "User information",
   "User Activation": "User Activation",
@@ -126,6 +127,7 @@
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
+  "comments": "Comments",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -147,6 +149,7 @@
   "wide_view": "Wide View",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
+  "Bookmarks": "Bookmarks",
   "In-App Notification": "Notifications",
   "original_path": "Original path",
   "new_path": "New path",
@@ -173,6 +176,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
+  "author_info": {
+    "created_at": "Created at",
+    "last_revision_posted_at": "Last revision posted at"
+  },
   "installer": {
     "tab": "Create account",
     "title": "Installer",
@@ -785,7 +792,7 @@
     "paths_recovered": "Paths recovered successfully",
     "path_recovery_failed": "Path recovery failed"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
   },

+ 4 - 2
apps/app/public/static/locales/ja_JP/commons.json

@@ -63,7 +63,8 @@
     "no_notification": "通知はありません",
     "all": "全て",
     "unopend": "未読",
-    "mark_all_as_read": "全て既読にする"
+    "mark_all_as_read": "全て既読にする",
+    "only_unread": "未読のみ"
   },
 
   "personal_dropdown": {
@@ -95,7 +96,8 @@
     "Page URL": "ページURL",
     "Permanent link": "パーマリンク",
     "Page path and permanent link": "ページ名とパーマリンク",
-    "Markdown link": "マークダウン形式のリンク"
+    "Markdown link": "マークダウン形式のリンク",
+    "Append params": "パラメータの追加"
   },
 
   "crop_image_modal": {

+ 9 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -57,7 +57,7 @@
   "Timeline View": "タイムライン",
   "History": "更新履歴",
   "attachment_data": "添付データ",
-  "No_attachments_yet": "No attachments yet.",
+  "No_attachments_yet": "添付データはありません",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available in this version": "このバージョンでは利用できません",
@@ -83,6 +83,7 @@
   "Go to this version": "このバージョンを見る",
   "View diff": "差分を表示",
   "No diff": "差分なし",
+  "Latest": "最新",
   "User ID": "ユーザーID",
   "User Settings": "ユーザー設定",
   "User Information": "ユーザー情報",
@@ -125,6 +126,7 @@
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "page_list": "ページリスト",
+  "comments": "コメント",
   "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
@@ -148,6 +150,7 @@
   "wide_view": "ワイドビュー",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
+  "Bookmarks": "ブックマーク",
   "In-App Notification": "通知",
   "original_path": "元のパス",
   "new_path": "新しいパス",
@@ -174,6 +177,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
+  "author_info": {
+    "created_at": "作成日",
+    "last_revision_posted_at": "最終更新日"
+  },
   "installer": {
     "tab": "アカウント作成",
     "title": "インストーラー",
@@ -818,7 +825,7 @@
     "paths_recovered": "パスを修復しました",
     "path_recovery_failed": "パスを修復できませんでした"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
   },

+ 4 - 2
apps/app/public/static/locales/zh_CN/commons.json

@@ -64,7 +64,8 @@
     "no_notification": "您没有任何通知",
     "all": "全部",
     "unopend": "未读",
-    "mark_all_as_read" : "标记为已读"
+    "mark_all_as_read" : "标记为已读",
+    "only_unread": "Only unread"
   },
 
   "personal_dropdown": {
@@ -96,7 +97,8 @@
 		"Page URL": "页面Url",
 		"Parmanent link": "参数化链接",
 		"Page path and parmanent link": "页面路径及参数化链接",
-		"Markdown link": "Markdown链接"
+		"Markdown link": "Markdown链接",
+    "Append params": "Append params"
 	},
 
   "crop_image_modal": {

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

@@ -85,6 +85,7 @@
   "Go to this version": "查看此版本",
   "View diff": "查看差异",
   "No diff": "无差异",
+  "Latest": "最新",
   "User ID": "用户ID",
   "Home": "首页",
   "My Drafts": "My Drafts",
@@ -133,6 +134,7 @@
   "Only me": "只有我",
   "Only inside the group": "仅组内",
   "page_list": "Page List",
+  "comments": "Comments",
   "Reselect the group": "重新选择组",
   "Shareable link": "可分享链接",
   "The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
@@ -153,6 +155,7 @@
   "wide_view": "视野开阔",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
+  "Bookmarks": "书签",
   "In-App Notification": "通知",
   "original_path": "Original path",
   "new_path": "New path",
@@ -180,6 +183,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
+  "author_info": {
+    "created_at": "Created at",
+    "last_revision_posted_at": "Last revision posted at"
+  },
   "installer": {
     "tab": "创建账户",
     "title": "安装",
@@ -788,7 +795,7 @@
     "paths_recovered": "成功恢复了页面路径",
     "path_recovery_failed": "路径恢复失败"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
   },

+ 10 - 2
apps/app/src/client/services/page-operation.ts

@@ -5,7 +5,10 @@ import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 
 import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
-import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import {
+  useCurrentPageId, useSWRMUTxCurrentPage, useSWRxApplicableGrant, useSWRxTagsInfo,
+  useSWRxCurrentGrantData,
+} from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
@@ -99,6 +102,8 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { mutate: mutateCurrentGrantData } = useSWRxCurrentGrantData(pageId);
+  const { mutate: mutateApplicableGrant } = useSWRxApplicableGrant(pageId);
 
   // update swr 'currentPageId', 'currentPage', remote states
   return useCallback(async() => {
@@ -121,6 +126,9 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
       mutateEditingMarkdown(updatedPage.revision.body);
     }
 
+    mutateCurrentGrantData();
+    mutateApplicableGrant();
+
     const remoterevisionData = {
       remoteRevisionId: updatedPage.revision._id,
       remoteRevisionBody: updatedPage.revision.body,
@@ -131,7 +139,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
     setRemoteLatestPageData(remoterevisionData);
   },
   // eslint-disable-next-line max-len
-  [pageId, mutateTagsInfo, syncTagsInfoForEditor, mutateCurrentPageId, mutateCurrentPage, opts?.supressEditingMarkdownMutation, setRemoteLatestPageData, mutateEditingMarkdown]);
+  [pageId, mutateTagsInfo, syncTagsInfoForEditor, mutateCurrentPageId, mutateCurrentPage, opts?.supressEditingMarkdownMutation, mutateCurrentGrantData, mutateApplicableGrant, setRemoteLatestPageData, mutateEditingMarkdown]);
 };
 
 export const unlink = async(path: string): Promise<void> => {

+ 8 - 12
apps/app/src/client/util/bookmark-utils.ts

@@ -1,18 +1,12 @@
 import type { IRevision, Ref } from '@growi/core';
 
-import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, BookmarkedPage } from '~/interfaces/bookmark-info';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 
-// Check if bookmark folder item has children
-export const hasChildren = (item: BookmarkFolderItems | BookmarkFolderItems[]): boolean => {
-  if (item === null) {
-    return false;
-  }
-  if (Array.isArray(item)) {
-    return item.length > 0;
-  }
-  return item.children && item.children.length > 0;
+// Check if bookmark folder item has childFolder or bookmarks
+export const hasChildren = ({ childFolder, bookmarks }: { childFolder?: BookmarkFolderItems[], bookmarks?: BookmarkedPage[] }): boolean => {
+  return !!((childFolder && childFolder.length > 0) || (bookmarks && bookmarks.length > 0));
 };
 
 // Add new folder helper
@@ -41,8 +35,10 @@ export const toggleBookmark = async(pageId: string, status: boolean): Promise<vo
 };
 
 // Update Bookmark folder
-export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<void> => {
+export const updateBookmarkFolder = async(
+    bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[],
+): Promise<void> => {
   await apiv3Put('/bookmark-folder', {
-    bookmarkFolderId, name, parent, children,
+    bookmarkFolderId, name, parent, childFolder,
   });
 };

+ 4 - 2
apps/app/src/components/AuthorInfo/AuthorInfo.tsx

@@ -4,6 +4,7 @@ import type { IUser } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
 
@@ -18,6 +19,7 @@ export type AuthorInfoProps = {
 }
 
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
+  const { t } = useTranslation();
   const {
     date, user, mode = 'create', locate = 'subnav',
   } = props;
@@ -31,8 +33,8 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     ? 'Created by'
     : 'Updated by';
   const infoLabelForFooter = mode === 'create'
-    ? 'Created at'
-    : 'Last revision posted at';
+    ? t('author_info.created_at')
+    : t('author_info.last_revision_posted_at');
   const userLabel = user != null
     ? (
       <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>

+ 31 - 29
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -1,6 +1,5 @@
-import {
-  FC, useCallback, useState,
-} from 'react';
+import type { FC } from 'react';
+import { useCallback, useState } from 'react';
 
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
@@ -10,10 +9,9 @@ import {
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { FolderIcon } from '~/components/Icons/FolderIcon';
-import {
-  BookmarkFolderItems, DragItemDataType, DragItemType, DRAG_ITEM_TYPE,
-} from '~/interfaces/bookmark-info';
-import { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
+import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
+import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useBookmarkFolderDeleteModal } from '~/stores/modal';
 
 import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
@@ -42,7 +40,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   } = props;
 
   const {
-    name, _id: folderId, children, parent, bookmarks,
+    name, _id: folderId, childFolder, parent, bookmarks,
   } = bookmarkFolder;
 
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
@@ -52,7 +50,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
 
-  const childrenExists = hasChildren(children);
+  const childrenExists = hasChildren({ childFolder, bookmarks });
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
@@ -65,14 +63,14 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
     try {
       // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName, parent as any, children);
+      await updateBookmarkFolder(folderId, folderName, parent as any, childFolder);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, children, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, childFolder, folderId, parent]);
 
   // Create new folder / subfolder handler
   const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
@@ -99,7 +97,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.children);
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.childFolder);
           bookmarkFolderTreeMutation();
         }
       }
@@ -129,7 +127,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       // Maximum folder hierarchy of 2 levels
       // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
       // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
-      if (item.bookmarkFolder.children.length !== 0 || bookmarkFolder.parent != null) {
+      if (item.bookmarkFolder.childFolder.length !== 0 || bookmarkFolder.parent != null) {
         return false;
       }
 
@@ -142,9 +140,15 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     return true;
   };
 
+  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+    if (!childrenExists) {
+      return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
+    }
+    return `grw-foldertree-triangle-btn btn px-0 ${isOpen ? 'grw-foldertree-open' : ''}`;
+  };
 
   const renderChildFolder = () => {
-    return isOpen && children?.map((childFolder) => {
+    return isOpen && childFolder?.map((childFolder) => {
       return (
         <div key={childFolder._id} className="grw-foldertree-item-children">
           <BookmarkFolderItem
@@ -201,13 +205,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
     try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.children);
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.childFolder);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolder._id, bookmarkFolder.children, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+  }, [bookmarkFolder._id, bookmarkFolder.childFolder, bookmarkFolder.name, bookmarkFolderTreeMutation]);
 
   return (
     <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
@@ -221,22 +225,20 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         isDropable={isDropable}
       >
         <li
-          className="list-group-item list-group-item-action border-0 py-0 pe-3 d-flex align-items-center"
+          className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded"
           onClick={loadChildFolder}
           style={{ paddingLeft }}
         >
           <div className="grw-triangle-container d-flex justify-content-center">
-            {childrenExists && (
-              <button
-                type="button"
-                className={`grw-foldertree-triangle-btn btn ${isOpen ? 'grw-foldertree-open' : ''}`}
-                onClick={loadChildFolder}
-              >
-                <div className="d-flex justify-content-center">
-                  <span className="material-symbols-outlined">arrow_right</span>
-                </div>
-              </button>
-            )}
+            <button
+              type="button"
+              className={triangleBtnClassName(isOpen, childrenExists)}
+              onClick={loadChildFolder}
+            >
+              <div className="d-flex justify-content-center">
+                <span className="material-symbols-outlined">arrow_right</span>
+              </div>
+            </button>
           </div>
           <div>
             <FolderIcon isOpen={isOpen} />
@@ -249,7 +251,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             />
           ) : (
             <>
-              <div className="grw-foldertree-title-anchor ps-2">
+              <div className="grw-foldertree-title-anchor ps-1">
                 <p className="text-truncate m-auto ">{name}</p>
               </div>
             </>

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -156,7 +156,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                   />
                 </div>
-                {folder.children?.map(child => (
+                {folder.childFolder?.map(child => (
                   <div key={child._id}>
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"

+ 4 - 4
apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -33,6 +33,9 @@ $grw-bookmark-item-padding-left: 35px;
       .grw-visible-on-hover {
         display: block;
       }
+      .page-list-meta {
+        display: none;
+      }
     }
 
     .grw-foldertree-triangle-btn {
@@ -54,10 +57,7 @@ $grw-bookmark-item-padding-left: 35px;
 
   .grw-foldertree-item-container {
     .grw-triangle-container {
-      // TODO: ignore width frickering
-      // https://redmine.weseek.co.jp/issues/130828
-      // min-width: 35px;
-      height: 40px;
+      height:30px;
     }
 
     .grw-bookmark-item-list{

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

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
 import { toastSuccess } from '~/client/util/toastr';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import type { OnDeletedFunction } from '~/interfaces/ui';
 import {
   useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
 } from '~/stores/bookmark';
@@ -103,7 +103,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   return (
     <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}>
-      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
+      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}>
         {bookmarkFolders?.map((bookmarkFolder) => {
           return (
             <BookmarkFolderItem

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

@@ -39,7 +39,7 @@ type Props = {
 
 export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_FOLDER_PADDING = 15;
-  const BASE_BOOKMARK_PADDING = 20;
+  const BASE_BOOKMARK_PADDING = 16;
 
   const { t } = useTranslation();
   const router = useRouter();
@@ -56,7 +56,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level + 1));
+  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
   const dragItem: Partial<DragItemDataType> = {
     ...bookmarkedPage, parentFolder,
   };
@@ -148,7 +148,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       useDragMode={isOperable}
     >
       <li
-        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 me-auto d-flex align-items-center"
+        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 pe-1 me-auto d-flex align-items-center rounded"
         key={bookmarkedPage._id}
         id={bookmarkItemId}
         style={{ paddingLeft }}

+ 2 - 1
apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -120,6 +120,7 @@ export const CopyDropdown = (props) => {
 
         <DropdownMenu
           strategy="fixed"
+          container="body"
         >
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">
@@ -134,7 +135,7 @@ export const CopyDropdown = (props) => {
                   checked={isParamsAppended}
                   onChange={toggleAppendParams}
                 />
-                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>Append params</label>
+                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>{ t('copy_to_clipboard.Append params') }</label>
               </div>
             ) }
           </div>

+ 1 - 9
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -14,21 +14,13 @@
     z-index: bs.$zindex-sticky;
   }
 
+  // TODO:Responsive font size
   // set smaller font-size when sticky
   .sticky-inner-wrapper.active {
     h1 {
       font-size: 1.75rem !important;
     }
   }
-  // avoid sticky-top nav to turnate page path
-  .is-collapse-with-top {
-    @include bs.media-breakpoint-down(md) {
-      max-width: calc(100% - 350px);
-    }
-    @include bs.media-breakpoint-up(md) {
-      max-width: calc(100% - 500px);
-    }
-  }
 }
 
 .grw-page-path-nav :global {

+ 62 - 15
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -1,5 +1,8 @@
-import type { FC } from 'react';
-import React from 'react';
+import React, {
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
 
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -7,6 +10,9 @@ import dynamic from 'next/dynamic';
 import Sticky from 'react-stickynode';
 
 import { useIsNotFound } from '~/stores/page';
+import {
+  usePageControlsX, useCurrentProductNavWidth, useSidebarMode,
+} from '~/stores/ui';
 
 import LinkedPagePath from '../../../models/linked-page-path';
 import { PagePathHierarchicalLink } from '../PagePathHierarchicalLink';
@@ -25,6 +31,7 @@ type Props = {
   isCollapseParents?: boolean,
   formerLinkClassName?: string,
   latterLinkClassName?: string,
+  maxWidth?: number,
 }
 
 const CopyDropdown = dynamic(() => import('../CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
@@ -33,10 +40,10 @@ const Separator = ({ className }: {className?: string}): JSX.Element => {
   return <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>/</span>;
 };
 
-export const PagePathNav: FC<Props> = (props: Props) => {
+export const PagePathNav = (props: Props): JSX.Element => {
   const {
     pageId, pagePath, isWipPage, isSingleLineMode, isCollapseParents,
-    formerLinkClassName, latterLinkClassName,
+    formerLinkClassName, latterLinkClassName, maxWidth,
   } = props;
   const dPagePath = new DevidedPagePath(pagePath, false, true);
 
@@ -82,7 +89,7 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   const copyDropdownId = `copydropdown-${pageId}`;
 
   return (
-    <div>
+    <div style={{ maxWidth }}>
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
       <div className="d-flex align-items-center">
         <h1 className={`m-0 ${latterLinkClassName}`}>
@@ -103,25 +110,65 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   );
 };
 
+PagePathNav.displayName = 'PagePathNav';
+
 
 type PagePathNavStickyProps = Omit<Props, 'isCollapseParents'>;
 
 export const PagePathNavSticky = (props: PagePathNavStickyProps): JSX.Element => {
+
+  const { data: pageControlsX } = usePageControlsX();
+  const { data: sidebarWidth } = useCurrentProductNavWidth();
+  const { data: sidebarMode } = useSidebarMode();
+  const pagePathNavRef = useRef<HTMLDivElement>(null);
+
+  const [navMaxWidth, setNavMaxWidth] = useState<number | undefined>();
+
+  useEffect(() => {
+    if (pageControlsX == null || pagePathNavRef.current == null || sidebarWidth == null) {
+      return;
+    }
+    setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
+  }, [pageControlsX, pagePathNavRef, sidebarWidth]);
+
+  useEffect(() => {
+    // wait for the end of the animation of the opening and closing of the sidebar
+    const timeout = setTimeout(() => {
+      if (pageControlsX == null || pagePathNavRef.current == null || sidebarMode == null) {
+        return;
+      }
+      setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
+    }, 200);
+    return () => {
+      clearTimeout(timeout);
+    };
+  }, [pageControlsX, pagePathNavRef, sidebarMode]);
+
   return (
     // Controlling pointer-events
     //  1. disable pointer-events with 'pe-none'
-    <Sticky className={`${styles['grw-page-path-nav-sticky']} mb-4`} innerClass="mt-1 pe-none" innerActiveClass="active">
-      {({ status }: { status: boolean }) => {
-        const isCollapseParents = status === Sticky.STATUS_FIXED;
-        return (
+    <div ref={pagePathNavRef}>
+      <Sticky className={`${styles['grw-page-path-nav-sticky']} mb-4`} innerClass="mt-1 pe-none" innerActiveClass="active">
+        {({ status }) => {
+          const isCollapseParents = status === Sticky.STATUS_FIXED;
+          return (
           // Controlling pointer-events
           //  2. enable pointer-events with 'pe-auto' only against the children
           //      which width is minimized by 'd-inline-block'
-          <div className={`d-inline-block pe-auto ${isCollapseParents ? 'is-collapse-with-top' : ''}`}>
-            <PagePathNav {...props} isCollapseParents={isCollapseParents} latterLinkClassName={isCollapseParents ? 'fs-3  text-truncate' : 'fs-2'} />
-          </div>
-        );
-      }}
-    </Sticky>
+          //
+            <div className="d-inline-block pe-auto">
+              <PagePathNav
+                {...props}
+                isCollapseParents={isCollapseParents}
+                latterLinkClassName={isCollapseParents ? 'fs-3  text-truncate' : 'fs-2'}
+                maxWidth={isCollapseParents ? navMaxWidth : undefined}
+              />
+            </div>
+          );
+        }}
+      </Sticky>
+    </div>
   );
 };
+
+PagePathNavSticky.displayName = 'PagePathNavSticky';

+ 2 - 2
apps/app/src/components/ContentLinkButtons.tsx

@@ -13,7 +13,7 @@ const BookMarkLinkButton = React.memo(() => {
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
         <span className="material-symbols-outlined p-0">bookmark</span>
-        <span>{t('footer.bookmarks')}</span>
+        <span>{t('user_home_page.bookmarks')}</span>
       </button>
     </ScrollLink>
   );
@@ -30,7 +30,7 @@ const RecentlyCreatedLinkButton = React.memo(() => {
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
         <span className="growi-custom-icons mx-1">recently_created</span>
-        <span>{t('footer.recently_created')}</span>
+        <span>{t('user_home_page.recently_created')}</span>
       </button>
     </ScrollLink>
   );

+ 2 - 2
apps/app/src/components/Icons/FolderIcon.tsx

@@ -9,10 +9,10 @@ export const FolderIcon = (props: Props): JSX.Element => {
   return (
     <>
       {!isOpen ? (
-        <span className="material-symbols-outlined">folder_open</span>
+        <span className="material-symbols-outlined">folder</span>
 
       ) : (
-        <span className="material-symbols-outlined">folder</span>
+        <span className="material-symbols-outlined">folder_open</span>
       )
       }
     </>

+ 8 - 6
apps/app/src/components/LoginForm/LoginForm.tsx

@@ -272,15 +272,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
     return (
       <>
-        <div className="text-center text-line d-flex align-items-center mb-3">
-          <p className="text-white mb-0">{t('or')}</p>
-        </div>
         <div className="mt-2">
           { enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />) }
         </div>
       </>
     );
-  }, [props, t]);
+  }, [props]);
 
   const resetRegisterErrors = useCallback(() => {
     if (registerErrors.length === 0) return;
@@ -514,10 +511,15 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     <div className={moduleClass}>
       <div className="nologin-dialog mx-auto rounded-4 rounded-top-0" id="nologin-dialog" data-testid="login-form">
         <div className="row mx-0">
-          <div className="col-12 px-md-4">
+          <div className="col-12 px-md-4 pb-5">
             <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
+                {isLocalOrLdapStrategiesEnabled && isSomeExternalAuthEnabled && (
+                  <div className="text-center text-line d-flex align-items-center mb-3">
+                    <p className="text-white mb-0">{t('or')}</p>
+                  </div>
+                )}
                 {isSomeExternalAuthEnabled && renderExternalAuthLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
                   <div className="mt-4">
@@ -533,7 +535,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 )}
                 {/* Sign up link */}
                 {isRegistrationEnabled && (
-                  <div className="mt-2 mb-5">
+                  <div className="mt-2">
                     <a
                       href="#register"
                       className="btn btn-sm btn-secondary btn-function col-10 col-sm-9 mx-auto py-1 d-flex"

+ 9 - 0
apps/app/src/components/Navbar/GrowiContextualSubNavigation.module.scss

@@ -1,4 +1,13 @@
 @use '~/styles/mixins';
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+.grw-contextual-sub-navigation {
+  @include bs.media-breakpoint-down(md) {
+    // set min-height to keep the height
+    //  even if the item is empty on the share link page
+    min-height: 46px;
+  }
+}
 
 @include mixins.editing() {
   .grw-contextual-sub-navigation {

+ 68 - 46
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,4 +1,5 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useMemo } from 'react';
+
 
 import { isPopulated } from '@growi/core';
 import type {
@@ -10,6 +11,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
+import Sticky from 'react-stickynode';
 import { DropdownItem } from 'reactstrap';
 
 import { useShouldExpandContent } from '~/client/services/layout';
@@ -30,6 +32,7 @@ import { mutatePageTree } from '~/stores/page-listing';
 import {
   useEditorMode, useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
+  useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 import { CreateTemplateModal } from '../CreateTemplateModal';
@@ -194,12 +197,16 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
+  const [isStickyActive, setStickyActive] = useState(false);
+
+
   const path = currentPage?.path ?? currentPathname;
   // const grant = currentPage?.grant ?? grantData?.grant;
   // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
@@ -288,53 +295,68 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     );
   }, [isLinkSharingDisabled, isReadOnlyUser, pageId, revisionId]);
 
+  // hide sub controls when sticky on mobile device
+  const hideSubControls = useMemo(() => {
+    return !isDeviceLargerThanMd && isStickyActive;
+  }, [isDeviceLargerThanMd, isStickyActive]);
+
   return (
     <>
-      <GroundGlassBar
-        className={`${styles['grw-contextual-sub-navigation']}
-          d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
-        `}
-        data-testid="grw-contextual-sub-nav"
-        id="grw-contextual-sub-nav"
-      >
-        {pageId != null && (
-          <PageControls
-            pageId={pageId}
-            revisionId={revisionId}
-            shareLinkId={shareLinkId}
-            path={path ?? currentPathname} // If the page is empty, "path" is undefined
-            expandContentWidth={shouldExpandContent}
-            disableSeenUserInfoPopover={isSharedUser}
-            showPageControlDropdown={isAbleToShowPageManagement}
-            additionalMenuItemRenderer={additionalMenuItemsRenderer}
-            onClickDuplicateMenuItem={duplicateItemClickedHandler}
-            onClickRenameMenuItem={renameItemClickedHandler}
-            onClickDeleteMenuItem={deleteItemClickedHandler}
-            onClickSwitchContentWidth={switchContentWidthHandler}
-          />
-        )}
-
-        {isAbleToChangeEditorMode && (
-          <PageEditorModeManager
-            editorMode={editorMode}
-            isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
-            path={path}
-            // grant={grant}
-            // grantUserGroupId={grantUserGroupId}
-          />
-        )}
-
-        { isGuestUser && (
-          <div className="mt-2">
-            <Link href="/login#register" className="btn me-2" prefetch={false}>
-              <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
-            </Link>
-            <Link href="/login#login" className="btn btn-primary" prefetch={false}>
-              <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
-            </Link>
-          </div>
-        ) }
-      </GroundGlassBar>
+      <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
+
+      <Sticky className="z-1" onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}>
+        <GroundGlassBar>
+
+          <nav
+            className={`${styles['grw-contextual-sub-navigation']}
+              d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
+            `}
+            data-testid="grw-contextual-sub-nav"
+            id="grw-contextual-sub-nav"
+          >
+
+            {pageId != null && (
+              <PageControls
+                pageId={pageId}
+                revisionId={revisionId}
+                shareLinkId={shareLinkId}
+                path={path ?? currentPathname} // If the page is empty, "path" is undefined
+                expandContentWidth={shouldExpandContent}
+                disableSeenUserInfoPopover={isSharedUser}
+                hideSubControls={hideSubControls}
+                showPageControlDropdown={isAbleToShowPageManagement}
+                additionalMenuItemRenderer={additionalMenuItemsRenderer}
+                onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                onClickRenameMenuItem={renameItemClickedHandler}
+                onClickDeleteMenuItem={deleteItemClickedHandler}
+                onClickSwitchContentWidth={switchContentWidthHandler}
+              />
+            )}
+
+            {isAbleToChangeEditorMode && (
+              <PageEditorModeManager
+                editorMode={editorMode}
+                isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
+                path={path}
+                // grant={grant}
+                // grantUserGroupId={grantUserGroupId}
+              />
+            )}
+
+            { isGuestUser && (
+              <div className="mt-2">
+                <Link href="/login#register" className="btn me-2" prefetch={false}>
+                  <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
+                </Link>
+                <Link href="/login#login" className="btn btn-primary" prefetch={false}>
+                  <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
+                </Link>
+              </div>
+            ) }
+          </nav>
+
+        </GroundGlassBar>
+      </Sticky>
 
       {path != null && currentUser != null && !isReadOnlyUser && (
         <CreateTemplateModal

+ 8 - 2
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,4 +1,6 @@
-import React from 'react';
+import React, {
+  useCallback,
+} from 'react';
 
 import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsSearchPage } from '~/stores/context';
@@ -19,6 +21,10 @@ export const GrowiNavbarBottom = (): JSX.Element => {
   const { data: isSearchPage } = useIsSearchPage();
   const { open: openSearchModal } = useSearchModal();
 
+  const searchButtonClickHandler = useCallback(() => {
+    openSearchModal();
+  }, [openSearchModal]);
+
   return (
     <GroundGlassBar className={`
       ${styles['grw-navbar-bottom']}
@@ -54,7 +60,7 @@ export const GrowiNavbarBottom = (): JSX.Element => {
                 <a
                   role="button"
                   className="nav-link btn-lg"
-                  onClick={openSearchModal}
+                  onClick={searchButtonClickHandler}
                 >
                   <span className="material-symbols-outlined fs-2">search</span>
                 </a>

+ 15 - 6
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -8,10 +8,10 @@ import {
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { IPageGrantData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
 import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 
 type ModalProps = {
   isOpen: boolean
@@ -99,10 +99,19 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.userRelatedGrantedGroups == null || grantData.userRelatedGrantedGroups.length === 0) {
-        return t('fix_page_grant.modal.grant_label.isForbidden');
+      const groupGrantData = grantData.groupGrantData;
+      if (groupGrantData != null) {
+        const userRelatedGrantedGroups = groupGrantData.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted);
+        if (userRelatedGrantedGroups.length > 0) {
+          const grantedGroupNames = [
+            ...userRelatedGrantedGroups.map(group => group.name),
+            ...groupGrantData.nonUserRelatedGrantedGroups.map(group => group.name),
+          ];
+          return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantedGroupNames.join(', ')})`;
+        }
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.userRelatedGrantedGroups.map(g => g.name).join(', ')})`;
+
+      return t('fix_page_grant.modal.grant_label.isForbidden');
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -278,7 +287,7 @@ export const FixPageGrantAlert = (): JSX.Element => {
 
   const [isOpen, setOpen] = useState<boolean>(false);
 
-  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(currentUser != null ? pageId : null);
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(currentUser != null ? pageId : null);
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
 
   // Dependencies

+ 68 - 55
apps/app/src/components/PageControls/PageControls.tsx

@@ -1,4 +1,6 @@
-import React, { memo, useCallback, useMemo } from 'react';
+import React, {
+  memo, useCallback, useEffect, useMemo, useRef,
+} from 'react';
 
 import type {
   IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
@@ -6,6 +8,7 @@ import type {
 import {
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
+import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
@@ -15,7 +18,9 @@ import {
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
-import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import {
+  EditorMode, useEditorMode, useIsDeviceLargerThanMd, usePageControlsX,
+} from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
@@ -49,10 +54,10 @@ const Tags = (props: TagsProps): JSX.Element => {
     <div className="grw-tag-labels-container d-flex align-items-center">
       <button
         type="button"
-        className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
+        className="btn btn-sm btn-outline-neutral-secondary"
         onClick={onClickEditTagsButton}
       >
-        <span className="material-symbols-outlined me-2">local_offer</span>
+        <span className="material-symbols-outlined me-1">local_offer</span>
         Tags
       </button>
     </div>
@@ -94,7 +99,13 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
 
 
 type CommonProps = {
+  pageId: string,
+  shareLinkId?: string | null,
+  revisionId?: string | null,
+  path?: string | null,
+  expandContentWidth?: boolean,
   disableSeenUserInfoPopover?: boolean,
+  hideSubControls?: boolean,
   showPageControlDropdown?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
@@ -105,12 +116,7 @@ type CommonProps = {
 }
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageId: string,
-  shareLinkId?: string | null,
-  revisionId?: string | null,
-  path?: string | null,
   pageInfo: IPageInfoForOperation,
-  expandContentWidth?: boolean,
   onClickEditTagsButton: () => void,
 }
 
@@ -118,7 +124,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const {
     pageInfo,
     pageId, revisionId, path, shareLinkId, expandContentWidth,
-    disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
+    disableSeenUserInfoPopover, hideSubControls, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
     onClickEditTagsButton, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
@@ -132,6 +138,19 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
+  const { mutateAndSave: mutatePageControlsX } = usePageControlsX();
+
+  const pageControlsRef = useRef<HTMLDivElement>(null);
+  const [pageControlsRect] = useRect(pageControlsRef);
+
+  useEffect(() => {
+    if (pageControlsRect?.x == null) {
+      return;
+    }
+    mutatePageControlsX(pageControlsRect.x);
+  }, [pageControlsRect?.x, mutatePageControlsX]);
+
+
   // Put in a mixture of seenUserIds and likerIds data to make the cache work
   const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
   const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
@@ -253,46 +272,52 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const isViewMode = editorMode === EditorMode.View;
 
   return (
-    <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+    <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
       { isDeviceLargerThanMd && (
         <SearchButton />
       )}
+
       {revisionId != null && !isViewMode && _isIPageInfoForOperation && (
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
         />
       )}
-      {revisionId != null && _isIPageInfoForOperation && (
-        <SubscribeButton
-          status={pageInfo.subscriptionStatus}
-          onClick={subscribeClickhandler}
-        />
-      )}
-      {revisionId != null && _isIPageInfoForOperation && (
-        <LikeButtons
-          onLikeClicked={likeClickhandler}
-          sumOfLikers={sumOfLikers}
-          isLiked={isLiked}
-          likers={likers}
-        />
-      )}
-      {revisionId != null && _isIPageInfoForOperation && (
-        <BookmarkButtons
-          pageId={pageId}
-          isBookmarked={pageInfo.isBookmarked}
-          bookmarkCount={pageInfo.bookmarkCount}
-        />
-      )}
-      {revisionId != null && (
-        <SeenUserInfo
-          seenUsers={seenUsers}
-          sumOfSeenUsers={sumOfSeenUsers}
-          disabled={disableSeenUserInfoPopover}
-        />
+
+      { !hideSubControls && (
+        <div className="hstack gap-1">
+          {revisionId != null && _isIPageInfoForOperation && (
+            <SubscribeButton
+              status={pageInfo.subscriptionStatus}
+              onClick={subscribeClickhandler}
+            />
+          )}
+          {revisionId != null && _isIPageInfoForOperation && (
+            <LikeButtons
+              onLikeClicked={likeClickhandler}
+              sumOfLikers={sumOfLikers}
+              isLiked={isLiked}
+              likers={likers}
+            />
+          )}
+          {revisionId != null && _isIPageInfoForOperation && (
+            <BookmarkButtons
+              pageId={pageId}
+              isBookmarked={pageInfo.isBookmarked}
+              bookmarkCount={pageInfo.bookmarkCount}
+            />
+          )}
+          {revisionId != null && (
+            <SeenUserInfo
+              seenUsers={seenUsers}
+              sumOfSeenUsers={sumOfSeenUsers}
+              disabled={disableSeenUserInfoPopover}
+            />
+          ) }
+        </div>
       ) }
+
       { showPageControlDropdown && _isIPageInfoForOperation && (
         <PageItemControl
-          alignEnd
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
@@ -309,18 +334,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   );
 };
 
-type PageControlsProps = CommonProps & {
-  pageId: string,
-  shareLinkId?: string | null,
-  revisionId?: string | null,
-  path?: string | null,
-  expandContentWidth?: boolean,
-};
+type PageControlsProps = CommonProps;
 
 export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   const {
-    pageId, revisionId, path, shareLinkId, expandContentWidth,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
+    pageId, revisionId, shareLinkId,
+    ...rest
   } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
@@ -344,17 +363,11 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
 
   return (
     <PageControlsSubstance
-      {...props}
       pageInfo={pageInfo}
       pageId={pageId}
       revisionId={revisionId}
-      path={path}
       onClickEditTagsButton={onClickEditTagsButton}
-      onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-      onClickRenameMenuItem={onClickRenameMenuItem}
-      onClickDeleteMenuItem={onClickDeleteMenuItem}
-      onClickSwitchContentWidth={onClickSwitchContentWidth}
-      expandContentWidth={expandContentWidth}
+      {...rest}
     />
   );
 });

+ 1 - 1
apps/app/src/components/PageControls/SearchButton.tsx

@@ -17,7 +17,7 @@ const SearchButton = (): JSX.Element => {
   return (
     <button
       type="button"
-      className={`me-3 btn btn-search ${styles['btn-search']}`}
+      className={`btn btn-search ${styles['btn-search']}`}
       onClick={searchButtonClickHandler}
       data-testid="open-search-modal-button"
     >

+ 10 - 9
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -36,7 +36,7 @@ import {
   useWaitingSaveProcessing,
 } from '~/stores/editor';
 import {
-  useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData,
+  useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData, useSWRxCurrentGrantData,
 } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
@@ -93,7 +93,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: grantData } = useSelectedGrant();
+  const { data: selectedGrant } = useSelectedGrant();
   const { data: editingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: templateBodyData } = useTemplateBodyData();
@@ -105,6 +105,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: defaultIndentSize } = useDefaultIndentSize();
   const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
   const { data: editorSettings } = useEditorSettings();
+  const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(currentPage?._id);
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
   const onConflict = useConflictResolver();
@@ -166,9 +167,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const scrollPreviewHandlerThrottle = useMemo(() => throttle(25, scrollPreviewHandler), [scrollPreviewHandler]);
 
   const save: Save = useCallback(async(revisionId, markdown, opts, onConflict) => {
-    if (pageId == null || grantData == null) {
+    if (pageId == null || selectedGrant == null) {
       logger.error('Some materials to save are invalid', {
-        pageId, grantData,
+        pageId, selectedGrant,
       });
       throw new Error('Some materials to save are invalid');
     }
@@ -181,16 +182,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         revisionId,
         wip: opts?.wip,
         body: markdown ?? '',
-        grant: grantData?.grant,
+        grant: selectedGrant?.grant,
         origin: Origin.Editor,
-        userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
-          return { item: group.id, type: group.type };
-        }),
+        userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
         ...(opts ?? {}),
       });
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       mutatePageTree();
+      // sync current grant data after update
+      mutateIsGrantNormalized();
 
       return page;
     }
@@ -210,7 +211,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     finally {
       mutateWaitingSaveProcessing(false);
     }
-  }, [pageId, grantData, mutateWaitingSaveProcessing, t]);
+  }, [pageId, selectedGrant, mutateWaitingSaveProcessing, t, mutateIsGrantNormalized]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
     const markdown = codeMirrorEditor?.getDoc();

+ 1 - 1
apps/app/src/components/PageHistory/Revision.tsx

@@ -64,7 +64,7 @@ export const Revision = (props: RevisionProps): JSX.Element => {
         <div className="ms-2">
           <div className="revision-history-author mb-1">
             <strong><Username user={author}></Username></strong>
-            { isLatestRevision && <span className="badge bg-info ms-2">Latest</span> }
+            { isLatestRevision && <span className="badge bg-info ms-2">{t('Latest')}</span> }
           </div>
           <div className="mb-1">
             <UserDate dateTime={revision.createdAt} />

+ 2 - 2
apps/app/src/components/PageList/PageListItemS.tsx

@@ -35,14 +35,14 @@ export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
       <UserPicture user={page.lastUpdateUser} noLink={noLink} />
       {isNarrowView ? (
         <Clamp lines={2}>
-          <div className={`mx-2 ${styles['page-title']} ${noLink ? 'text-break' : ''}`}>
+          <div className={`mx-1 ${styles['page-title']} ${noLink ? 'text-break' : ''}`}>
             {pagePathElement}
           </div>
         </Clamp>
       ) : (
         pagePathElement
       )}
-      <span className="ms-2">
+      <span className="ms-1">
         <PageListMeta page={page} shouldSpaceOutIcon />
       </span>
     </>

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

@@ -122,7 +122,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
           <div className="d-flex" data-testid="page-comment-button">
             <PageAccessoriesControl
               icon={<span className="material-symbols-outlined">chat</span>}
-              label="Comments"
+              label={t('comments')}
               count={pageInfo != null ? (pageInfo as IPageInfoForOperation).commentCount : undefined}
               onClick={() => scroller.scrollTo('comments-container', { smooth: false, offset: -120 })}
             />

+ 2 - 19
apps/app/src/components/SavePageControls.tsx

@@ -10,7 +10,6 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem, Modal,
 } from 'reactstrap';
 
-import type { IPageGrantData } from '~/interfaces/page';
 import {
   useIsEditable, useIsAclEnabled,
   useIsSlackConfigured,
@@ -18,7 +17,6 @@ import {
 import { useWaitingSaveProcessing, useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxCurrentPage, useCurrentPagePath } from '~/stores/page';
 import {
-  useSelectedGrant,
   useEditorMode, useIsDeviceLargerThanMd,
 
 } from '~/stores/ui';
@@ -131,7 +129,6 @@ export const SavePageControls = (): JSX.Element | null => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: isEditable } = useIsEditable();
   const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
 
   const { data: editorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
@@ -161,11 +158,7 @@ export const SavePageControls = (): JSX.Element | null => {
     setSlackChannels(slackChannels);
   }, []);
 
-  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
-    mutateGrant(grantData);
-  }, [mutateGrant]);
-
-  if (isEditable == null || isAclEnabled == null || grantData == null) {
+  if (isEditable == null || isAclEnabled == null) {
     return null;
   }
 
@@ -173,8 +166,6 @@ export const SavePageControls = (): JSX.Element | null => {
     return null;
   }
 
-  const { grant, userRelatedGrantedGroups } = grantData;
-
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
 
   return (
@@ -201,12 +192,7 @@ export const SavePageControls = (): JSX.Element | null => {
             {
               isAclEnabled && (
                 <div className="me-2">
-                  <GrantSelector
-                    grant={grant}
-                    disabled={isGrantSelectorDisabledPage}
-                    userRelatedGrantedGroups={userRelatedGrantedGroups}
-                    onUpdateGrant={updateGrantHandler}
-                  />
+                  <GrantSelector disabled={isGrantSelectorDisabledPage} />
                 </div>
               )
             }
@@ -233,11 +219,8 @@ export const SavePageControls = (): JSX.Element | null => {
                   isAclEnabled && (
                     <>
                       <GrantSelector
-                        grant={grant}
                         disabled={isGrantSelectorDisabledPage}
                         openInModal
-                        userRelatedGrantedGroups={userRelatedGrantedGroups}
-                        onUpdateGrant={updateGrantHandler}
                       />
                     </>
                   )

+ 90 - 52
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -1,7 +1,7 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 import {
-  PageGrant, isPopulated, GroupType, type IGrantedGroup,
+  PageGrant, GroupType, getIdForRef,
 } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -12,10 +12,12 @@ import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
-import type { IPageGrantData } from '~/interfaces/page';
+import type { UserRelatedGroupsData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
+import { useCurrentPageId, useSWRxCurrentGrantData } from '~/stores/page';
+import { useSelectedGrant } from '~/stores/ui';
 
-import { useMyUserGroups } from './use-my-user-groups';
 
 const AVAILABLE_GRANTS = [
   {
@@ -41,14 +43,6 @@ const AVAILABLE_GRANTS = [
 type Props = {
   disabled?: boolean,
   openInModal?: boolean,
-  grant: PageGrant,
-  userRelatedGrantedGroups?: {
-    id: string,
-    name: string,
-    type: GroupType,
-  }[]
-
-  onUpdateGrant?: (grantData: IPageGrantData) => void,
 }
 
 /**
@@ -60,9 +54,6 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const {
     disabled,
     openInModal,
-    userRelatedGrantedGroups,
-    onUpdateGrant,
-    grant: currentGrant,
   } = props;
 
 
@@ -71,12 +62,35 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
 
   const shouldFetch = isSelectGroupModalShown;
-  const { data: myUserGroups, update: updateMyUserGroups } = useMyUserGroups(shouldFetch);
+  const { data: selectedGrant, mutate: mutateSelectedGrant } = useSelectedGrant();
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: grantData } = useSWRxCurrentGrantData(currentPageId);
+
+  const currentPageGrantData = grantData?.grantData.currentPageGrant;
+  const groupGrantData = currentPageGrantData?.groupGrantData;
+
+  const applyCurrentPageGrantToSelectedGrant = useCallback(() => {
+    const currentPageGrant = grantData?.grantData.currentPageGrant;
+    if (currentPageGrant == null) return;
+
+    const userRelatedGrantedGroups = currentPageGrant.groupGrantData
+      ?.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted)?.map((group) => {
+        return { item: group.id, type: group.type };
+      }) ?? [];
+    mutateSelectedGrant({
+      grant: currentPageGrant.grant,
+      userRelatedGrantedGroups,
+    });
+  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant]);
+
+  // sync grant data
+  useEffect(() => {
+    applyCurrentPageGrantToSelectedGrant();
+  }, [applyCurrentPageGrantToSelectedGrant]);
 
   const showSelectGroupModal = useCallback(() => {
-    updateMyUserGroups();
     setIsSelectGroupModalShown(true);
-  }, [updateMyUserGroups]);
+  }, []);
 
   /**
    * change event handler for grant selector
@@ -84,28 +98,27 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const changeGrantHandler = useCallback((grant: PageGrant) => {
     // select group
     if (grant === 5) {
+      if (selectedGrant?.grant !== 5) applyCurrentPageGrantToSelectedGrant();
       showSelectGroupModal();
       return;
     }
 
-    if (onUpdateGrant != null) {
-      onUpdateGrant({ grant, userRelatedGrantedGroups: undefined });
+    mutateSelectedGrant({ grant, userRelatedGrantedGroups: undefined });
+  }, [mutateSelectedGrant, showSelectGroupModal, applyCurrentPageGrantToSelectedGrant, selectedGrant?.grant]);
+
+  const groupListItemClickHandler = useCallback((clickedGroup: UserRelatedGroupsData) => {
+    const userRelatedGrantedGroups = selectedGrant?.userRelatedGrantedGroups ?? [];
+
+    let userRelatedGrantedGroupsCopy = [...userRelatedGrantedGroups];
+    if (userRelatedGrantedGroupsCopy.find(group => getIdForRef(group.item) === clickedGroup.id) == null) {
+      const grantGroupInfo = { item: clickedGroup.id, type: clickedGroup.type };
+      userRelatedGrantedGroupsCopy.push(grantGroupInfo);
     }
-  }, [onUpdateGrant, showSelectGroupModal]);
-
-  const groupListItemClickHandler = useCallback((grantGroup: IGrantedGroup) => {
-    if (onUpdateGrant != null && isPopulated(grantGroup.item)) {
-      let userRelatedGrantedGroupsCopy = userRelatedGrantedGroups != null ? [...userRelatedGrantedGroups] : [];
-      const grantGroupInfo = { id: grantGroup.item._id, name: grantGroup.item.name, type: grantGroup.type };
-      if (userRelatedGrantedGroupsCopy.find(group => group.id === grantGroupInfo.id) == null) {
-        userRelatedGrantedGroupsCopy.push(grantGroupInfo);
-      }
-      else {
-        userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => group.id !== grantGroupInfo.id);
-      }
-      onUpdateGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
+    else {
+      userRelatedGrantedGroupsCopy = userRelatedGrantedGroupsCopy.filter(group => getIdForRef(group.item) !== clickedGroup.id);
     }
-  }, [onUpdateGrant, userRelatedGrantedGroups]);
+    mutateSelectedGrant({ grant: 5, userRelatedGrantedGroups: userRelatedGrantedGroupsCopy });
+  }, [mutateSelectedGrant, selectedGrant?.userRelatedGrantedGroups]);
 
   /**
    * Render grant selector DOM.
@@ -115,8 +128,13 @@ export const GrantSelector = (props: Props): JSX.Element => {
     let dropdownToggleBtnColor;
     let dropdownToggleLabelElm;
 
+    const userRelatedGrantedGroups = groupGrantData?.userRelatedGroups.filter((group) => {
+      return selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id);
+    }) ?? [];
+    const nonUserRelatedGrantedGroups = groupGrantData?.nonUserRelatedGrantedGroups ?? [];
+
     const dropdownMenuElems = AVAILABLE_GRANTS.map((opt) => {
-      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0)
+      const label = ((opt.grant === 5 && opt.reselectLabel != null) && userRelatedGrantedGroups.length > 0)
         ? opt.reselectLabel // when grantGroup is selected
         : opt.label;
 
@@ -128,7 +146,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       );
 
       // set dropdownToggleBtnColor, dropdownToggleLabelElm
-      if (opt.grant === 1 || opt.grant === currentGrant) {
+      if (opt.grant === 1 || opt.grant === selectedGrant?.grant) {
         dropdownToggleBtnColor = opt.btnStyleClass;
         dropdownToggleLabelElm = labelElm;
       }
@@ -137,19 +155,20 @@ export const GrantSelector = (props: Props): JSX.Element => {
     });
 
     // add specified group option
-    if (userRelatedGrantedGroups != null && userRelatedGrantedGroups.length > 0) {
+    if (selectedGrant?.grant === PageGrant.GRANT_USER_GROUP && (userRelatedGrantedGroups.length > 0 || nonUserRelatedGrantedGroups.length > 0)) {
+      const grantedGroupNames = [...userRelatedGrantedGroups.map(group => group.name), ...nonUserRelatedGrantedGroups.map(group => group.name)];
       const labelElm = (
         <span>
           <span className="material-symbols-outlined me-1">account_tree</span>
           <span className="label">
-            {userRelatedGrantedGroups.length > 1
+            {grantedGroupNames.length > 1
               ? (
               // substring for group name truncate
                 <span>
-                  {`${userRelatedGrantedGroups[0].name.substring(0, 30)}, ... `}
-                  <span className="badge bg-primary">+{userRelatedGrantedGroups.length - 1}</span>
+                  {`${grantedGroupNames[0].substring(0, 30)}, ... `}
+                  <span className="badge bg-primary">+{grantedGroupNames.length - 1}</span>
                 </span>
-              ) : userRelatedGrantedGroups[0].name.substring(0, 30)}
+              ) : grantedGroupNames[0].substring(0, 30)}
           </span>
         </span>
       );
@@ -177,7 +196,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t, openInModal]);
+  }, [changeGrantHandler, disabled, groupGrantData, selectedGrant, t, openInModal]);
 
   /**
    * Render select grantgroup modal.
@@ -188,7 +207,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     // show spinner
-    if (myUserGroups == null) {
+    if (groupGrantData == null) {
       return (
         <div className="my-3 text-center">
           <LoadingSpinner className="mx-auto text-muted fs-4" />
@@ -196,7 +215,9 @@ export const GrantSelector = (props: Props): JSX.Element => {
       );
     }
 
-    if (myUserGroups.length === 0) {
+    const { userRelatedGroups, nonUserRelatedGrantedGroups } = groupGrantData;
+
+    if (userRelatedGroups.length === 0) {
       return (
         <div>
           <h4>{t('user_group.belonging_to_no_group')}</h4>
@@ -209,20 +230,37 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
     return (
       <div className="d-flex flex-column">
-        { myUserGroups.map((group) => {
-          const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
-          const activeClass = groupIsGranted ? 'active' : '';
+        { userRelatedGroups.map((group) => {
+          const isGroupGranted = selectedGrant?.userRelatedGrantedGroups?.some(grantedGroup => getIdForRef(grantedGroup.item) === group.id);
+          const cannotGrantGroup = group.status === UserGroupPageGrantStatus.cannotGrant;
+          const activeClass = isGroupGranted ? 'active' : '';
 
           return (
             <button
               className={`btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3 ${activeClass}`}
               type="button"
-              key={group.item._id}
+              key={group.id}
               onClick={() => groupListItemClickHandler(group)}
+              disabled={cannotGrantGroup}
+            >
+              <input type="checkbox" checked={isGroupGranted} disabled={cannotGrantGroup} />
+              <p className="ms-3 mb-0">{group.name}</p>
+              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.provider}</span>}
+              {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+            </button>
+          );
+        }) }
+        { nonUserRelatedGrantedGroups.map((group) => {
+          return (
+            <button
+              className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3 active"
+              type="button"
+              key={group.id}
+              disabled
             >
-              <input type="checkbox" checked={groupIsGranted} />
-              <p className="ms-3 mb-0">{group.item.name}</p>
-              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.item.provider}</span>}
+              <input type="checkbox" checked disabled />
+              <p className="ms-3 mb-0">{group.name}</p>
+              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
@@ -231,7 +269,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
       </div>
     );
 
-  }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
+  }, [currentUser?.admin, groupListItemClickHandler, shouldFetch, t, groupGrantData, selectedGrant?.userRelatedGrantedGroups]);
 
   const renderModalCloseButton = useCallback(() => {
     return (

+ 0 - 38
apps/app/src/components/SavePageControls/GrantSelector/use-my-user-groups.ts

@@ -1,38 +0,0 @@
-import { GroupType } from '@growi/core';
-
-import { useSWRxMyExternalUserGroups } from '~/features/external-user-group/client/stores/external-user-group';
-import { useSWRxMyUserGroups } from '~/stores/user-group';
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const useMyUserGroups = (shouldFetch: boolean) => {
-  const { data: myUserGroups, mutate: mutateMyUserGroups } = useSWRxMyUserGroups(shouldFetch);
-  const { data: myExternalUserGroups, mutate: mutateMyExternalUserGroups } = useSWRxMyExternalUserGroups(shouldFetch);
-
-  const update = () => {
-    mutateMyUserGroups();
-    mutateMyExternalUserGroups();
-  };
-
-  if (myUserGroups == null || myExternalUserGroups == null) {
-    return { data: null, update };
-  }
-
-  const myUserGroupsData = myUserGroups
-    .map((group) => {
-      return {
-        item: group,
-        type: GroupType.userGroup,
-      };
-    });
-  const myExternalUserGroupsData = myExternalUserGroups
-    .map((group) => {
-      return {
-        item: group,
-        type: GroupType.externalUserGroup,
-      };
-    });
-
-  const data = [...myUserGroupsData, ...myExternalUserGroupsData];
-
-  return { data, update };
-};

+ 2 - 6
apps/app/src/components/SearchPage/SearchControl.tsx

@@ -7,8 +7,7 @@ import { useTranslation } from 'next-i18next';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import type { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 
-import SearchForm from '../SearchForm';
-
+import { SearchModalTriggerinput } from './SearchModalTriggerinput';
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
 
@@ -88,11 +87,8 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
     <div className="shadow-sm">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
-          <SearchForm
-            isSearchServiceReachable={isSearchServiceReachable}
+          <SearchModalTriggerinput
             keywordOnInit={keyword}
-            disableIncrementalSearch
-            onSubmit={searchFormSubmittedHandler}
           />
         </div>
       </div>

+ 31 - 0
apps/app/src/components/SearchPage/SearchModalTriggerinput.tsx

@@ -0,0 +1,31 @@
+import React, {
+  useCallback,
+} from 'react';
+
+import { useSearchModal } from '../../features/search/client/stores/search';
+
+type Props = {
+  keywordOnInit: string,
+};
+
+export const SearchModalTriggerinput: React.FC<Props> = (props: Props) => {
+  const { keywordOnInit } = props;
+
+  const { open: openSearchModal } = useSearchModal();
+
+  const inputClickHandler = useCallback(() => {
+    openSearchModal(keywordOnInit);
+  }, [openSearchModal, keywordOnInit]);
+
+  return (
+    <div>
+      <input
+        className="form-control"
+        type="input"
+        value={keywordOnInit}
+        onClick={inputClickHandler}
+        readOnly
+      />
+    </div>
+  );
+};

+ 4 - 1
apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -46,7 +46,10 @@
   $grw-page-editor-mode-manager-width: 90px;
   $grw-contextual-subnavigation-padding-right: 12px;
   $gap: 8px;
-  width: calc(100vw - #{$grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
+
+  @include bs.media-breakpoint-up(sm) {
+    width: calc(100vw - #{$grw-page-controls-width + $grw-page-editor-mode-manager-width + $grw-contextual-subnavigation-padding-right + $gap * 2});
+  }
 
   @include bs.media-breakpoint-up(md) {
     $grw-page-editor-mode-manager-width: 140px;

+ 1 - 1
apps/app/src/components/Sidebar/Bookmarks.tsx

@@ -17,7 +17,7 @@ export const Bookmarks = () : JSX.Element => {
         <h4 className="mb-0 py-4">{t('Bookmarks')}</h4>
       </div>
       {isGuestUser ? (
-        <h4 className="ps-3">
+        <h4 className="fs-6">
           { t('Not available for guest') }
         </h4>
       ) : (

+ 2 - 2
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -37,8 +37,8 @@ export const BookmarkContents = (): JSX.Element => {
   }, [mutateBookmarkFolders]);
 
   return (
-    <div className="ms-3">
-      <div className="col-8 mb-2">
+    <div>
+      <div className="mb-2">
         <button
           type="button"
           className="btn btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"

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

@@ -12,11 +12,12 @@ type InAppNotificationFormsProps = {
 }
 export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.Element => {
   const { onChangeUnopendNotificationsVisible } = props;
+  const { t } = useTranslation('commons');
 
   return (
     <div className="my-2">
       <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">Only unread</label>
+        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">{t('in_app_notification.only_unread')}</label>
         <input
           id="flexSwitchCheckDefault"
           className="form-check-input"

+ 2 - 2
apps/app/src/components/UsersHomepageFooter.tsx

@@ -25,7 +25,7 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
       <div className="grw-user-page-list-m d-edit-none">
         <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
           <span style={{ fontSize: '1.3em' }} className="material-symbols-outlined">bookmark</span>
-          {t('footer.bookmarks')}
+          {t('user_home_page.bookmarks')}
           <span className="ms-auto ps-2 ">
             <button type="button" className={`btn btn-sm grw-expand-compress-btn ${isExpanded ? 'active' : ''}`} onClick={() => setIsExpanded(!isExpanded)}>
               {isExpanded ? <span className="material-symbols-outlined">expand</span> : <span className="material-symbols-outlined">compress</span>}
@@ -40,7 +40,7 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
       <div className="grw-user-page-list-m mt-5 d-edit-none">
         <h2 id="recently-created-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
           <span className="growi-custom-icons me-1">recently_created</span>
-          {t('footer.recently_created')}
+          {t('user_home_page.recently_created')}
         </h2>
         <div id="user-created-list" className={`page-list ${styles['page-list']}`}>
           <RecentCreated userId={creatorId} />

+ 4 - 12
apps/app/src/features/external-user-group/client/stores/external-user-group.ts

@@ -1,14 +1,13 @@
 import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import {
+import type {
   IExternalUserGroupHasId, IExternalUserGroupRelationHasId, KeycloakGroupSyncSettings, LdapGroupSyncSettings,
 } from '~/features/external-user-group/interfaces/external-user-group';
-import {
-  ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupListResult, UserGroupRelationListResult,
-} from '~/interfaces/user-group-response';
+import type { ChildUserGroupListResult, IUserGroupRelationHasIdPopulatedUser, UserGroupRelationListResult } from '~/interfaces/user-group-response';
 
 export const useSWRxLdapGroupSyncSettings = (): SWRResponse<LdapGroupSyncSettings, Error> => {
   return useSWR(
@@ -28,13 +27,6 @@ export const useSWRxKeycloakGroupSyncSettings = (): SWRResponse<KeycloakGroupSyn
   );
 };
 
-export const useSWRxMyExternalUserGroups = (shouldFetch: boolean): SWRResponse<IExternalUserGroupHasId[], Error> => {
-  return useSWR(
-    shouldFetch ? '/me/external-user-groups' : null,
-    endpoint => apiv3Get<UserGroupListResult<IExternalUserGroupHasId>>(endpoint).then(result => result.data.userGroups),
-  );
-};
-
 export const useSWRxExternalUserGroup = (groupId: string | null): SWRResponse<IExternalUserGroupHasId, Error> => {
   return useSWRImmutable(
     groupId != null ? `/external-user-groups/${groupId}` : null,

+ 7 - 1
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -50,9 +50,15 @@ const SearchModal = (): JSX.Element => {
 
   useEffect(() => {
     if (!searchModalData?.isOpened) {
+      return;
+    }
+    if (searchModalData?.searchKeyword == null) {
       setSearchKeyword('');
     }
-  }, [searchModalData?.isOpened]);
+    else {
+      setSearchKeyword(searchModalData.searchKeyword);
+    }
+  }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
 
   return (
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal} data-testid="search-modal">

+ 9 - 4
apps/app/src/features/search/client/stores/search.ts

@@ -1,13 +1,16 @@
-import { SWRResponse } from 'swr';
+import { useCallback } from 'react';
+
+import type { SWRResponse } from 'swr';
 
 import { useStaticSWR } from '~/stores/use-static-swr';
 
 type SearchModalStatus = {
   isOpened: boolean,
+  searchKeyword?: string,
 }
 
 type SearchModalUtils = {
-  open(): void
+  open(keywordOnInit?: string): void
   close(): void
 }
 export const useSearchModal = (status?: SearchModalStatus): SWRResponse<SearchModalStatus, Error> & SearchModalUtils => {
@@ -16,7 +19,9 @@ export const useSearchModal = (status?: SearchModalStatus): SWRResponse<SearchMo
 
   return {
     ...swrResponse,
-    open: () => swrResponse.mutate({ isOpened: true }),
-    close: () => swrResponse.mutate({ isOpened: false }),
+    open: useCallback((keywordOnInit?: string) => {
+      swrResponse.mutate({ isOpened: true, searchKeyword: keywordOnInit });
+    }, [swrResponse]),
+    close: useCallback(() => swrResponse.mutate({ isOpened: false }), [swrResponse]),
   };
 };

+ 1 - 1
apps/app/src/interfaces/bookmark-info.ts

@@ -24,7 +24,7 @@ export interface IBookmarkFolder {
 
 export interface BookmarkFolderItems extends IBookmarkFolder {
   _id: string;
-  children: BookmarkFolderItems[];
+  childFolder: BookmarkFolderItems[];
   bookmarks: BookmarkedPage[];
 }
 

+ 30 - 4
apps/app/src/interfaces/page.ts

@@ -2,6 +2,8 @@ import type {
   GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant, Origin,
 } from '@growi/core';
 
+import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
+
 import type { IPageOperationProcessData } from './page-operation';
 
 export {
@@ -10,13 +12,37 @@ export {
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean, processData?: IPageOperationProcessData}>;
 
-export type IPageGrantData = {
-  grant: PageGrant,
-  userRelatedGrantedGroups?: {
+export const UserGroupPageGrantStatus = {
+  isGranted: 'isGranted',
+  notGranted: 'notGranted',
+  cannotGrant: 'cannotGrant',
+};
+type UserGroupPageGrantStatus = typeof UserGroupPageGrantStatus[keyof typeof UserGroupPageGrantStatus];
+export type UserRelatedGroupsData = {
+  id: string,
+  name: string,
+  type: GroupType,
+  provider?: ExternalGroupProviderType,
+  status: UserGroupPageGrantStatus,
+}
+export type GroupGrantData = {
+  userRelatedGroups: UserRelatedGroupsData[],
+  nonUserRelatedGrantedGroups: {
     id: string,
     name: string,
     type: GroupType,
-  }[]
+    provider?: ExternalGroupProviderType,
+  }[],
+}
+// current grant data of page
+export type IPageGrantData = {
+  grant: PageGrant,
+  groupGrantData?: GroupGrantData,
+}
+// grant selected by user which is not yet applied
+export type IPageSelectedGrant = {
+  grant: PageGrant,
+  userRelatedGrantedGroups?: IGrantedGroup[]
 }
 
 export type IDeleteSinglePageApiv1Result = {

+ 2 - 1
apps/app/src/interfaces/user-ui-settings.ts

@@ -1,7 +1,8 @@
-import { SidebarContentsType } from './ui';
+import type { SidebarContentsType } from './ui';
 
 export interface IUserUISettings {
   currentSidebarContents: SidebarContentsType,
+  currentPageControlsX: number,
   currentProductNavWidth: number,
   preferCollapsedModeByUser: boolean,
 }

+ 5 - 47
apps/app/src/pages/[[...path]].page.tsx

@@ -3,9 +3,8 @@ import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
-import { isIPageInfoForEntity, isPopulated } from '@growi/core';
+import { isIPageInfoForEntity } from '@growi/core';
 import type {
-  GroupType,
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision,
 } from '@growi/core';
 import {
@@ -26,7 +25,6 @@ import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
@@ -44,7 +42,7 @@ import {
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
-  useSWRxCurrentPage, useSWRMUTxCurrentPage, useSWRxIsGrantNormalized, useCurrentPageId,
+  useSWRxCurrentPage, useSWRMUTxCurrentPage, useCurrentPageId,
   useIsNotFound, useIsLatestRevision, useTemplateTagData, useTemplateBodyData,
 } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
@@ -172,8 +170,6 @@ type Props = CommonProps & {
   skipSSR: boolean,
   ssrMaxRevisionBodyLength: number,
 
-  grantData?: IPageGrantData,
-
   rendererConfig: RendererConfig,
 };
 
@@ -241,9 +237,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
 
-  const { data: grantData } = useSWRxIsGrantNormalized(pageId);
-  const { mutate: mutateSelectedGrant } = useSelectedGrant();
-
   const { mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
 
   const { mutate: mutateTemplateTagData } = useTemplateTagData();
@@ -270,12 +263,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     }
   }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
 
-  // sync grant data
-  useEffect(() => {
-    const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;
-    mutateSelectedGrant(grantDataToApply);
-  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant, props.grantData]);
-
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
     const decodedURI = decodeURI(window.location.pathname);
@@ -329,9 +316,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
         <title>{title}</title>
       </Head>
       <div className="dynamic-layout-root justify-content-between">
-        <nav className="sticky-top">
-          <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
-        </nav>
+
+        <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
 
         <DisplaySwitcher
           pageView={(
@@ -413,7 +399,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 
   const Page = crowi.model('Page') as PageModel;
   const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager, pageGrantService } = crowi;
+  const { pageService, configManager } = crowi;
 
   let currentPathname = props.currentPathname;
 
@@ -457,34 +443,6 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
     await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
   }
 
-  if (page == null && user != null) {
-    const templateData = await Page.findTemplate(props.currentPathname);
-    if (templateData != null) {
-      props.templateTagData = templateData.templateTags as string[];
-      props.templateBodyData = templateData.templateBody as string;
-    }
-
-    // apply parent page grant, without groups that user isn't related to
-    const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
-    if (ancestor != null) {
-      ancestor.populate('grantedGroups.item');
-      const userRelatedGrantedGroups = (await pageGrantService.getUserRelatedGrantedGroups(ancestor, user)).map((group) => {
-        if (isPopulated(group.item)) {
-          return {
-            id: group.item._id,
-            name: group.item.name,
-            type: group.type,
-          };
-        }
-        return null;
-      }).filter((info): info is NonNullable<{id: string, name: string, type: GroupType}> => info != null);
-      props.grantData = {
-        grant: ancestor.grant,
-        userRelatedGrantedGroups,
-      };
-    }
-  }
-
   props.pageWithMeta = pageWithMeta;
 }
 

+ 3 - 3
apps/app/src/pages/share/[[...path]].page.tsx

@@ -32,6 +32,7 @@ import {
   getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, skipSSR, addActivity,
 } from '../utils/commons';
 
+
 const logger = loggerFactory('growi:next-page:share');
 
 type Props = CommonProps & {
@@ -120,9 +121,8 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
       </Head>
 
       <div className="dynamic-layout-root justify-content-between">
-        <nav className="sticky-top">
-          <GrowiContextualSubNavigationForSharedPage page={currentPage ?? props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
-        </nav>
+
+        <GrowiContextualSubNavigationForSharedPage page={currentPage ?? props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
 
         <ShareLinkPageView
           pagePath={pagePath}

+ 8 - 9
apps/app/src/server/models/bookmark-folder.ts

@@ -1,10 +1,9 @@
 import type { IPageHasId } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
-import monggoose, {
-  Types, Document, Model, Schema,
-} from 'mongoose';
+import type { Types, Document, Model } from 'mongoose';
+import monggoose, { Schema } from 'mongoose';
 
-import { BookmarkFolderItems, IBookmarkFolder } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, IBookmarkFolder } from '~/interfaces/bookmark-info';
 
 import loggerFactory from '../../utils/logger';
 import { getOrCreateModel } from '../util/mongoose-utils';
@@ -21,13 +20,13 @@ export interface BookmarkFolderDocument extends Document {
   owner: Types.ObjectId
   parent?: Types.ObjectId | undefined
   bookmarks?: Types.ObjectId[],
-  children?: BookmarkFolderDocument[]
+  childFolder?: BookmarkFolderDocument[]
 }
 
 export interface BookmarkFolderModel extends Model<BookmarkFolderDocument>{
   createByParameters(params: IBookmarkFolder): Promise<BookmarkFolderDocument>
   deleteFolderAndChildren(bookmarkFolderId: Types.ObjectId | string): Promise<{deletedCount: number}>
-  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<BookmarkFolderDocument>
+  updateBookmarkFolder(bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[]): Promise<BookmarkFolderDocument>
   insertOrUpdateBookmarkedPage(pageId: IPageHasId, userId: Types.ObjectId | string, folderId: string | null): Promise<BookmarkFolderDocument | null>
   updateBookmark(pageId: Types.ObjectId | string, status: boolean, userId: Types.ObjectId| string): Promise<BookmarkFolderDocument | null>
 }
@@ -51,7 +50,7 @@ const bookmarkFolderSchema = new Schema<BookmarkFolderDocument, BookmarkFolderMo
   toObject: { virtuals: true },
 });
 
-bookmarkFolderSchema.virtual('children', {
+bookmarkFolderSchema.virtual('childFolder', {
   ref: 'BookmarkFolder',
   localField: '_id',
   foreignField: 'parent',
@@ -108,7 +107,7 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
     bookmarkFolderId: string,
     name: string,
     parentId: string | null,
-    children: BookmarkFolderItems[],
+    childFolder: BookmarkFolderItems[],
 ):
  Promise<BookmarkFolderDocument> {
   const updateFields: {name: string, parent: Types.ObjectId | null} = {
@@ -127,7 +126,7 @@ bookmarkFolderSchema.statics.updateBookmarkFolder = async function(
     if (parentFolder?.parent != null) {
       throw new Error('Update bookmark folder failed');
     }
-    if (children.length !== 0) {
+    if (childFolder.length !== 0) {
       throw new Error('Update bookmark folder failed');
     }
   }

+ 1 - 1
apps/app/src/server/models/user-group-relation.ts

@@ -1,5 +1,5 @@
 import {
-  getIdForRef, isPopulated, type IUserGroupHasId, type IUserGroupRelation,
+  getIdForRef, isPopulated, type IUserGroupRelation,
 } from '@growi/core';
 import type { Model, Document } from 'mongoose';
 import mongoose, { Schema } from 'mongoose';

+ 5 - 1
apps/app/src/server/models/user-ui-settings.ts

@@ -1,6 +1,7 @@
 import type { Ref, IUser } from '@growi/core';
+import type { Model, Document } from 'mongoose';
 import {
-  Schema, Model, Document,
+  Schema,
 } from 'mongoose';
 
 
@@ -22,6 +23,9 @@ const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
     enum: SidebarContentsType,
     default: SidebarContentsType.RECENT,
   },
+  currentPageControlsX: {
+    type: Number,
+  },
   currentProductNavWidth: { type: Number },
   preferCollapsedModeByUser: { type: Boolean, default: false },
 });

+ 8 - 8
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -1,8 +1,8 @@
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
-import { Types } from 'mongoose';
+import type { Types } from 'mongoose';
 
-import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { InvalidParentBookmarkFolderError } from '~/server/models/errors';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
@@ -25,7 +25,7 @@ const validator = {
           throw new Error('Maximum folder hierarchy of 2 levels');
         }
       }),
-    body('children').optional().isArray().withMessage('Children must be an array'),
+    body('childFolder').optional().isArray().withMessage('Children must be an array'),
     body('bookmarkFolderId').optional().isMongoId().withMessage('Bookark Folder ID must be a valid mongo ID'),
   ],
   bookmarkPage: [
@@ -73,7 +73,7 @@ module.exports = (crowi) => {
         parentFolderId?: Types.ObjectId | string,
     ) => {
       const folders = await BookmarkFolder.find({ owner: userId, parent: parentFolderId })
-        .populate('children')
+        .populate('childFolder')
         .populate({
           path: 'bookmarks',
           model: 'Bookmark',
@@ -90,7 +90,7 @@ module.exports = (crowi) => {
       const returnValue: BookmarkFolderItems[] = [];
 
       const promises = folders.map(async(folder: BookmarkFolderItems) => {
-        const children = await getBookmarkFolders(userId, folder._id);
+        const childFolder = await getBookmarkFolders(userId, folder._id);
 
         // !! DO NOT THIS SERIALIZING OUTSIDE OF PROMISES !! -- 05.23.2023 ryoji-s
         // Serializing outside of promises will cause not populated.
@@ -101,7 +101,7 @@ module.exports = (crowi) => {
           name: folder.name,
           owner: folder.owner,
           bookmarks,
-          children,
+          childFolder,
           parent: folder.parent,
         };
         return res;
@@ -139,10 +139,10 @@ module.exports = (crowi) => {
 
   router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarkFolder, async(req, res) => {
     const {
-      bookmarkFolderId, name, parent, children,
+      bookmarkFolderId, name, parent, childFolder,
     } = req.body;
     try {
-      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, children);
+      const bookmarkFolder = await BookmarkFolder.updateBookmarkFolder(bookmarkFolderId, name, parent, childFolder);
       return res.apiv3({ bookmarkFolder });
     }
     catch (err) {

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

@@ -118,7 +118,5 @@ module.exports = (crowi, app) => {
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
   router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
 
-  router.use('/me', require('./me')(crowi));
-
   return [router, routerForAdmin, routerForAuth];
 };

+ 0 - 54
apps/app/src/server/routes/apiv3/me.ts

@@ -1,54 +0,0 @@
-import { type IUserHasId } from '@growi/core';
-import { Router, Request } from 'express';
-
-import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import loggerFactory from '~/utils/logger';
-
-import UserGroupRelation from '../../models/user-group-relation';
-
-import { ApiV3Response } from './interfaces/apiv3-response';
-
-const logger = loggerFactory('growi:routes:apiv3:me');
-
-const router = Router();
-
-interface AuthorizedRequest extends Request {
-  user?: IUserHasId
-}
-
-module.exports = function(crowi) {
-  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-
-  const ApiResponse = require('../../util/apiResponse');
-
-  /**
-   * retrieve user-group documents
-   */
-  router.get('/user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const userGroups = await UserGroupRelation.findAllGroupsForUser(req.user);
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (e) {
-      logger.error(e);
-      return res.apiv3Err(e, 500);
-    }
-  });
-
-  /**
-   * retrieve external-user-group-relation documents
-   */
-  router.get('/external-user-groups', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
-    try {
-      const userGroups = await ExternalUserGroupRelation.findAllGroupsForUser(req.user);
-      return res.json(ApiResponse.success({ userGroups }));
-    }
-    catch (e) {
-      logger.error(e);
-      return res.apiv3Err(e, 500);
-    }
-  });
-
-  return router;
-};

+ 24 - 43
apps/app/src/server/routes/apiv3/page/index.js → apps/app/src/server/routes/apiv3/page/index.ts

@@ -1,23 +1,25 @@
 import path from 'path';
 
+import type { IPage } from '@growi/core';
 import {
   AllSubscriptionStatusType, SubscriptionStatusType,
 } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-utils';
+import mongoose from 'mongoose';
 import sanitize from 'sanitize-filename';
 
-import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IPageGrantData } from '~/interfaces/page';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { excludeReadOnlyUser } from '~/server/middlewares/exclude-read-only-user';
 import { GlobalNotificationSettingEvent } from '~/server/models';
+import type { PageModel } from '~/server/models/page';
 import Subscription from '~/server/models/subscription';
-import UserGroup from '~/server/models/user-group';
 import { configManager } from '~/server/service/config-manager';
+import type { IPageGrantService } from '~/server/service/page-grant';
 import { preNotifyService } from '~/server/service/pre-notify';
-import { divideByType } from '~/server/util/granted-group';
 import loggerFactory from '~/utils/logger';
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
@@ -466,8 +468,7 @@ module.exports = (crowi) => {
       return res.apiv3Err(err, 500);
     }
 
-    const result = { page };
-    result.seenUser = page.seenUsers;
+    const result = { page, seenUser: page.seenUsers };
 
     const parameters = {
       targetModel: SupportedTargetModel.MODEL_PAGE,
@@ -538,12 +539,12 @@ module.exports = (crowi) => {
   /**
    * @swagger
    *
-   *    /page/is-grant-normalized:
+   *    /page/grant-data:
    *      get:
    *        tags: [Page]
    *        summary: /page/info
-   *        description: Retrieve current page's isGrantNormalized value
-   *        operationId: getIsGrantNormalized
+   *        description: Retrieve current page's grant data
+   *        operationId: getPageGrantData
    *        parameters:
    *          - name: pageId
    *            in: query
@@ -552,7 +553,7 @@ module.exports = (crowi) => {
    *              $ref: '#/components/schemas/Page/properties/_id'
    *        responses:
    *          200:
-   *            description: Successfully retrieved current isGrantNormalized.
+   *            description: Successfully retrieved current grant data.
    *            content:
    *              application/json:
    *                schema:
@@ -565,10 +566,12 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.get('/is-grant-normalized', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
+  router.get('/grant-data', loginRequiredStrictly, validator.isGrantNormalized, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
 
-    const Page = crowi.model('Page');
+    const Page = mongoose.model<IPage, PageModel>('Page');
+    const pageGrantService = crowi.pageGrantService as IPageGrantService;
+
     const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
 
     if (page == null) {
@@ -579,29 +582,19 @@ module.exports = (crowi) => {
     const {
       path, grant, grantedUsers, grantedGroups,
     } = page;
-
-    let isGrantNormalized;
+    let isGrantNormalized = false;
     try {
-      isGrantNormalized = await crowi.pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
+      isGrantNormalized = await pageGrantService.isGrantNormalized(req.user, path, grant, grantedUsers, grantedGroups, false, false);
     }
     catch (err) {
       logger.error('Error occurred while processing isGrantNormalized.', err);
       return res.apiv3Err(err, 500);
     }
 
-    const userRelatedGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(page, req.user);
-    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(userRelatedGrantedGroups);
-    const currentPageUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroups } });
-    const currentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
-    const grantedUserGroupData = currentPageUserGroups.map((group) => {
-      return { id: group._id, name: group.name, type: 'UserGroup' };
-    });
-    const grantedExternalUserGroupData = currentPageExternalUserGroups.map((group) => {
-      return { id: group._id, name: group.name, type: 'ExternalUserGroup' };
-    });
-    const currentPageGrant = {
-      grant,
-      userRelatedGrantedGroups: [...grantedUserGroupData, ...grantedExternalUserGroupData],
+    const currentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(page, req.user);
+    const currentPageGrant: IPageGrantData = {
+      grant: page.grant,
+      groupGrantData: currentPageGroupGrantData,
     };
 
     // page doesn't have parent page
@@ -626,22 +619,10 @@ module.exports = (crowi) => {
       return res.apiv3({ isGrantNormalized, grantData });
     }
 
-    const userRelatedParentGrantedGroups = await crowi.pageGrantService.getUserRelatedGrantedGroups(parentPage, req.user);
-    const {
-      grantedUserGroups: parentGrantedUserGroupIds,
-      grantedExternalUserGroups: parentGrantedExternalUserGroupIds,
-    } = divideByType(userRelatedParentGrantedGroups);
-    const parentPageUserGroups = await UserGroup.find({ _id: { $in: parentGrantedUserGroupIds } });
-    const parentPageExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: parentGrantedExternalUserGroupIds } });
-    const parentGrantedUserGroupData = parentPageUserGroups.map((group) => {
-      return { id: group._id, name: group.name };
-    });
-    const parentGrantedExternalUserGroupData = parentPageExternalUserGroups.map((group) => {
-      return { id: group._id, name: group.name };
-    });
-    const parentPageGrant = {
-      grant: parentPage.grant,
-      userRelatedGrantedGroups: [...parentGrantedUserGroupData, ...parentGrantedExternalUserGroupData],
+    const parentPageGroupGrantData = await pageGrantService.getPageGroupGrantData(parentPage, req.user);
+    const parentPageGrant: IPageGrantData = {
+      grant,
+      groupGrantData: parentPageGroupGrantData,
     };
 
     const grantData = {

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

@@ -1,3 +1,4 @@
+import type { IPage } from '@growi/core';
 import {
   type IGrantedGroup,
   PageGrant, GroupType, getIdForRef, isPopulated,
@@ -8,8 +9,11 @@ import {
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose from 'mongoose';
 
+import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
+import type { UserRelatedGroupsData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus, type GroupGrantData } from '~/interfaces/page';
 import type { IRecordApplicableGrant, PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import UserGroup from '~/server/models/user-group';
@@ -98,9 +102,12 @@ export interface IPageGrantService {
     userRelatedGroups: PopulatedGrantedGroup[], previousGrantedGroups: IGrantedGroup[], grant?: PageGrant, grantedGroups?: IGrantedGroup[],
   ) => boolean,
   getUserRelatedGroups: (user) => Promise<PopulatedGrantedGroup[]>,
+  getPopulatedGrantedGroups: (grantedGroups: IGrantedGroup[]) => Promise<PopulatedGrantedGroup[]>,
   getUserRelatedGrantedGroups: (page: PageDocument, user) => Promise<IGrantedGroup[]>,
   getUserRelatedGrantedGroupsSyncronously: (userRelatedGroups: PopulatedGrantedGroup[], page: PageDocument) => IGrantedGroup[],
-  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean
+  isUserGrantedPageAccess: (page: PageDocument, user, userRelatedGroups: PopulatedGrantedGroup[]) => boolean,
+  getPageGroupGrantData: (page: PageDocument, user) => Promise<GroupGrantData>,
+  calcApplicableGrantData: (page, user) => Promise<IRecordApplicableGrant>
 }
 
 class PageGrantService implements IPageGrantService {
@@ -112,7 +119,7 @@ class PageGrantService implements IPageGrantService {
   }
 
   private validateComparableTarget(comparable: ComparableTarget) {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     const { grant, grantedUserIds, grantedGroupIds } = comparable;
 
@@ -134,7 +141,7 @@ class PageGrantService implements IPageGrantService {
      */
     this.validateComparableTarget(target);
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     /*
      * ancestor side
@@ -284,53 +291,45 @@ class PageGrantService implements IPageGrantService {
    * Prepare ComparableTarget
    * @returns Promise<ComparableAncestor>
    */
-  private async generateComparableTarget(
-      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined, includeApplicable: boolean,
+  private async generateComparableTargetWithApplicableData(
+      grant: PageGrant | undefined, grantedUserIds: ObjectIdLike[] | undefined, grantedGroupIds: IGrantedGroup[] | undefined,
   ): Promise<ComparableTarget> {
-    if (includeApplicable) {
-      const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
-      let applicableUserIds: ObjectIdLike[] | undefined;
-      let applicableGroupIds: ObjectIdLike[] | undefined;
-
-      if (grant === Page.GRANT_USER_GROUP) {
-        if (grantedGroupIds == null || grantedGroupIds.length === 0) {
-          throw Error('Target user group is not given');
-        }
+    let applicableUserIds: ObjectIdLike[] | undefined;
+    let applicableGroupIds: ObjectIdLike[] | undefined;
 
-        const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroupIds);
-        const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
-        const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
-        if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
-          throw Error('Target user group does not exist');
-        }
+    if (grant === Page.GRANT_USER_GROUP) {
+      if (grantedGroupIds == null || grantedGroupIds.length === 0) {
+        throw Error('Target user group is not given');
+      }
 
-        const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: targetUserGroups.map(g => g._id) } });
-        const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: targetExternalUserGroups.map(g => g._id) } });
-        applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(u => u.relatedUser as ObjectIdLike)));
-
-        const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
-          return UserGroup.findGroupsWithDescendantsById(group._id);
-        }))).flat();
-        const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
-          return ExternalUserGroup.findGroupsWithDescendantsById(group._id);
-        }))).flat();
-        applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
+      const { grantedUserGroups: grantedUserGroupIds, grantedExternalUserGroups: grantedExternalUserGroupIds } = divideByType(grantedGroupIds);
+      const targetUserGroups = await UserGroup.find({ _id: { $in: grantedUserGroupIds } });
+      const targetExternalUserGroups = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroupIds } });
+      if (targetUserGroups.length === 0 && targetExternalUserGroups.length === 0) {
+        throw Error('Target user group does not exist');
       }
 
-      return {
-        grant,
-        grantedUserIds,
-        grantedGroupIds,
-        applicableUserIds,
-        applicableGroupIds,
-      };
+      const userGroupRelations = await UserGroupRelation.find({ relatedGroup: { $in: targetUserGroups.map(g => g._id) } });
+      const externalUserGroupRelations = await ExternalUserGroupRelation.find({ relatedGroup: { $in: targetExternalUserGroups.map(g => g._id) } });
+      applicableUserIds = Array.from(new Set([...userGroupRelations, ...externalUserGroupRelations].map(u => u.relatedUser as ObjectIdLike)));
+
+      const applicableUserGroups = (await Promise.all(targetUserGroups.map((group) => {
+        return UserGroup.findGroupsWithDescendantsById(group._id);
+      }))).flat();
+      const applicableExternalUserGroups = (await Promise.all(targetExternalUserGroups.map((group) => {
+        return ExternalUserGroup.findGroupsWithDescendantsById(group._id);
+      }))).flat();
+      applicableGroupIds = [...applicableUserGroups, ...applicableExternalUserGroups].map(g => g._id);
     }
 
     return {
       grant,
       grantedUserIds,
       grantedGroupIds,
+      applicableUserIds,
+      applicableGroupIds,
     };
   }
 
@@ -340,7 +339,7 @@ class PageGrantService implements IPageGrantService {
    * @returns Promise<ComparableAncestor>
    */
   private async generateComparableAncestor(targetPath: string, includeNotMigratedPages: boolean): Promise<ComparableAncestor> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const { PageQueryBuilder } = Page;
 
     let applicableUserIds: ObjectIdLike[] | undefined;
@@ -395,7 +394,7 @@ class PageGrantService implements IPageGrantService {
    * @returns ComparableDescendants
    */
   private async generateComparableDescendants(targetPath: string, user, includeNotMigratedPages = false): Promise<ComparableDescendants> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     // Build conditions
     const $match: {$or: any} = {
@@ -515,11 +514,11 @@ class PageGrantService implements IPageGrantService {
     const comparableAncestor = await this.generateComparableAncestor(targetPath, includeNotMigratedPages);
 
     if (!shouldCheckDescendants) { // checking the parent is enough
-      const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, false);
+      const comparableTarget: ComparableTarget = { grant, grantedUserIds, grantedGroupIds };
       return this.validateGrant(comparableTarget, comparableAncestor);
     }
 
-    const comparableTarget = await this.generateComparableTarget(grant, grantedUserIds, grantedGroupIds, true);
+    const comparableTarget = await this.generateComparableTargetWithApplicableData(grant, grantedUserIds, grantedGroupIds);
     const comparableDescendants = await this.generateComparableDescendants(targetPath, user, includeNotMigratedPages);
 
     return this.validateGrant(comparableTarget, comparableAncestor, comparableDescendants);
@@ -564,7 +563,7 @@ class PageGrantService implements IPageGrantService {
   }
 
   async calcApplicableGrantData(page, user): Promise<IRecordApplicableGrant> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
 
     // -- Public only if top page
     const isOnlyPublicApplicable = isTopPage(page.path);
@@ -655,6 +654,77 @@ class PageGrantService implements IPageGrantService {
     return data;
   }
 
+  /**
+   * Get the group grant data of page.
+   * To calculate if a group can be granted to page, the same logic as isGrantNormalized will be executed, except only the ancestor info will be used.
+   */
+  async getPageGroupGrantData(page: PageDocument, user): Promise<GroupGrantData> {
+    if (isTopPage(page.path)) {
+      return { userRelatedGroups: [], nonUserRelatedGrantedGroups: [] };
+    }
+
+    const userRelatedGroups = await this.getUserRelatedGroups(user);
+    let userRelatedGroupsData: UserRelatedGroupsData[] = userRelatedGroups.map((group) => {
+      const provider = group.type === GroupType.externalUserGroup ? group.item.provider : undefined;
+      return {
+        // default status as notGranted
+        id: group.item._id.toString(), name: group.item.name, type: group.type, provider, status: UserGroupPageGrantStatus.notGranted,
+      };
+    });
+
+    const nonUserRelatedGrantedGroups: {
+      id: string,
+      name: string,
+      type: GroupType,
+      provider?: ExternalGroupProviderType,
+    }[] = [];
+
+    const populatedGrantedGroups = await this.getPopulatedGrantedGroups(page.grantedGroups);
+
+    // Set the status of user-related granted groups as isGranted
+    // Append non-user-related granted groups to nonUserRelatedGrantedGroups
+    populatedGrantedGroups.forEach((group) => {
+      const userRelatedGrantedGroup = userRelatedGroupsData.find((userRelatedGroup) => {
+        return userRelatedGroup.id === group.item._id.toString();
+      });
+      if (userRelatedGrantedGroup != null) {
+        userRelatedGrantedGroup.status = UserGroupPageGrantStatus.isGranted;
+      }
+      else {
+        const provider = group.type === GroupType.externalUserGroup ? group.item.provider : undefined;
+        nonUserRelatedGrantedGroups.push({
+          id: group.item._id.toString(), name: group.item.name, type: group.type, provider,
+        });
+      }
+    });
+
+    // Check if group can be granted to page for non-granted groups
+    const grantedUserIds = page.grantedUsers?.map(user => getIdForRef(user)) ?? [];
+    const comparableAncestor = await this.generateComparableAncestor(page.path, false);
+    userRelatedGroupsData = userRelatedGroupsData.map((groupData) => {
+      if (groupData.status === UserGroupPageGrantStatus.isGranted) {
+        return groupData;
+      }
+      const groupsToGrant = [...(page.grantedGroups ?? []), { item: groupData.id, type: groupData.type }];
+      const comparableTarget: ComparableTarget = {
+        grant: PageGrant.GRANT_USER_GROUP,
+        grantedUserIds,
+        grantedGroupIds: groupsToGrant,
+      };
+      const status = this.validateGrant(comparableTarget, comparableAncestor) ? UserGroupPageGrantStatus.notGranted : UserGroupPageGrantStatus.cannotGrant;
+      return { ...groupData, status };
+    });
+
+    const statusPriority = {
+      [UserGroupPageGrantStatus.notGranted]: 0,
+      [UserGroupPageGrantStatus.isGranted]: 1,
+      [UserGroupPageGrantStatus.cannotGrant]: 2,
+    };
+    userRelatedGroupsData.sort((a, b) => statusPriority[a.status] - statusPriority[b.status]);
+
+    return { userRelatedGroups: userRelatedGroupsData, nonUserRelatedGrantedGroups };
+  }
+
   /*
    * get all groups that user is related to
    */
@@ -671,6 +741,20 @@ class PageGrantService implements IPageGrantService {
     ];
   }
 
+  async getPopulatedGrantedGroups(grantedGroups: IGrantedGroup[]): Promise<PopulatedGrantedGroup[]> {
+    const { grantedUserGroups, grantedExternalUserGroups } = divideByType(grantedGroups);
+    const userGroupDocuments = await UserGroup.find({ _id: { $in: grantedUserGroups } });
+    const externalUserGroupDocuments = await ExternalUserGroup.find({ _id: { $in: grantedExternalUserGroups } });
+    return [
+      ...(userGroupDocuments.map((group) => {
+        return { type: GroupType.userGroup, item: group };
+      })),
+      ...(externalUserGroupDocuments.map((group) => {
+        return { type: GroupType.externalUserGroup, item: group };
+      })),
+    ];
+  }
+
   /*
    * get all groups of Page that user is related to
    */

+ 3 - 3
apps/app/src/stores/page.tsx

@@ -265,9 +265,9 @@ export const useSWRxInfinitePageRevisions = (
 };
 
 /*
- * Grant normalization fetching hooks
+ * Grant data fetching hooks
  */
-export const useSWRxIsGrantNormalized = (
+export const useSWRxCurrentGrantData = (
     pageId: string | null | undefined,
 ): SWRResponse<IResIsGrantNormalized, Error> => {
 
@@ -276,7 +276,7 @@ export const useSWRxIsGrantNormalized = (
   const { data: isNotFound } = useIsNotFound();
 
   const key = !isGuestUser && !isReadOnlyUser && !isNotFound && pageId != null
-    ? ['/page/is-grant-normalized', pageId]
+    ? ['/page/grant-data', pageId]
     : null;
 
   return useSWRImmutable(

+ 18 - 3
apps/app/src/stores/ui.tsx

@@ -17,7 +17,7 @@ import useSWRImmutable from 'swr/immutable';
 
 import type { IFocusable } from '~/client/interfaces/focusable';
 import { scheduleToPut } from '~/client/services/user-ui-settings';
-import type { IPageGrantData } from '~/interfaces/page';
+import type { IPageGrantData, IPageSelectedGrant } from '~/interfaces/page';
 import { SidebarContentsType, SidebarMode } from '~/interfaces/ui';
 import type { UpdateDescCountData } from '~/interfaces/websocket';
 import {
@@ -271,6 +271,21 @@ export const useCurrentSidebarContents = (
   return withUtils(swrResponse, { mutateAndSave });
 };
 
+export const usePageControlsX = (
+    initialData?: number,
+): SWRResponseWithUtils<MutateAndSaveUserUISettingsUtils<number>, number> => {
+  const swrResponse = useSWRStatic('pageControlsX', initialData, { fallbackData: 1000 });
+
+  const { mutate } = swrResponse;
+
+  const mutateAndSave: MutateAndSaveUserUISettings<number> = useCallback((data, opt?) => {
+    scheduleToPut({ currentPageControlsX: data });
+    return mutate(data, opt);
+  }, [mutate]);
+
+  return withUtils(swrResponse, { mutateAndSave });
+};
+
 export const useCurrentProductNavWidth = (initialData?: number): SWRResponseWithUtils<MutateAndSaveUserUISettingsUtils<number>, number> => {
   const swrResponse = useSWRStatic('productNavWidth', initialData, { fallbackData: 320 });
 
@@ -348,8 +363,8 @@ export const useSidebarMode = (): SWRResponseWithUtils<DetectSidebarModeUtils, S
   };
 };
 
-export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
-  return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
+export const useSelectedGrant = (initialData?: Nullable<IPageSelectedGrant>): SWRResponse<Nullable<IPageSelectedGrant>, Error> => {
+  return useSWRStatic<Nullable<IPageSelectedGrant>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
 };
 
 type PageTreeDescCountMapUtils = {

+ 2 - 10
apps/app/src/stores/user-group.tsx

@@ -2,24 +2,16 @@ import type {
   IPageHasId, IUserGroupHasId, IUserGroupRelationHasId,
 } from '@growi/core';
 import { type SWRResponseWithUtils, withUtils } from '@growi/core/dist/swr';
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import {
+import type {
   IUserGroupRelationHasIdPopulatedUser,
   UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 
-
-export const useSWRxMyUserGroups = (shouldFetch: boolean): SWRResponse<IUserGroupHasId[], Error> => {
-  return useSWR(
-    shouldFetch ? '/me/user-groups' : null,
-    endpoint => apiv3Get<UserGroupListResult>(endpoint).then(result => result.data.userGroups),
-  );
-};
-
 export const useSWRxUserGroup = (groupId: string | null): SWRResponse<IUserGroupHasId, Error> => {
   return useSWRImmutable(
     groupId != null ? `/user-groups/${groupId}` : null,

+ 164 - 18
apps/app/test/integration/service/page-grant.test.js → apps/app/test/integration/service/page-grant.test.ts

@@ -1,14 +1,15 @@
 import { GroupType, PageGrant } from '@growi/core';
 import mongoose from 'mongoose';
 
-import { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
-import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
-import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import UserGroup from '~/server/models/user-group';
-
+import { ExternalGroupProviderType } from '../../../src/features/external-user-group/interfaces/external-user-group';
+import ExternalUserGroup from '../../../src/features/external-user-group/server/models/external-user-group';
+import ExternalUserGroupRelation from '../../../src/features/external-user-group/server/models/external-user-group-relation';
+import { UserGroupPageGrantStatus } from '../../../src/interfaces/page';
+import UserGroup from '../../../src/server/models/user-group';
+import UserGroupRelation from '../../../src/server/models/user-group-relation';
+import type { IPageGrantService } from '../../../src/server/service/page-grant';
 import { getInstance } from '../setup-crowi';
 
-
 /*
  * There are 3 grant types to test.
  * GRANT_PUBLIC, GRANT_OWNER, GRANT_USER_GROUP
@@ -19,13 +20,12 @@ describe('PageGrantService', () => {
    */
   let User;
   let Page;
-  let UserGroupRelation;
 
   /*
    * global instances
    */
   let crowi;
-  let pageGrantService;
+  let pageGrantService: IPageGrantService;
   let xssSpy;
 
   let user1;
@@ -86,6 +86,14 @@ describe('PageGrantService', () => {
   const pageE3GroupChildPath = '/E3/GroupChild';
   const pageE3User1Path = '/E3/User1';
 
+  // getPageGroupGrantData test data
+  let user3;
+  let groupGrantDataTestChildPagePath;
+  let groupGrantDataTestParentUserGroupId;
+  let groupGrantDataTestChildUserGroupId;
+  let groupGrantDataTestExternalUserGroupId;
+  let groupGrantDataTestExternalUserGroupId2;
+
   const createDocumentsToTestIsGrantNormalized = async() => {
     // Users
     await User.insertMany([
@@ -375,6 +383,97 @@ describe('PageGrantService', () => {
     pageE3User1 = await Page.findOne({ path: pageE3User1Path });
   };
 
+  const createDocumentsToTestGetPageGroupGrantData = async() => {
+    await User.insertMany([
+      { name: 'User3', username: 'User3', email: 'user3@example.com' },
+    ]);
+    user3 = await User.findOne({ username: 'User3' });
+
+    groupGrantDataTestParentUserGroupId = new mongoose.Types.ObjectId();
+    groupGrantDataTestChildUserGroupId = new mongoose.Types.ObjectId();
+    await UserGroup.insertMany([
+      {
+        _id: groupGrantDataTestParentUserGroupId, // cannotGrant
+        name: 'groupGrantDataTestParentGroup',
+        parent: null,
+      },
+      {
+        _id: groupGrantDataTestChildUserGroupId, // isGranted
+        name: 'groupGrantDataTestChildGroup',
+        parent: groupGrantDataTestParentUserGroupId,
+      },
+    ]);
+
+    await UserGroupRelation.insertMany([
+      {
+        relatedGroup: groupGrantDataTestParentUserGroupId._id,
+        relatedUser: user3._id,
+      },
+      {
+        relatedGroup: groupGrantDataTestChildUserGroupId._id,
+        relatedUser: user3._id,
+      },
+    ]);
+
+    groupGrantDataTestExternalUserGroupId = new mongoose.Types.ObjectId();
+    groupGrantDataTestExternalUserGroupId2 = new mongoose.Types.ObjectId();
+    await ExternalUserGroup.insertMany([
+      {
+        _id: groupGrantDataTestExternalUserGroupId,
+        name: 'groupGrantDataTestExternalGroup',
+        externalId: 'groupGrantDataTestExternalGroup',
+        provider: ExternalGroupProviderType.ldap,
+        parent: null,
+      },
+      {
+        _id: groupGrantDataTestExternalUserGroupId2,
+        name: 'groupGrantDataTestExternalGroup2',
+        externalId: 'groupGrantDataTestExternalGroup2',
+        provider: ExternalGroupProviderType.ldap,
+        parent: null,
+      },
+    ]);
+
+    await ExternalUserGroupRelation.insertMany([
+      {
+        relatedGroup: groupGrantDataTestExternalUserGroupId._id,
+        relatedUser: user3._id,
+      },
+    ]);
+
+    const groupGrantDataTestParentPagePath = '/groupGrantDataTestParentPage';
+    const groupGrantDataTestParentPageId = new mongoose.Types.ObjectId();
+    groupGrantDataTestChildPagePath = '/groupGrantDataTestParentPage/groupGrantDataTestChildPagePath';
+    await Page.insertMany([
+      {
+        _id: groupGrantDataTestParentPageId,
+        path: groupGrantDataTestParentPagePath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user3._id,
+        lastUpdateUser: user3._id,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: groupGrantDataTestChildUserGroupId._id, type: GroupType.userGroup },
+          { item: groupGrantDataTestExternalUserGroupId._id, type: GroupType.externalUserGroup },
+          { item: groupGrantDataTestExternalUserGroupId2._id, type: GroupType.externalUserGroup },
+        ],
+        parent: rootPage._id,
+      },
+      {
+        path: groupGrantDataTestChildPagePath,
+        grant: Page.GRANT_USER_GROUP,
+        creator: user3._id,
+        lastUpdateUser: user3._id,
+        grantedUsers: null,
+        grantedGroups: [
+          { item: groupGrantDataTestChildUserGroupId._id, type: GroupType.userGroup },
+          { item: groupGrantDataTestExternalUserGroupId2._id, type: GroupType.externalUserGroup },
+        ],
+        parent: groupGrantDataTestParentPageId,
+      },
+    ]);
+  };
+
   /*
    * prepare before all tests
    */
@@ -385,11 +484,11 @@ describe('PageGrantService', () => {
 
     User = mongoose.model('User');
     Page = mongoose.model('Page');
-    UserGroupRelation = mongoose.model('UserGroupRelation');
 
     rootPage = await Page.findOne({ path: '/' });
 
     await createDocumentsToTestIsGrantNormalized();
+    await createDocumentsToTestGetPageGroupGrantData();
 
     xssSpy = jest.spyOn(crowi.xss, 'process').mockImplementation(path => path);
   });
@@ -398,7 +497,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: root, Target: public', async() => {
       const targetPath = '/NEW';
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -410,7 +509,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: root, Target: GroupParent', async() => {
       const targetPath = '/NEW_GroupParent';
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
@@ -422,7 +521,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: under-root public, Target: public', async() => {
       const targetPath = `${pageRootPublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -434,7 +533,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: under-root GroupParent, Target: GroupParent', async() => {
       const targetPath = `${pageRootGroupParentPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
@@ -446,7 +545,7 @@ describe('PageGrantService', () => {
     test('Should return true when Ancestor: public, Target: public', async() => {
       const targetPath = `${pageE1PublicPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -470,7 +569,7 @@ describe('PageGrantService', () => {
     test('Should return false when Ancestor: owned by GroupParent, Target: public', async() => {
       const targetPath = `${pageE3GroupParentPath}/NEW`;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = false;
 
@@ -482,7 +581,7 @@ describe('PageGrantService', () => {
     test('Should return false when Ancestor: owned by GroupChild, Target: GroupParent', async() => {
       const targetPath = `${pageE3GroupChildPath}/NEW`;
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = false;
 
@@ -496,7 +595,7 @@ describe('PageGrantService', () => {
     test('Should return true when Target: public, Descendant: public', async() => {
       const targetPath = emptyPagePath1;
       const grant = Page.GRANT_PUBLIC;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [];
       const shouldCheckDescendants = true;
 
@@ -520,7 +619,7 @@ describe('PageGrantService', () => {
     test('Should return true when Target: owned by GroupParent, Descendant: GroupParent, GroupChild and User1', async() => {
       const targetPath = emptyPagePath3;
       const grant = Page.GRANT_USER_GROUP;
-      const grantedUserIds = null;
+      const grantedUserIds = undefined;
       const grantedGroupIds = [{ item: groupParent._id, type: GroupType.userGroup }, { item: externalGroupParent._id, type: GroupType.externalUserGroup }];
       const shouldCheckDescendants = true;
 
@@ -818,4 +917,51 @@ describe('PageGrantService', () => {
       );
     });
   });
+  describe('Test for getPageGroupGrantData', () => {
+    test('return expected group grant data', async() => {
+      const groupGrantDataTestChildPage = await Page.findOne({ path: groupGrantDataTestChildPagePath });
+      const result = await pageGrantService.getPageGroupGrantData(groupGrantDataTestChildPage, user3);
+      expect(result).toStrictEqual({
+        userRelatedGroups: [
+          {
+            id: groupGrantDataTestExternalUserGroupId.toString(),
+            name: 'groupGrantDataTestExternalGroup',
+            type: GroupType.externalUserGroup,
+            provider: ExternalGroupProviderType.ldap,
+            status: UserGroupPageGrantStatus.notGranted,
+          },
+          {
+            id: groupGrantDataTestChildUserGroupId.toString(),
+            name: 'groupGrantDataTestChildGroup',
+            type: GroupType.userGroup,
+            provider: undefined,
+            status: UserGroupPageGrantStatus.isGranted,
+          },
+          {
+            id: groupGrantDataTestParentUserGroupId.toString(),
+            name: 'groupGrantDataTestParentGroup',
+            type: GroupType.userGroup,
+            provider: undefined,
+            status: UserGroupPageGrantStatus.cannotGrant,
+          },
+        ],
+        nonUserRelatedGrantedGroups: [
+          {
+            id: groupGrantDataTestExternalUserGroupId2.toString(),
+            name: 'groupGrantDataTestExternalGroup2',
+            type: GroupType.externalUserGroup,
+            provider: ExternalGroupProviderType.ldap,
+          },
+        ],
+      });
+    });
+
+    test('return empty arrays when page is root', async() => {
+      const result = await pageGrantService.getPageGroupGrantData(rootPage, user1);
+      expect(result).toStrictEqual({
+        userRelatedGroups: [],
+        nonUserRelatedGrantedGroups: [],
+      });
+    });
+  });
 });

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

@@ -37,7 +37,7 @@
     "@growi/remark-growi-directive": "link:../remark-growi-directive",
     "@growi/ui": "link:../ui",
     "escape-string-regexp": "^4.0.0",
-    "express": "^4.16.1",
+    "express": "^4.19.2",
     "http-errors": "^2.0.0",
     "mongoose": "^6.11.3",
     "swr": "^2.2.2"

+ 141 - 126
yarn.lock

@@ -1928,7 +1928,7 @@
     "@growi/remark-growi-directive" "link:packages/remark-growi-directive"
     "@growi/ui" "link:packages/ui"
     escape-string-regexp "^4.0.0"
-    express "^4.16.1"
+    express "^4.19.2"
     http-errors "^2.0.0"
     mongoose "^6.11.3"
     swr "^2.2.2"
@@ -4229,6 +4229,13 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-stickynode@^4.0.3":
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/@types/react-stickynode/-/react-stickynode-4.0.3.tgz#77e592cf84590319648678117424f921e5d1b4da"
+  integrity sha512-K7YkwdhXQE4YVxIVweix4nkpdG4onm/dcnKK+qCj0vgUrNiKng+09zOfjF5AlOcC1HQkg5yxVLwp/0AzT84R0w==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*", "@types/react@>=16.0.0", "@types/react@>=16.9.11", "@types/react@^18.2.14":
   version "18.2.19"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.19.tgz#f77cb2c8307368e624d464a25b9675fa35f95a8b"
@@ -4691,13 +4698,13 @@ abstract-logging@^2.0.0, abstract-logging@^2.0.1:
   resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839"
   integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==
 
-accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
-  version "1.3.7"
-  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
-  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
+accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
   dependencies:
-    mime-types "~2.1.24"
-    negotiator "0.6.2"
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
 
 acorn-jsx@^5.3.2:
   version "5.3.2"
@@ -5460,21 +5467,23 @@ bn.js@^4.0.0:
   version "4.11.8"
   resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
 
-body-parser@1.19.0, body-parser@^1.18.2:
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
-  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+body-parser@1.20.2, body-parser@^1.18.2:
+  version "1.20.2"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
+  integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==
   dependencies:
-    bytes "3.1.0"
-    content-type "~1.0.4"
+    bytes "3.1.2"
+    content-type "~1.0.5"
     debug "2.6.9"
-    depd "~1.1.2"
-    http-errors "1.7.2"
+    depd "2.0.0"
+    destroy "1.2.0"
+    http-errors "2.0.0"
     iconv-lite "0.4.24"
-    on-finished "~2.3.0"
-    qs "6.7.0"
-    raw-body "2.4.0"
-    type-is "~1.6.17"
+    on-finished "2.4.1"
+    qs "6.11.0"
+    raw-body "2.5.2"
+    type-is "~1.6.18"
+    unpipe "1.0.0"
 
 boolbase@^1.0.0:
   version "1.0.0"
@@ -5669,10 +5678,10 @@ bytes@3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
 
-bytes@3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
-  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
+bytes@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
 
 c8@^7.0.0:
   version "7.13.0"
@@ -6488,16 +6497,17 @@ constant-case@^3.0.4:
     tslib "^2.0.3"
     upper-case "^2.0.2"
 
-content-disposition@0.5.3:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
-  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
+content-disposition@0.5.4:
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
   dependencies:
-    safe-buffer "5.1.2"
+    safe-buffer "5.2.1"
 
-content-type@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+content-type@~1.0.4, content-type@~1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
 convert-source-map@^2.0.0:
   version "2.0.0"
@@ -6525,6 +6535,11 @@ cookie@0.4.0:
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
   integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
 
+cookie@0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
+  integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
+
 cookie@~0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
@@ -7391,9 +7406,10 @@ dequal@^2.0.0, dequal@^2.0.3:
   resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
   integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
 
-destroy@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
+destroy@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
 
 detect-file@^1.0.0:
   version "1.0.0"
@@ -8404,38 +8420,39 @@ express-validator@^6.14.0:
     lodash "^4.17.21"
     validator "^13.7.0"
 
-express@^4.16.1, express@^4.17.1:
-  version "4.17.1"
-  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
-  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
+express@^4.17.1, express@^4.19.2:
+  version "4.19.2"
+  resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465"
+  integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==
   dependencies:
-    accepts "~1.3.7"
+    accepts "~1.3.8"
     array-flatten "1.1.1"
-    body-parser "1.19.0"
-    content-disposition "0.5.3"
+    body-parser "1.20.2"
+    content-disposition "0.5.4"
     content-type "~1.0.4"
-    cookie "0.4.0"
+    cookie "0.6.0"
     cookie-signature "1.0.6"
     debug "2.6.9"
-    depd "~1.1.2"
+    depd "2.0.0"
     encodeurl "~1.0.2"
     escape-html "~1.0.3"
     etag "~1.8.1"
-    finalhandler "~1.1.2"
+    finalhandler "1.2.0"
     fresh "0.5.2"
+    http-errors "2.0.0"
     merge-descriptors "1.0.1"
     methods "~1.1.2"
-    on-finished "~2.3.0"
+    on-finished "2.4.1"
     parseurl "~1.3.3"
     path-to-regexp "0.1.7"
-    proxy-addr "~2.0.5"
-    qs "6.7.0"
+    proxy-addr "~2.0.7"
+    qs "6.11.0"
     range-parser "~1.2.1"
-    safe-buffer "5.1.2"
-    send "0.17.1"
-    serve-static "1.14.1"
-    setprototypeof "1.1.1"
-    statuses "~1.5.0"
+    safe-buffer "5.2.1"
+    send "0.18.0"
+    serve-static "1.15.0"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
     type-is "~1.6.18"
     utils-merge "1.0.1"
     vary "~1.1.2"
@@ -8664,17 +8681,17 @@ filter-obj@^1.1.0:
   resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b"
   integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==
 
-finalhandler@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
-  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
+finalhandler@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
+  integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==
   dependencies:
     debug "2.6.9"
     encodeurl "~1.0.2"
     escape-html "~1.0.3"
-    on-finished "~2.3.0"
+    on-finished "2.4.1"
     parseurl "~1.3.3"
-    statuses "~1.5.0"
+    statuses "2.0.1"
     unpipe "~1.0.0"
 
 find-cache-dir@^3.3.1, find-cache-dir@^3.3.2:
@@ -8834,9 +8851,10 @@ format@^0.2.0:
   resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b"
   integrity sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=
 
-forwarded@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
+forwarded@0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
+  integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
 
 fragment-cache@^0.2.1:
   version "0.2.1"
@@ -9762,18 +9780,7 @@ http-cache-semantics@^4.1.0:
   resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
   integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
 
-http-errors@1.7.2:
-  version "1.7.2"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
-  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-errors@^2.0.0:
+http-errors@2.0.0, http-errors@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
   integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
@@ -9784,7 +9791,7 @@ http-errors@^2.0.0:
     statuses "2.0.1"
     toidentifier "1.0.1"
 
-http-errors@~1.7.2, http-errors@~1.7.3:
+http-errors@~1.7.3:
   version "1.7.3"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
   integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
@@ -10030,10 +10037,6 @@ inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, i
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
-inherits@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-
 ini@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5"
@@ -12672,6 +12675,11 @@ mime-db@1.51.0, "mime-db@>= 1.43.0 < 2":
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c"
   integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==
 
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
 mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.19, mime-types@~2.1.24:
   version "2.1.34"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24"
@@ -12679,6 +12687,13 @@ mime-types@^2.0.8, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.19, m
   dependencies:
     mime-db "1.51.0"
 
+mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
 mime@1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
@@ -13037,10 +13052,6 @@ ms@2.0.0:
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
   integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
 
-ms@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
-
 ms@2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@@ -13166,12 +13177,7 @@ ncp@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
 
-negotiator@0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
-  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
-
-negotiator@^0.6.3:
+negotiator@0.6.3, negotiator@^0.6.3:
   version "0.6.3"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
   integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
@@ -13642,12 +13648,19 @@ oidc-token-hash@^5.0.1:
   resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6"
   integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==
 
-on-finished@2.3.0, on-finished@^2.3.0, on-finished@~2.3.0:
+on-finished@2.3.0, on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
   dependencies:
     ee-first "1.1.1"
 
+on-finished@2.4.1, on-finished@^2.3.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
+
 on-headers@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
@@ -14414,12 +14427,12 @@ property-information@^6.0.0:
   resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.1.1.tgz#5ca85510a3019726cb9afed4197b7b8ac5926a22"
   integrity sha512-hrzC564QIl0r0vy4l6MvRLhafmUowhO/O3KgVSoXIbbA2Sz4j8HGpJc6T2cubRVwMwpdiG/vKGfhT4IixmKN9w==
 
-proxy-addr@~2.0.5:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
-  integrity sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==
+proxy-addr@~2.0.7:
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
+  integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
   dependencies:
-    forwarded "~0.1.2"
+    forwarded "0.2.0"
     ipaddr.js "1.9.1"
 
 proxy-from-env@1.0.0:
@@ -14490,10 +14503,12 @@ qs@6.10.4:
   dependencies:
     side-channel "^1.0.4"
 
-qs@6.7.0:
-  version "6.7.0"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
-  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
+qs@6.11.0:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
+  integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
+  dependencies:
+    side-channel "^1.0.4"
 
 qs@^6.10.2, qs@^6.11.1:
   version "6.11.1"
@@ -14562,13 +14577,13 @@ rate-limiter-flexible@^2.3.7:
   resolved "https://registry.yarnpkg.com/rate-limiter-flexible/-/rate-limiter-flexible-2.3.7.tgz#c23e1f818a1575f1de1fd173437f4072125e1615"
   integrity sha512-dmc+J/IffVBvHlqq5/XClsdLdkOdQV/tjrz00cwneHUbEDYVrf4aUDAyR4Jybcf2+Vpn4NwoVrnnAyt/D0ciWw==
 
-raw-body@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
-  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+raw-body@2.5.2:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
+  integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
   dependencies:
-    bytes "3.1.0"
-    http-errors "1.7.2"
+    bytes "3.1.2"
+    http-errors "2.0.0"
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
@@ -14804,10 +14819,10 @@ react-scroll@^1.8.7:
     lodash.throttle "^4.1.1"
     prop-types "^15.7.2"
 
-react-stickynode@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/react-stickynode/-/react-stickynode-4.1.0.tgz#ecd80987f64b98f999c589cd4b992eee6bed9562"
-  integrity sha512-zylWgfad75jLfh/gYIayDcDWIDwO4weZrsZqDpjZ/axhF06zRjdCWFBgUr33Pvv2+htKWqPSFksWTyB6aMQ1ZQ==
+react-stickynode@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/react-stickynode/-/react-stickynode-4.1.1.tgz#ea63509a1d83195a7846d8f39be1e4e9ccbf1d3e"
+  integrity sha512-+Xp3xantrxbFjqNiSbpvsZwCqZYiPq0njKTA+QsIZdmEHih1H/lOV9/LpS37d+v92iSydJJTZMeRaENWeqGeIA==
   dependencies:
     classnames "^2.0.0"
     core-js "^3.6.5"
@@ -15661,7 +15676,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2:
+safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -15769,24 +15784,24 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
   integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-send@0.17.1:
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
-  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
+send@0.18.0:
+  version "0.18.0"
+  resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"
+  integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==
   dependencies:
     debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
+    depd "2.0.0"
+    destroy "1.2.0"
     encodeurl "~1.0.2"
     escape-html "~1.0.3"
     etag "~1.8.1"
     fresh "0.5.2"
-    http-errors "~1.7.2"
+    http-errors "2.0.0"
     mime "1.6.0"
-    ms "2.1.1"
-    on-finished "~2.3.0"
+    ms "2.1.3"
+    on-finished "2.4.1"
     range-parser "~1.2.1"
-    statuses "~1.5.0"
+    statuses "2.0.1"
 
 sentence-case@^3.0.4:
   version "3.0.4"
@@ -15802,15 +15817,15 @@ seq-queue@^0.0.5:
   resolved "https://registry.yarnpkg.com/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e"
   integrity sha1-1WgS4cAXpuTnw+Ojeh2m143TyT4=
 
-serve-static@1.14.1:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
-  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
+serve-static@1.15.0:
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540"
+  integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==
   dependencies:
     encodeurl "~1.0.2"
     escape-html "~1.0.3"
     parseurl "~1.3.3"
-    send "0.17.1"
+    send "0.18.0"
 
 set-blocking@^2.0.0:
   version "2.0.0"
@@ -16342,7 +16357,7 @@ statuses@2.0.1, statuses@>=2.0.0:
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
   integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
 
-"statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+"statuses@>= 1.5.0 < 2":
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
 
@@ -17493,7 +17508,7 @@ type-fest@^0.8.1:
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
   integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
 
-type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
+type-is@^1.6.4, type-is@~1.6.18:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
   integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==