Просмотр исходного кода

Merge branch 'master' into imprv/119788-121726-admin-can-use-reset-password-without-email-setting

ryoji-s 2 лет назад
Родитель
Сommit
6556a60991
63 измененных файлов с 753 добавлено и 294 удалено
  1. 7 0
      apps/app/public/static/locales/en_US/admin.json
  2. 7 0
      apps/app/public/static/locales/ja_JP/admin.json
  3. 8 1
      apps/app/public/static/locales/zh_CN/admin.json
  4. 26 0
      apps/app/src/client/services/AdminUsersContainer.js
  5. 10 11
      apps/app/src/client/services/renderer/renderer.tsx
  6. 40 0
      apps/app/src/components/Admin/Users/GrantReadOnlyButton.tsx
  7. 40 0
      apps/app/src/components/Admin/Users/RevokeReadOnlyMenuItem.tsx
  8. 6 2
      apps/app/src/components/Admin/Users/UserMenu.tsx
  9. 5 0
      apps/app/src/components/Admin/Users/UserTable.tsx
  10. 4 1
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  11. 4 0
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  12. 3 1
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  13. 7 6
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  14. 3 1
      apps/app/src/components/DescendantsPageList.tsx
  15. 1 0
      apps/app/src/components/IdenticalPathPage.tsx
  16. 17 11
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  17. 17 14
      apps/app/src/components/Navbar/GrowiNavbar.tsx
  18. 3 3
      apps/app/src/components/Navbar/GrowiSubNavigation.tsx
  19. 8 7
      apps/app/src/components/Navbar/SubNavButtons.tsx
  20. 0 1
      apps/app/src/components/NotAvailableForGuest.tsx
  21. 28 0
      apps/app/src/components/NotAvailableForReadOnlyUser.tsx
  22. 14 11
      apps/app/src/components/Page/RenderTagLabels.tsx
  23. 3 3
      apps/app/src/components/Page/TagLabels.tsx
  24. 4 3
      apps/app/src/components/PageAccessoriesModal.tsx
  25. 8 5
      apps/app/src/components/PageAttachment.tsx
  26. 14 11
      apps/app/src/components/PageComment.tsx
  27. 11 8
      apps/app/src/components/PageComment/CommentEditor.tsx
  28. 3 1
      apps/app/src/components/PageList/PageList.tsx
  29. 3 1
      apps/app/src/components/PageList/PageListItemL.tsx
  30. 4 3
      apps/app/src/components/PageStatusAlert.tsx
  31. 5 2
      apps/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  32. 5 2
      apps/app/src/components/ReactMarkdownComponents/Header.tsx
  33. 5 2
      apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx
  34. 17 14
      apps/app/src/components/SearchPage.tsx
  35. 5 2
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  36. 3 1
      apps/app/src/components/SearchPage/SearchResultList.tsx
  37. 4 2
      apps/app/src/components/Sidebar/PageTree.tsx
  38. 15 9
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  39. 3 1
      apps/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  40. 5 4
      apps/app/src/components/TrashPageList.tsx
  41. 0 0
      apps/app/src/features/mermaid-plugin/components/MermaidViewer.tsx
  42. 1 0
      apps/app/src/features/mermaid-plugin/components/index.ts
  43. 2 0
      apps/app/src/features/mermaid-plugin/index.ts
  44. 1 0
      apps/app/src/features/mermaid-plugin/services/index.ts
  45. 0 0
      apps/app/src/features/mermaid-plugin/services/mermaid.ts
  46. 6 0
      apps/app/src/interfaces/activity.ts
  47. 3 2
      apps/app/src/pages/trash.page.tsx
  48. 25 0
      apps/app/src/server/middlewares/exclude-read-only-user.ts
  49. 18 6
      apps/app/src/server/models/user.js
  50. 4 4
      apps/app/src/server/routes/apiv3/page.js
  51. 79 76
      apps/app/src/server/routes/apiv3/pages.js
  52. 5 6
      apps/app/src/server/routes/apiv3/share-links.js
  53. 19 18
      apps/app/src/server/routes/apiv3/slack-integration-settings.js
  54. 105 0
      apps/app/src/server/routes/apiv3/users.js
  55. 18 17
      apps/app/src/server/routes/index.js
  56. 6 12
      apps/app/src/server/service/config-loader.ts
  57. 18 3
      apps/app/src/stores/context.tsx
  58. 3 2
      apps/app/src/stores/editor.tsx
  59. 5 2
      apps/app/src/stores/page.tsx
  60. 3 2
      apps/app/src/stores/ui.tsx
  61. 48 0
      apps/app/test/unit/middlewares/exclude-read-only-user.test.ts
  62. 8 0
      bin/data-migrations/v6/src/processor.js
  63. 1 0
      packages/core/src/interfaces/user.ts

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

@@ -736,6 +736,7 @@
     },
     "user_table": {
       "administrator": "Administrator",
+      "read_only": "Read Only",
       "edit_menu": "Edit menu",
       "reset_password": "Reset password",
       "administrator_menu": "Administrator Menu",
@@ -745,6 +746,8 @@
       "remove_admin_access": "Remove admin access",
       "cannot_remove": "You cannot remove yourself from administrator",
       "give_admin_access": "Give admin access",
+      "revoke_read_only_access": "Revoke read only access",
+      "grant_read_only_access": "Grant read only access",
       "send_invitation_email": "Send invitation email",
       "resend_invitation_email": "Resend invitation email"
     },
@@ -1017,6 +1020,8 @@
     "ADMIN_USERS_DEACTIVATE": "Deactivate user",
     "ADMIN_USERS_GIVE_ADMIN": "Give admin access",
     "ADMIN_USERS_REMOVE_ADMIN": "Remove admin access",
+    "ADMIN_USERS_GRANT_READ_ONLY": "Grant read only access",
+    "ADMIN_USERS_REVOKE_READ_ONLY": "Revoke read only access",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
     "ADMIN_USERS_REMOVE": "Remove user",
     "ADMIN_USER_GROUP_CREATE": "Create User Group",
@@ -1035,6 +1040,8 @@
   "toaster": {
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_read_only": "Succeeded to grant {{username}} read only",
+    "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",

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

@@ -744,6 +744,7 @@
     },
     "user_table": {
       "administrator": "管理者",
+      "read_only": "閲覧のみ",
       "edit_menu": "編集メニュー",
       "reset_password": "パスワードの再発行",
       "administrator_menu": "管理者メニュー",
@@ -753,6 +754,8 @@
       "remove_admin_access": "管理者から外す",
       "cannot_remove": "自分自身を管理者から外すことはできません",
       "give_admin_access": "管理者にする",
+      "revoke_read_only_access": "閲覧のみアクセス権を外す",
+      "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "send_invitation_email": "招待メールの送信",
       "resend_invitation_email": "招待メールの再送信"
     },
@@ -1025,6 +1028,8 @@
     "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
     "ADMIN_USERS_GIVE_ADMIN": "管理者にする",
     "ADMIN_USERS_REMOVE_ADMIN": "管理者から外す",
+    "ADMIN_USERS_GRANT_READ_ONLY": "閲覧のみアクセス権を付与する",
+    "ADMIN_USERS_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
     "ADMIN_USERS_REMOVE": "ユーザーの削除",
     "ADMIN_USER_GROUP_CREATE": "ユーザーグループの作成",
@@ -1043,6 +1048,8 @@
   "toaster": {
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",
+    "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
+    "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",

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

@@ -744,6 +744,7 @@
     },
     "user_table": {
       "administrator": "管理员",
+      "read_only": "只浏览",
       "edit_menu": "编辑菜单",
       "reset_password": "重置密码",
       "administrator_menu": "管理员菜单",
@@ -753,6 +754,8 @@
       "remove_admin_access": "删除管理员访问权限",
       "cannot_remove": "您不能从管理员中删除自己",
       "give_admin_access": "授予管理员访问权限",
+      "revoke_read_only_access": "取消只读访问",
+      "grant_read_only_access": "给予只读权限",
       "send_invitation_email": "发送邀请邮件",
       "resend_invitation_email": "重发邀请函"
     },
@@ -1025,6 +1028,8 @@
     "ADMIN_USERS_DEACTIVATE": "停用用户",
     "ADMIN_USERS_GIVE_ADMIN": "授予管理员访问权限",
     "ADMIN_USERS_REMOVE_ADMIN": "删除管理员访问权限",
+    "ADMIN_USERS_GRANT_READ_ONLY": "给予只读权限",
+    "ADMIN_USERS_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
     "ADMIN_USERS_REMOVE": "删除用户",
     "ADMIN_USER_GROUP_CREATE": "创建用户组",
@@ -1042,7 +1047,9 @@
   },
   "toaster": {
     "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_read_only": "Succeeded to grant {{username}} read only",
+    "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
 		"activate_user_success": "Succeeded to activating {{username}}",
 		"deactivate_user_success": "Succeeded to deactivate {{username}}",
     "remove_user_success": "Succeeded to removing {{username}}",

+ 26 - 0
apps/app/src/client/services/AdminUsersContainer.js

@@ -230,6 +230,32 @@ export default class AdminUsersContainer extends Container {
     return username;
   }
 
+  /**
+   * Grant user read only access
+   * @memberOf AdminUsersContainer
+   * @param {string} userId
+   * @return {string} username
+   */
+  async grantUserReadOnly(userId) {
+    const response = await apiv3Put(`/users/${userId}/grant-read-only`);
+    const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
+    return username;
+  }
+
+  /**
+   * Revoke user read only access
+   * @memberOf AdminUsersContainer
+   * @param {string} userId
+   * @return {string} username
+   */
+  async revokeUserReadOnly(userId) {
+    const response = await apiv3Put(`/users/${userId}/revoke-read-only`);
+    const { username } = response.data.userData;
+    await this.retrieveUsersByPagingNum(this.state.activePage);
+    return username;
+  }
+
   /**
    * Activate user
    * @memberOf AdminUsersContainer

+ 10 - 11
apps/app/src/client/services/renderer/renderer.tsx

@@ -17,15 +17,14 @@ import type { Pluggable } from 'unified';
 
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
-import { MermaidViewer } from '~/components/ReactMarkdownComponents/MermaidViewer';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
+import * as mermaidPlugin from '~/features/mermaid-plugin';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
-import * as mermaid from '~/services/renderer/remark-plugins/mermaid';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
@@ -63,7 +62,7 @@ export const generateViewOptions = (
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
     refsGrowiPlugin.remarkPlugin,
-    mermaid.remarkPlugin,
+    mermaidPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -79,7 +78,7 @@ export const generateViewOptions = (
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
       refsGrowiPlugin.sanitizeOption,
-      mermaid.sanitizeOption,
+      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -109,7 +108,7 @@ export const generateViewOptions = (
     components.gallery = refsGrowiPlugin.Gallery;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
-    components.mermaid = MermaidViewer;
+    components.mermaid = mermaidPlugin.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -169,7 +168,7 @@ export const generateSimpleViewOptions = (
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
     refsGrowiPlugin.remarkPlugin,
-    mermaid.remarkPlugin,
+    mermaidPlugin.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -189,7 +188,7 @@ export const generateSimpleViewOptions = (
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
       refsGrowiPlugin.sanitizeOption,
-      mermaid.sanitizeOption,
+      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -211,7 +210,7 @@ export const generateSimpleViewOptions = (
     components.refsimg = refsGrowiPlugin.RefsImgImmutable;
     components.gallery = refsGrowiPlugin.GalleryImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = MermaidViewer;
+    components.mermaid = mermaidPlugin.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -246,7 +245,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
     refsGrowiPlugin.remarkPlugin,
-    mermaid.remarkPlugin,
+    mermaidPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -263,7 +262,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       refsGrowiPlugin.sanitizeOption,
       drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
-      mermaid.sanitizeOption,
+      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -285,7 +284,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.refsimg = refsGrowiPlugin.RefsImgImmutable;
     components.gallery = refsGrowiPlugin.GalleryImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = MermaidViewer;
+    components.mermaid = mermaidPlugin.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {

+ 40 - 0
apps/app/src/components/Admin/Users/GrantReadOnlyButton.tsx

@@ -0,0 +1,40 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+const GrantReadOnlyButton: React.FC<{
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}> = ({ adminUsersContainer, user }): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const onClickGrantReadOnlyBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.grantUserReadOnly(user._id);
+      toastSuccess(t('toaster.grant_user_read_only', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return (
+    <button className="dropdown-item" type="button" onClick={onClickGrantReadOnlyBtnHandler}>
+      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.grant_read_only_access')}
+    </button>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+// eslint-disable-next-line max-len
+const GrantReadOnlyButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GrantReadOnlyButton, [AdminUsersContainer]);
+
+export default GrantReadOnlyButtonWrapper;

+ 40 - 0
apps/app/src/components/Admin/Users/RevokeReadOnlyMenuItem.tsx

@@ -0,0 +1,40 @@
+import React, { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+const RevokeReadOnlyMenuItem: React.FC<{
+  adminUsersContainer: AdminUsersContainer,
+  user: IUserHasId,
+}> = ({ adminUsersContainer, user }): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const clickRevokeReadOnlyBtnHandler = useCallback(async() => {
+    try {
+      const username = await adminUsersContainer.revokeUserReadOnly(user._id);
+      toastSuccess(t('toaster.revoke_user_read_only', { username }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminUsersContainer, t, user._id]);
+
+  return (
+    <button className="dropdown-item" type="button" onClick={clickRevokeReadOnlyBtnHandler}>
+      <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.revoke_read_only_access')}
+    </button>
+  );
+};
+
+/**
+* Wrapper component for using unstated
+*/
+// eslint-disable-next-line max-len
+const RevokeReadOnlyMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RevokeReadOnlyMenuItem, [AdminUsersContainer]);
+
+export default RevokeReadOnlyMenuItemWrapper;

+ 6 - 2
apps/app/src/components/Admin/Users/UserMenu.tsx

@@ -11,7 +11,9 @@ import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import GiveAdminButton from './GiveAdminButton';
+import GrantReadOnlyButton from './GrantReadOnlyButton';
 import RemoveAdminMenuItem from './RemoveAdminMenuItem';
+import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import StatusActivateButton from './StatusActivateButton';
 import StatusSuspendedMenuItem from './StatusSuspendMenuItem';
@@ -81,8 +83,10 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li>
-          {user.admin === true && <RemoveAdminMenuItem user={user} />}
-          {user.admin === false && <GiveAdminButton user={user} />}
+          {user.admin ? <RemoveAdminMenuItem user={user} /> : <GiveAdminButton user={user} />}
+        </li>
+        <li>
+          {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}
         </li>
       </>
     );

+ 5 - 0
apps/app/src/components/Admin/Users/UserTable.tsx

@@ -157,6 +157,11 @@ const UserTable = (props: UserTableProps) => {
                       {t('admin:user_management.user_table.administrator')}
                     </span>
                   )}
+                  {(user.readOnly) && (
+                    <span className="badge badge-light badge-pill ml-2">
+                      {t('admin:user_management.user_table.read_only')}
+                    </span>
+                  )}
                 </td>
                 <td>
                   <strong>{user.username}</strong>

+ 4 - 1
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -23,6 +23,7 @@ import { BookmarkItem } from './BookmarkItem';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type BookmarkFolderItemProps = {
+  isReadOnlyUser: boolean
   bookmarkFolder: BookmarkFolderItems
   isOpen?: boolean
   level: number
@@ -36,7 +37,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const BASE_FOLDER_PADDING = 15;
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
-    bookmarkFolder, isOpen: _isOpen = false, level, root, isUserHomePage,
+    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, level, root, isUserHomePage,
     onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
   } = props;
 
@@ -146,6 +147,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         <div key={childFolder._id} className="grw-foldertree-item-children">
           <BookmarkFolderItem
             key={childFolder._id}
+            isReadOnlyUser={isReadOnlyUser}
             bookmarkFolder={childFolder}
             level={level + 1}
             root={root}
@@ -163,6 +165,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       return (
         <BookmarkItem
           key={bookmark._id}
+          isReadOnlyUser={isReadOnlyUser}
           bookmarkedPage={bookmark.page}
           level={level + 1}
           parentFolder={bookmarkFolder}

+ 4 - 0
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -8,6 +8,7 @@ import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useSWRxCurrentUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
+import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 
@@ -26,6 +27,7 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
 
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
   const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild();
@@ -90,6 +92,7 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
           return (
             <BookmarkFolderItem
               key={bookmarkFolder._id}
+              isReadOnlyUser={!!isReadOnlyUser}
               bookmarkFolder={bookmarkFolder}
               isOpen={false}
               level={0}
@@ -104,6 +107,7 @@ export const BookmarkFolderTree: React.FC<{isUserHomePage?: boolean}> = ({ isUse
           <div key={userBookmark._id} className="grw-foldertree-item-container grw-root-bookmarks">
             <BookmarkItem
               key={userBookmark._id}
+              isReadOnlyUser={!!isReadOnlyUser}
               bookmarkedPage={userBookmark}
               level={0}
               parentFolder={null}

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

@@ -22,6 +22,7 @@ import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type Props = {
+  isReadOnlyUser: boolean
   bookmarkedPage: IPageHasId,
   level: number,
   parentFolder: BookmarkFolderItems | null,
@@ -37,7 +38,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    bookmarkedPage, onClickDeleteBookmarkHandler,
+    isReadOnlyUser, bookmarkedPage, onClickDeleteBookmarkHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
 
@@ -133,6 +134,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           <PageItemControl
             pageId={bookmarkedPage._id}
             isEnableActions
+            isReadOnlyUser={isReadOnlyUser}
             pageInfo={fetchedPageInfo}
             forceHideMenuItems={[MenuItemType.DUPLICATE]}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}

+ 7 - 6
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -38,6 +38,7 @@ export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
 type CommonProps = {
   pageInfo?: IPageInfoAll,
   isEnableActions?: boolean,
+  isReadOnlyUser?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
@@ -64,7 +65,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading, pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    pageId, isLoading, pageInfo, isEnableActions, isReadOnlyUser, forceHideMenuItems, operationProcessData,
     onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
     onClickRevertMenuItem, onClickPathRecoveryMenuItem,
     additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
@@ -176,7 +177,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
             data-testid="open-page-move-rename-modal-btn"
@@ -188,7 +189,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Duplicate */}
-        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
+        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && !isReadOnlyUser && (
           <DropdownItem
             onClick={duplicateItemClickedHandler}
             data-testid="open-page-duplicate-modal-btn"
@@ -200,7 +201,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Revert */}
-        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser && pageInfo.isRevertible && (
           <DropdownItem
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -218,7 +219,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* PathRecovery */}
-        { !forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) && isEnableActions && shouldShowPathRecoveryButton && (
+        { !forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) && isEnableActions && !isReadOnlyUser && shouldShowPathRecoveryButton && (
           <DropdownItem
             onClick={pathRecoveryItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -230,7 +231,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 3 - 1
apps/app/src/components/DescendantsPageList.tsx

@@ -11,7 +11,7 @@ import {
 import { IPagingResult } from '~/interfaces/paging-result';
 import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import {
-  useIsGuestUser, useIsSharedUser,
+  useIsGuestUser, useIsReadOnlyUser, useIsSharedUser,
 } from '~/stores/context';
 import {
   mutatePageTree,
@@ -45,6 +45,7 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
   const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
@@ -107,6 +108,7 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
       <PageList
         pages={pageWithMetas}
         isEnableActions={!isGuestUser}
+        isReadOnlyUser={!!isReadOnlyUser}
         forceHideMenuItems={forceHideMenuItems}
         onPagesDeleted={pageDeletedHandler}
         onPagePutBacked={pagePutBackedHandler}

+ 1 - 0
apps/app/src/components/IdenticalPathPage.tsx

@@ -75,6 +75,7 @@ export const IdenticalPathPage = (): JSX.Element => {
                 page={pageWithMeta}
                 isSelected={false}
                 isEnableActions
+                isReadOnlyUser={false}
                 showPageUpdatedTime
               />
             );

+ 17 - 11
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -17,7 +17,7 @@ import {
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentPathname,
-  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useIsContainerFluid, useIsIdenticalPath,
+  useCurrentUser, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId, useIsContainerFluid, useIsIdenticalPath,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
@@ -81,6 +81,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
 
   const { open: openPresentationModal } = usePagePresentationModal();
@@ -117,7 +118,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
       */}
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
-        disabled={isGuestUser || isSharedUser}
+        disabled={!!isGuestUser || !!isSharedUser}
         data-testid="open-page-accessories-modal-btn-with-history-tab"
         className="grw-page-control-dropdown-item"
       >
@@ -138,7 +139,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         {t('attachment_data')}
       </DropdownItem>
 
-      { !isGuestUser && !isSharedUser && (
+      { !isGuestUser && !isReadOnlyUser && !isSharedUser && (
         <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
           <DropdownItem
             onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
@@ -212,6 +213,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isNotFound } = useIsNotFound();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isContainerFluid } = useIsContainerFluid();
 
@@ -336,9 +338,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       if (revisionId == null || pageId == null) {
         return (
           <>
-            <CreateTemplateMenuItems
+            {!isReadOnlyUser
+            && <CreateTemplateMenuItems
               onClickTemplateMenuItem={templateMenuItemClickHandler}
             />
+            }
           </>);
       }
       return (
@@ -348,10 +352,12 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             revisionId={revisionId}
             isLinkSharingDisabled={isLinkSharingDisabled}
           />
-          <DropdownItem divider />
-          <CreateTemplateMenuItems
-            onClickTemplateMenuItem={templateMenuItemClickHandler}
-          />
+          {!isReadOnlyUser && <>
+            <DropdownItem divider />
+            <CreateTemplateMenuItems
+              onClickTemplateMenuItem={templateMenuItemClickHandler}
+            /></>
+          }
         </>
       );
     };
@@ -384,7 +390,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
-                isBtnDisabled={isGuestUser}
+                isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
                 editorMode={editorMode}
               />
             )}
@@ -407,7 +413,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
           ) }
         </div>
 
-        {path != null && currentUser != null && (
+        {path != null && currentUser != null && !isReadOnlyUser && (
           <CreateTemplateModal
             path={path}
             isOpen={isPageTemplateModalShown}
@@ -429,7 +435,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       pageId={currentPage?._id}
       showDrawerToggler={isDrawerMode}
       showTagLabel={isAbleToShowTagLabel}
-      isGuestUser={isGuestUser}
+      isTagLabelsDisabled={!!isGuestUser || !!isReadOnlyUser}
       isDrawerMode={isDrawerMode}
       isCompactMode={isCompactMode}
       tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}

+ 17 - 14
apps/app/src/components/Navbar/GrowiNavbar.tsx

@@ -9,7 +9,7 @@ import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import {
-  useIsSearchPage, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useIsDefaultLogo,
+  useIsSearchPage, useIsGuestUser, useIsReadOnlyUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useIsDefaultLogo,
 } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
@@ -31,6 +31,7 @@ const NavbarRight = memo((): JSX.Element => {
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
 
   // ripple
   const newButtonRef = useRef(null);
@@ -47,18 +48,20 @@ const NavbarRight = memo((): JSX.Element => {
           <InAppNotificationDropdown />
         </li>
 
-        <li className="nav-item d-none d-md-block">
-          <button
-            className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
-            type="button"
-            ref={newButtonRef}
-            data-testid="newPageBtn"
-            onClick={() => openCreateModal(currentPagePath || '')}
-          >
-            <i className="icon-pencil mr-2"></i>
-            <span className="d-none d-lg-block">{ t('commons:New') }</span>
-          </button>
-        </li>
+        {!isReadOnlyUser
+          && <li className="nav-item d-none d-md-block">
+            <button
+              className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
+              type="button"
+              ref={newButtonRef}
+              data-testid="newPageBtn"
+              onClick={() => openCreateModal(currentPagePath || '')}
+            >
+              <i className="icon-pencil mr-2"></i>
+              <span className="d-none d-lg-block">{ t('commons:New') }</span>
+            </button>
+          </li>
+        }
 
         <li className="grw-apperance-mode-dropdown nav-item dropdown">
           <AppearanceModeDropdown isAuthenticated={isAuthenticated} />
@@ -69,7 +72,7 @@ const NavbarRight = memo((): JSX.Element => {
         </li>
       </>
     );
-  }, [t, isAuthenticated, openCreateModal, currentPagePath]);
+  }, [isReadOnlyUser, t, isAuthenticated, openCreateModal, currentPagePath]);
 
   const notAuthenticatedNavItem = useMemo(() => {
     return (

+ 3 - 3
apps/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -27,7 +27,7 @@ export type GrowiSubNavigationProps = {
   isNotFound?: boolean,
   showDrawerToggler?: boolean,
   showTagLabel?: boolean,
-  isGuestUser?: boolean,
+  isTagLabelsDisabled?: boolean,
   isDrawerMode?: boolean,
   isCompactMode?: boolean,
   tags?: string[],
@@ -43,7 +43,7 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
   const {
     pageId, pagePath,
     showDrawerToggler, showTagLabel,
-    isGuestUser, isDrawerMode, isCompactMode,
+    isTagLabelsDisabled, isDrawerMode, isCompactMode,
     tags, tagsUpdatedHandler,
     rightComponent: RightComponent,
     additionalClasses = [],
@@ -70,7 +70,7 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
           { (showTagLabel && !isCompactMode) && (
             <div className="grw-taglabels-container">
               { tags != null
-                ? <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
+                ? <TagLabels tags={tags} isTagLabelsDisabled={isTagLabelsDisabled ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
                 : <TagLabelsSkeleton />
               }
             </div>

+ 8 - 7
apps/app/src/components/Navbar/SubNavButtons.tsx

@@ -10,7 +10,7 @@ import { toastError } from '~/client/util/toastr';
 import {
   IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
@@ -90,6 +90,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
@@ -104,7 +105,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
 
   const subscribeClickhandler = useCallback(async() => {
-    if (isGuestUser == null || isGuestUser) {
+    if (isGuestUser ?? true) {
       return;
     }
     if (!isIPageInfoForOperation(pageInfo)) {
@@ -116,7 +117,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const likeClickhandler = useCallback(async() => {
-    if (isGuestUser == null || isGuestUser) {
+    if (isGuestUser ?? true) {
       return;
     }
     if (!isIPageInfoForOperation(pageInfo)) {
@@ -127,7 +128,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     mutatePageInfo();
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
-
   const duplicateMenuItemClickHandler = useCallback(async(_pageId: string): Promise<void> => {
     if (onClickDuplicateMenuItem == null || path == null) {
       return;
@@ -172,7 +172,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
-    if (onClickSwitchContentWidth == null || isGuestUser == null || isGuestUser) {
+    if (onClickSwitchContentWidth == null || (isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
@@ -184,7 +184,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     catch (err) {
       toastError(err);
     }
-  }, [isGuestUser, onClickSwitchContentWidth, pageId, pageInfo]);
+  }, [isGuestUser, isReadOnlyUser, onClickSwitchContentWidth, pageId, pageInfo]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {
@@ -245,8 +245,9 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
+          isReadOnlyUser={!!isReadOnlyUser}
           forceHideMenuItems={forceHideMenuItemsWithBookmark}
-          additionalMenuItemOnTopRenderer={additionalMenuItemOnTopRenderer}
+          additionalMenuItemOnTopRenderer={!isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 0 - 1
apps/app/src/components/NotAvailableForGuest.tsx

@@ -6,7 +6,6 @@ import { useIsGuestUser } from '~/stores/context';
 
 import { NotAvailable } from './NotAvailable';
 
-
 type NotAvailableForGuestProps = {
   children: JSX.Element
 }

+ 28 - 0
apps/app/src/components/NotAvailableForReadOnlyUser.tsx

@@ -0,0 +1,28 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useIsReadOnlyUser } from '~/stores/context';
+
+import { NotAvailable } from './NotAvailable';
+
+export const NotAvailableForReadOnlyUser: React.FC<{
+  children: JSX.Element
+}> = React.memo(({ children }) => {
+  const { t } = useTranslation();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const isDisabled = !!isReadOnlyUser;
+  const title = t('Not available for read only user');
+
+  return (
+    <NotAvailable
+      isDisabled={isDisabled}
+      title={title}
+      classNamePrefix="grw-not-available-for-read-only-user"
+    >
+      {children}
+    </NotAvailable>
+  );
+});
+NotAvailableForReadOnlyUser.displayName = 'NotAvailableForReadOnlyUser';

+ 14 - 11
apps/app/src/components/Page/RenderTagLabels.tsx

@@ -3,15 +3,16 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 
 type RenderTagLabelsProps = {
   tags: string[],
-  isGuestUser: boolean,
+  isTagLabelsDisabled: boolean,
   openEditorModal?: () => void,
 }
 
 const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
-  const { tags, isGuestUser, openEditorModal } = props;
+  const { tags, isTagLabelsDisabled, openEditorModal } = props;
   const { t } = useTranslation();
 
   function openEditorHandler() {
@@ -33,15 +34,17 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
         );
       })}
       <NotAvailableForGuest>
-        <div id="edit-tags-btn-wrapper-for-tooltip">
-          <a
-            className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isGuestUser && 'disabled'}`}
-            onClick={openEditorHandler}
-          >
-            { isTagsEmpty && <>{ t('Add tags for this page') }</>}
-            <i className={`icon-plus ${isTagsEmpty && 'ml-1'}`}/>
-          </a>
-        </div>
+        <NotAvailableForReadOnlyUser>
+          <div id="edit-tags-btn-wrapper-for-tooltip">
+            <a
+              className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isTagLabelsDisabled && 'disabled'}`}
+              onClick={openEditorHandler}
+            >
+              { isTagsEmpty && <>{ t('Add tags for this page') }</>}
+              <i className={`icon-plus ${isTagsEmpty && 'ml-1'}`}/>
+            </a>
+          </div>
+        </NotAvailableForReadOnlyUser>
       </NotAvailableForGuest>
     </>
 

+ 3 - 3
apps/app/src/components/Page/TagLabels.tsx

@@ -9,7 +9,7 @@ import styles from './TagLabels.module.scss';
 
 type Props = {
   tags?: string[],
-  isGuestUser: boolean,
+  isTagLabelsDisabled: boolean,
   tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
 }
 
@@ -18,7 +18,7 @@ export const TagLabelsSkeleton = (): JSX.Element => {
 };
 
 export const TagLabels:FC<Props> = (props: Props) => {
-  const { tags, isGuestUser, tagsUpdateInvoked } = props;
+  const { tags, isTagLabelsDisabled, tagsUpdateInvoked } = props;
 
   const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
 
@@ -41,7 +41,7 @@ export const TagLabels:FC<Props> = (props: Props) => {
         <RenderTagLabels
           tags={tags}
           openEditorModal={openEditorModal}
-          isGuestUser={isGuestUser}
+          isTagLabelsDisabled={isTagLabelsDisabled}
         />
       </div>
       <TagEditModal

+ 4 - 3
apps/app/src/components/PageAccessoriesModal.tsx

@@ -6,7 +6,7 @@ import {
 } from 'reactstrap';
 
 import {
-  useDisableLinkSharing, useIsGuestUser, useIsSharedUser,
+  useDisableLinkSharing, useIsGuestUser, useIsReadOnlyUser, useIsSharedUser,
 } from '~/stores/context';
 import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
 
@@ -34,6 +34,7 @@ const PageAccessoriesModal = (): JSX.Element => {
 
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isLinkSharingDisabled } = useDisableLinkSharing();
 
   const { data: status, mutate, close } = usePageAccessoriesModal();
@@ -93,10 +94,10 @@ const PageAccessoriesModal = (): JSX.Element => {
           return <ShareLink />;
         },
         i18n: t('share_links.share_link_management'),
-        isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
+        isLinkEnabled: () => !isGuestUser && !isReadOnlyUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
-  }, [t, close, sourceRevisionId, targetRevisionId, isGuestUser, isSharedUser, isLinkSharingDisabled]);
+  }, [t, close, sourceRevisionId, targetRevisionId, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
 
   const buttons = useMemo(() => (
     <div className="d-flex flex-nowrap">

+ 8 - 5
apps/app/src/components/PageAttachment.tsx

@@ -5,7 +5,7 @@ import React, {
 import { IAttachmentHasId } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
 import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
@@ -25,6 +25,9 @@ const PageAttachment = (): JSX.Element => {
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const isPageAttachmentDisabled = !!isGuestUser || !!isReadOnlyUser;
 
   // States
   const [pageNumber, setPageNumber] = useState(1);
@@ -93,13 +96,13 @@ const PageAttachment = (): JSX.Element => {
         attachments={dataAttachments.attachments}
         inUse={inUseAttachmentsMap}
         onAttachmentDeleteClicked={onAttachmentDeleteClicked}
-        isUserLoggedIn={!isGuestUser}
+        isUserLoggedIn={!isPageAttachmentDisabled}
       />
     );
-  }, [dataAttachments, inUseAttachmentsMap, isGuestUser, onAttachmentDeleteClicked]);
+  }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
 
   const renderDeleteAttachmentModal = useCallback(() => {
-    if (isGuestUser) {
+    if (isPageAttachmentDisabled) {
       return <></>;
     }
 
@@ -120,7 +123,7 @@ const PageAttachment = (): JSX.Element => {
       />
     );
   // eslint-disable-next-line max-len
-  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
+  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isPageAttachmentDisabled, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
 
   const renderPaginationWrapper = useCallback(() => {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {

+ 14 - 11
apps/app/src/components/PageComment.tsx

@@ -15,6 +15,7 @@ import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
 import { NotAvailableForGuest } from './NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from './NotAvailableForReadOnlyUser';
 import { Comment } from './PageComment/Comment';
 import { CommentEditor } from './PageComment/CommentEditor';
 import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
@@ -177,17 +178,19 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                   {(!isReadOnly && !showEditorIds.has(comment._id)) && (
                     <div className="d-flex flex-row-reverse">
                       <NotAvailableForGuest>
-                        <Button
-                          outline
-                          color="secondary"
-                          size="sm"
-                          className="btn-comment-reply"
-                          onClick={() => {
-                            setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
-                          }}
-                        >
-                          <i className="icon-fw icon-action-undo"></i> Reply
-                        </Button>
+                        <NotAvailableForReadOnlyUser>
+                          <Button
+                            outline
+                            color="secondary"
+                            size="sm"
+                            className="btn-comment-reply"
+                            onClick={() => {
+                              setShowEditorIds(previousState => new Set(previousState.add(comment._id)));
+                            }}
+                          >
+                            <i className="icon-fw icon-action-undo"></i> Reply
+                          </Button>
+                        </NotAvailableForReadOnlyUser>
                       </NotAvailableForGuest>
                     </div>
                   )}

+ 11 - 8
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -21,6 +21,7 @@ import { useCurrentPagePath } from '~/stores/page';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 import Editor from '../PageEditor/Editor';
 
 import { CommentPreview } from './CommentPreview';
@@ -235,14 +236,16 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     return (
       <div className="text-center">
         <NotAvailableForGuest>
-          <button
-            type="button"
-            className="btn btn-lg btn-link"
-            onClick={() => setIsReadyToUse(true)}
-            data-testid="open-comment-editor-button"
-          >
-            <i className="icon-bubble"></i> Add Comment
-          </button>
+          <NotAvailableForReadOnlyUser>
+            <button
+              type="button"
+              className="btn btn-lg btn-link"
+              onClick={() => setIsReadyToUse(true)}
+              data-testid="open-comment-editor-button"
+            >
+              <i className="icon-bubble"></i> Add Comment
+            </button>
+          </NotAvailableForReadOnlyUser>
         </NotAvailableForGuest>
       </div>
     );

+ 3 - 1
apps/app/src/components/PageList/PageList.tsx

@@ -14,6 +14,7 @@ import styles from './PageList.module.scss';
 type Props<M extends IPageInfoForEntity> = {
   pages: IPageWithMeta<M>[],
   isEnableActions?: boolean,
+  isReadOnlyUser: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   onPagesDeleted?: OnDeletedFunction,
   onPagePutBacked?: OnPutBackedFunction,
@@ -22,7 +23,7 @@ type Props<M extends IPageInfoForEntity> = {
 const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   const { t } = useTranslation();
   const {
-    pages, isEnableActions, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+    pages, isEnableActions, isReadOnlyUser, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
   } = props;
 
   if (pages == null) {
@@ -40,6 +41,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
       key={page.data._id}
       page={page}
       isEnableActions={isEnableActions}
+      isReadOnlyUser={isReadOnlyUser}
       forceHideMenuItems={forceHideMenuItems}
       onPageDeleted={onPagesDeleted}
       onPagePutBacked={onPagePutBacked}

+ 3 - 1
apps/app/src/components/PageList/PageListItemL.tsx

@@ -38,6 +38,7 @@ type Props = {
   page: IPageWithSearchMeta | IPageWithMeta<IPageInfoForListing & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isEnableActions?: boolean,
+  isReadOnlyUser: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   showPageUpdatedTime?: boolean, // whether to show page's updated time at the top-right corner of item
   onCheckboxChanged?: (isChecked: boolean, pageId: string) => void,
@@ -50,7 +51,7 @@ type Props = {
 
 const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (props: Props, ref): JSX.Element => {
   const {
-    page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions,
+    page: { data: pageData, meta: pageMeta }, isSelected, isEnableActions, isReadOnlyUser,
     forceHideMenuItems,
     showPageUpdatedTime,
     onClickItem, onCheckboxChanged, onPageDuplicated, onPageRenamed, onPageDeleted, onPagePutBacked,
@@ -259,6 +260,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   pageId={pageData._id}
                   pageInfo={isIPageInfoForListing(pageMeta) ? pageMeta : undefined}
                   isEnableActions={isEnableActions}
+                  isReadOnlyUser={isReadOnlyUser}
                   forceHideMenuItems={forceHideMenuItems}
                   onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
                   onClickRenameMenuItem={renameMenuItemClickHandler}

+ 4 - 3
apps/app/src/components/PageStatusAlert.tsx

@@ -3,7 +3,7 @@ import React, { useCallback, useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
 import * as ReactDOMServer from 'react-dom/server';
 
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useEditingMarkdown, useIsConflict } from '~/stores/editor';
 import {
   useHasDraftOnHackmd, useIsHackmdDraftUpdatingInRealtime, useRevisionIdHackmdSynced,
@@ -32,7 +32,8 @@ export const PageStatusAlert = (): JSX.Element => {
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { open: openConflictDiffModal } = useConflictDiffModal();
   const { mutate: mutateEditorMode } = useEditorMode();
-  const { data: isGuest } = useIsGuestUser();
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
 
   // store remote latest page data
   const { data: revisionIdHackmdSynced } = useRevisionIdHackmdSynced();
@@ -154,7 +155,7 @@ export const PageStatusAlert = (): JSX.Element => {
     getContentsForDraftExistsAlert,
   ]);
 
-  if (isGuest || alertComponentContents == null) { return <></> }
+  if (!!isGuestUser || !!isReadOnlyUser || alertComponentContents == null) { return <></> }
 
   const { additionalClasses, label, btn } = alertComponentContents;
 

+ 5 - 2
apps/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -9,7 +9,9 @@ import {
 } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 
-import { useIsGuestUser, useIsSharedUser, useShareLinkId } from '~/stores/context';
+import {
+  useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
 
 import '@growi/remark-drawio/dist/style.css';
 import styles from './DrawioViewerWithEditButton.module.scss';
@@ -27,6 +29,7 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
   const { bol, eol } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
 
@@ -52,7 +55,7 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
     }
   }, []);
 
-  const showEditButton = isRendered && !isGuestUser && !isSharedUser && shareLinkId == null;
+  const showEditButton = isRendered && !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
 
   return (
     <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>

+ 5 - 2
apps/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -5,7 +5,9 @@ import EventEmitter from 'events';
 import { useRouter } from 'next/router';
 import { Element } from 'react-markdown/lib/rehype-filter';
 
-import { useIsGuestUser, useIsSharedUser, useShareLinkId } from '~/stores/context';
+import {
+  useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
 import { NextLink } from './NextLink';
@@ -60,6 +62,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
 
@@ -107,7 +110,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
     };
   }, [activateByHash, router.events]);
 
-  const showEditButton = !isGuestUser && !isSharedUser && shareLinkId == null;
+  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
 
   return (
     <CustomTag id={id} className={`revision-head ${styles['revision-head']} ${isActive ? 'blink' : ''}`}>

+ 5 - 2
apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -4,7 +4,9 @@ import EventEmitter from 'events';
 
 import { Element } from 'react-markdown/lib/rehype-filter';
 
-import { useIsGuestUser, useIsSharedUser, useShareLinkId } from '~/stores/context';
+import {
+  useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
+} from '~/stores/context';
 
 import styles from './TableWithEditButton.module.scss';
 
@@ -24,6 +26,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
   const { children, node, className } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
 
@@ -34,7 +37,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
     globalEmitter.emit('launchHandsonTableModal', bol, eol);
   }, [bol, eol]);
 
-  const showEditButton = !isGuestUser && !isSharedUser && shareLinkId == null;
+  const showEditButton = !isGuestUser && !isReadOnlyUser && !isSharedUser && shareLinkId == null;
 
   return (
     <div className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}>

+ 17 - 14
apps/app/src/components/SearchPage.tsx

@@ -13,6 +13,7 @@ import { useIsSearchServiceReachable, useShowPageLimitationL } from '~/stores/co
 import { ISearchConditions, ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 
 import { NotAvailableForGuest } from './NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from './NotAvailableForReadOnlyUser';
 import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
@@ -185,21 +186,23 @@ export const SearchPage = (): JSX.Element => {
 
     return (
       <NotAvailableForGuest>
-        <OperateAllControl
-          ref={selectAllControlRef}
-          isCheckboxDisabled={isDisabled}
-          onCheckboxChanged={selectAllCheckboxChangedHandler}
-        >
-          <button
-            type="button"
-            className="btn btn-outline-danger text-nowrap border-0 px-2"
-            disabled={isDisabled}
-            onClick={deleteAllButtonClickedHandler}
+        <NotAvailableForReadOnlyUser>
+          <OperateAllControl
+            ref={selectAllControlRef}
+            isCheckboxDisabled={isDisabled}
+            onCheckboxChanged={selectAllCheckboxChangedHandler}
           >
-            <i className="icon-fw icon-trash"></i>
-            {t('search_result.delete_all_selected_page')}
-          </button>
-        </OperateAllControl>
+            <button
+              type="button"
+              className="btn btn-outline-danger text-nowrap border-0 px-2"
+              disabled={isDisabled}
+              onClick={deleteAllButtonClickedHandler}
+            >
+              <i className="icon-fw icon-trash"></i>
+              {t('search_result.delete_all_selected_page')}
+            </button>
+          </OperateAllControl>
+        </NotAvailableForReadOnlyUser>
       </NotAvailableForGuest>
     );
   }, [deleteAllButtonClickedHandler, hitsCount, selectAllCheckboxChangedHandler, t]);

+ 5 - 2
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -9,7 +9,9 @@ import { ISelectableAll } from '~/client/interfaces/selectable-all';
 import { toastSuccess } from '~/client/util/toastr';
 import { IFormattedSearchResult, IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDeletedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsSearchServiceConfigured, useIsSearchServiceReachable } from '~/stores/context';
+import {
+  useIsGuestUser, useIsReadOnlyUser, useIsSearchServiceConfigured, useIsSearchServiceReachable,
+} from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { mutatePageTree } from '~/stores/page-listing';
 
@@ -54,6 +56,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
   const searchResultListRef = useRef<ISelectableAll|null>(null);
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
 
@@ -206,7 +209,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
             <SearchResultContent
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
-              showPageControlDropdown={!isGuestUser}
+              showPageControlDropdown={!(isGuestUser || isReadOnlyUser)}
               forceHideMenuItems={forceHideMenuItems}
             />
           )}

+ 3 - 1
apps/app/src/components/SearchPage/SearchResultList.tsx

@@ -11,7 +11,7 @@ import {
   IPageInfoForListing, IPageWithMeta, isIPageInfoForListing,
 } from '~/interfaces/page';
 import { IPageSearchMeta, IPageWithSearchMeta } from '~/interfaces/search';
-import { useIsGuestUser } from '~/stores/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { mutatePageTree, useSWRxPageInfoForList } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 
@@ -41,6 +41,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     .map(page => page.data._id);
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet, null, true, true);
 
   const itemsRef = useRef<(ISelectable|null)[]>([]);
@@ -131,6 +132,7 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
             ref={c => itemsRef.current[i] = c}
             page={page}
             isEnableActions={!isGuestUser}
+            isReadOnlyUser={!!isReadOnlyUser}
             isSelected={page.data._id === selectedPageId}
             forceHideMenuItems={forceHideMenuItems}
             onClickItem={clickItemHandler}

+ 4 - 2
apps/app/src/components/Sidebar/PageTree.tsx

@@ -2,7 +2,7 @@ import React, { FC, memo } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { useTargetAndAncestors, useIsGuestUser } from '~/stores/context';
+import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
@@ -24,6 +24,7 @@ const PageTree: FC = memo(() => {
   const { t } = useTranslation();
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
@@ -68,12 +69,13 @@ const PageTree: FC = memo(() => {
       <PageTreeHeader />
       <ItemsTree
         isEnableActions={!isGuestUser}
+        isReadOnlyUser={!!isReadOnlyUser}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         targetAndAncestorsData={targetAndAncestorsData}
       />
 
-      {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
+      {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
         <div className="grw-pagetree-footer border-top py-3 w-100">
           <div className="private-legacy-pages-link px-3 py-2">
             <PrivateLegacyPagesLink />

+ 15 - 9
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -18,6 +18,7 @@ import { ValidationTarget } from '~/client/util/input-validator';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
 import { TriangleIcon } from '~/components/Icons/TriangleIcon';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnlyUser';
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
@@ -40,6 +41,7 @@ const logger = loggerFactory('growi:cli:Item');
 
 interface ItemProps {
   isEnableActions: boolean
+  isReadOnlyUser: boolean
   itemNode: ItemNode
   targetPathOrId?: Nullable<string>
   isOpen?: boolean
@@ -109,7 +111,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions,
+    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
   } = props;
 
   const { page, children } = itemNode;
@@ -486,6 +488,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <PageItemControl
               pageId={page._id}
               isEnableActions={isEnableActions}
+              isReadOnlyUser={isReadOnlyUser}
               onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
               onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
               onClickRenameMenuItem={renameMenuItemClickHandler}
@@ -505,14 +508,16 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
         {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
           <NotAvailableForGuest>
-            <button
-              id='page-create-button-in-page-tree'
-              type="button"
-              className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
-              onClick={onClickPlusButton}
-            >
-              <i className="icon-plus d-block p-0" />
-            </button>
+            <NotAvailableForReadOnlyUser>
+              <button
+                id='page-create-button-in-page-tree'
+                type="button"
+                className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+                onClick={onClickPlusButton}
+              >
+                <i className="icon-plus d-block p-0" />
+              </button>
+            </NotAvailableForReadOnlyUser>
           </NotAvailableForGuest>
         )}
       </li>
@@ -534,6 +539,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <div key={node.page._id} className="grw-pagetree-item-children">
             <Item
               isEnableActions={isEnableActions}
+              isReadOnlyUser={isReadOnlyUser}
               itemNode={node}
               isOpen={false}
               targetPathOrId={targetPathOrId}

+ 3 - 1
apps/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -90,6 +90,7 @@ const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondSta
 
 type ItemsTreeProps = {
   isEnableActions: boolean
+  isReadOnlyUser: boolean
   targetPath: string
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
@@ -100,7 +101,7 @@ type ItemsTreeProps = {
  */
 const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser,
   } = props;
 
   const { t } = useTranslation();
@@ -278,6 +279,7 @@ const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
           itemNode={initialItemNode}
           isOpen
           isEnableActions={isEnableActions}
+          isReadOnlyUser={isReadOnlyUser}
           onRenamed={onRenamed}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}
           onClickDeleteMenuItem={onClickDeleteMenuItem}

+ 5 - 4
apps/app/src/components/TrashPageList.tsx

@@ -8,7 +8,7 @@ import {
   IPageHasId,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
-import { useShowPageLimitationXL } from '~/stores/context';
+import { useIsReadOnlyUser, useShowPageLimitationXL } from '~/stores/context';
 import { useEmptyTrashModal } from '~/stores/modal';
 import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page-listing';
 
@@ -28,9 +28,10 @@ const convertToIDataWithMeta = (page) => {
 
 const useEmptyTrashButton = () => {
 
+  const { t } = useTranslation();
   const { data: limit } = useShowPageLimitationXL();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: pagingResult, mutate: mutatePageLists } = useSWRxPageList('/trash', 1, limit);
-  const { t } = useTranslation();
   const { open: openEmptyTrashModal } = useEmptyTrashModal();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
@@ -59,8 +60,8 @@ const useEmptyTrashButton = () => {
   }, [deletablePages, onEmptiedTrashHandler, openEmptyTrashModal, pagingResult?.totalCount]);
 
   const emptyTrashButton = useMemo(() => {
-    return <EmptyTrashButton onEmptyTrashButtonClick={emptyTrashClickHandler} disableEmptyButton={deletablePages?.length === 0} />;
-  }, [emptyTrashClickHandler, deletablePages?.length]);
+    return <EmptyTrashButton onEmptyTrashButtonClick={emptyTrashClickHandler} disableEmptyButton={deletablePages?.length === 0 || !!isReadOnlyUser} />;
+  }, [emptyTrashClickHandler, deletablePages?.length, isReadOnlyUser]);
 
   return emptyTrashButton;
 };

+ 0 - 0
apps/app/src/components/ReactMarkdownComponents/MermaidViewer.tsx → apps/app/src/features/mermaid-plugin/components/MermaidViewer.tsx


+ 1 - 0
apps/app/src/features/mermaid-plugin/components/index.ts

@@ -0,0 +1 @@
+export { MermaidViewer } from './MermaidViewer';

+ 2 - 0
apps/app/src/features/mermaid-plugin/index.ts

@@ -0,0 +1,2 @@
+export * from './components';
+export * from './services';

+ 1 - 0
apps/app/src/features/mermaid-plugin/services/index.ts

@@ -0,0 +1 @@
+export { remarkPlugin, sanitizeOption } from './mermaid';

+ 0 - 0
apps/app/src/services/renderer/remark-plugins/mermaid.ts → apps/app/src/features/mermaid-plugin/services/mermaid.ts


+ 6 - 0
apps/app/src/interfaces/activity.ts

@@ -151,6 +151,8 @@ const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
 const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
 const ACTION_ADMIN_USERS_GIVE_ADMIN = 'ADMIN_USERS_GIVE_ADMIN';
 const ACTION_ADMIN_USERS_REMOVE_ADMIN = 'ADMIN_USERS_REMOVE_ADMIN';
+const ACTION_ADMIN_USERS_GRANT_READ_ONLY = 'ADMIN_USERS_GRANT_READ_ONLY';
+const ACTION_ADMIN_USERS_REVOKE_READ_ONLY = 'ADMIN_USERS_REVOKE_READ_ONLY';
 const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
 const ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL = 'ADMIN_USERS_SEND_INVITATION_EMAIL';
 const ACTION_ADMIN_USERS_REMOVE = 'ADMIN_USERS_REMOVE';
@@ -329,6 +331,8 @@ export const SupportedAction = {
   ACTION_ADMIN_USERS_DEACTIVATE,
   ACTION_ADMIN_USERS_GIVE_ADMIN,
   ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_READ_ONLY,
+  ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
   ACTION_ADMIN_USERS_REMOVE,
   ACTION_ADMIN_USER_GROUP_CREATE,
@@ -514,6 +518,8 @@ export const LargeActionGroup = {
   ACTION_ADMIN_USERS_DEACTIVATE,
   ACTION_ADMIN_USERS_GIVE_ADMIN,
   ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_READ_ONLY,
+  ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
   ACTION_ADMIN_USERS_REMOVE,
   ACTION_ADMIN_USER_GROUP_CREATE,

+ 3 - 2
apps/app/src/pages/trash.page.tsx

@@ -16,7 +16,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useIsReadOnlyUser,
 } from '../stores/context';
 
 import type { NextPageWithLayout } from './_app.page';
@@ -57,6 +57,7 @@ const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
 
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
 
   const title = generateCustomTitleForPage(props, '/trash');
 
@@ -70,7 +71,7 @@ const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
           <GrowiSubNavigation
             pagePath="/trash"
             showDrawerToggler={isDrawerMode}
-            isGuestUser={isGuestUser}
+            isTagLabelsDisabled={!!isGuestUser || !!isReadOnlyUser}
             isDrawerMode={isDrawerMode}
             additionalClasses={['container-fluid']}
           />

+ 25 - 0
apps/app/src/server/middlewares/exclude-read-only-user.ts

@@ -0,0 +1,25 @@
+import { ErrorV3 } from '@growi/core';
+import { NextFunction, Response } from 'express';
+import { Request } from 'express-validator/src/base';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:middleware:exclude-read-only-user');
+
+export const excludeReadOnlyUser = (req: Request, res: Response & { apiv3Err }, next: () => NextFunction): NextFunction => {
+  const user = req.user;
+
+  if (user == null) {
+    logger.warn('req.user is null');
+    return next();
+  }
+
+  if (user.readOnly) {
+    const message = 'This user is read only user';
+    logger.warn(message);
+
+    return res.apiv3Err(new ErrorV3(message, 'validation_failed'));
+  }
+
+  return next();
+};

+ 18 - 6
apps/app/src/server/models/user.js

@@ -7,7 +7,6 @@ import loggerFactory from '~/utils/logger';
 
 const crypto = require('crypto');
 
-const debug = require('debug')('growi:models:user');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
@@ -68,6 +67,7 @@ module.exports = function(crowi) {
     },
     lastLoginAt: { type: Date },
     admin: { type: Boolean, default: 0, index: true },
+    readOnly: { type: Boolean, default: 0 },
     isInvitationEmailSended: { type: Boolean, default: false },
     isQuestionnaireEnabled: { type: Boolean, default: true },
   }, {
@@ -266,31 +266,43 @@ module.exports = function(crowi) {
   };
 
   userSchema.methods.removeFromAdmin = async function() {
-    debug('Remove from admin', this);
+    logger.debug('Remove from admin', this);
     this.admin = 0;
     return this.save();
   };
 
   userSchema.methods.makeAdmin = async function() {
-    debug('Admin', this);
+    logger.debug('Admin', this);
     this.admin = 1;
     return this.save();
   };
 
+  userSchema.methods.grantReadOnly = async function() {
+    logger.debug('Grant read only flag', this);
+    this.readOnly = 1;
+    return this.save();
+  };
+
+  userSchema.methods.revokeReadOnly = async function() {
+    logger.debug('Revoke read only flag', this);
+    this.readOnly = 0;
+    return this.save();
+  };
+
   userSchema.methods.asyncMakeAdmin = async function(callback) {
     this.admin = 1;
     return this.save();
   };
 
   userSchema.methods.statusActivate = async function() {
-    debug('Activate User', this);
+    logger.debug('Activate User', this);
     this.status = STATUS_ACTIVE;
     const userData = await this.save();
     return userEvent.emit('activated', userData);
   };
 
   userSchema.methods.statusSuspend = async function() {
-    debug('Suspend User', this);
+    logger.debug('Suspend User', this);
     this.status = STATUS_SUSPENDED;
     if (this.email === undefined || this.email === null) { // migrate old data
       this.email = '-';
@@ -305,7 +317,7 @@ module.exports = function(crowi) {
   };
 
   userSchema.methods.statusDelete = async function() {
-    debug('Delete User', this);
+    logger.debug('Delete User', this);
 
     const now = new Date();
     const deletedLabel = `deleted_at_${now.getTime()}`;

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

@@ -4,12 +4,12 @@ import {
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 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 Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
 import loggerFactory from '~/utils/logger';
 
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
@@ -542,7 +542,7 @@ module.exports = (crowi) => {
     return res.apiv3(data);
   });
 
-  router.put('/:pageId/grant', loginRequiredStrictly, validator.updateGrant, apiV3FormValidator, async(req, res) => {
+  router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
     const { grant, grantedGroup } = req.body;
 
@@ -837,7 +837,7 @@ module.exports = (crowi) => {
   });
 
 
-  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly,
+  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
       const { expandContentWidth } = req.body;

+ 79 - 76
apps/app/src/server/routes/apiv3/pages.js

@@ -6,6 +6,7 @@ import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { excludeReadOnlyUser } from '../../middlewares/exclude-read-only-user';
 import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
 import { ErrorV3 } from '@growi/core';
@@ -292,7 +293,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
+  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
     const {
       body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
     } = req.body;
@@ -504,7 +505,7 @@ module.exports = (crowi) => {
    *          409:
    *            description: page path is already existed
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, revisionId } = req.body;
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
@@ -575,35 +576,36 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator, async(req, res) => {
+  router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
+    async(req, res) => {
 
-    const { pageId } = req.body;
-    const { user } = req;
+      const { pageId } = req.body;
+      const { user } = req;
 
-    // The user has permission to resume rename operation if page is returned.
-    const page = await Page.findByIdAndViewer(pageId, user, null, true);
-    if (page == null) {
-      const msg = 'The operation is forbidden for this user';
-      const code = 'forbidden-user';
-      return res.apiv3Err(new ErrorV3(msg, code), 403);
-    }
+      // The user has permission to resume rename operation if page is returned.
+      const page = await Page.findByIdAndViewer(pageId, user, null, true);
+      if (page == null) {
+        const msg = 'The operation is forbidden for this user';
+        const code = 'forbidden-user';
+        return res.apiv3Err(new ErrorV3(msg, code), 403);
+      }
 
-    const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
-    if (pageOp == null) {
-      const msg = 'PageOperation document for Rename Sub operation not found.';
-      const code = 'document_not_found';
-      return res.apiv3Err(new ErrorV3(msg, code), 404);
-    }
+      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+      if (pageOp == null) {
+        const msg = 'PageOperation document for Rename Sub operation not found.';
+        const code = 'document_not_found';
+        return res.apiv3Err(new ErrorV3(msg, code), 404);
+      }
 
-    try {
-      await crowi.pageService.resumeRenameSubOperation(page, pageOp);
-    }
-    catch (err) {
-      logger.error(err);
-      return res.apiv3Err(err, 500);
-    }
-    return res.apiv3();
-  });
+      try {
+        await crowi.pageService.resumeRenameSubOperation(page, pageOp);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err, 500);
+      }
+      return res.apiv3();
+    });
 
   /**
    * @swagger
@@ -616,7 +618,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to remove all trash pages
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, addActivity, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
 
     const pagesInTrash = await crowi.pageService.findAllTrashPages(req.user);
@@ -746,61 +748,62 @@ module.exports = (crowi) => {
    *          500:
    *            description: Internal server error.
    */
-  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, addActivity, validator.duplicatePage, apiV3FormValidator, async(req, res) => {
-    const { pageId, isRecursively } = req.body;
+  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, validator.duplicatePage, apiV3FormValidator,
+    async(req, res) => {
+      const { pageId, isRecursively } = req.body;
 
-    const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
+      const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
 
-    const isCreatable = isCreatablePage(newPagePath);
-    if (!isCreatable) {
-      return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
-    }
+      const isCreatable = isCreatablePage(newPagePath);
+      if (!isCreatable) {
+        return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
+      }
 
-    // check page existence
-    const isExist = (await Page.count({ path: newPagePath })) > 0;
-    if (isExist) {
-      return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
-    }
+      // check page existence
+      const isExist = (await Page.count({ path: newPagePath })) > 0;
+      if (isExist) {
+        return res.apiv3Err(new ErrorV3(`Page exists '${newPagePath})'`, 'already_exists'), 409);
+      }
 
-    const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
-    const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
-    if (page == null || isEmptyAndNotRecursively) {
-      res.code = 'Page is not found';
-      logger.error('Failed to find the pages');
-      return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
-    }
+      const isEmptyAndNotRecursively = page?.isEmpty && !isRecursively;
+      if (page == null || isEmptyAndNotRecursively) {
+        res.code = 'Page is not found';
+        logger.error('Failed to find the pages');
+        return res.apiv3Err(new ErrorV3(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'), 401);
+      }
 
-    const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
-    const result = { page: serializePageSecurely(newParentPage) };
+      const newParentPage = await crowi.pageService.duplicate(page, newPagePath, req.user, isRecursively);
+      const result = { page: serializePageSecurely(newParentPage) };
 
-    // copy the page since it's used and updated in crowi.pageService.duplicate
-    const copyPage = { ...page };
-    copyPage.path = newPagePath;
-    try {
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
-    }
-    catch (err) {
-      logger.error('Create grobal notification failed', err);
-    }
+      // copy the page since it's used and updated in crowi.pageService.duplicate
+      const copyPage = { ...page };
+      copyPage.path = newPagePath;
+      try {
+        await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_CREATE, copyPage, req.user);
+      }
+      catch (err) {
+        logger.error('Create grobal notification failed', err);
+      }
 
-    // create subscription (parent page only)
-    try {
-      await crowi.inAppNotificationService.createSubscription(req.user.id, newParentPage._id, subscribeRuleNames.PAGE_CREATE);
-    }
-    catch (err) {
-      logger.error('Failed to create subscription document', err);
-    }
+      // create subscription (parent page only)
+      try {
+        await crowi.inAppNotificationService.createSubscription(req.user.id, newParentPage._id, subscribeRuleNames.PAGE_CREATE);
+      }
+      catch (err) {
+        logger.error('Failed to create subscription document', err);
+      }
 
-    const parameters = {
-      targetModel: SupportedTargetModel.MODEL_PAGE,
-      target: page,
-      action: SupportedAction.ACTION_PAGE_DUPLICATE,
-    };
-    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+      const parameters = {
+        targetModel: SupportedTargetModel.MODEL_PAGE,
+        target: page,
+        action: SupportedAction.ACTION_PAGE_DUPLICATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters, page);
 
-    return res.apiv3(result);
-  });
+      return res.apiv3(result);
+    });
 
   /**
    * @swagger
@@ -851,7 +854,7 @@ module.exports = (crowi) => {
 
   });
 
-  router.post('/delete', accessTokenParser, loginRequiredStrictly, validator.deletePages, apiV3FormValidator, async(req, res) => {
+  router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
     const {
       pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
     } = req.body;
@@ -913,7 +916,7 @@ module.exports = (crowi) => {
 
 
   // eslint-disable-next-line max-len
-  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
+  router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
     const { convertPath } = req.body;
 
     // Convert by path
@@ -935,7 +938,7 @@ module.exports = (crowi) => {
   });
 
   // eslint-disable-next-line max-len
-  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
+  router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
 
     // Convert by pageIds

+ 5 - 6
apps/app/src/server/routes/apiv3/share-links.js

@@ -4,11 +4,10 @@ import { ErrorV3 } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 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 loggerFactory from '~/utils/logger';
 
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-
-
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
 const express = require('express');
@@ -135,7 +134,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    */
 
-  router.post('/', loginRequired, linkSharingRequired, addActivity, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, excludeReadOnlyUser, linkSharingRequired, addActivity, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -187,7 +186,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete o all share links related one page
   */
-  router.delete('/', loginRequired, addActivity, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
+  router.delete('/', loginRequired, excludeReadOnlyUser, addActivity, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
 
@@ -261,7 +260,7 @@ module.exports = (crowi) => {
   *          200:
   *            description: Succeeded to delete one share link
   */
-  router.delete('/:id', loginRequired, addActivity, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
+  router.delete('/:id', loginRequired, excludeReadOnlyUser, addActivity, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const { user } = req;
 

+ 19 - 18
apps/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -507,28 +507,29 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to delete access tokens for slack
    */
-  router.delete('/slack-app-integrations/:id', validator.deleteIntegration, apiV3FormValidator, addActivity, async(req, res) => {
-    const { id } = req.params;
+  router.delete('/slack-app-integrations/:id', loginRequiredStrictly, adminRequired, validator.deleteIntegration, apiV3FormValidator, addActivity,
+    async(req, res) => {
+      const { id } = req.params;
 
-    try {
-      const response = await SlackAppIntegration.findOneAndDelete({ _id: id });
+      try {
+        const response = await SlackAppIntegration.findOneAndDelete({ _id: id });
 
-      // update primary
-      const countOfPrimary = await SlackAppIntegration.countDocuments({ isPrimary: true });
-      if (countOfPrimary === 0) {
-        await SlackAppIntegration.updateOne({}, { isPrimary: true });
-      }
+        // update primary
+        const countOfPrimary = await SlackAppIntegration.countDocuments({ isPrimary: true });
+        if (countOfPrimary === 0) {
+          await SlackAppIntegration.updateOne({}, { isPrimary: true });
+        }
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_DELETE });
+        activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_DELETE });
 
-      return res.apiv3({ response });
-    }
-    catch (error) {
-      const msg = 'Error occured in deleting access token for slack app tokens';
-      logger.error('Error', error);
-      return res.apiv3Err(new ErrorV3(msg, 'update-slackAppTokens-failed'), 500);
-    }
-  });
+        return res.apiv3({ response });
+      }
+      catch (error) {
+        const msg = 'Error occured in deleting access token for slack app tokens';
+        logger.error('Error', error);
+        return res.apiv3Err(new ErrorV3(msg, 'update-slackAppTokens-failed'), 500);
+      }
+    });
 
   router.put('/proxy-uri', loginRequiredStrictly, adminRequired, addActivity, validator.proxyUri, apiV3FormValidator, async(req, res) => {
     const { proxyUri } = req.body;

+ 105 - 0
apps/app/src/server/routes/apiv3/users.js

@@ -542,6 +542,111 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(err));
     }
   });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/{id}/grant-read-only:
+   *      put:
+   *        tags: [Users]
+   *        operationId: ReadOnly
+   *        summary: /users/{id}/grant-read-only
+   *        description: Grant user read only access
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of user for read only access
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Grant user read only access success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: data of read only
+   */
+  router.put('/:id/grant-read-only', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userData = await User.findById(id);
+
+      if (userData == null) {
+        return res.apiv3Err(new ErrorV3('User not found'), 404);
+      }
+
+      await userData.grantReadOnly();
+
+      const serializedUserData = serializeUserSecurely(userData);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GRANT_READ_ONLY });
+
+      return res.apiv3({ userData: serializedUserData });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/{id}/revoke-read-only:
+   *      put:
+   *        tags: [Users]
+   *        operationId: revokeReadOnly
+   *        summary: /users/{id}/revoke-read-only
+   *        description: Revoke user read only access
+   *        parameters:
+   *          - name: id
+   *            in: path
+   *            required: true
+   *            description: id of user for removing read only access
+   *            schema:
+   *              type: string
+   *        responses:
+   *          200:
+   *            description: Revoke user read only access success
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    userData:
+   *                      type: object
+   *                      description: data of revoke read only
+   */
+  router.put('/:id/revoke-read-only', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+    const { id } = req.params;
+
+    try {
+      const userData = await User.findById(id);
+
+      if (userData == null) {
+        return res.apiv3Err(new ErrorV3('User not found'), 404);
+      }
+
+      await userData.revokeReadOnly();
+
+      const serializedUserData = serializeUserSecurely(userData);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REVOKE_READ_ONLY });
+
+      return res.apiv3({ userData: serializedUserData });
+    }
+    catch (err) {
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
+    }
+  });
+
   /**
    * @swagger
    *

+ 18 - 17
apps/app/src/server/routes/index.js

@@ -6,6 +6,7 @@ import { middlewareFactory as rateLimiterFactory } from '~/features/rate-limiter
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import { generateCertifyBrandLogoMiddleware } from '../middlewares/certify-brand-logo';
+import { excludeReadOnlyUser } from '../middlewares/exclude-read-only-user';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../middlewares/login-form-validator';
@@ -126,27 +127,27 @@ module.exports = function(crowi, app) {
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , addActivity, page.api.update);
+  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, page.api.update);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
-  apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , page.api.unlink); // (Avoid from API Token)
-  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, page.api.duplicate);
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , excludeReadOnlyUser, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , excludeReadOnlyUser, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , excludeReadOnlyUser, page.api.unlink); // (Avoid from API Token)
+  apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, addActivity, tag.api.update);
+  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
   apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
-  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , addActivity, comment.api.add);
-  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , addActivity, comment.api.update);
-  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , addActivity, comment.api.remove);
-
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,addActivity ,attachment.api.add);
-  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,attachment.api.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , addActivity ,attachment.api.remove);
-  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , attachment.api.removeProfileImage);
+  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.add);
+  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
+
+  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachment.api.add);
+  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachment.api.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachment.api.remove);
+  apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, attachment.api.removeProfileImage);
   apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
   // API v1
@@ -165,9 +166,9 @@ module.exports = function(crowi, app) {
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
-  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.integrate);
-  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.discard);
-  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.saveOnHackmd);
+  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, hackmd.validateForApi, hackmd.discard);
+  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))

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

@@ -73,12 +73,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
-  // BLOCKDIAG_URI: {
-  //   ns:      ,
-  //   key:     ,
-  //   type:    ,
-  //   default:
-  // },
   // OAUTH_GOOGLE_CLIENT_ID: {
   //   ns:      'crowi',
   //   key:     'security:passport-google:clientId',
@@ -508,6 +502,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  GCS_LIFETIME_SEC_FOR_TEMPORARY_URL: {
+    ns:      'crowi',
+    key:     'gcs:lifetimeSecForTemporaryUrl',
+    type:    ValueType.NUMBER,
+    default: 120,
+  },
   GCS_REFERENCE_FILE_WITH_RELAY_MODE: {
     ns:      'crowi',
     key:     'gcs:referenceFileWithRelayMode',
@@ -520,12 +520,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     default: false,
   },
-  GCS_LIFETIME_SEC_FOR_TEMPORARY_URL: {
-    ns:      'crowi',
-    key:     'gcs:lifetimeSecForTemporaryUrl',
-    type:    ValueType.NUMBER,
-    default: 120,
-  },
   PROMSTER_ENABLED: {
     ns:      'crowi',
     key:     'promster:isEnabled',

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

@@ -215,16 +215,31 @@ export const useIsGuestUser = (): SWRResponse<boolean, Error> => {
   );
 };
 
+export const useIsReadOnlyUser = (): SWRResponse<boolean, Error> => {
+  const { data: currentUser, isLoading: isCurrentUserLoading } = useCurrentUser();
+  const { data: isGuestUser, isLoading: isGuestUserLoding } = useIsGuestUser();
+
+  const isLoading = isCurrentUserLoading || isGuestUserLoding;
+  const isReadOnlyUser = !isGuestUser && !!currentUser?.readOnly;
+
+  return useSWRImmutable(
+    isLoading ? null : ['isReadOnlyUser', isReadOnlyUser, currentUser?._id],
+    () => isReadOnlyUser,
+    { fallbackData: isReadOnlyUser },
+  );
+};
+
 export const useIsEditable = (): SWRResponse<boolean, Error> => {
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isForbidden } = useIsForbidden();
   const { data: isNotCreatable } = useIsNotCreatable();
   const { data: isIdenticalPath } = useIsIdenticalPath();
 
   return useSWRImmutable(
-    ['isEditable', isGuestUser, isForbidden, isNotCreatable, isIdenticalPath],
-    ([, isGuestUser, isForbidden, isNotCreatable, isIdenticalPath]) => {
-      return (!isForbidden && !isIdenticalPath && !isNotCreatable && !isGuestUser);
+    ['isEditable', isGuestUser, isReadOnlyUser, isForbidden, isNotCreatable, isIdenticalPath],
+    ([, isGuestUser, isReadOnlyUser, isForbidden, isNotCreatable, isIdenticalPath]) => {
+      return (!isForbidden && !isIdenticalPath && !isNotCreatable && !isGuestUser && !isReadOnlyUser);
     },
   );
 };

+ 3 - 2
apps/app/src/stores/editor.tsx

@@ -10,7 +10,7 @@ import { IEditorSettings } from '~/interfaces/editor-settings';
 import { SlackChannels } from '~/interfaces/user-trigger-notification';
 
 import {
-  useCurrentUser, useDefaultIndentSize, useIsGuestUser,
+  useCurrentUser, useDefaultIndentSize, useIsGuestUser, useIsReadOnlyUser,
 } from './context';
 // import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useSWRxTagsInfo } from './page';
@@ -37,9 +37,10 @@ type EditorSettingsOperation = {
 export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperation, IEditorSettings, Error> => {
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
 
   const swrResult = useSWRImmutable(
-    isGuestUser ? null : ['/personal-setting/editor-settings', currentUser?.username],
+    (isGuestUser || isReadOnlyUser) ? null : ['/personal-setting/editor-settings', currentUser?.username],
     ([endpoint]) => apiv3Get(endpoint).then(result => result.data),
     {
       // use: [localStorageMiddleware], // store to localStorage for initialization fastly

+ 5 - 2
apps/app/src/stores/page.tsx

@@ -19,7 +19,9 @@ import { IRevision, IRevisionHasId } from '~/interfaces/revision';
 
 import { IPageTagsInfo } from '../interfaces/tag';
 
-import { useCurrentPathname, useShareLinkId, useIsGuestUser } from './context';
+import {
+  useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
+} from './context';
 import { useStaticSWR } from './use-static-swr';
 
 const { isPermalink: _isPermalink } = pagePathUtils;
@@ -197,9 +199,10 @@ export const useSWRxIsGrantNormalized = (
 ): SWRResponse<IResIsGrantNormalized, Error> => {
 
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isNotFound } = useIsNotFound();
 
-  const key = !isGuestUser && !isNotFound && pageId != null
+  const key = !isGuestUser && !isReadOnlyUser && !isNotFound && pageId != null
     ? ['/page/is-grant-normalized', pageId]
     : null;
 

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

@@ -26,7 +26,7 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import {
-  useIsEditable,
+  useIsEditable, useIsReadOnlyUser,
   useIsSharedUser, useIsIdenticalPath, useCurrentUser, useShareLinkId,
 } from './context';
 import { useStaticSWR } from './use-static-swr';
@@ -413,9 +413,10 @@ export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRR
 
 export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
   const { data: currentUser } = useCurrentUser();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isTrashPage } = useIsTrashPage();
 
-  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null);
+  return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null && !isReadOnlyUser);
 };
 
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {

+ 48 - 0
apps/app/test/unit/middlewares/exclude-read-only-user.test.ts

@@ -0,0 +1,48 @@
+import { ErrorV3 } from '@growi/core';
+
+import { excludeReadOnlyUser } from '../../../src/server/middlewares/exclude-read-only-user';
+
+describe('excludeReadOnlyUser', () => {
+  let req;
+  let res;
+  let next;
+
+  beforeEach(() => {
+    req = {
+      user: {},
+    };
+    res = {
+      apiv3Err: jest.fn(),
+    };
+    next = jest.fn();
+  });
+
+  test('should call next if user is not found', () => {
+    req.user = null;
+
+    excludeReadOnlyUser(req, res, next);
+
+    expect(next).toBeCalled();
+    expect(res.apiv3Err).not.toBeCalled();
+  });
+
+  test('should call next if user is not read only', () => {
+    req.user.readOnly = false;
+
+    excludeReadOnlyUser(req, res, next);
+
+    expect(next).toBeCalled();
+    expect(res.apiv3Err).not.toBeCalled();
+  });
+
+  test('should return error response if user is read only', () => {
+    req.user.readOnly = true;
+
+    excludeReadOnlyUser(req, res, next);
+
+    expect(next).not.toBeCalled();
+    expect(res.apiv3Err).toBeCalledWith(
+      new ErrorV3('This user is read only user', 'validatioin_failed'),
+    );
+  });
+});

+ 8 - 0
bin/data-migrations/v6/src/processor.js

@@ -31,6 +31,11 @@ function bracketlinkProcessor(body) {
   return body.replace(oldBracketLinkRegExp, '[[$1]]');
 }
 
+function mdcontPrefixProcessor(body) {
+  var oldMdcontPrefixRegExp = /#mdcont-/g;
+  return body.replace(oldMdcontPrefixRegExp, '#');
+}
+
 // processor for MIGRATION_TYPE=custom
 function customProcessor(body) {
   // ADD YOUR PROCESS HERE!
@@ -60,6 +65,9 @@ function getProcessorArray(migrationType) {
     case 'v6-bracketlink':
       oldFormatProcessors = [bracketlinkProcessor];
       break;
+    case 'mdcont':
+      oldFormatProcessors = [mdcontPrefixProcessor];
+      break;
     case 'v6':
       oldFormatProcessors = [drawioProcessor, plantumlProcessor, tsvProcessor, csvProcessor, bracketlinkProcessor];
       break;

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

@@ -13,6 +13,7 @@ export type IUser = {
   imageUrlCached: string,
   isGravatarEnabled: boolean,
   admin: boolean,
+  readOnly: boolean,
   apiToken?: string,
   isEmailPublished: boolean,
   isInvitationEmailSended: boolean,