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

Merge branch 'master' into feat/rich-attachment

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

+ 1 - 0
apps/app/package.json

@@ -119,6 +119,7 @@
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
+    "mermaid": "^10.1.0",
     "method-override": "^3.0.0",
     "migrate-mongo": "^8.2.3",
     "mkdirp": "^1.0.3",

+ 12 - 4
apps/app/public/static/locales/en_US/admin.json

@@ -338,7 +338,8 @@
       "title": "Site URL settings",
       "desc": "This is for the site URL setting.",
       "warn": "Some features don't work because the site URL is not set.",
-      "help": "Site full URL beginning from <code>http://</code> or <code>https://</code>."
+      "help": "Site full URL beginning from <code>http://</code> or <code>https://</code>.",
+      "note_for_the_only_env_option": "The Site URL is fixed to the value of the environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> ."
     },
     "confidential_name": "Confidential name",
     "confidential_example": "ex): internal use only",
@@ -735,6 +736,7 @@
     },
     "user_table": {
       "administrator": "Administrator",
+      "read_only": "Read Only",
       "edit_menu": "Edit menu",
       "reset_password": "Reset password",
       "administrator_menu": "Administrator Menu",
@@ -744,14 +746,16 @@
       "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"
     },
     "reset_password": "Reset Password",
     "reset_password_modal": {
-      "reset_password_info": "When a password is reset, a newly password is sent to the target user.",
-      "password_reset_message": "The temporary password was sent to the below user and strongly recommend to change another one immediately.",
-      "reset_password_alert": "If the e-mail transmission fails, please make sure that e-mail settings are correct and reset password again.",
+      "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+      "password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
+      "send_new_password": "Please send the new password to the user.",
       "target_user": "Target User",
       "new_password": "New Password"
     },
@@ -1016,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",
@@ -1034,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}}",

+ 12 - 4
apps/app/public/static/locales/ja_JP/admin.json

@@ -346,7 +346,8 @@
       "title": "サイトURL設定",
       "desc": "サイトURLを設定します。",
       "warn": "サイトURLが設定されていないため、一部機能が動作しない状態になっています。",
-      "help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL"
+      "help": "<code>http://</code> または <code>https://</code> から始まるサイトのURL",
+      "note_for_the_only_env_option": "現在サイトURLは環境変数の値によって固定されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください"
     },
     "confidential_name": "コンフィデンシャル表示",
     "confidential_example": "例: 社外秘",
@@ -743,6 +744,7 @@
     },
     "user_table": {
       "administrator": "管理者",
+      "read_only": "閲覧のみ",
       "edit_menu": "編集メニュー",
       "reset_password": "パスワードの再発行",
       "administrator_menu": "管理者メニュー",
@@ -752,14 +754,16 @@
       "remove_admin_access": "管理者から外す",
       "cannot_remove": "自分自身を管理者から外すことはできません",
       "give_admin_access": "管理者にする",
+      "revoke_read_only_access": "閲覧のみアクセス権を外す",
+      "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "send_invitation_email": "招待メールの送信",
       "resend_invitation_email": "招待メールの再送信"
     },
     "reset_password": "パスワードのリセット",
     "reset_password_modal": {
-      "reset_password_info": "パスワードをリセットすると新規発行したパスワードを対象ユーザーに送信します。",
-      "password_reset_message": "対象ユーザーに一時的なパスワードを送信しました。新しく別のパスワードを設定するよう伝えてください。",
-      "reset_password_alert": "送信に失敗した場合はメール設定が正しいことを確認し再度パスワードのリセットを行ってください",
+      "password_never_seen": "表示されたパスワードはこの画面を閉じると二度と表示できませんのでご注意ください。",
+      "password_reset_message": "対象ユーザーに下記のパスワードを伝え、すぐに新しく別のパスワードを設定するよう伝えてください。",
+      "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
       "target_user": "対象ユーザー",
       "new_password": "新しいパスワード"
     },
@@ -1024,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,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}}を削除しました",

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

@@ -346,7 +346,8 @@
       "title": "主页URL设置",
       "desc": "用于网站URL设置。",
       "warn": "某些功能不起作用,因为未设置网站URL。",
-      "help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>."
+      "help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
+      "note_for_the_only_env_option": "站点 URL 固定为环境变量的值。<br>要更改此设置,请更改为 false 或删除环境变量 <code>{{env}}</code> 的值。"
     },
     "confidential_name": "内部名称",
     "confidential_example": "ex):仅供内部使用",
@@ -743,6 +744,7 @@
     },
     "user_table": {
       "administrator": "管理员",
+      "read_only": "只浏览",
       "edit_menu": "编辑菜单",
       "reset_password": "重置密码",
       "administrator_menu": "管理员菜单",
@@ -752,14 +754,16 @@
       "remove_admin_access": "删除管理员访问权限",
       "cannot_remove": "您不能从管理员中删除自己",
       "give_admin_access": "授予管理员访问权限",
+      "revoke_read_only_access": "取消只读访问",
+      "grant_read_only_access": "给予只读权限",
       "send_invitation_email": "发送邀请邮件",
       "resend_invitation_email": "重发邀请函"
     },
     "reset_password": "重置密码",
     "reset_password_modal": {
-      "reset_password_info": "When a password is reset, a newly password is sent to the target user.",
-      "password_reset_message": "The temporary password was sent to the below user and strongly recommend to change another one immediately.",
-      "reset_password_alert": "If the e-mail transmission fails, please make sure that e-mail settings are correct and reset password again.",
+      "password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+      "password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
+      "send_new_password": "Please send the new password to the user.",
       "target_user": "Target User",
       "new_password": "New Password"
     },
@@ -1024,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": "创建用户组",
@@ -1041,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}}",

+ 0 - 1
apps/app/resource/locales/ja_JP/admin/userResetPassword.txt

@@ -9,4 +9,3 @@ New Password: <%- password %>
 --
 <%- appTitle %>
 <%- url %>
-

+ 2 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -26,6 +26,7 @@ export default class AdminAppContainer extends Container {
 
       isV5Compatible: null,
       siteUrl: '',
+      siteUrlUseOnlyEnvVars: null,
       envSiteUrl: '',
       isSetSiteUrl: true,
       isMailerSetup: false,
@@ -88,6 +89,7 @@ export default class AdminAppContainer extends Container {
       fileUpload: appSettingsParams.fileUpload,
       isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
+      siteUrlUseOnlyEnvVars: appSettingsParams.siteUrlUseOnlyEnvVars,
       envSiteUrl: appSettingsParams.envSiteUrl,
       isSetSiteUrl: !!appSettingsParams.siteUrl,
       isMailerSetup: appSettingsParams.isMailerSetup,

+ 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 - 0
apps/app/src/client/services/renderer/renderer.tsx

@@ -18,6 +18,7 @@ import { Attachment } from '~/components/ReactMarkdownComponents/Attachment';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 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';
@@ -64,6 +65,7 @@ export const generateViewOptions = (
     lsxGrowiPlugin.remarkPlugin,
     attachmentPlugin.remarkPlugin,
     refsGrowiPlugin.remarkPlugin,
+    mermaidPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -80,6 +82,7 @@ export const generateViewOptions = (
       lsxGrowiPlugin.sanitizeOption,
       attachmentPlugin.sanitizeOption,
       refsGrowiPlugin.sanitizeOption,
+      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -110,6 +113,7 @@ export const generateViewOptions = (
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
     components.attachment = Attachment;
+    components.mermaid = mermaidPlugin.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -170,6 +174,7 @@ export const generateSimpleViewOptions = (
     lsxGrowiPlugin.remarkPlugin,
     attachmentPlugin.remarkPlugin,
     refsGrowiPlugin.remarkPlugin,
+    mermaidPlugin.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -190,6 +195,7 @@ export const generateSimpleViewOptions = (
       lsxGrowiPlugin.sanitizeOption,
       attachmentPlugin.sanitizeOption,
       refsGrowiPlugin.sanitizeOption,
+      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -212,6 +218,7 @@ export const generateSimpleViewOptions = (
     components.gallery = refsGrowiPlugin.GalleryImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
     components.attachment = Attachment;
+    components.mermaid = mermaidPlugin.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -247,6 +254,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     lsxGrowiPlugin.remarkPlugin,
     attachmentPlugin.remarkPlugin,
     refsGrowiPlugin.remarkPlugin,
+    mermaidPlugin.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -264,6 +272,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
       attachmentPlugin.sanitizeOption,
+      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
@@ -286,6 +295,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.gallery = refsGrowiPlugin.GalleryImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
     components.attachment = Attachment;
+    components.mermaid = mermaidPlugin.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {

+ 15 - 0
apps/app/src/components/Admin/App/SiteUrlSetting.tsx

@@ -39,6 +39,20 @@ const SiteUrlSetting = (props: Props) => {
       {!adminAppContainer.state.isSetSiteUrl
           && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('site_url.warn')}</p>)}
 
+      { adminAppContainer.state.siteUrlUseOnlyEnvVars && (
+        <div className="row">
+          <div className="col-md-9 offset-md-3">
+            <p
+              className="alert alert-info"
+              // eslint-disable-next-line react/no-danger
+              dangerouslySetInnerHTML={{
+                __html: t('site_url.note_for_the_only_env_option', { env: 'APP_SITE_URL_USES_ONLY_ENV_VARS' }),
+              }}
+            />
+          </div>
+        </div>
+      ) }
+
       <div className="row form-group">
         <div className="col-md-9 offset-md-3">
           <table className="table settings-table">
@@ -60,6 +74,7 @@ const SiteUrlSetting = (props: Props) => {
                     type="text"
                     name="settingForm[app:siteUrl]"
                     defaultValue={adminAppContainer.state.siteUrl || ''}
+                    disabled={adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true}
                     onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
                     placeholder="e.g. https://my.growi.org"
                   />

+ 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;

+ 9 - 4
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -16,6 +16,7 @@ class PasswordResetModal extends React.Component {
     super(props);
 
     this.state = {
+      temporaryPassword: [],
       isPasswordResetDone: false,
     };
 
@@ -25,8 +26,9 @@ class PasswordResetModal extends React.Component {
   async resetPassword() {
     const { t, userForPasswordResetModal } = this.props;
     try {
-      await apiv3Put('/users/reset-password', { id: userForPasswordResetModal._id });
-      this.setState({ isPasswordResetDone: true });
+      const res = await apiv3Put('/users/reset-password', { id: userForPasswordResetModal._id });
+      const { newPassword } = res.data;
+      this.setState({ temporaryPassword: newPassword, isPasswordResetDone: true });
     }
     catch (err) {
       toastError(err);
@@ -39,8 +41,8 @@ class PasswordResetModal extends React.Component {
     return (
       <>
         <p>
-          {t('user_management.reset_password_modal.reset_password_info')}<br />
-          <span className="text-danger">{t('user_management.reset_password_modal.reset_password_alert')}</span>
+          {t('user_management.reset_password_modal.password_never_seen')}<br />
+          <span className="text-danger">{t('user_management.reset_password_modal.send_new_password')}</span>
         </p>
         <p>
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
@@ -58,6 +60,9 @@ class PasswordResetModal extends React.Component {
         <p>
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
+        <p>
+          {t('user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+        </p>
       </>
     );
   }

+ 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">

+ 6 - 3
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 { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
@@ -22,6 +22,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);
@@ -70,10 +73,10 @@ const PageAttachment = (): JSX.Element => {
         attachments={dataAttachments.attachments}
         inUse={inUseAttachmentsMap}
         onAttachmentDeleteClicked={onAttachmentDeleteClicked}
-        isUserLoggedIn={!isGuestUser}
+        isUserLoggedIn={!isPageAttachmentDisabled}
       />
     );
-  }, [dataAttachments, inUseAttachmentsMap, isGuestUser, onAttachmentDeleteClicked]);
+  }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
 
   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;
 };

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

@@ -0,0 +1,28 @@
+import React, { useRef, useEffect, ReactNode } from 'react';
+
+import mermaid from 'mermaid';
+
+type MermaidViewerProps = {
+  children: ReactNode,
+}
+
+export const MermaidViewer = React.memo((props: MermaidViewerProps): JSX.Element => {
+  const { children } = props;
+
+  const ref = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (ref.current != null && children != null) {
+      mermaid.init({}, ref.current);
+    }
+  }, [children]);
+
+  return (
+    children
+      ? <div ref={ref} key={children as string}>
+        {children}
+      </div>
+      : <div key={children as string} />
+  );
+});
+MermaidViewer.displayName = 'MermaidViewer';

+ 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';

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

@@ -0,0 +1,24 @@
+import type { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { Plugin } from 'unified';
+import { Node } from 'unist';
+import { visit } from 'unist-util-visit';
+
+function rewriteNode(node: Node) {
+  // replace node
+  const data = node.data ?? (node.data = {});
+  data.hName = 'mermaid';
+}
+
+export const remarkPlugin: Plugin = function() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === 'code' && node.lang === 'mermaid') {
+        rewriteNode(node);
+      }
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['mermaid'],
+};

+ 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()}`;

+ 8 - 0
apps/app/src/server/routes/apiv3/app-settings.js

@@ -237,6 +237,7 @@ module.exports = (crowi) => {
       fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
+      siteUrlUseOnlyEnvVars: crowi.configManager.getConfig('crowi', 'app:siteUrl:useOnlyEnvVars'),
       envSiteUrl: crowi.configManager.getConfigFromEnvVars('crowi', 'app:siteUrl'),
       isMailerSetup: crowi.mailService.isMailerSetup,
       fromAddress: crowi.configManager.getConfig('crowi', 'mail:from'),
@@ -361,6 +362,13 @@ module.exports = (crowi) => {
    */
   router.put('/site-url-setting', loginRequiredStrictly, adminRequired, addActivity, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
 
+    const useOnlyEnvVars = crowi.configManager.getConfig('crowi', 'app:siteUrl:useOnlyEnvVars');
+
+    if (useOnlyEnvVars) {
+      const msg = 'Updating the Site URL is prohibited on this system.';
+      return res.apiv3Err(new ErrorV3(msg, 'update-siteUrlSetting-prohibited'));
+    }
+
     const requestSiteUrlSettingParams = {
       'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
     };

+ 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;

+ 112 - 30
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
    *
@@ -835,7 +940,9 @@ module.exports = (crowi) => {
    *            application/json:
    *              schema:
    *                properties:
-   *                  id:
+   *                  newPassword:
+   *                    type: string
+   *                  user:
    *                    type: string
    *                    description: user id for reset password
    *        responses:
@@ -843,44 +950,19 @@ module.exports = (crowi) => {
    *            description: success resrt password
    */
   router.put('/reset-password', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
-    const { appService, mailService } = crowi;
     const { id } = req.body;
 
-    let newPassword;
-    let user;
-
     try {
-      [newPassword, user] = await Promise.all([
+      const [newPassword, user] = await Promise.all([
         await User.resetPasswordByRandomString(id),
         await User.findById(id)]);
 
       activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_PASSWORD_RESET });
+      return res.apiv3({ newPassword, user });
     }
     catch (err) {
-      const msg = 'Error occurred during password reset request procedure.';
-      logger.error(err);
-      return res.apiv3Err(`${msg} Cause: ${err}`);
-    }
-
-    try {
-      await mailService.send({
-        to: user.email,
-        subject: 'Your password has been reset by the administrator',
-        template: path.join(crowi.localeDir, 'en_US/admin/userResetPassword.txt'),
-        vars: {
-          email: user.email,
-          password: newPassword,
-          url: crowi.appService.getSiteUrl(),
-          appTitle: appService.getAppTitle(),
-        },
-      });
-
-      return res.apiv3({});
-    }
-    catch (err) {
-      const msg = 'Error occurred during password reset send e-mail.';
-      logger.error(err);
-      return res.apiv3Err(`${msg} Cause: ${err}`);
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(err));
     }
   });
 

+ 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))

+ 12 - 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',
@@ -139,6 +133,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
+  APP_SITE_URL_USES_ONLY_ENV_VARS: {
+    ns:      'crowi',
+    key:     'app:siteUrl:useOnlyEnvVars',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   PUBLISH_OPEN_API: {
     ns:      'crowi',
     key:     'app:publishOpenAPI',
@@ -502,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',
@@ -514,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',

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

@@ -11,6 +11,10 @@ import { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:ConfigManager');
 
+const KEYS_FOR_APP_SITE_URL_USES_ONLY_ENV_OPTION = [
+  'app:siteUrl',
+];
+
 const KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION = [
   'security:passport-local:isEnabled',
 ];
@@ -233,8 +237,13 @@ export default class ConfigManager implements S2sMessageHandlable {
    */
   shouldSearchedFromEnvVarsOnly(namespace, key) {
     return (namespace === 'crowi' && (
-      // local strategy
+      // siteUrl
       (
+        KEYS_FOR_APP_SITE_URL_USES_ONLY_ENV_OPTION.includes(key)
+        && this.defaultSearch('crowi', 'app:siteUrl:useOnlyEnvVars')
+      )
+      // local strategy
+      || (
         KEYS_FOR_LOCAL_STRATEGY_USE_ONLY_ENV_OPTION.includes(key)
         && this.defaultSearch('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions')
       )

+ 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,

+ 420 - 6
yarn.lock

@@ -1265,6 +1265,11 @@
   resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
   integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
 
+"@braintree/sanitize-url@^6.0.0":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f"
+  integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==
+
 "@browser-bunyan/console-formatted-stream@^1.8.0":
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.8.0.tgz#dda9dcab6ce445cbf2911045709930757e5d48c1"
@@ -1918,6 +1923,13 @@
   resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
   integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
 
+"@khanacademy/simple-markdown@^0.8.6":
+  version "0.8.6"
+  resolved "https://registry.yarnpkg.com/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz#9c9aef1f5ce2ce60292d13849165965a57c26f25"
+  integrity sha512-mAUlR9lchzfqunR89pFvNI51jQKsMpJeWYsYWw0DQcUXczn/T/V6510utgvm7X0N3zN87j1SvuKk8cMbl9IAFw==
+  dependencies:
+    "@types/react" ">=16.0.0"
+
 "@lykmapipo/common@>=0.34.2", "@lykmapipo/common@>=0.34.3":
   version "0.34.3"
   resolved "https://registry.yarnpkg.com/@lykmapipo/common/-/common-0.34.3.tgz#eb74fa4af14f2f1e59ddd42491f05ab69f96bd71"
@@ -3231,6 +3243,15 @@
     "@types/scheduler" "*"
     csstype "^3.0.2"
 
+"@types/react@>=16.0.0":
+  version "18.2.6"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.6.tgz#5cd53ee0d30ffc193b159d3516c8c8ad2f19d571"
+  integrity sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==
+  dependencies:
+    "@types/prop-types" "*"
+    "@types/scheduler" "*"
+    csstype "^3.0.2"
+
 "@types/retry@^0.12.0":
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
@@ -5078,6 +5099,11 @@ commander@6.2.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.0.tgz#b990bfb8ac030aedc6d11bc04d1488ffef56db75"
   integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==
 
+commander@7, commander@^7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+
 commander@9.2.0:
   version "9.2.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9"
@@ -5098,11 +5124,6 @@ commander@^6.2.1:
   resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
   integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
 
-commander@^7.2.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
-  integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
-
 commander@^8.0.0, commander@^8.1.0:
   version "8.3.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
@@ -5431,6 +5452,20 @@ cors@~2.8.5:
     object-assign "^4"
     vary "^1"
 
+cose-base@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-1.0.3.tgz#650334b41b869578a543358b80cda7e0abe0a60a"
+  integrity sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==
+  dependencies:
+    layout-base "^1.0.0"
+
+cose-base@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-2.2.0.tgz#1c395c35b6e10bb83f9769ca8b817d614add5c01"
+  integrity sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==
+  dependencies:
+    layout-base "^2.0.0"
+
 cosmiconfig@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d"
@@ -5640,6 +5675,280 @@ cypress@^12.0.1:
     untildify "^4.0.0"
     yauzl "^2.10.0"
 
+cytoscape-cose-bilkent@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz#762fa121df9930ffeb51a495d87917c570ac209b"
+  integrity sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==
+  dependencies:
+    cose-base "^1.0.0"
+
+cytoscape-fcose@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz#e4d6f6490df4fab58ae9cea9e5c3ab8d7472f471"
+  integrity sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==
+  dependencies:
+    cose-base "^2.2.0"
+
+cytoscape@^3.23.0:
+  version "3.24.0"
+  resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.24.0.tgz#764e4ca3df37160b1c55244c648afd303a07e109"
+  integrity sha512-W9fJMrAfr/zKFzDCpRR/wn6uoEQ7gfbJmxPK5DadXj69XyAhZYi1QXLOE+UXJfXVXxqGM1o1eeiIrtxrtB43zA==
+  dependencies:
+    heap "^0.2.6"
+    lodash "^4.17.21"
+
+"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.3.tgz#39f1f4954e4a09ff69ac597c2d61906b04e84740"
+  integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ==
+  dependencies:
+    internmap "1 - 2"
+
+d3-axis@3:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-axis/-/d3-axis-3.0.0.tgz#c42a4a13e8131d637b745fc2973824cfeaf93322"
+  integrity sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==
+
+d3-brush@3:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-brush/-/d3-brush-3.0.0.tgz#6f767c4ed8dcb79de7ede3e1c0f89e63ef64d31c"
+  integrity sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-drag "2 - 3"
+    d3-interpolate "1 - 3"
+    d3-selection "3"
+    d3-transition "3"
+
+d3-chord@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-chord/-/d3-chord-3.0.1.tgz#d156d61f485fce8327e6abf339cb41d8cbba6966"
+  integrity sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==
+  dependencies:
+    d3-path "1 - 3"
+
+"d3-color@1 - 3", d3-color@3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
+  integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
+
+d3-contour@4:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-4.0.2.tgz#bb92063bc8c5663acb2422f99c73cbb6c6ae3bcc"
+  integrity sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==
+  dependencies:
+    d3-array "^3.2.0"
+
+d3-delaunay@6:
+  version "6.0.4"
+  resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz#98169038733a0a5babbeda55054f795bb9e4a58b"
+  integrity sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==
+  dependencies:
+    delaunator "5"
+
+"d3-dispatch@1 - 3", d3-dispatch@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
+  integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
+
+"d3-drag@2 - 3", d3-drag@3:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
+  integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-selection "3"
+
+"d3-dsv@1 - 3", d3-dsv@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-3.0.1.tgz#c63af978f4d6a0d084a52a673922be2160789b73"
+  integrity sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==
+  dependencies:
+    commander "7"
+    iconv-lite "0.6"
+    rw "1"
+
+"d3-ease@1 - 3", d3-ease@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
+  integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
+
+d3-fetch@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-fetch/-/d3-fetch-3.0.1.tgz#83141bff9856a0edb5e38de89cdcfe63d0a60a22"
+  integrity sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==
+  dependencies:
+    d3-dsv "1 - 3"
+
+d3-force@3:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-3.0.0.tgz#3e2ba1a61e70888fe3d9194e30d6d14eece155c4"
+  integrity sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-quadtree "1 - 3"
+    d3-timer "1 - 3"
+
+"d3-format@1 - 3", d3-format@3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
+  integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
+
+d3-geo@3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.1.0.tgz#74fd54e1f4cebd5185ac2039217a98d39b0a4c0e"
+  integrity sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==
+  dependencies:
+    d3-array "2.5.0 - 3"
+
+d3-hierarchy@3:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6"
+  integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==
+
+"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
+  integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
+  dependencies:
+    d3-color "1 - 3"
+
+"d3-path@1 - 3", d3-path@3, d3-path@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
+  integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
+
+d3-polygon@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398"
+  integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==
+
+"d3-quadtree@1 - 3", d3-quadtree@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f"
+  integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
+
+d3-random@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4"
+  integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==
+
+d3-scale-chromatic@3:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a"
+  integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
+  dependencies:
+    d3-color "1 - 3"
+    d3-interpolate "1 - 3"
+
+d3-scale@4:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
+  integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
+  dependencies:
+    d3-array "2.10.0 - 3"
+    d3-format "1 - 3"
+    d3-interpolate "1.2.0 - 3"
+    d3-time "2.1.1 - 3"
+    d3-time-format "2 - 4"
+
+"d3-selection@2 - 3", d3-selection@3:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
+  integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
+
+d3-shape@3:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
+  integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
+  dependencies:
+    d3-path "^3.1.0"
+
+"d3-time-format@2 - 4", d3-time-format@4:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
+  integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
+  dependencies:
+    d3-time "1 - 3"
+
+"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
+  integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
+  dependencies:
+    d3-array "2 - 3"
+
+"d3-timer@1 - 3", d3-timer@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
+  integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
+
+"d3-transition@2 - 3", d3-transition@3:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
+  integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
+  dependencies:
+    d3-color "1 - 3"
+    d3-dispatch "1 - 3"
+    d3-ease "1 - 3"
+    d3-interpolate "1 - 3"
+    d3-timer "1 - 3"
+
+d3-zoom@3:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
+  integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
+  dependencies:
+    d3-dispatch "1 - 3"
+    d3-drag "2 - 3"
+    d3-interpolate "1 - 3"
+    d3-selection "2 - 3"
+    d3-transition "2 - 3"
+
+d3@^7.4.0, d3@^7.8.2:
+  version "7.8.4"
+  resolved "https://registry.yarnpkg.com/d3/-/d3-7.8.4.tgz#e35d45800e4068cab07e59e5d883a4bb42ab217f"
+  integrity sha512-q2WHStdhiBtD8DMmhDPyJmXUxr6VWRngKyiJ5EfXMxPw+tqT6BhNjhJZ4w3BHsNm3QoVfZLY8Orq/qPFczwKRA==
+  dependencies:
+    d3-array "3"
+    d3-axis "3"
+    d3-brush "3"
+    d3-chord "3"
+    d3-color "3"
+    d3-contour "4"
+    d3-delaunay "6"
+    d3-dispatch "3"
+    d3-drag "3"
+    d3-dsv "3"
+    d3-ease "3"
+    d3-fetch "3"
+    d3-force "3"
+    d3-format "3"
+    d3-geo "3"
+    d3-hierarchy "3"
+    d3-interpolate "3"
+    d3-path "3"
+    d3-polygon "3"
+    d3-quadtree "3"
+    d3-random "3"
+    d3-scale "4"
+    d3-scale-chromatic "3"
+    d3-selection "3"
+    d3-shape "3"
+    d3-time "3"
+    d3-time-format "4"
+    d3-timer "3"
+    d3-transition "3"
+    d3-zoom "3"
+
+dagre-d3-es@7.0.10:
+  version "7.0.10"
+  resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc"
+  integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==
+  dependencies:
+    d3 "^7.8.2"
+    lodash-es "^4.17.21"
+
 damerau-levenshtein@^1.0.7:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -5851,6 +6160,13 @@ del@6.0.0:
     rimraf "^3.0.2"
     slash "^3.0.0"
 
+delaunator@5:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.0.tgz#60f052b28bd91c9b4566850ebf7756efe821d81b"
+  integrity sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==
+  dependencies:
+    robust-predicates "^3.0.0"
+
 delayed-stream@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@@ -6019,6 +6335,11 @@ dom-helpers@^5.2.0:
     "@babel/runtime" "^7.8.7"
     csstype "^3.0.2"
 
+dompurify@2.4.5:
+  version "2.4.5"
+  resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.5.tgz#0e89a27601f0bad978f9a924e7a05d5d2cccdd87"
+  integrity sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA==
+
 dot-case@^3.0.3, dot-case@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
@@ -6132,6 +6453,11 @@ electron-to-chromium@^1.4.284:
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.300.tgz#37097e9bcdef687fb98abb5184434bdb958dfcd9"
   integrity sha512-tHLIBkKaxvG6NnDWuLgeYrz+LTwAnApHm2R3KBNcRrFn0qLmTrqQeB4X4atfN6YJbkOOOSdRBeQ89OfFUelnEQ==
 
+elkjs@^0.8.2:
+  version "0.8.2"
+  resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e"
+  integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==
+
 emittery@^0.10.2:
   version "0.10.2"
   resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.10.2.tgz#902eec8aedb8c41938c46e9385e9db7e03182933"
@@ -8176,6 +8502,11 @@ header-case@^2.0.3, header-case@^2.0.4:
     capital-case "^1.0.4"
     tslib "^2.0.3"
 
+heap@^0.2.6:
+  version "0.2.7"
+  resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.7.tgz#1e6adf711d3f27ce35a81fe3b7bd576c2260a8fc"
+  integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==
+
 helmet@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/helmet/-/helmet-4.6.0.tgz#579971196ba93c5978eb019e4e8ec0e50076b4df"
@@ -8388,7 +8719,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24:
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-iconv-lite@^0.6.2, iconv-lite@^0.6.3:
+iconv-lite@0.6, iconv-lite@^0.6.2, iconv-lite@^0.6.3:
   version "0.6.3"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
   integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
@@ -8562,6 +8893,11 @@ internal-slot@^1.0.3:
     has "^1.0.3"
     side-channel "^1.0.4"
 
+"internmap@1 - 2":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
+  integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
+
 interpret@^1.0.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
@@ -9823,6 +10159,11 @@ katex@^0.16.4:
   dependencies:
     commander "^8.0.0"
 
+khroma@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.0.0.tgz#7577de98aed9f36c7a474c4d453d94c0d6c6588b"
+  integrity sha512-2J8rDNlQWbtiNYThZRvmMv5yt44ZakX+Tz5ZIp/mN1pt4snn+m030Va5Z4v8xA0cQFDXBwO/8i42xL4QPsVk3g==
+
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@@ -9883,6 +10224,16 @@ language-tags@^1.0.5:
   dependencies:
     language-subtag-registry "~0.3.2"
 
+layout-base@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-1.0.2.tgz#1291e296883c322a9dd4c5dd82063721b53e26e2"
+  integrity sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==
+
+layout-base@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285"
+  integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==
+
 lazy-ass@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513"
@@ -10058,6 +10409,11 @@ locate-path@^6.0.0:
   dependencies:
     p-locate "^5.0.0"
 
+lodash-es@^4.17.21:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
+  integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
+
 lodash.debounce@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
@@ -10705,6 +11061,29 @@ merge@^2.1.0:
   resolved "https://registry.yarnpkg.com/merge/-/merge-2.1.1.tgz#59ef4bf7e0b3e879186436e8481c06a6c162ca98"
   integrity sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==
 
+mermaid@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.1.0.tgz#6e40d5250174f4750ca6548e4ee00f6ae210855a"
+  integrity sha512-LYekSMNJygI1VnMizAPUddY95hZxOjwZxr7pODczILInO0dhQKuhXeu4sargtnuTwCilSuLS7Uiq/Qn7HTVrmA==
+  dependencies:
+    "@braintree/sanitize-url" "^6.0.0"
+    "@khanacademy/simple-markdown" "^0.8.6"
+    cytoscape "^3.23.0"
+    cytoscape-cose-bilkent "^4.1.0"
+    cytoscape-fcose "^2.1.0"
+    d3 "^7.4.0"
+    dagre-d3-es "7.0.10"
+    dayjs "^1.11.7"
+    dompurify "2.4.5"
+    elkjs "^0.8.2"
+    khroma "^2.0.0"
+    lodash-es "^4.17.21"
+    non-layered-tidy-tree-layout "^2.0.2"
+    stylis "^4.1.2"
+    ts-dedent "^2.2.0"
+    uuid "^9.0.0"
+    web-worker "^1.2.0"
+
 method-override@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/method-override/-/method-override-3.0.0.tgz#6ab0d5d574e3208f15b0c9cf45ab52000468d7a2"
@@ -11606,6 +11985,11 @@ nodemailer@^6.6.2:
   resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.2.tgz#e184c9ed5bee245a3e0bcabc7255866385757114"
   integrity sha512-YSzu7TLbI+bsjCis/TZlAXBoM4y93HhlIgo0P5oiA2ua9Z4k+E2Fod//ybIzdJxOlXGRcHIh/WaeCBehvxZb/Q==
 
+non-layered-tidy-tree-layout@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804"
+  integrity sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==
+
 noop6@^1.0.1:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/noop6/-/noop6-1.0.8.tgz#eff06e2e5b3621e9e5618f389d6a2294f76e64ad"
@@ -13868,6 +14252,11 @@ rndm@1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c"
 
+robust-predicates@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a"
+  integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==
+
 rollup@^3.18.0:
   version "3.20.6"
   resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.20.6.tgz#53c0fd73e397269d2ce5f0ec12851457dd53cacd"
@@ -13885,6 +14274,11 @@ run-parallel@^1.1.4, run-parallel@^1.1.9:
   resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679"
   integrity sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==
 
+rw@1:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
+  integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
+
 rxjs@^6.5.3:
   version "6.6.7"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
@@ -14949,6 +15343,11 @@ stylelint@^14.2.0:
     v8-compile-cache "^2.3.0"
     write-file-atomic "^3.0.3"
 
+stylis@^4.1.2:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51"
+  integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==
+
 subarg@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2"
@@ -15402,6 +15801,11 @@ trough@^2.0.0:
   resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876"
   integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==
 
+ts-dedent@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5"
+  integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
+
 ts-deepmerge@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ts-deepmerge/-/ts-deepmerge-3.0.0.tgz#231c48901606eb104ab51a74cb447af0e9e669e4"
@@ -16031,6 +16435,11 @@ uuid@^3.1.0, uuid@^3.3.2:
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
+uuid@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
+  integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
+
 uvu@^0.5.0:
   version "0.5.6"
   resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df"
@@ -16177,6 +16586,11 @@ web-namespaces@^2.0.0:
   resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692"
   integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==
 
+web-worker@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da"
+  integrity sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==
+
 webidl-conversions@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"