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

Merge branch 'dev/5.0.x' into feat/81920-83419-fix-search-right-pane

NEEDLEMAN3\tatsu 4 лет назад
Родитель
Сommit
1d08be6898
81 измененных файлов с 1843 добавлено и 718 удалено
  1. 3 3
      packages/app/package.json
  2. 10 0
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  3. 14 3
      packages/app/resource/locales/en_US/translation.json
  4. 11 0
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  5. 13 2
      packages/app/resource/locales/ja_JP/translation.json
  6. 10 0
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  7. 14 3
      packages/app/resource/locales/zh_CN/translation.json
  8. 4 4
      packages/app/src/client/app.jsx
  9. 25 0
      packages/app/src/client/nologin.jsx
  10. 12 1
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  11. 5 1
      packages/app/src/client/services/ContextExtractor.tsx
  12. 3 5
      packages/app/src/client/services/EditorContainer.js
  13. 15 0
      packages/app/src/client/util/editor.ts
  14. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  15. 46 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  16. 7 4
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  17. 148 0
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  18. 59 27
      packages/app/src/components/LoginForm.jsx
  19. 1 1
      packages/app/src/components/Navbar/GlobalSearch.jsx
  20. 4 3
      packages/app/src/components/Navbar/GrowiNavbarBottom.jsx
  21. 26 7
      packages/app/src/components/Page.jsx
  22. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  23. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  24. 22 4
      packages/app/src/components/PageEditor.jsx
  25. 21 21
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  26. 22 5
      packages/app/src/components/PageEditorByHackmd.jsx
  27. 26 6
      packages/app/src/components/SavePageControls.jsx
  28. 21 0
      packages/app/src/components/SearchPage.jsx
  29. 1 1
      packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx
  30. 29 25
      packages/app/src/components/SearchPage/SearchControl.tsx
  31. 1 1
      packages/app/src/components/SearchPage/SearchOptionModal.tsx
  32. 5 5
      packages/app/src/components/SearchPage/SearchPageForm.jsx
  33. 8 3
      packages/app/src/components/SearchPage/SearchPageLayout.tsx
  34. 5 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  35. 58 32
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  36. 3 2
      packages/app/src/components/SearchPage/SortControl.tsx
  37. 1 1
      packages/app/src/components/Sidebar.tsx
  38. 1 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  39. 5 7
      packages/app/src/components/Sidebar/PageTree.tsx
  40. 21 20
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  41. 12 4
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  42. 3 5
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx
  43. 0 87
      packages/app/src/components/SlackNotification.jsx
  44. 67 0
      packages/app/src/components/SlackNotification.tsx
  45. 8 7
      packages/app/src/interfaces/search.ts
  46. 22 0
      packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts
  47. 4 0
      packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts
  48. 1 1
      packages/app/src/server/models/named-query.ts
  49. 61 4
      packages/app/src/server/models/page.ts
  50. 67 0
      packages/app/src/server/models/user-registration-order.ts
  51. 10 0
      packages/app/src/server/models/user.js
  52. 12 0
      packages/app/src/server/routes/apiv3/index.js
  53. 17 0
      packages/app/src/server/routes/apiv3/page-listing.ts
  54. 3 0
      packages/app/src/server/routes/apiv3/security-setting.js
  55. 138 0
      packages/app/src/server/routes/apiv3/user-activation.ts
  56. 6 0
      packages/app/src/server/routes/index.js
  57. 114 0
      packages/app/src/server/routes/user-activation.ts
  58. 9 6
      packages/app/src/server/routes/user.js
  59. 6 0
      packages/app/src/server/service/config-loader.ts
  60. 92 20
      packages/app/src/server/service/page.js
  61. 7 1
      packages/app/src/server/service/search.ts
  62. 1 1
      packages/app/src/server/views/admin/users.html
  63. 3 2
      packages/app/src/server/views/login.html
  64. 52 0
      packages/app/src/server/views/user-activation.html
  65. 3 0
      packages/app/src/stores/context.tsx
  66. 9 0
      packages/app/src/stores/editor.tsx
  67. 1 0
      packages/app/src/styles/_layout.scss
  68. 31 19
      packages/app/src/styles/_override-bootstrap-variables.scss
  69. 11 6
      packages/app/src/styles/_page-tree.scss
  70. 47 26
      packages/app/src/styles/_search.scss
  71. 2 7
      packages/app/src/styles/_sidebar.scss
  72. 0 2
      packages/app/src/styles/_subnav.scss
  73. 1 1
      packages/app/src/styles/_tag.scss
  74. 4 1
      packages/app/src/styles/atoms/_buttons.scss
  75. 8 2
      packages/app/src/styles/theme/_apply-colors-dark.scss
  76. 8 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  77. 13 0
      packages/app/src/styles/theme/_apply-colors.scss
  78. 78 0
      packages/app/src/test/integration/service/page.test.js
  79. 7 7
      packages/ui/src/components/PagePath/PageListMeta.jsx
  80. 4 2
      packages/ui/src/components/User/UserPicture.jsx
  81. 218 301
      yarn.lock

+ 3 - 3
packages/app/package.json

@@ -72,7 +72,7 @@
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
-    "aws-sdk": "^2.88.0",
+    "aws-sdk": "^2.1044.0",
     "axios": "^0.24.0",
     "axios": "^0.24.0",
     "body-parser": "^1.18.2",
     "body-parser": "^1.18.2",
     "browser-bunyan": "^1.6.3",
     "browser-bunyan": "^1.6.3",
@@ -131,7 +131,7 @@
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
-    "re2": "^1.16.0",
+    "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "react-image-crop": "^8.3.0",
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
@@ -167,7 +167,7 @@
     "@types/react-dom": "^17.0.9",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
     "bootstrap": "^4.5.0",
     "bootstrap": "^4.5.0",
-    "browser-sync": "^2.26.3",
+    "browser-sync": "^2.27.7",
     "bunyan-debug": "^2.0.0",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "cli": "~1.0.1",
     "codemirror": "^5.63.0",
     "codemirror": "^5.63.0",

+ 10 - 0
packages/app/resource/locales/en_US/notifications/userActivation.txt

@@ -0,0 +1,10 @@
+Account confirmation
+
+Hi, {{ email }}
+
+An acount has been created in GROWI {{ appTitle }}.
+To activate your account, click on the link below.
+
+{{ url }}
+
+If you did not created the account, you can safely ignore this email.

+ 14 - 3
packages/app/resource/locales/en_US/translation.json

@@ -189,6 +189,7 @@
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
   },
   },
   "page_register": {
   "page_register": {
+    "send_email": "Send email",
     "notice": {
     "notice": {
       "restricted": "Admin approval required.",
       "restricted": "Admin approval required.",
       "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
       "restricted_defail": "Once the admin approves your sign up, you'll be able to access this wiki."
@@ -665,7 +666,12 @@
       "enable_local": "Enable ID/Password",
       "enable_local": "Enable ID/Password",
       "password_reset_by_users": "Password reset by users",
       "password_reset_by_users": "Password reset by users",
       "enable_password_reset_by_users": "Enable password reset by users",
       "enable_password_reset_by_users": "Enable password reset by users",
-      "password_reset_desc": "when forgot password, users are able to reset it by themselves."
+      "password_reset_desc": "when forgot password, users are able to reset it by themselves.",
+      "email_authentication": "Email authentication on user registration",
+      "enable_email_authentication": "Enable email authentication",
+      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration.",
+      "please_enable_mailer": "Please setup mailer first.",
+      "need_complete_mail_setting_warning": "To use the following functions, please complete the mail settings."
     },
     },
     "ldap": {
     "ldap": {
       "enable_ldap": "Enable LDAP",
       "enable_ldap": "Enable LDAP",
@@ -883,7 +889,7 @@
     "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
     "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
     "application_already_installed": "Application already installed.",
     "application_already_installed": "Application already installed.",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
     "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
-    "user_id_is_not_available.":"This User ID is not available.",
+    "user_id_is_not_available":"This User ID is not available.",
     "email_address_is_already_registered":"This email address is already registered.",
     "email_address_is_already_registered":"This email address is already registered.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
     "failed_to_register":"Failed to register.",
     "failed_to_register":"Failed to register.",
@@ -893,7 +899,9 @@
     "unable_to_use_this_user":"Unable to use this user.",
     "unable_to_use_this_user":"Unable to use this user.",
     "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
     "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
-    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
+    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}",
+    "successfully_send_email_auth":"We sent an email to {{email}}. Please click the URL in the email and complete the registration.",
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired."
   },
   },
   "grid_edit":{
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
@@ -922,5 +930,8 @@
     "success_to_send_email": "Success to send email",
     "success_to_send_email": "Success to send email",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+  },
+  "pagetree": {
+    "private_legacy_pages": "Private Legacy Pages"
   }
   }
 }
 }

+ 11 - 0
packages/app/resource/locales/ja_JP/notifications/userActivation.txt

@@ -0,0 +1,11 @@
+仮登録完了のお知らせ
+
+{{ email }} さん
+
+GROWI {{ appTitle }} で仮登録が完了いたしました。
+
+ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
+
+{{ url }}
+
+※当メールの内容に心当たりがない場合は、このメールを無視してください。

+ 13 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -191,6 +191,7 @@
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
   },
   },
   "page_register": {
   "page_register": {
+    "send_email": "メールを送る",
     "notice": {
     "notice": {
       "restricted": "この Wiki への新規登録は制限されています。",
       "restricted": "この Wiki への新規登録は制限されています。",
       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
       "restricted_defail": "利用を開始するには、新規登録後、管理者による承認が必要です。"
@@ -662,7 +663,12 @@
       "enable_local": "ID/Password を有効にする",
       "enable_local": "ID/Password を有効にする",
       "password_reset_by_users": "ユーザーによるパスワード再設定",
       "password_reset_by_users": "ユーザーによるパスワード再設定",
       "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
       "enable_password_reset_by_users": "ユーザーによるパスワード再設定を有効にする",
-      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。"
+      "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
+      "email_authentication": "ユーザー登録時のメール認証",
+      "enable_email_authentication": "メール認証を有効にする",
+      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
+      "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
+      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
     },
     },
     "ldap": {
     "ldap": {
       "enable_ldap": "LDAP を有効にする",
       "enable_ldap": "LDAP を有効にする",
@@ -886,7 +892,9 @@
     "unable_to_use_this_user":"利用できないユーザーIDです。",
     "unable_to_use_this_user":"利用できないユーザーIDです。",
     "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
     "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
-    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
+    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}",
+    "successfully_send_email_auth":"{{email}} にメールを送信しました。添付されたURLをクリックし、本登録を完了させてください",
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。"
   },
   },
   "grid_edit":{
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
@@ -915,5 +923,8 @@
     "success_to_send_email": "メールを送信しました",
     "success_to_send_email": "メールを送信しました",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+  },
+  "pagetree": {
+    "private_legacy_pages": "待避所"
   }
   }
 }
 }

+ 10 - 0
packages/app/resource/locales/zh_CN/notifications/userActivation.txt

@@ -0,0 +1,10 @@
+确认账户创建
+
+致{{ email }},
+
+已使用 GROWI {{ appTitle }} 创建帐户。
+单击下面的链接以激活您的帐户。
+
+{{ url }}
+
+如果您尚未创建,请忽略此电子邮件。

+ 14 - 3
packages/app/resource/locales/zh_CN/translation.json

@@ -189,6 +189,7 @@
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
 	},
 	},
 	"page_register": {
 	"page_register": {
+    "send_email": "发电子邮件",
 		"notice": {
 		"notice": {
 			"restricted": "需要管理员批准。",
 			"restricted": "需要管理员批准。",
 			"restricted_defail": "一旦管理员批准您的注册,您就可以访问此wiki。"
 			"restricted_defail": "一旦管理员批准您的注册,您就可以访问此wiki。"
@@ -640,7 +641,12 @@
       "enable_local": "Enable ID/Password",
       "enable_local": "Enable ID/Password",
       "password_reset_by_users": "用户重置密码",
       "password_reset_by_users": "用户重置密码",
       "enable_password_reset_by_users": "启用用户重置密码",
       "enable_password_reset_by_users": "启用用户重置密码",
-      "password_reset_desc": "忘记密码时,用户可以自行重置"
+      "password_reset_desc": "忘记密码时,用户可以自行重置",
+      "email_authentication": "用户注册时的电子邮件身份验证",
+      "enable_email_authentication": "启用电子邮件身份验证",
+      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
+      "please_enable_mailer": "请先设置邮件程序。",
+      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
 		},
 		},
 		"ldap": {
 		"ldap": {
 			"enable_ldap": "Enable LDAP",
 			"enable_ldap": "Enable LDAP",
@@ -886,7 +892,7 @@
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"aws_sttings_required": "使用此功能所需的AWS设置。请询问管理员。",
 		"application_already_installed": "应用程序已安装。",
 		"application_already_installed": "应用程序已安装。",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
 		"email_address_could_not_be_used": "无法使用此电子邮件地址。(确保允许的电子邮件地址)",
-		"user_id_is_not_available.": "此用户ID不可用。",
+		"user_id_is_not_available": "此用户ID不可用。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"email_address_is_already_registered": "此电子邮件地址已注册。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"can_not_register_maximum_number_of_users": "注册的用户数不能超过最大值。",
 		"failed_to_register": "注册失败。",
 		"failed_to_register": "注册失败。",
@@ -896,7 +902,9 @@
 		"unable_to_use_this_user": "无法使用此用户。",
 		"unable_to_use_this_user": "无法使用此用户。",
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
-		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
+		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}",
+    "successfully_send_email_auth":"我们向 {{email}} 发送了一封电子邮件。 请点击邮件中的网址并完成注册。",
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。"
 	},
 	},
   "grid_edit":{
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
@@ -925,5 +933,8 @@
     "success_to_send_email": "我发了一封电子邮件",
     "success_to_send_email": "我发了一封电子邮件",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+  },
+  "pagetree": {
+    "private_legacy_pages": "私人遗留页面"
   }
   }
 }
 }

+ 4 - 4
packages/app/src/client/app.jsx

@@ -96,10 +96,6 @@ Object.assign(componentMappings, {
   'trash-page-list': <TrashPageList />,
   'trash-page-list': <TrashPageList />,
 
 
   'not-found-page': <NotFoundPage />,
   'not-found-page': <NotFoundPage />,
-  'not-found-alert': <NotFoundAlert
-    isGuestUserMode={appContainer.isGuestUser}
-    isHidden={pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage}
-  />,
 
 
   'forbidden-page': <ForbiddenPage />,
   'forbidden-page': <ForbiddenPage />,
 
 
@@ -115,6 +111,10 @@ Object.assign(componentMappings, {
   'duplicated-alert': <DuplicatedAlert />,
   'duplicated-alert': <DuplicatedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'redirected-alert': <RedirectedAlert />,
   'renamed-alert': <RenamedAlert />,
   'renamed-alert': <RenamedAlert />,
+  'not-found-alert': <NotFoundAlert
+    isGuestUserMode={appContainer.isGuestUser}
+    isHidden={pageContainer.state.pageId != null ? (pageContainer.state.isNotCreatable || pageContainer.state.isTrashPage) : false} // !!DO NOT MOVE THIS!! https://github.com/weseek/growi/pull/4899
+  />,
 });
 });
 
 
 // additional definitions if data exists
 // additional definitions if data exists

+ 25 - 0
packages/app/src/client/nologin.jsx

@@ -11,6 +11,7 @@ import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
 import LoginForm from '../components/LoginForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
+import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 
 
 const i18n = i18nFactory();
 const i18n = i18nFactory();
 
 
@@ -39,6 +40,7 @@ if (loginFormElem) {
   const name = loginFormElem.dataset.name;
   const name = loginFormElem.dataset.name;
   const email = loginFormElem.dataset.email;
   const email = loginFormElem.dataset.email;
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
   const isRegistrationEnabled = loginFormElem.dataset.isRegistrationEnabled === 'true';
+  const isEmailAuthenticationEnabled = loginFormElem.dataset.isEmailAuthenticationEnabled === 'true';
   const registrationMode = loginFormElem.dataset.registrationMode;
   const registrationMode = loginFormElem.dataset.registrationMode;
   const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
   const isPasswordResetEnabled = loginFormElem.dataset.isPasswordResetEnabled === 'true';
 
 
@@ -69,6 +71,7 @@ if (loginFormElem) {
           name={name}
           name={name}
           email={email}
           email={email}
           isRegistrationEnabled={isRegistrationEnabled}
           isRegistrationEnabled={isRegistrationEnabled}
+          isEmailAuthenticationEnabled={isEmailAuthenticationEnabled}
           registrationMode={registrationMode}
           registrationMode={registrationMode}
           registrationWhiteList={registrationWhiteList}
           registrationWhiteList={registrationWhiteList}
           isPasswordResetEnabled={isPasswordResetEnabled}
           isPasswordResetEnabled={isPasswordResetEnabled}
@@ -111,3 +114,25 @@ if (passwordResetExecutionFormElem) {
     passwordResetExecutionFormElem,
     passwordResetExecutionFormElem,
   );
   );
 }
 }
+
+// render UserActivationForm
+const UserActivationForm = document.getElementById('user-activation-form');
+if (UserActivationForm) {
+
+  const messageErrors = UserActivationForm.dataset.messageErrors;
+  const inputs = UserActivationForm.dataset.inputs;
+  const email = UserActivationForm.dataset.email;
+  const token = UserActivationForm.dataset.token;
+
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <CompleteUserRegistrationForm
+        messageErrors={messageErrors}
+        inputs={inputs}
+        email={email}
+        token={token}
+      />
+    </I18nextProvider>,
+    UserActivationForm,
+  );
+}

+ 12 - 1
packages/app/src/client/services/AdminLocalSecurityContainer.js

@@ -23,6 +23,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationWhiteList: [],
       registrationWhiteList: [],
       useOnlyEnvVars: false,
       useOnlyEnvVars: false,
       isPasswordResetEnabled: false,
       isPasswordResetEnabled: false,
+      isEmailAuthenticationEnabled: false,
     };
     };
 
 
   }
   }
@@ -36,6 +37,7 @@ export default class AdminLocalSecurityContainer extends Container {
         registrationMode: localSetting.registrationMode,
         registrationMode: localSetting.registrationMode,
         registrationWhiteList: localSetting.registrationWhiteList,
         registrationWhiteList: localSetting.registrationWhiteList,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
+        isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -75,15 +77,23 @@ export default class AdminLocalSecurityContainer extends Container {
     this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
     this.setState({ isPasswordResetEnabled: !this.state.isPasswordResetEnabled });
   }
   }
 
 
+  /**
+   * Switch email authentication enabled
+   */
+  switchIsEmailAuthenticationEnabled() {
+    this.setState({ isEmailAuthenticationEnabled: !this.state.isEmailAuthenticationEnabled });
+  }
+
   /**
   /**
    * update local security setting
    * update local security setting
    */
    */
   async updateLocalSecuritySetting() {
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList, isPasswordResetEnabled } = this.state;
+    const { registrationWhiteList, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
     const response = await this.appContainer.apiv3.put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
       registrationMode: this.state.registrationMode,
       registrationWhiteList,
       registrationWhiteList,
       isPasswordResetEnabled,
       isPasswordResetEnabled,
+      isEmailAuthenticationEnabled,
     });
     });
 
 
     const { localSettingParams } = response.data;
     const { localSettingParams } = response.data;
@@ -92,6 +102,7 @@ export default class AdminLocalSecurityContainer extends Container {
       registrationMode: localSettingParams.registrationMode,
       registrationMode: localSettingParams.registrationMode,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       registrationWhiteList: localSettingParams.registrationWhiteList,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
+      isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
     });
     });
 
 
     return localSettingParams;
     return localSettingParams;

+ 5 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -5,7 +5,7 @@ import {
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
-  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
+  useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors, useSlackChannels,
 } from '../../stores/context';
 } from '../../stores/context';
 import {
 import {
   useIsDeviceSmallerThanMd,
   useIsDeviceSmallerThanMd,
@@ -61,6 +61,7 @@ const ContextExtractorOnce: FC = () => {
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const creator = JSON.parse(mainContent?.getAttribute('data-page-creator') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(mainContent?.getAttribute('data-target-and-ancestors') || jsonNull);
   const targetAndAncestors = JSON.parse(mainContent?.getAttribute('data-target-and-ancestors') || jsonNull);
+  const slackChannels = mainContent?.getAttribute('data-slack-channels') || '';
 
 
   /*
   /*
    * use static swr
    * use static swr
@@ -114,6 +115,9 @@ const ContextExtractorOnce: FC = () => {
   usePreferDrawerModeOnEditByUser();
   usePreferDrawerModeOnEditByUser();
   useIsDeviceSmallerThanMd();
   useIsDeviceSmallerThanMd();
 
 
+  // Editor
+  useSlackChannels(slackChannels);
+
   return null;
   return null;
 };
 };
 
 

+ 3 - 5
packages/app/src/client/services/EditorContainer.js

@@ -27,9 +27,6 @@ export default class EditorContainer extends Container {
     this.state = {
     this.state = {
       tags: null,
       tags: null,
 
 
-      isSlackEnabled: false,
-      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
-
       grant: 1, // default: public
       grant: 1, // default: public
       grantGroupId: null,
       grantGroupId: null,
       grantGroupName: null,
       grantGroupName: null,
@@ -143,10 +140,11 @@ export default class EditorContainer extends Container {
     }
     }
   }
   }
 
 
+  // TODO: Remove when SWR is complete
   getCurrentOptionsToSave() {
   getCurrentOptionsToSave() {
     const opt = {
     const opt = {
-      isSlackEnabled: this.state.isSlackEnabled,
-      slackChannels: this.state.slackChannels,
+      // isSlackEnabled: this.state.isSlackEnabled,
+      // slackChannels: this.state.slackChannels,
       grant: this.state.grant,
       grant: this.state.grant,
       pageTags: this.state.tags,
       pageTags: this.state.tags,
     };
     };

+ 15 - 0
packages/app/src/client/util/editor.ts

@@ -0,0 +1,15 @@
+import EditorContainer from '~/client/services/EditorContainer';
+
+type OptionsToSave = {
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  grant: number;
+  pageTags: string[];
+  grantUserGroupId?: string;
+};
+
+// TODO: Remove editorContainer upon migration to SWR
+export const getOptionsToSave = (isSlackEnabled: boolean, slackChannels: string, editorContainer: EditorContainer): OptionsToSave => {
+  const optionsToSave = editorContainer.getCurrentOptionsToSave();
+  return { ...optionsToSave, isSlackEnabled, slackChannels };
+};

+ 1 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -48,7 +48,7 @@ class AppSettingsPageContents extends React.Component {
 
 
         <div className="row mt-5">
         <div className="row mt-5">
           <div className="col-lg-12">
           <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.mail_settings')}</h2>
+            <h2 className="admin-setting-header" id="mail-settings">{t('admin:app_setting.mail_settings')}</h2>
             <MailSetting />
             <MailSetting />
           </div>
           </div>
         </div>
         </div>

+ 46 - 2
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -31,9 +31,15 @@ class LocalSecuritySettingContents extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    const { registrationMode, isPasswordResetEnabled } = adminLocalSecurityContainer.state;
+    const {
+      t,
+      adminGeneralSecurityContainer,
+      adminLocalSecurityContainer,
+      appContainer,
+    } = this.props;
+    const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
     const { isLocalEnabled } = adminGeneralSecurityContainer.state;
+    const { isMailerSetup } = appContainer.config;
 
 
     return (
     return (
       <React.Fragment>
       <React.Fragment>
@@ -46,6 +52,17 @@ class LocalSecuritySettingContents extends React.Component {
         )}
         )}
         <h2 className="alert-anchor border-bottom">{t('security_setting.Local.name')}</h2>
         <h2 className="alert-anchor border-bottom">{t('security_setting.Local.name')}</h2>
 
 
+        {!isMailerSetup && (
+          <div className="row">
+            <div className="col-12">
+              <div className="alert alert-danger">
+                <span>{t('security_setting.Local.need_complete_mail_setting_warning')}</span>
+                <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
+              </div>
+            </div>
+          </div>
+        )}
+
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
           <p
           <p
             className="alert alert-info"
             className="alert alert-info"
@@ -178,6 +195,33 @@ class LocalSecuritySettingContents extends React.Component {
               </div>
               </div>
             </div>
             </div>
 
 
+            <div className="row">
+              <label className="col-12 col-md-3 text-left text-md-right  col-form-label">{t('security_setting.Local.email_authentication')}</label>
+              <div className="col-12 col-md-6">
+                <div className="custom-control custom-switch custom-checkbox-success">
+                  <input
+                    type="checkbox"
+                    className="custom-control-input"
+                    id="isEmailAuthenticationEnabled"
+                    checked={isEmailAuthenticationEnabled}
+                    onChange={() => adminLocalSecurityContainer.switchIsEmailAuthenticationEnabled()}
+                  />
+                  <label className="custom-control-label" htmlFor="isEmailAuthenticationEnabled">
+                    {t('security_setting.Local.enable_email_authentication')}
+                  </label>
+                </div>
+                {!isMailerSetup && (
+                  <div className="alert alert-warning p-1 my-1 small d-inline-block">
+                    <span>{t('security_setting.Local.please_enable_mailer')}</span>
+                    <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
+                  </div>
+                )}
+                <p className="form-text text-muted small">
+                  {t('security_setting.Local.enable_email_authentication_desc')}
+                </p>
+              </div>
+            </div>
+
             <div className="row my-3">
             <div className="row my-3">
               <div className="offset-3 col-6">
               <div className="offset-3 col-6">
                 <button
                 <button

+ 7 - 4
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -8,12 +8,15 @@ import { IPageHasId } from '~/interfaces/page';
 type PageItemControlProps = {
 type PageItemControlProps = {
   page: Partial<IPageHasId>
   page: Partial<IPageHasId>
   isEnableActions: boolean
   isEnableActions: boolean
+  isDeletable: boolean
   onClickDeleteButton?: (pageId: string) => void
   onClickDeleteButton?: (pageId: string) => void
 }
 }
 
 
 const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
 const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
 
 
-  const { page, isEnableActions, onClickDeleteButton } = props;
+  const {
+    page, isEnableActions, onClickDeleteButton, isDeletable,
+  } = props;
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
   const deleteButtonHandler = () => {
   const deleteButtonHandler = () => {
@@ -25,10 +28,10 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
     <>
     <>
       <button
       <button
         type="button"
         type="button"
-        className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0"
+        className="btn-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management py-0 px-2"
         data-toggle="dropdown"
         data-toggle="dropdown"
       >
       >
-        <i className="fa fa-ellipsis-v text-muted"></i>
+        <i className="fa fa-ellipsis-v text-muted p-1"></i>
       </button>
       </button>
       <div className="dropdown-menu dropdown-menu-right">
       <div className="dropdown-menu dropdown-menu-right">
 
 
@@ -74,7 +77,7 @@ const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps)
             {t('Move/Rename')}
             {t('Move/Rename')}
           </button>
           </button>
         )}
         )}
-        {isEnableActions && (
+        {isDeletable && isEnableActions && (
           <>
           <>
             <div className="dropdown-divider"></div>
             <div className="dropdown-divider"></div>
             <button className="dropdown-item text-danger pt-2" type="button" onClick={deleteButtonHandler}>
             <button className="dropdown-item text-danger pt-2" type="button" onClick={deleteButtonHandler}>

+ 148 - 0
packages/app/src/components/CompleteUserRegistrationForm.tsx

@@ -0,0 +1,148 @@
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '../client/util/apiNotification';
+
+interface Props {
+  messageErrors?: any,
+  inputs?: any,
+  email: string,
+  token: string,
+}
+
+const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
+
+  const { t } = useTranslation();
+  const {
+    messageErrors,
+    email,
+    token,
+  } = props;
+
+  const [usernameAvailable, setUsernameAvailable] = useState(true);
+  const [username, setUsername] = useState('');
+  const [name, setName] = useState('');
+  const [password, setPassword] = useState('');
+  const [disableForm, setDisableForm] = useState(false);
+
+  useEffect(() => {
+    const delayDebounceFn = setTimeout(async() => {
+      try {
+        const { data } = await apiv3Get('/check_username', { username });
+        if (data.ok) {
+          setUsernameAvailable(data.valid);
+        }
+      }
+      catch (error) {
+        toastError(error, 'Error occurred when checking username');
+      }
+    }, 500);
+
+    return () => clearTimeout(delayDebounceFn);
+  }, [username]);
+
+  async function submitRegistration() {
+    setDisableForm(true);
+    try {
+      await apiv3Post('/complete-registration', {
+        username, name, password, token,
+      });
+      toastSuccess('Registration succeed');
+      window.location.href = '/login';
+    }
+    catch (err) {
+      toastError(err, 'Registration failed');
+      setDisableForm(false);
+    }
+  }
+
+  return (
+    <>
+      <div id="register-form-errors">
+        {messageErrors && (
+          <div className="alert alert-danger">
+            { messageErrors }
+          </div>
+        )}
+      </div>
+      <div id="register-dialog">
+
+        <fieldset id="registration-form" disabled={disableForm}>
+          <input type="hidden" name="token" value={token} />
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-envelope"></i></span>
+            </div>
+            <input type="text" className="form-control" disabled value={email} />
+          </div>
+          <div className="input-group" id="input-group-username">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-user"></i></span>
+            </div>
+            <input
+              type="text"
+              className="form-control"
+              placeholder={t('User ID')}
+              name="username"
+              onChange={e => setUsername(e.target.value)}
+              required
+            />
+          </div>
+          {!usernameAvailable && (
+            <p className="form-text text-red">
+              <span id="help-block-username"><i className="icon-fw icon-ban"></i>{t('installer.unavaliable_user_id')}</span>
+            </p>
+          )}
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-tag"></i></span>
+            </div>
+            <input
+              type="text"
+              className="form-control"
+              placeholder={t('Name')}
+              name="name"
+              value={name}
+              onChange={e => setName(e.target.value)}
+              required
+            />
+          </div>
+
+          <div className="input-group">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-lock"></i></span>
+            </div>
+            <input
+              type="password"
+              className="form-control"
+              placeholder={t('Password')}
+              name="password"
+              value={password}
+              onChange={e => setPassword(e.target.value)}
+              required
+            />
+          </div>
+
+          <div className="input-group justify-content-center d-flex mt-5">
+            <button type="button" onClick={submitRegistration} className="btn btn-fill" id="register">
+              <div className="eff"></div>
+              <span className="btn-label"><i className="icon-user-follow"></i></span>
+              <span className="btn-label-text">{t('Create')}</span>
+            </button>
+          </div>
+
+          <div className="input-group mt-5 d-flex justify-content-center">
+            <a href="https://growi.org" className="link-growi-org">
+              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+            </a>
+          </div>
+
+        </fieldset>
+      </div>
+    </>
+  );
+
+};
+
+export default CompleteUserRegistrationForm;

+ 59 - 27
packages/app/src/components/LoginForm.jsx

@@ -148,6 +148,7 @@ class LoginForm extends React.Component {
     const {
     const {
       t,
       t,
       appContainer,
       appContainer,
+      isEmailAuthenticationEnabled,
       username,
       username,
       name,
       name,
       email,
       email,
@@ -155,6 +156,15 @@ class LoginForm extends React.Component {
       registrationWhiteList,
       registrationWhiteList,
     } = this.props;
     } = this.props;
 
 
+    const { isMailerSetup } = appContainer.config;
+    let registerAction = '/register';
+
+    let submitText = t('Sign up');
+    if (isEmailAuthenticationEnabled) {
+      registerAction = '/user-activation/register';
+      submitText = t('page_register.send_email');
+    }
+
     return (
     return (
       <React.Fragment>
       <React.Fragment>
         {registrationMode === 'Restricted' && (
         {registrationMode === 'Restricted' && (
@@ -164,27 +174,44 @@ class LoginForm extends React.Component {
             {t('page_register.notice.restricted_defail')}
             {t('page_register.notice.restricted_defail')}
           </p>
           </p>
         )}
         )}
-        <form role="form" action="/register" method="post" id="register-form">
-          <div className="input-group" id="input-group-username">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-user"></i>
-              </span>
-            </div>
-            <input type="text" className="form-control rounded-0" placeholder={t('User ID')} name="registerForm[username]" defaultValue={username} required />
-          </div>
-          <p className="form-text text-danger">
-            <span id="help-block-username"></span>
+        { (!isMailerSetup && isEmailAuthenticationEnabled) && (
+          <p className="alert alert-danger">
+            <span>{t('security_setting.Local.please_enable_mailer')}</span>
           </p>
           </p>
+        )}
 
 
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-tag"></i>
-              </span>
+        <form role="form" action={registerAction} method="post" id="register-form">
+
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group" id="input-group-username">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-user"></i>
+                  </span>
+                </div>
+                <input
+                  type="text"
+                  className="form-control rounded-0"
+                  placeholder={t('User ID')}
+                  name="registerForm[username]"
+                  defaultValue={username}
+                  required
+                />
+              </div>
+              <p className="form-text text-danger">
+                <span id="help-block-username"></span>
+              </p>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-tag"></i>
+                  </span>
+                </div>
+                <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
+              </div>
             </div>
             </div>
-            <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
-          </div>
+          )}
 
 
           <div className="input-group">
           <div className="input-group">
             <div className="input-group-prepend">
             <div className="input-group-prepend">
@@ -210,23 +237,27 @@ class LoginForm extends React.Component {
             </>
             </>
           )}
           )}
 
 
-          <div className="input-group">
-            <div className="input-group-prepend">
-              <span className="input-group-text">
-                <i className="icon-lock"></i>
-              </span>
+          {!isEmailAuthenticationEnabled && (
+            <div>
+              <div className="input-group">
+                <div className="input-group-prepend">
+                  <span className="input-group-text">
+                    <i className="icon-lock"></i>
+                  </span>
+                </div>
+                <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
+              </div>
             </div>
             </div>
-            <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
-          </div>
+          )}
 
 
           <div className="input-group justify-content-center my-4">
           <div className="input-group justify-content-center my-4">
             <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
             <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
-            <button type="submit" className="btn btn-fill rounded-0" id="register">
+            <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
               <div className="eff"></div>
               <div className="eff"></div>
               <span className="btn-label">
               <span className="btn-label">
                 <i className="icon-user-follow"></i>
                 <i className="icon-user-follow"></i>
               </span>
               </span>
-              <span className="btn-label-text">{t('Sign up')}</span>
+              <span className="btn-label-text">{submitText}</span>
             </button>
             </button>
           </div>
           </div>
         </form>
         </form>
@@ -314,6 +345,7 @@ LoginForm.propTypes = {
   registrationMode: PropTypes.string,
   registrationMode: PropTypes.string,
   registrationWhiteList: PropTypes.array,
   registrationWhiteList: PropTypes.array,
   isPasswordResetEnabled: PropTypes.bool,
   isPasswordResetEnabled: PropTypes.bool,
+  isEmailAuthenticationEnabled: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLocalStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   isLdapStrategySetup: PropTypes.bool,
   objOfIsExternalAuthEnableds: PropTypes.object,
   objOfIsExternalAuthEnableds: PropTypes.object,

+ 1 - 1
packages/app/src/components/Navbar/GlobalSearch.jsx

@@ -82,7 +82,7 @@ class GlobalSearch extends React.Component {
             dropup={dropup}
             dropup={dropup}
           />
           />
           <div className="btn-group-submit-search">
           <div className="btn-group-submit-search">
-            <span className="btn-link text-decoration-none" onClick={this.search}>
+            <span role="button" className="btn-link text-decoration-none" onClick={this.search}>
               <i className="icon-magnifier"></i>
               <i className="icon-magnifier"></i>
             </span>
             </span>
           </div>
           </div>

+ 4 - 3
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
 import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 import { useCreateModalStatus, useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { useCurrentPagePath } from '~/stores/context';
 
 
 import GlobalSearch from './GlobalSearch';
 import GlobalSearch from './GlobalSearch';
 
 
@@ -10,6 +10,7 @@ const GrowiNavbarBottom = (props) => {
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { open: openCreateModal } = useCreateModalStatus();
   const { open: openCreateModal } = useCreateModalStatus();
+  const { data: currentPagePath } = useCurrentPagePath();
 
 
   const additionalClasses = ['grw-navbar-bottom'];
   const additionalClasses = ['grw-navbar-bottom'];
   if (isDrawerOpened) {
   if (isDrawerOpened) {
@@ -34,7 +35,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => openCreateModal()}
+              onClick={() => mutateDrawerOpened(true)}
             >
             >
               <i className="icon-menu"></i>
               <i className="icon-menu"></i>
             </a>
             </a>
@@ -53,7 +54,7 @@ const GrowiNavbarBottom = (props) => {
             <a
             <a
               role="button"
               role="button"
               className="nav-link btn-lg"
               className="nav-link btn-lg"
-              onClick={() => openCreateModal(true)}
+              onClick={() => openCreateModal(currentPagePath || '')}
             >
             >
               <i className="icon-pencil"></i>
               <i className="icon-pencil"></i>
             </a>
             </a>

+ 26 - 7
packages/app/src/components/Page.jsx

@@ -17,8 +17,12 @@ import DrawioModal from './PageEditor/DrawioModal';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 import mdu from './PageEditor/MarkdownDrawioUtil';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
 // TODO: remove this when omitting unstated is completed
 // TODO: remove this when omitting unstated is completed
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
+import { useIsSlackEnabled } from '~/stores/editor';
+import { useSlackChannels } from '~/stores/context';
 
 
 const logger = loggerFactory('growi:Page');
 const logger = loggerFactory('growi:Page');
 
 
@@ -73,8 +77,10 @@ class Page extends React.Component {
   }
   }
 
 
   async saveHandlerForHandsontableModal(markdownTable) {
   async saveHandlerForHandsontableModal(markdownTable) {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
       markdownTable,
@@ -103,8 +109,10 @@ class Page extends React.Component {
   }
   }
 
 
   async saveHandlerForDrawioModal(drawioData) {
   async saveHandlerForDrawioModal(drawioData) {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     const newMarkdown = mdu.replaceDrawioInMarkdown(
     const newMarkdown = mdu.replaceDrawioInMarkdown(
       drawioData,
       drawioData,
@@ -163,16 +171,27 @@ Page.propTypes = {
 
 
   // TODO: remove this when omitting unstated is completed
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 };
 
 
 const PageWrapper = (props) => {
 const PageWrapper = (props) => {
-  const { data } = useEditorMode();
+  const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
 
 
-  if (data == null) {
+  if (editorMode == null) {
     return null;
     return null;
   }
   }
 
 
-  return <Page {...props} editorMode={data} />;
+  return (
+    <Page
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
 };
 };
 
 
 export default withUnstatedContainers(PageWrapper, [AppContainer, PageContainer, EditorContainer]);
 export default withUnstatedContainers(PageWrapper, [AppContainer, PageContainer, EditorContainer]);

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -17,7 +17,7 @@ import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import Editor from '../PageEditor/Editor';
 import Editor from '../PageEditor/Editor';
-import SlackNotification from '../SlackNotification';
+import { SlackNotification } from '../SlackNotification';
 
 
 import CommentPreview from './CommentPreview';
 import CommentPreview from './CommentPreview';
 import NotAvailableForGuest from '../NotAvailableForGuest';
 import NotAvailableForGuest from '../NotAvailableForGuest';

+ 1 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -154,7 +154,7 @@ const PageDeleteModal: FC<Props> = (props: Props) => {
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {pages.map((page) => {
           {pages.map((page) => {
-            return <div><code>{ page.path }</code></div>;
+            return <div key={page.pageId}><code>{ page.path }</code></div>;
           })}
           })}
         </div>
         </div>
         {renderDeleteRecursivelyForm()}
         {renderDeleteRecursivelyForm()}

+ 22 - 4
packages/app/src/components/PageEditor.jsx

@@ -15,9 +15,12 @@ import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
 // TODO: remove this when omitting unstated is completed
 // TODO: remove this when omitting unstated is completed
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
-import { useIsEditable } from '~/stores/context';
+import { useIsEditable, useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
 
 
 const logger = loggerFactory('growi:PageEditor');
 const logger = loggerFactory('growi:PageEditor');
 
 
@@ -128,8 +131,11 @@ class PageEditor extends React.Component {
    * save and update state of containers
    * save and update state of containers
    */
    */
   async onSaveWithShortcut() {
   async onSaveWithShortcut() {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, editorContainer, pageContainer,
+    } = this.props;
+
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
@@ -360,12 +366,22 @@ const PageEditorHOCWrapper = withUnstatedContainers(PageEditor, [AppContainer, P
 const PageEditorWrapper = (props) => {
 const PageEditorWrapper = (props) => {
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
 
 
   if (isEditable == null || editorMode == null) {
   if (isEditable == null || editorMode == null) {
     return null;
     return null;
   }
   }
 
 
-  return <PageEditorHOCWrapper {...props} isEditable={isEditable} editorMode={editorMode} />;
+  return (
+    <PageEditorHOCWrapper
+      {...props}
+      isEditable={isEditable}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
 };
 };
 
 
 PageEditor.propTypes = {
 PageEditor.propTypes = {
@@ -377,6 +393,8 @@ PageEditor.propTypes = {
 
 
   // TODO: remove this when omitting unstated is completed
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 };
 
 
 export default PageEditorWrapper;
 export default PageEditorWrapper;

+ 21 - 21
packages/app/src/components/PageEditor/EditorNavbarBottom.jsx → packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { Collapse, Button } from 'reactstrap';
 import { Collapse, Button } from 'reactstrap';
@@ -9,13 +9,15 @@ import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
 } from '~/stores/ui';
 } from '~/stores/ui';
 
 
-import SlackNotification from '../SlackNotification';
+import { SlackNotification } from '../SlackNotification';
 import SlackLogo from '../SlackLogo';
 import SlackLogo from '../SlackLogo';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import SavePageControls from '../SavePageControls';
 import SavePageControls from '../SavePageControls';
 
 
 import OptionsSelector from './OptionsSelector';
 import OptionsSelector from './OptionsSelector';
+import { useIsSlackEnabled } from '~/stores/editor';
+import { useSlackChannels } from '~/stores/context';
 
 
 const EditorNavbarBottom = (props) => {
 const EditorNavbarBottom = (props) => {
 
 
@@ -28,9 +30,18 @@ const EditorNavbarBottom = (props) => {
 
 
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
-
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels, mutate: mutateSlackChannels } = useSlackChannels();
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const additionalClasses = ['grw-editor-navbar-bottom'];
 
 
+  const isSlackEnabledToggleHandler = useCallback(
+    (bool: boolean) => mutateIsSlackEnabled(bool), [mutateIsSlackEnabled],
+  );
+
+  const slackChannelsChangedHandler = useCallback(
+    (slackChannels: string) => mutateSlackChannels(slackChannels), [mutateSlackChannels],
+  );
+
   const renderDrawerButton = () => (
   const renderDrawerButton = () => (
     <button
     <button
       type="button"
       type="button"
@@ -41,15 +52,6 @@ const EditorNavbarBottom = (props) => {
     </button>
     </button>
   );
   );
 
 
-  const slackEnabledFlagChangedHandler = (isSlackEnabled) => {
-    props.editorContainer.setState({ isSlackEnabled });
-  };
-
-  const slackChannelsChangedHandler = (slackChannels) => {
-    props.editorContainer.setState({ slackChannels });
-  };
-
-  // eslint-disable-next-line react/prop-types
   const renderExpandButton = () => (
   const renderExpandButton = () => (
     <div className="d-md-none ml-2">
     <div className="d-md-none ml-2">
       <button
       <button
@@ -69,15 +71,14 @@ const EditorNavbarBottom = (props) => {
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
       {/* Collapsed SlackNotification */}
       {/* Collapsed SlackNotification */}
       {isSlackConfigured && (
       {isSlackConfigured && (
-        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd}>
+        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd === true}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
           <nav className={`navbar navbar-expand-lg border-top ${additionalClasses.join(' ')}`}>
             <SlackNotification
             <SlackNotification
-              isSlackEnabled={props.editorContainer.state.isSlackEnabled}
-              slackChannels={props.editorContainer.state.slackChannels}
-              onEnabledFlagChange={slackEnabledFlagChangedHandler}
+              isSlackEnabled={isSlackEnabled ?? false}
+              slackChannels={slackChannels}
+              onEnabledFlagChange={isSlackEnabledToggleHandler}
               onChannelChange={slackChannelsChangedHandler}
               onChannelChange={slackChannelsChangedHandler}
               id="idForEditorNavbarBottomForMobile"
               id="idForEditorNavbarBottomForMobile"
-              popUp
             />
             />
           </nav>
           </nav>
         </Collapse>
         </Collapse>
@@ -104,12 +105,11 @@ const EditorNavbarBottom = (props) => {
           ) : (
           ) : (
             <div className="mr-2">
             <div className="mr-2">
               <SlackNotification
               <SlackNotification
-                isSlackEnabled={props.editorContainer.state.isSlackEnabled}
-                slackChannels={props.editorContainer.state.slackChannels}
-                onEnabledFlagChange={slackEnabledFlagChangedHandler}
+                isSlackEnabled={isSlackEnabled ?? false}
+                slackChannels={slackChannels}
+                onEnabledFlagChange={isSlackEnabledToggleHandler}
                 onChannelChange={slackChannelsChangedHandler}
                 onChannelChange={slackChannelsChangedHandler}
                 id="idForEditorNavbarBottom"
                 id="idForEditorNavbarBottom"
-                popUp={false}
               />
               />
             </div>
             </div>
           ))}
           ))}

+ 22 - 5
packages/app/src/components/PageEditorByHackmd.jsx

@@ -11,8 +11,12 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
 // TODO: remove this when omitting unstated is completed
 // TODO: remove this when omitting unstated is completed
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
+import { useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
 
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
 
@@ -166,8 +170,10 @@ class PageEditorByHackmd extends React.Component {
    * @param {string} markdown
    * @param {string} markdown
    */
    */
   async onSaveWithShortcut(markdown) {
   async onSaveWithShortcut(markdown) {
-    const { pageContainer, editorContainer } = this.props;
-    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
@@ -423,13 +429,22 @@ class PageEditorByHackmd extends React.Component {
 const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
 const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
 
 
 const PageEditorByHackmdWrapper = (props) => {
 const PageEditorByHackmdWrapper = (props) => {
-  const { data } = useEditorMode();
+  const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
 
 
-  if (data == null) {
+  if (editorMode == null) {
     return null;
     return null;
   }
   }
 
 
-  return <PageEditorByHackmdHOCWrapper {...props} editorMode={data} />;
+  return (
+    <PageEditorByHackmdHOCWrapper
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
 };
 };
 
 
 PageEditorByHackmd.propTypes = {
 PageEditorByHackmd.propTypes = {
@@ -441,6 +456,8 @@ PageEditorByHackmd.propTypes = {
 
 
   // TODO: remove this when omitting unstated is completed
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(PageEditorByHackmdWrapper);
 export default withTranslation()(PageEditorByHackmdWrapper);

+ 26 - 6
packages/app/src/components/SavePageControls.jsx

@@ -17,9 +17,12 @@ import EditorContainer from '~/client/services/EditorContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import GrantSelector from './SavePageControls/GrantSelector';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 
+import { getOptionsToSave } from '~/client/util/editor';
+
 // TODO: remove this when omitting unstated is completed
 // TODO: remove this when omitting unstated is completed
 import { useEditorMode } from '~/stores/ui';
 import { useEditorMode } from '~/stores/ui';
-import { useIsEditable } from '~/stores/context';
+import { useIsEditable, useSlackChannels } from '~/stores/context';
+import { useIsSlackEnabled } from '~/stores/editor';
 
 
 const logger = loggerFactory('growi:SavePageControls');
 const logger = loggerFactory('growi:SavePageControls');
 
 
@@ -43,13 +46,16 @@ class SavePageControls extends React.Component {
   }
   }
 
 
   async save() {
   async save() {
-    const { pageContainer, editorContainer } = this.props;
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
     // disable unsaved warning
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
     editorContainer.disableUnsavedWarning();
 
 
     try {
     try {
       // save
       // save
-      await pageContainer.saveAndReload(editorContainer.getCurrentOptionsToSave(), this.props.editorMode);
+      const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+      await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
@@ -58,11 +64,14 @@ class SavePageControls extends React.Component {
   }
   }
 
 
   saveAndOverwriteScopesOfDescendants() {
   saveAndOverwriteScopesOfDescendants() {
-    const { pageContainer, editorContainer } = this.props;
+    const {
+      isSlackEnabled, slackChannels, pageContainer, editorContainer,
+    } = this.props;
     // disable unsaved warning
     // disable unsaved warning
     editorContainer.disableUnsavedWarning();
     editorContainer.disableUnsavedWarning();
     // save
     // save
-    const optionsToSave = Object.assign(editorContainer.getCurrentOptionsToSave(), {
+    const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, editorContainer);
+    const optionsToSave = Object.assign(currentOptionsToSave, {
       overwriteScopesOfDescendants: true,
       overwriteScopesOfDescendants: true,
     });
     });
     pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
@@ -117,6 +126,8 @@ const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [App
 const SavePageControlsWrapper = (props) => {
 const SavePageControlsWrapper = (props) => {
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannels } = useSlackChannels();
 
 
   if (isEditable == null || editorMode == null) {
   if (isEditable == null || editorMode == null) {
     return null;
     return null;
@@ -126,7 +137,14 @@ const SavePageControlsWrapper = (props) => {
     return null;
     return null;
   }
   }
 
 
-  return <SavePageControlsHOCWrapper {...props} editorMode={editorMode} />;
+  return (
+    <SavePageControlsHOCWrapper
+      {...props}
+      editorMode={editorMode}
+      isSlackEnabled={isSlackEnabled}
+      slackChannels={slackChannels}
+    />
+  );
 };
 };
 
 
 SavePageControls.propTypes = {
 SavePageControls.propTypes = {
@@ -138,6 +156,8 @@ SavePageControls.propTypes = {
 
 
   // TODO: remove this when omitting unstated is completed
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
 };
 };
 
 
 export default withTranslation()(SavePageControlsWrapper);
 export default withTranslation()(SavePageControlsWrapper);

+ 21 - 0
packages/app/src/components/SearchPage.jsx

@@ -14,6 +14,7 @@ import SearchControl from './SearchPage/SearchControl';
 import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import PageDeleteModal from './PageDeleteModal';
 import PageDeleteModal from './PageDeleteModal';
 import { useIsGuestUser } from '~/stores/context';
 import { useIsGuestUser } from '~/stores/context';
+import { apiv3Get } from '~/client/util/apiv3-client';
 
 
 export const specificPathNames = {
 export const specificPathNames = {
   user: '/user',
   user: '/user',
@@ -34,6 +35,7 @@ class SearchPage extends React.Component {
       focusedSearchResultData: null,
       focusedSearchResultData: null,
       selectedPagesIdList: new Set(),
       selectedPagesIdList: new Set(),
       searchResultCount: 0,
       searchResultCount: 0,
+      shortBodiesMap: null,
       activePage: 1,
       activePage: 1,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       pagingLimit: this.props.appContainer.config.pageLimitationL || 50,
       excludeUserPages: true,
       excludeUserPages: true,
@@ -140,6 +142,11 @@ class SearchPage extends React.Component {
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
     this.setState({ pagingLimit: limit }, () => this.search({ keyword: this.state.searchedKeyword }));
   }
   }
 
 
+  async fetchShortBodiesMap(pageIds) {
+    const res = await apiv3Get('/page-listing/short-bodies', { pageIds });
+    this.setState({ shortBodiesMap: res.data.shortBodiesMap });
+  }
+
   // todo: refactoring
   // todo: refactoring
   // refs: https://redmine.weseek.co.jp/issues/82139
   // refs: https://redmine.weseek.co.jp/issues/82139
   async search(data) {
   async search(data) {
@@ -171,6 +178,19 @@ class SearchPage extends React.Component {
         sort,
         sort,
         order,
         order,
       });
       });
+
+      /*
+       * non-await asynchronous short body fetch
+       */
+      const pageIds = res.data.map((page) => {
+        if (page.pageMeta?.elasticSearchResult != null && page.pageMeta?.elasticSearchResult?.snippet.length !== 0) {
+          return null;
+        }
+
+        return page.pageData._id;
+      }).filter(id => id != null);
+      this.fetchShortBodiesMap(pageIds);
+
       this.changeURL(keyword);
       this.changeURL(keyword);
       if (res.data.length > 0) {
       if (res.data.length > 0) {
         this.setState({
         this.setState({
@@ -288,6 +308,7 @@ class SearchPage extends React.Component {
         focusedSearchResultData={this.state.focusedSearchResultData}
         focusedSearchResultData={this.state.focusedSearchResultData}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         selectedPagesIdList={this.state.selectedPagesIdList || []}
         searchResultCount={this.state.searchResultCount}
         searchResultCount={this.state.searchResultCount}
+        shortBodiesMap={this.state.shortBodiesMap}
         activePage={this.state.activePage}
         activePage={this.state.activePage}
         pagingLimit={this.state.pagingLimit}
         pagingLimit={this.state.pagingLimit}
         onClickSearchResultItem={this.selectPage}
         onClickSearchResultItem={this.selectPage}

+ 1 - 1
packages/app/src/components/SearchPage/DeleteSelectedPageGroup.tsx

@@ -34,7 +34,7 @@ const DeleteSelectedPageGroup:FC<Props> = (props:Props) => {
         id="check-all-pages"
         id="check-all-pages"
         type="checkbox"
         type="checkbox"
         name="check-all-pages"
         name="check-all-pages"
-        className="custom-control custom-checkbox ml-1 align-self-center"
+        className="custom-control custom-checkbox ml-2 align-self-center"
         disabled={props.isSelectAllCheckboxDisabled}
         disabled={props.isSelectAllCheckboxDisabled}
         onClick={onClickCheckbox}
         onClick={onClickCheckbox}
         checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}
         checked={selectAllCheckboxType !== CheckboxType.NONE_CHECKED}

+ 29 - 25
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -80,7 +80,7 @@ const SearchControl: FC <Props> = (props: Props) => {
   };
   };
 
 
   return (
   return (
-    <div className="position-sticky fixed-top">
+    <div className="position-sticky fixed-top shadow-sm">
       <div className="search-page-nav d-flex py-3 align-items-center">
       <div className="search-page-nav d-flex py-3 align-items-center">
         <div className="flex-grow-1 mx-4">
         <div className="flex-grow-1 mx-4">
           <SearchPageFormTypeAny
           <SearchPageFormTypeAny
@@ -98,8 +98,8 @@ const SearchControl: FC <Props> = (props: Props) => {
         </div>
         </div>
       </div>
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
-      <div className="search-control d-flex align-items-center py-2 border-bottom border-gray">
-        <div className="d-flex mr-auto ml-4">
+      <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-3 px-3 border-bottom border-gray">
+        <div className="d-flex pl-md-2">
           {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
           {/* Todo: design will be fixed in #80324. Function will be implemented in #77525 */}
           <DeleteSelectedPageGroup
           <DeleteSelectedPageGroup
             isSelectAllCheckboxDisabled={searchResultCount === 0}
             isSelectAllCheckboxDisabled={searchResultCount === 0}
@@ -109,7 +109,7 @@ const SearchControl: FC <Props> = (props: Props) => {
           />
           />
         </div>
         </div>
         {/** filter option */}
         {/** filter option */}
-        <div className="d-lg-none mr-4">
+        <div className="d-lg-none ml-auto">
           <button
           <button
             type="button"
             type="button"
             className="btn"
             className="btn"
@@ -118,28 +118,32 @@ const SearchControl: FC <Props> = (props: Props) => {
             <i className="icon-equalizer"></i>
             <i className="icon-equalizer"></i>
           </button>
           </button>
         </div>
         </div>
-        <div className="d-none d-lg-flex align-items-center mr-4">
-          <div className="border border-gray mr-3">
-            <label className="px-3 py-2 mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
-              <input
-                className="mr-2"
-                type="checkbox"
-                id="flexCheckDefault"
-                onClick={switchExcludeUserPagesHandler}
-              />
-              {t('Include Subordinated Target Page', { target: '/user' })}
-            </label>
+        <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
+          <div className="card mr-3 mb-0">
+            <div className="card-body">
+              <label className="search-include-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckDefault">
+                <input
+                  className="mr-2"
+                  type="checkbox"
+                  id="flexCheckDefault"
+                  onClick={switchExcludeUserPagesHandler}
+                />
+                {t('Include Subordinated Target Page', { target: '/user' })}
+              </label>
+            </div>
           </div>
           </div>
-          <div className="border border-gray">
-            <label className="px-3 py-2 mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckChecked">
-              <input
-                className="mr-2"
-                type="checkbox"
-                id="flexCheckChecked"
-                onClick={switchExcludeTrashPagesHandler}
-              />
-              {t('Include Subordinated Target Page', { target: '/trash' })}
-            </label>
+          <div className="card mb-0">
+            <div className="card-body">
+              <label className="search-include-label mb-0 d-flex align-items-center text-secondary with-no-font-weight" htmlFor="flexCheckChecked">
+                <input
+                  className="mr-2"
+                  type="checkbox"
+                  id="flexCheckChecked"
+                  onClick={switchExcludeTrashPagesHandler}
+                />
+                {t('Include Subordinated Target Page', { target: '/trash' })}
+              </label>
+            </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

+ 1 - 1
packages/app/src/components/SearchPage/SearchOptionModal.tsx

@@ -43,7 +43,7 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
         Search Option
         Search Option
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        <div className="d-flex p-3">
+        <div className="d-flex p-2">
           <div className="border border-gray mr-3">
           <div className="border border-gray mr-3">
             <label className="px-3 py-2 mb-0 d-flex align-items-center">
             <label className="px-3 py-2 mb-0 d-flex align-items-center">
               <input
               <input

+ 5 - 5
packages/app/src/components/SearchPage/SearchPageForm.jsx

@@ -49,9 +49,9 @@ class SearchPageForm extends React.Component {
             onInputChange={this.onInputChange}
             onInputChange={this.onInputChange}
           />
           />
           <div className="btn-group-submit-search">
           <div className="btn-group-submit-search">
-            <button
-              className="btn border-0 pb-1"
-              type="button"
+            <span
+              role="button"
+              className="text-decoration-none"
               onClick={() => {
               onClick={() => {
                 try {
                 try {
                   this.search();
                   this.search();
@@ -61,8 +61,8 @@ class SearchPageForm extends React.Component {
                 }
                 }
               }}
               }}
             >
             >
-              <i className="pr-2 icon-magnifier"></i>
-            </button>
+              <i className="icon-magnifier"></i>
+            </span>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>

+ 8 - 3
packages/app/src/components/SearchPage/SearchPageLayout.tsx

@@ -49,16 +49,21 @@ const SearchPageLayout: FC<Props> = (props: Props) => {
                 <div className="input-group-prepend">
                 <div className="input-group-prepend">
                   <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
                   <label className="input-group-text text-secondary" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
                 </div>
                 </div>
-                <select className="custom-select" id="inputGroupSelect01" onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}>
+                <select
+                  defaultValue={props.pagingLimit}
+                  className="custom-select"
+                  id="inputGroupSelect01"
+                  onChange={(e) => { props.onPagingLimitChanged(Number(e.target.value)) }}
+                >
                   {[20, 50, 100, 200].map((limit) => {
                   {[20, 50, 100, 200].map((limit) => {
-                    return <option selected={limit === props.pagingLimit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
+                    return <option key={limit} value={limit}>{limit}{t('search_result.page_number_unit')}</option>;
                   })}
                   })}
                 </select>
                 </select>
               </div>
               </div>
             </div>
             </div>
 
 
             <div className="page-list">
             <div className="page-list">
-              <ul className="page-list-ul page-list-ul-flat pl-4 nav nav-pills"><SearchResultList></SearchResultList></ul>
+              <ul className="page-list-ul page-list-ul-flat px-md-4 nav nav-pills"><SearchResultList></SearchResultList></ul>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 5 - 1
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -11,6 +11,7 @@ type Props = {
   searchResultCount?: number,
   searchResultCount?: number,
   activePage?: number,
   activePage?: number,
   pagingLimit?: number,
   pagingLimit?: number,
+  shortBodiesMap?: Record<string, string>
   focusedSearchResultData?: IPageSearchResultData,
   focusedSearchResultData?: IPageSearchResultData,
   onPagingNumberChanged?: (activePage: number) => void,
   onPagingNumberChanged?: (activePage: number) => void,
   onClickSearchResultItem?: (pageId: string) => void,
   onClickSearchResultItem?: (pageId: string) => void,
@@ -20,7 +21,9 @@ type Props = {
 }
 }
 
 
 const SearchResultList: FC<Props> = (props:Props) => {
 const SearchResultList: FC<Props> = (props:Props) => {
-  const { focusedSearchResultData, selectedPagesIdList, isEnableActions } = props;
+  const {
+    focusedSearchResultData, selectedPagesIdList, isEnableActions, shortBodiesMap,
+  } = props;
 
 
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
   return (
@@ -33,6 +36,7 @@ const SearchResultList: FC<Props> = (props:Props) => {
             key={page.pageData._id}
             key={page.pageData._id}
             page={page}
             page={page}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}
+            shortBody={shortBodiesMap?.[page.pageData._id]}
             onClickSearchResultItem={props.onClickSearchResultItem}
             onClickSearchResultItem={props.onClickSearchResultItem}
             onClickCheckbox={props.onClickCheckbox}
             onClickCheckbox={props.onClickCheckbox}
             isChecked={isChecked}
             isChecked={isChecked}

+ 58 - 32
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -1,55 +1,71 @@
-import React, { FC } from 'react';
+import React, { FC, memo } from 'react';
 
 
 import Clamp from 'react-multiline-clamp';
 import Clamp from 'react-multiline-clamp';
 
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
-import { DevidedPagePath } from '@growi/core';
+import { pagePathUtils } from '@growi/core';
+import { useIsDeviceSmallerThanMd } from '~/stores/ui';
 
 
 import { IPageSearchResultData } from '../../interfaces/search';
 import { IPageSearchResultData } from '../../interfaces/search';
 import PageItemControl from '../Common/Dropdown/PageItemControl';
 import PageItemControl from '../Common/Dropdown/PageItemControl';
 
 
+const { isTopPage } = pagePathUtils;
 
 
 type Props = {
 type Props = {
   page: IPageSearchResultData,
   page: IPageSearchResultData,
   isSelected: boolean,
   isSelected: boolean,
   isChecked: boolean,
   isChecked: boolean,
   isEnableActions: boolean,
   isEnableActions: boolean,
+  shortBody?: string
   onClickCheckbox?: (pageId: string) => void,
   onClickCheckbox?: (pageId: string) => void,
   onClickSearchResultItem?: (pageId: string) => void,
   onClickSearchResultItem?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
   onClickDeleteButton?: (pageId: string) => void,
 }
 }
 
 
-const SearchResultListItem: FC<Props> = (props:Props) => {
+const SearchResultListItem: FC<Props> = memo((props:Props) => {
   const {
   const {
     // todo: refactoring variable name to clear what changed
     // todo: refactoring variable name to clear what changed
-    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions,
+    page: { pageData, pageMeta }, isSelected, onClickSearchResultItem, onClickCheckbox, isChecked, isEnableActions, shortBody,
   } = props;
   } = props;
 
 
+  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+
   // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
   // Add prefix 'id_' in pageId, because scrollspy of bootstrap doesn't work when the first letter of id attr of target component is numeral.
   const pageId = `#${pageData._id}`;
   const pageId = `#${pageData._id}`;
 
 
-  const isPathIncludedHtml = pageMeta.elasticSearchResult?.highlightedPath != null || pageData.path != null;
-  const dPagePath = new DevidedPagePath(pageData.path, false, true);
+  const pageTitle = (
+    <PagePathLabel
+      path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
+      isLatterOnly
+      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
+    >
+    </PagePathLabel>
+  );
   const pagePathElem = (
   const pagePathElem = (
     <PagePathLabel
     <PagePathLabel
       path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
       path={pageMeta.elasticSearchResult?.highlightedPath || pageData.path}
       isFormerOnly
       isFormerOnly
-      isPathIncludedHtml={isPathIncludedHtml}
+      isPathIncludedHtml={pageMeta.elasticSearchResult?.isHtmlInPath}
     />
     />
   );
   );
 
 
+  const responsiveListStyleClass = `${isDeviceSmallerThanMd ? '' : `list-group-item-action ${isSelected ? 'active' : ''}`}`;
+
   return (
   return (
-    <li key={pageData._id} className={`page-list-li search-page-item w-100 list-group-item-action pl-2 ${isSelected ? 'active' : ''}`}>
+    <li
+      key={pageData._id}
+      className={`w-100 page-list-li search-result-item border-bottom ${responsiveListStyleClass}`}
+    >
       <a
       <a
-        className="d-block py-4 h-100"
+        className="d-block h-100"
         href={pageId}
         href={pageId}
         onClick={() => onClickSearchResultItem != null && onClickSearchResultItem(pageData._id)}
         onClick={() => onClickSearchResultItem != null && onClickSearchResultItem(pageData._id)}
       >
       >
-        <div className="d-flex">
+        <div className="d-flex h-100">
           {/* checkbox */}
           {/* checkbox */}
-          <div className="form-check my-auto mr-3">
+          <div className="form-check d-flex align-items-center justify-content-center px-md-2 pl-3 pr-2 search-item-checkbox">
             <input
             <input
-              className="form-check-input my-auto"
+              className="form-check-input position-relative m-0"
               type="checkbox"
               type="checkbox"
               id="flexCheckDefault"
               id="flexCheckDefault"
               onChange={() => {
               onChange={() => {
@@ -60,35 +76,45 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               checked={isChecked}
               checked={isChecked}
             />
             />
           </div>
           </div>
-          <div className="w-100">
+          <div className="search-item-text p-md-3 pl-2 py-3 pr-3 flex-grow-1">
             {/* page path */}
             {/* page path */}
-            <small className="mb-1">
+            <h6 className="mb-1 py-1">
               <i className="icon-fw icon-home"></i>
               <i className="icon-fw icon-home"></i>
               {pagePathElem}
               {pagePathElem}
-            </small>
-            <div className="d-flex my-1 align-items-center">
+            </h6>
+            <div className="d-flex align-items-center mb-2">
+              {/* Picture */}
+              <span className="mr-2 d-none d-md-block">
+                <UserPicture user={pageData.lastUpdateUser} size="sm" />
+              </span>
               {/* page title */}
               {/* page title */}
-              <h3 className="mb-0">
-                <UserPicture user={pageData.lastUpdateUser} />
-                <span className="mx-2 search-result-page-title">{dPagePath.latter}</span>
-              </h3>
+              <span className="py-1 h5 mr-2 mb-0">
+                {pageTitle}
+              </span>
               {/* page meta */}
               {/* page meta */}
-              <div className="d-flex mx-2">
+              <div className="d-none d-md-flex item-meta py-0 px-1">
                 <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
                 <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} />
               </div>
               </div>
               {/* doropdown icon includes page control buttons */}
               {/* doropdown icon includes page control buttons */}
-              <div className="ml-auto">
-                <PageItemControl page={pageData} onClickDeleteButton={props.onClickDeleteButton} isEnableActions={isEnableActions} />
+              <div className="item-control ml-auto">
+                <PageItemControl
+                  page={pageData}
+                  onClickDeleteButton={props.onClickDeleteButton}
+                  isEnableActions={isEnableActions}
+                  isDeletable={!isTopPage(pageData.path)}
+                />
               </div>
               </div>
             </div>
             </div>
-            <div className="my-2 search-result-list-snippet">
-              {
-                pageMeta.elasticSearchResult != null && (
-                  <Clamp lines={2}>
-                    <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
-                  </Clamp>
-                )
-              }
+            <div className="search-result-list-snippet py-1">
+              <Clamp lines={2}>
+                {
+                  pageMeta.elasticSearchResult != null && pageMeta.elasticSearchResult?.snippet.length !== 0 ? (
+                    <div dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
+                  ) : (
+                    <div>{ shortBody != null ? shortBody : 'Loading ...' }</div> // TODO: improve indicator
+                  )
+                }
+              </Clamp>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
@@ -96,6 +122,6 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
       </a>
       </a>
     </li>
     </li>
   );
   );
-};
+});
 
 
 export default SearchResultListItem;
 export default SearchResultListItem;

+ 3 - 2
packages/app/src/components/SearchPage/SortControl.tsx

@@ -26,7 +26,7 @@ const SortControl: FC <Props> = (props: Props) => {
   };
   };
 
 
   const renderSortItem = (sort, order) => {
   const renderSortItem = (sort, order) => {
-    return <><span className="mr-3">{t(`search_result.sort_axis.${sort}`)}</span>{renderOrderIcon(order)}</>;
+    return <div className="d-flex align-items-center"><span className="mr-3">{t(`search_result.sort_axis.${sort}`)}</span>{renderOrderIcon(order)}</div>;
   };
   };
 
 
   return (
   return (
@@ -43,13 +43,14 @@ const SortControl: FC <Props> = (props: Props) => {
             className="btn border dropdown-toggle"
             className="btn border dropdown-toggle"
             data-toggle="dropdown"
             data-toggle="dropdown"
           >
           >
-            <span className="mr-4">{t(`search_result.sort_axis.${props.sort}`)}</span>
+            <span className="mr-4 text-secondary">{t(`search_result.sort_axis.${props.sort}`)}</span>
           </button>
           </button>
           <div className="dropdown-menu dropdown-menu-right">
           <div className="dropdown-menu dropdown-menu-right">
             {Object.values(SORT_AXIS).map((sortAxis) => {
             {Object.values(SORT_AXIS).map((sortAxis) => {
               const nextOrder = (props.sort !== sortAxis || props.order === ASC) ? DESC : ASC;
               const nextOrder = (props.sort !== sortAxis || props.order === ASC) ? DESC : ASC;
               return (
               return (
                 <button
                 <button
+                  key={sortAxis}
                   className="dropdown-item d-flex justify-content-between"
                   className="dropdown-item d-flex justify-content-between"
                   type="button"
                   type="button"
                   onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}
                   onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}

+ 1 - 1
packages/app/src/components/Sidebar.tsx

@@ -65,7 +65,7 @@ const SidebarContentsWrapper = () => {
         resetKey={resetKey}
         resetKey={resetKey}
       />
       />
 
 
-      <div id="grw-sidebar-contents-scroll-target">
+      <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
         <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
         <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
           <SidebarContents />
           <SidebarContents />
         </div>
         </div>

+ 1 - 1
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -34,7 +34,7 @@ const CustomSidebar: FC<Props> = (props: Props) => {
   const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
   const { data: page, mutate } = useSWRxPageByPath('/Sidebar');
 
 
   const isLoading = page === undefined;
   const isLoading = page === undefined;
-  const markdown = (page?.revision as IRevision)?.body;
+  const markdown = (page?.revision as IRevision | undefined)?.body;
 
 
   return (
   return (
     <>
     <>

+ 5 - 7
packages/app/src/components/Sidebar/PageTree.tsx

@@ -64,13 +64,11 @@ const PageTree: FC = memo(() => {
         />
         />
       </div>
       </div>
 
 
-      <div className="grw-sidebar-content-footer">
-        {
-          !isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-            <PrivateLegacyPages />
-          )
-        }
-      </div>
+      {!isGuestUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
+        <div className="grw-pagetree-footer border-top p-3 w-100">
+          <PrivateLegacyPages />
+        </div>
+      )}
     </>
     </>
   );
   );
 });
 });

+ 21 - 20
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 } from 'react';
 import nodePath from 'path';
 import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 
 
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
@@ -13,6 +14,8 @@ import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
 
 
+const { isTopPage } = pagePathUtils;
+
 
 
 interface ItemProps {
 interface ItemProps {
   isEnableActions: boolean
   isEnableActions: boolean
@@ -39,6 +42,7 @@ const markTarget = (children: ItemNode[], targetId?: string): void => {
 type ItemControlProps = {
 type ItemControlProps = {
   page: Partial<IPageHasId>
   page: Partial<IPageHasId>
   isEnableActions: boolean
   isEnableActions: boolean
+  isDeletable: boolean
   onClickDeleteButtonHandler?(): void
   onClickDeleteButtonHandler?(): void
   onClickPlusButtonHandler?(): void
   onClickPlusButtonHandler?(): void
 }
 }
@@ -66,7 +70,7 @@ const ItemControl: FC<ItemControlProps> = memo((props: ItemControlProps) => {
 
 
   return (
   return (
     <>
     <>
-      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} isEnableActions={props.isEnableActions} />
+      <PageItemControl page={props.page} onClickDeleteButton={onClickDeleteButton} isEnableActions={props.isEnableActions} isDeletable={props.isDeletable} />
       <button
       <button
         type="button"
         type="button"
         className="btn-link nav-link border-0 rounded grw-btn-page-management py-0"
         className="btn-link nav-link border-0 rounded grw-btn-page-management py-0"
@@ -171,20 +175,14 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       markTarget(newChildren, targetId);
       markTarget(newChildren, targetId);
       setCurrentChildren(newChildren);
       setCurrentChildren(newChildren);
     }
     }
-  }, [data]);
-
-  // TODO: improve style
-  const opacityStyle = { opacity: 1.0 };
-  if (page.isTarget) opacityStyle.opacity = 0.7;
-
-  const buttonClass = isOpen ? 'rotate' : '';
+  }, [data, isOpen]);
 
 
   return (
   return (
-    <div className="grw-pagetree-item-wrapper">
-      <div style={opacityStyle} className="grw-pagetree-item d-flex align-items-center">
+    <>
+      <div className={`grw-pagetree-item d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}>
         <button
         <button
           type="button"
           type="button"
-          className={`grw-pagetree-button btn ${buttonClass}`}
+          className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
           onClick={onClickLoadChildren}
           onClick={onClickLoadChildren}
         >
         >
           <div className="grw-triangle-icon">
           <div className="grw-triangle-icon">
@@ -203,11 +201,12 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             onClickDeleteButtonHandler={onClickDeleteButtonHandler}
             onClickDeleteButtonHandler={onClickDeleteButtonHandler}
             onClickPlusButtonHandler={() => { setNewPageInputShown(true) }}
             onClickPlusButtonHandler={() => { setNewPageInputShown(true) }}
             isEnableActions={isEnableActions}
             isEnableActions={isEnableActions}
+            isDeletable={!page.isEmpty && !isTopPage(page.path as string)}
           />
           />
         </div>
         </div>
       </div>
       </div>
 
 
-      {!isEnableActions && (
+      {isEnableActions && (
         <ClosableTextInput
         <ClosableTextInput
           isShown={isNewPageInputShown}
           isShown={isNewPageInputShown}
           placeholder={t('Input title')}
           placeholder={t('Input title')}
@@ -218,16 +217,18 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       )}
       )}
       {
       {
         isOpen && hasChildren() && currentChildren.map(node => (
         isOpen && hasChildren() && currentChildren.map(node => (
-          <Item
-            key={node.page._id}
-            isEnableActions={isEnableActions}
-            itemNode={node}
-            isOpen={false}
-            onClickDeleteByPage={onClickDeleteByPage}
-          />
+          <div key={node.page._id} className="ml-3 mt-2">
+            <Item
+              isEnableActions={isEnableActions}
+              itemNode={node}
+              isOpen={false}
+              targetId={targetId}
+              onClickDeleteByPage={onClickDeleteByPage}
+            />
+          </div>
         ))
         ))
       }
       }
-    </div>
+    </>
   );
   );
 
 
 };
 };

+ 12 - 4
packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx

@@ -1,4 +1,4 @@
-import React, { FC } from 'react';
+import React, { FC, useState } from 'react';
 
 
 import { IPageHasId } from '../../../interfaces/page';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
@@ -29,14 +29,19 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   const paths = Object.keys(ancestorsChildren);
   const paths = Object.keys(ancestorsChildren);
 
 
   let currentNode = rootNode;
   let currentNode = rootNode;
-  paths.forEach((path) => {
+  paths.every((path) => {
+    // stop rendering when non-migrated pages found
+    if (currentNode == null) {
+      return false;
+    }
+
     const childPages = ancestorsChildren[path];
     const childPages = ancestorsChildren[path];
     currentNode.children = ItemNode.generateNodesFromPages(childPages);
     currentNode.children = ItemNode.generateNodesFromPages(childPages);
-
     const nextNode = currentNode.children.filter((node) => {
     const nextNode = currentNode.children.filter((node) => {
       return paths.includes(node.page.path as string);
       return paths.includes(node.page.path as string);
     })[0];
     })[0];
     currentNode = nextNode;
     currentNode = nextNode;
+    return true;
   });
   });
 
 
   return rootNode;
   return rootNode;
@@ -88,6 +93,8 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
   const { data: rootPageData, error: error2 } = useSWRxRootPage();
 
 
+  const [isRenderedCompletely, setRenderedCompletely] = useState(false);
+
   const DeleteModal = (
   const DeleteModal = (
     <PageDeleteModal
     <PageDeleteModal
       isOpen={isDeleteModalOpen}
       isOpen={isDeleteModalOpen}
@@ -107,8 +114,9 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
   /*
   /*
    * Render completely
    * Render completely
    */
    */
-  if (ancestorsChildrenData != null && rootPageData != null) {
+  if (!isRenderedCompletely && ancestorsChildrenData != null && rootPageData != null) {
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
     const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
+    setRenderedCompletely(true); // render once
     return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetId, onClickDeleteByPage);
     return renderByInitialNode(initialNode, DeleteModal, isEnableActions, targetId, onClickDeleteByPage);
   }
   }
 
 

+ 3 - 5
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPages.tsx

@@ -5,11 +5,9 @@ const PrivateLegacyPages: FC = memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
-    <div className="grw-prvt-legacy-pages p-3">
-      <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5">
-        <i className="icon-drawer mr-2"></i> PrivateLegacyPages
-      </a>
-    </div>
+    <a href="/private-legacy-pages?q=[nq:PrivateLegacyPages]" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
+      <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
+    </a>
   );
   );
 });
 });
 
 

+ 0 - 87
packages/app/src/components/SlackNotification.jsx

@@ -1,87 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-import { UncontrolledPopover, PopoverHeader, PopoverBody } from 'reactstrap';
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class SlackNotification
- * @extends {React.Component}
- */
-
-class SlackNotification extends React.Component {
-
-  constructor(props) {
-    super(props);
-    this.idForSlackPopover = `${this.props.id}ForSlackPopover`;
-    this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
-    this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
-  }
-
-  updateCheckboxHandler(event) {
-    const value = event.target.checked;
-    if (this.props.onEnabledFlagChange != null) {
-      this.props.onEnabledFlagChange(value);
-    }
-  }
-
-  updateSlackChannelsHandler(event) {
-    const value = event.target.value;
-    if (this.props.onChannelChange != null) {
-      this.props.onChannelChange(value);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="grw-slack-notification w-100">
-        <div className="grw-input-group-slack-notification input-group extended-setting">
-          <label className="input-group-addon">
-            <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
-              <input
-                type="checkbox"
-                className="custom-control-input border-0"
-                id={this.props.id}
-                checked={this.props.isSlackEnabled}
-                onChange={this.updateCheckboxHandler}
-              />
-              <label className="custom-control-label align-center" htmlFor={this.props.id}>
-              </label>
-            </div>
-          </label>
-          <input
-            className="grw-form-control-slack-notification form-control align-top pl-0"
-            id={this.idForSlackPopover}
-            type="text"
-            value={this.props.slackChannels}
-            placeholder="Input channels"
-            onChange={this.updateSlackChannelsHandler}
-          />
-          <UncontrolledPopover trigger="focus" placement="top" target={this.idForSlackPopover}>
-            <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
-            <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
-          </UncontrolledPopover>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-SlackNotification.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  popUp: PropTypes.bool.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-  onEnabledFlagChange: PropTypes.func,
-  onChannelChange: PropTypes.func,
-  id: PropTypes.string.isRequired,
-};
-
-export default withTranslation()(SlackNotification);

+ 67 - 0
packages/app/src/components/SlackNotification.tsx

@@ -0,0 +1,67 @@
+/* eslint-disable react/prop-types */
+import React, { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
+
+
+type SlackNotificationProps = {
+  id: string;
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  onEnabledFlagChange?: (isSlackEnabled: boolean) => void;
+  onChannelChange?: (value: string) => void;
+};
+
+export const SlackNotification: FC<SlackNotificationProps> = ({
+  id, isSlackEnabled, slackChannels, onEnabledFlagChange, onChannelChange,
+}) => {
+  const { t } = useTranslation();
+  const idForSlackPopover = `${id}ForSlackPopover`;
+
+  const updateCheckboxHandler = (event: { target: { checked: boolean }; }) => {
+    const value = event.target.checked;
+    if (onEnabledFlagChange != null) {
+      onEnabledFlagChange(value);
+    }
+  };
+
+  const updateSlackChannelsHandler = (event: { target: { value: string } }) => {
+    const value = event.target.value;
+    if (onChannelChange != null) {
+      onChannelChange(value);
+    }
+  };
+
+
+  return (
+    <div className="grw-slack-notification w-100">
+      <div className="grw-input-group-slack-notification input-group extended-setting">
+        <label className="input-group-addon">
+          <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
+            <input
+              type="checkbox"
+              className="custom-control-input border-0"
+              id={id}
+              checked={isSlackEnabled}
+              onChange={updateCheckboxHandler}
+            />
+            <label className="custom-control-label align-center" htmlFor={id}></label>
+          </div>
+        </label>
+        <input
+          className="grw-form-control-slack-notification form-control align-top pl-0"
+          id={idForSlackPopover}
+          type="text"
+          value={slackChannels}
+          placeholder="Input channels"
+          onChange={updateSlackChannelsHandler}
+        />
+        <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
+          <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
+          <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
+        </UncontrolledPopover>
+      </div>
+    </div>
+
+  );
+};

+ 8 - 7
packages/app/src/interfaces/search.ts

@@ -7,15 +7,16 @@ export enum CheckboxType {
 }
 }
 
 
 export type IPageSearchResultData = {
 export type IPageSearchResultData = {
-  pageData: IPageHasId,
+  pageData: IPageHasId;
   pageMeta: {
   pageMeta: {
-    bookmarkCount?: number,
+    bookmarkCount?: number;
     elasticSearchResult?: {
     elasticSearchResult?: {
-      snippet: string,
-      highlightedPath: string,
-    },
-  },
-}
+      snippet: string;
+      highlightedPath: string;
+      isHtmlInPath: boolean;
+    };
+  };
+};
 
 
 export const SORT_AXIS = {
 export const SORT_AXIS = {
   RELATION_SCORE: 'relationScore',
   RELATION_SCORE: 'relationScore',

+ 22 - 0
packages/app/src/server/middlewares/inject-user-registration-order-by-token-middleware.ts

@@ -0,0 +1,22 @@
+import createError from 'http-errors';
+
+import UserRegistrationOrder from '../models/user-registration-order';
+
+export default async(req, res, next): Promise<void> => {
+  const token = req.params.token || req.body.token;
+
+  if (token == null) {
+    return next(createError(400, 'Token not found', { code: 'token-not-found' }));
+  }
+
+  const userRegistrationOrder = await UserRegistrationOrder.findOne({ token });
+
+  // check if the token is valid
+  if (userRegistrationOrder == null || userRegistrationOrder.isExpired() || userRegistrationOrder.isRevoked) {
+    return next(createError(400, 'userRegistrationOrder is null or expired or revoked', { code: 'password-reset-order-is-not-appropriate' }));
+  }
+
+  req.userRegistrationOrder = userRegistrationOrder;
+
+  return next();
+};

+ 4 - 0
packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts

@@ -19,6 +19,10 @@ async function getSettings(userId: string): Promise<Partial<IUserUISettings> | n
 
 
 module.exports = () => {
 module.exports = () => {
   return async(req, res, next) => {
   return async(req, res, next) => {
+    if (req.user == null) {
+      return next();
+    }
+
     try {
     try {
       res.locals.userUISettings = await getSettings(req.user._id);
       res.locals.userUISettings = await getSettings(req.user._id);
     }
     }

+ 1 - 1
packages/app/src/server/models/named-query.ts

@@ -6,7 +6,7 @@ import mongoose, {
 
 
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
-import { INamedQuery, SearchDelegatorName } from '../../interfaces/named-query';
+import { INamedQuery, SearchDelegatorName } from '~/interfaces/named-query';
 
 
 const logger = loggerFactory('growi:models:named-query');
 const logger = loggerFactory('growi:models:named-query');
 
 

+ 61 - 4
packages/app/src/server/models/page.ts

@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
 
 import mongoose, {
 import mongoose, {
-  Schema, Model, Document,
+  Schema, Model, Document, AnyObject,
 } from 'mongoose';
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
@@ -39,7 +39,7 @@ type TargetAndAncestorsResult = {
 }
 }
 export interface PageModel extends Model<PageDocument> {
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
-  createEmptyPagesByPaths(paths: string[]): Promise<void>
+  createEmptyPagesByPaths(paths: string[], publicOnly?: boolean): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean): Promise<PageDocument[]>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
@@ -141,9 +141,9 @@ const generateChildrenRegExp = (path: string): RegExp => {
 /*
 /*
  * Create empty pages if the page in paths didn't exist
  * Create empty pages if the page in paths didn't exist
  */
  */
-schema.statics.createEmptyPagesByPaths = async function(paths: string[]): Promise<void> {
+schema.statics.createEmptyPagesByPaths = async function(paths: string[], publicOnly = false): Promise<void> {
   // find existing parents
   // find existing parents
-  const builder = new PageQueryBuilder(this.find({}, { _id: 0, path: 1 }));
+  const builder = new PageQueryBuilder(this.find(publicOnly ? { grant: GRANT_PUBLIC } : {}, { _id: 0, path: 1 }));
   const existingPages = await builder
   const existingPages = await builder
     .addConditionToListByPathsArray(paths)
     .addConditionToListByPathsArray(paths)
     .query
     .query
@@ -359,3 +359,60 @@ export default (crowi: Crowi): any => {
 
 
   return getOrCreateModel<PageDocument, PageModel>('Page', schema);
   return getOrCreateModel<PageDocument, PageModel>('Page', schema);
 };
 };
+
+/*
+ * Aggregation utilities
+ */
+// TODO: use the original type when upgraded https://github.com/Automattic/mongoose/blob/master/index.d.ts#L3090
+type PipelineStageMatch = {
+  $match: AnyObject
+};
+
+export const generateGrantCondition = async(
+    user, _userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+): Promise<PipelineStageMatch> => {
+  let userGroups = _userGroups;
+  if (user != null && userGroups == null) {
+    const UserGroupRelation: any = mongoose.model('UserGroupRelation');
+    userGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
+  }
+
+  const grantConditions: AnyObject[] = [
+    { grant: null },
+    { grant: GRANT_PUBLIC },
+  ];
+
+  if (showAnyoneKnowsLink) {
+    grantConditions.push({ grant: GRANT_RESTRICTED });
+  }
+
+  if (showPagesRestrictedByOwner) {
+    grantConditions.push(
+      { grant: GRANT_SPECIFIED },
+      { grant: GRANT_OWNER },
+    );
+  }
+  else if (user != null) {
+    grantConditions.push(
+      { grant: GRANT_SPECIFIED, grantedUsers: user._id },
+      { grant: GRANT_OWNER, grantedUsers: user._id },
+    );
+  }
+
+  if (showPagesRestrictedByGroup) {
+    grantConditions.push(
+      { grant: GRANT_USER_GROUP },
+    );
+  }
+  else if (userGroups != null && userGroups.length > 0) {
+    grantConditions.push(
+      { grant: GRANT_USER_GROUP, grantedGroup: { $in: userGroups } },
+    );
+  }
+
+  return {
+    $match: {
+      $or: grantConditions,
+    },
+  };
+};

+ 67 - 0
packages/app/src/server/models/user-registration-order.ts

@@ -0,0 +1,67 @@
+import mongoose, {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import uniqueValidator from 'mongoose-unique-validator';
+import crypto from 'crypto';
+import { getOrCreateModel } from '@growi/core';
+
+export interface IUserRegistrationOrder {
+  token: string,
+  email: string,
+  isRevoked: boolean,
+  createdAt: Date,
+  expiredAt: Date,
+}
+
+export interface UserRegistrationOrderDocument extends IUserRegistrationOrder, Document {
+  isExpired(): Promise<boolean>
+  revokeOneTimeToken(): Promise<void>
+}
+
+export interface UserRegistrationOrderModel extends Model<UserRegistrationOrderDocument> {
+  generateOneTimeToken(): string
+  createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
+}
+
+const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
+  token: { type: String, required: true, unique: true },
+  email: { type: String, required: true },
+  isRevoked: { type: Boolean, default: false, required: true },
+  createdAt: { type: Date, default: new Date(Date.now()), required: true },
+  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+});
+schema.plugin(uniqueValidator);
+
+schema.statics.generateOneTimeToken = function() {
+  const buf = crypto.randomBytes(256);
+  const token = buf.toString('hex');
+
+  return token;
+};
+
+schema.statics.createUserRegistrationOrder = async function(email) {
+  let token;
+  let duplicateToken;
+
+  do {
+    token = this.generateOneTimeToken();
+    // eslint-disable-next-line no-await-in-loop
+    duplicateToken = await this.findOne({ token });
+  } while (duplicateToken != null);
+
+  const userRegistrationOrderData = await this.create({ token, email });
+
+  return userRegistrationOrderData;
+};
+
+schema.methods.isExpired = function() {
+  return this.expiredAt.getTime() < Date.now();
+};
+
+schema.methods.revokeOneTimeToken = async function() {
+  this.isRevoked = true;
+  return this.save();
+};
+
+export default getOrCreateModel<UserRegistrationOrderDocument, UserRegistrationOrderModel>('UserRegistrationOrder', schema);

+ 10 - 0
packages/app/src/server/models/user.js

@@ -483,6 +483,16 @@ module.exports = function(crowi) {
     return usernameUsable;
     return usernameUsable;
   };
   };
 
 
+  userSchema.statics.isRegisterableEmail = async function(email) {
+    let isEmailUsable = true;
+
+    const userData = await this.findOne({ email });
+    if (userData) {
+      isEmailUsable = false;
+    }
+    return isEmailUsable;
+  };
+
   userSchema.statics.isRegisterable = function(email, username, callback) {
   userSchema.statics.isRegisterable = function(email, username, callback) {
     const User = this;
     const User = this;
     let emailUsable = true;
     let emailUsable = true;

+ 12 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -1,4 +1,6 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import * as userActivation from './user-activation';
+import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 
 
 import pageListing from './page-listing';
 import pageListing from './page-listing';
 
 
@@ -57,7 +59,17 @@ module.exports = (crowi) => {
 
 
   router.use('/forgot-password', require('./forgot-password')(crowi));
   router.use('/forgot-password', require('./forgot-password')(crowi));
 
 
+  const user = require('../user')(crowi, null);
+  router.get('/check_username', user.api.checkUsername);
+
+  router.post('/complete-registration',
+    injectUserRegistrationOrderByTokenMiddleware,
+    userActivation.completeRegistrationRules(),
+    userActivation.validateCompleteRegistration,
+    userActivation.completeRegistrationAction(crowi));
+
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
+
   return router;
   return router;
 };
 };

+ 17 - 0
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -27,6 +27,9 @@ const validator = {
     query('id').isMongoId(),
     query('id').isMongoId(),
     query('path').isString(),
     query('path').isString(),
   ], 'id or path is required'),
   ], 'id or path is required'),
+  pageIdsRequired: [
+    query('pageIds').isArray().withMessage('pageIds is required'),
+  ],
 };
 };
 
 
 /*
 /*
@@ -90,5 +93,19 @@ export default (crowi: Crowi): Router => {
     }
     }
   });
   });
 
 
+  // eslint-disable-next-line max-len
+  router.get('/short-bodies', accessTokenParser, loginRequired, validator.pageIdsRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { pageIds } = req.query;
+
+    try {
+      const shortBodiesMap = await crowi.pageService.shortBodiesMapByPageIds(pageIds, req.user);
+      return res.apiv3({ shortBodiesMap });
+    }
+    catch (err) {
+      logger.error('Error occurred while fetching shortBodiesMap.', err);
+      return res.apiv3Err(new ErrorV3('Error occurred while fetching shortBodiesMap.'));
+    }
+  });
+
   return router;
   return router;
 };
 };

+ 3 - 0
packages/app/src/server/routes/apiv3/security-setting.js

@@ -381,6 +381,7 @@ module.exports = (crowi) => {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       },
       },
       generalAuth: {
       generalAuth: {
         isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
         isLocalEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEnabled'),
@@ -749,6 +750,7 @@ module.exports = (crowi) => {
       'security:registrationMode': req.body.registrationMode,
       'security:registrationMode': req.body.registrationMode,
       'security:registrationWhiteList': req.body.registrationWhiteList,
       'security:registrationWhiteList': req.body.registrationWhiteList,
       'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
       'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
+      'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
     };
     };
     try {
     try {
       await updateAndReloadStrategySettings('local', requestParams);
       await updateAndReloadStrategySettings('local', requestParams);
@@ -757,6 +759,7 @@ module.exports = (crowi) => {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
+        isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       };
       };
       return res.apiv3({ localSettingParams });
       return res.apiv3({ localSettingParams });
     }
     }

+ 138 - 0
packages/app/src/server/routes/apiv3/user-activation.ts

@@ -0,0 +1,138 @@
+import path from 'path';
+import * as express from 'express';
+import { body, validationResult } from 'express-validator';
+import ErrorV3 from '../../models/vo/error-apiv3';
+
+// validation rules for complete registration form
+export const completeRegistrationRules = () => {
+  return [
+    body('username')
+      .matches(/^[\da-zA-Z\-_.]+$/)
+      .withMessage('Username has invalid characters')
+      .not()
+      .isEmpty()
+      .withMessage('Username field is required'),
+    body('name').not().isEmpty().withMessage('Name field is required'),
+    body('token').not().isEmpty().withMessage('Token value is required'),
+    body('password')
+      .matches(/^[\x20-\x7F]*$/)
+      .withMessage('Password has invalid character')
+      .isLength({ min: 6 })
+      .withMessage('Password minimum character should be more than 6 characters')
+      .not()
+      .isEmpty()
+      .withMessage('Password field is required'),
+  ];
+};
+
+// middleware to validate complete registration form
+export const validateCompleteRegistration = (req, res, next) => {
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  return res.apiv3Err(extractedErrors);
+};
+
+async function sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url) {
+  const promises = admins.map((admin) => {
+    return mailService.send({
+      to: admin.email,
+      subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
+      template,
+      vars: {
+        createdUser: userData,
+        admin,
+        url,
+        appTitle,
+      },
+    });
+  });
+}
+
+export const completeRegistrationAction = (crowi) => {
+  const User = crowi.model('User');
+  const {
+    configManager,
+    aclService,
+    appService,
+    mailService,
+  } = crowi;
+
+  return async function(req, res) {
+    if (req.user != null) {
+      return res.apiv3Err(new ErrorV3('You have been logged in', 'registration-failed'), 403);
+    }
+
+    // config で closed ならさよなら
+    if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED) {
+      return res.apiv3Err(new ErrorV3('Registration closed', 'registration-failed'), 403);
+    }
+
+    const { userRegistrationOrder } = req;
+    const registerForm = req.body;
+
+    const email = userRegistrationOrder.email;
+    const name = registerForm.name;
+    const username = registerForm.username;
+    const password = registerForm.password;
+
+    // email と username の unique チェックする
+    User.isRegisterable(email, username, (isRegisterable, errOn) => {
+      let isError = false;
+      let errorMessage = '';
+      if (!User.isEmailValid(email)) {
+        isError = true;
+        errorMessage += req.t('message.email_address_could_not_be_used');
+      }
+      if (!isRegisterable) {
+        if (!errOn.username) {
+          isError = true;
+          errorMessage += req.t('message.user_id_is_not_available');
+        }
+        if (!errOn.email) {
+          isError = true;
+          errorMessage += req.t('message.email_address_is_already_registered');
+        }
+      }
+      if (isError) {
+        return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+      }
+
+      if (configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled') === true) {
+        User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
+          if (err) {
+            if (err.name === 'UserUpperLimitException') {
+              errorMessage = req.t('message.can_not_register_maximum_number_of_users');
+            }
+            else {
+              errorMessage = req.t('message.failed_to_register');
+            }
+            return res.apiv3Err(new ErrorV3(errorMessage, 'registration-failed'), 403);
+          }
+
+          userRegistrationOrder.revokeOneTimeToken();
+
+          if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+            const admins = await User.findAdmins();
+            const appTitle = appService.getAppTitle();
+            const template = path.join(crowi.localeDir, 'en_US/admin/userWaitingActivation.txt');
+            const url = appService.getSiteUrl();
+
+            sendEmailToAllAdmins(userData, admins, appTitle, mailService, template, url);
+          }
+
+          req.flash('successMessage', req.t('message.successfully_created', { username }));
+          res.apiv3({ status: 'ok' });
+        });
+      }
+      else {
+        return res.apiv3Err(new ErrorV3('Email authentication configuration is disabled', 'registration-failed'), 403);
+      }
+    });
+  };
+};

+ 6 - 0
packages/app/src/server/routes/index.js

@@ -1,9 +1,11 @@
 import express from 'express';
 import express from 'express';
 
 
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
+import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 
 
 import * as forgotPassword from './forgot-password';
 import * as forgotPassword from './forgot-password';
 import * as privateLegacyPages from './private-legacy-pages';
 import * as privateLegacyPages from './private-legacy-pages';
+import * as userActivation from './user-activation';
 
 
 const multer = require('multer');
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
 const autoReap = require('multer-autoreap');
@@ -195,6 +197,10 @@ module.exports = function(crowi, app) {
 
 
   app.use('/private-legacy-pages', express.Router()
   app.use('/private-legacy-pages', express.Router()
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
     .get('/', privateLegacyPages.renderPrivateLegacyPages));
+  app.use('/user-activation', express.Router()
+    .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
+    .use(userActivation.tokenErrorHandlerMiddeware));
+  app.post('/user-activation/register', apiLimiter, applicationInstalled, csrf, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
 
   app.get('/share/:linkId', page.showSharedPage);
   app.get('/share/:linkId', page.showSharedPage);
 
 

+ 114 - 0
packages/app/src/server/routes/user-activation.ts

@@ -0,0 +1,114 @@
+import path from 'path';
+import { body, validationResult } from 'express-validator';
+import UserRegistrationOrder from '../models/user-registration-order';
+
+export const form = (req, res): void => {
+  const { userRegistrationOrder } = req;
+  return res.render('user-activation', { userRegistrationOrder });
+};
+
+async function makeRegistrationEmailToken(email, crowi) {
+  const {
+    configManager,
+    mailService,
+    localeDir,
+    appService,
+  } = crowi;
+
+  const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
+  const i18n = grobalLang;
+  const appUrl = appService.getSiteUrl();
+
+  const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
+  const oneTimeUrl = url.href;
+  const txtFileName = 'userActivation';
+
+  return mailService.send({
+    to: email,
+    subject: txtFileName,
+    template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
+    vars: {
+      appTitle: appService.getAppTitle(),
+      email,
+      url: oneTimeUrl,
+    },
+  });
+}
+
+export const registerAction = (crowi) => {
+  const User = crowi.model('User');
+
+  return async function(req, res) {
+    const registerForm = req.body.registerForm || {};
+    const email = registerForm.email;
+    const isRegisterableEmail = await User.isRegisterableEmail(email);
+
+    if (!isRegisterableEmail) {
+      req.body.registerForm.email = email;
+      req.flash('registerWarningMessage', req.t('message.email_address_is_already_registered'));
+      req.flash('email', email);
+
+      return res.redirect('/login#register');
+    }
+
+    makeRegistrationEmailToken(email, crowi);
+
+    req.flash('successMessage', req.t('message.successfully_send_email_auth', { email }));
+
+    return res.redirect('/login');
+  };
+};
+
+// middleware to handle error
+export const tokenErrorHandlerMiddeware = (err, req, res, next) => {
+  if (err != null) {
+    req.flash('errorMessage', req.t('message.incorrect_token_or_expired_url'));
+    return res.redirect('/login#register');
+  }
+  next();
+};
+
+// validation rules for registration form when email authentication enabled
+export const registerRules = () => {
+  return [
+    body('registerForm.email')
+      .isEmail()
+      .withMessage('Email format is invalid.')
+      .exists()
+      .withMessage('Email field is required.'),
+  ];
+};
+
+// middleware to validate complete registration form
+export const validateCompleteRegistrationForm = (req, res, next) => {
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    return next();
+  }
+
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('errors', extractedErrors);
+  req.flash('inputs', req.body);
+
+  const token = req.body.token;
+  return res.redirect(`/user-activation/${token}`);
+};
+
+// middleware to validate register form if email authentication enabled
+export const validateRegisterForm = (req, res, next) => {
+  const errors = validationResult(req);
+  if (errors.isEmpty()) {
+    return next();
+  }
+
+  req.form = { isValid: false };
+  const extractedErrors: string[] = [];
+  errors.array().map(err => extractedErrors.push(err.msg));
+
+  req.flash('registerWarningMessage', extractedErrors);
+
+  res.redirect('back');
+};

+ 9 - 6
packages/app/src/server/routes/user.js

@@ -56,20 +56,23 @@ module.exports = function(crowi, app) {
 
 
   actions.api = api;
   actions.api = api;
 
 
-  api.checkUsername = function(req, res) {
+  api.checkUsername = async function(req, res) {
     const username = req.query.username;
     const username = req.query.username;
 
 
-    User.findUserByUsername(username)
+    let valid = false;
+    await User.findUserByUsername(username)
       .then((userData) => {
       .then((userData) => {
         if (userData) {
         if (userData) {
-          return res.json({ valid: false });
+          valid = false;
+        }
+        else {
+          valid = true;
         }
         }
-
-        return res.json({ valid: true });
       })
       })
       .catch((err) => {
       .catch((err) => {
-        return res.json({ valid: true });
+        valid = false;
       });
       });
+    return res.json(ApiResponse.success({ valid }));
   };
   };
 
 
   /**
   /**

+ 6 - 0
packages/app/src/server/service/config-loader.ts

@@ -313,6 +313,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     type:    ValueType.BOOLEAN,
     default: true,
     default: true,
   },
   },
+  LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED: {
+    ns:      'crowi',
+    key:     'security:passport-local:isEmailAuthenticationEnabled',
+    type:    ValueType.BOOLEAN,
+    default: false,
+  },
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
   SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',
     key:     'security:passport-saml:useOnlyEnvVarsForSomeOptions',

+ 92 - 20
packages/app/src/server/service/page.js

@@ -1,6 +1,7 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+import { generateGrantCondition } from '~/server/models/page';
 
 
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const escapeStringRegexp = require('escape-string-regexp');
 const escapeStringRegexp = require('escape-string-regexp');
@@ -771,6 +772,70 @@ class PageService {
     }
     }
   }
   }
 
 
+  async shortBodiesMapByPageIds(pageIds = [], user) {
+    const Page = mongoose.model('Page');
+    const MAX_LENGTH = 350;
+
+    // aggregation options
+    const viewerCondition = await generateGrantCondition(user, null);
+    const filterByIds = {
+      _id: { $in: pageIds.map(id => mongoose.Types.ObjectId(id)) },
+    };
+
+    let pages;
+    try {
+      pages = await Page
+        .aggregate([
+          // filter by pageIds
+          {
+            $match: filterByIds,
+          },
+          // filter by viewer
+          viewerCondition,
+          // lookup: https://docs.mongodb.com/v4.4/reference/operator/aggregation/lookup/
+          {
+            $lookup: {
+              from: 'revisions',
+              let: { localRevision: '$revision' },
+              pipeline: [
+                {
+                  $match: {
+                    $expr: {
+                      $eq: ['$_id', '$$localRevision'],
+                    },
+                  },
+                },
+                {
+                  $project: {
+                    revision: { $substr: ['$body', 0, MAX_LENGTH] },
+                  },
+                },
+              ],
+              as: 'revisionData',
+            },
+          },
+          // projection
+          {
+            $project: {
+              _id: 1,
+              revisionData: 1,
+            },
+          },
+        ]).exec();
+    }
+    catch (err) {
+      logger.error('Error occurred while generating shortBodiesMap');
+      throw err;
+    }
+
+    const shortBodiesMap = {};
+    pages.forEach((page) => {
+      shortBodiesMap[page._id] = page.revisionData?.[0]?.revision;
+    });
+
+    return shortBodiesMap;
+  }
+
   validateCrowi() {
   validateCrowi() {
     if (this.crowi == null) {
     if (this.crowi == null) {
       throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
       throw new Error('"crowi" is null. Init User model with "crowi" argument first.');
@@ -801,35 +866,37 @@ class PageService {
   }
   }
 
 
   async v5InitialMigration(grant) {
   async v5InitialMigration(grant) {
-    const socket = this.crowi.socketIoService.getAdminSocket();
-    try {
-      await this._v5RecursiveMigration(grant);
-    }
-    catch (err) {
-      logger.error('V5 initial miration failed.', err);
-      socket.emit('v5InitialMirationFailed', { error: err.message });
-
-      throw err;
-    }
-
+    // const socket = this.crowi.socketIoService.getAdminSocket();
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
     const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
     const indexStatus = await Page.aggregate([{ $indexStats: {} }]);
     const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
     const pathIndexStatus = indexStatus.filter(status => status.name === 'path_1')?.[0];
     const isPathIndexExists = pathIndexStatus != null;
     const isPathIndexExists = pathIndexStatus != null;
     const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
     const isUnique = isPathIndexExists && pathIndexStatus.spec?.unique === true;
 
 
+    // drop unique index first
     if (isUnique || !isPathIndexExists) {
     if (isUnique || !isPathIndexExists) {
       try {
       try {
         await this._v5NormalizeIndex(isPathIndexExists);
         await this._v5NormalizeIndex(isPathIndexExists);
       }
       }
       catch (err) {
       catch (err) {
         logger.error('V5 index normalization failed.', err);
         logger.error('V5 index normalization failed.', err);
-        socket.emit('v5IndexNormalizationFailed', { error: err.message });
+        // socket.emit('v5IndexNormalizationFailed', { error: err.message });
 
 
         throw err;
         throw err;
       }
       }
     }
     }
 
 
+    // then migrate
+    try {
+      await this._v5RecursiveMigration(grant, null, true);
+    }
+    catch (err) {
+      logger.error('V5 initial miration failed.', err);
+      // socket.emit('v5InitialMirationFailed', { error: err.message });
+
+      throw err;
+    }
+
     await this._setIsV5CompatibleTrue();
     await this._setIsV5CompatibleTrue();
   }
   }
 
 
@@ -868,7 +935,7 @@ class PageService {
   }
   }
 
 
   // TODO: use websocket to show progress
   // TODO: use websocket to show progress
-  async _v5RecursiveMigration(grant, regexps) {
+  async _v5RecursiveMigration(grant, regexps, publicOnly = false) {
     const BATCH_SIZE = 100;
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
     const PAGES_LIMIT = 1000;
     const Page = this.crowi.model('Page');
     const Page = this.crowi.model('Page');
@@ -931,7 +998,7 @@ class PageService {
         const parentPaths = Array.from(parentPathsSet);
         const parentPaths = Array.from(parentPathsSet);
 
 
         // fill parents with empty pages
         // fill parents with empty pages
-        await Page.createEmptyPagesByPaths(parentPaths);
+        await Page.createEmptyPagesByPaths(parentPaths, publicOnly);
 
 
         // find parents again
         // find parents again
         const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
         const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }));
@@ -952,13 +1019,18 @@ class PageService {
             parentPath = parentPath.replace(bracket, `\\${bracket}`);
             parentPath = parentPath.replace(bracket, `\\${bracket}`);
           });
           });
 
 
+          const filter = {
+            // regexr.com/6889f
+            // ex. /parent/any_child OR /any_level1
+            path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
+          };
+          if (grant != null) {
+            filter.grant = grant;
+          }
+
           return {
           return {
             updateMany: {
             updateMany: {
-              filter: {
-                // regexr.com/6889f
-                // ex. /parent/any_child OR /any_level1
-                path: { $regex: new RegExp(`^${parentPath}(\\/[^/]+)\\/?$`, 'g') },
-              },
+              filter,
               update: {
               update: {
                 parent: parentId,
                 parent: parentId,
               },
               },
@@ -1001,7 +1073,7 @@ class PageService {
     await streamToPromise(migratePagesStream);
     await streamToPromise(migratePagesStream);
 
 
     if (await Page.exists(filter) && shouldContinue) {
     if (await Page.exists(filter) && shouldContinue) {
-      return this._v5RecursiveMigration(grant, regexps);
+      return this._v5RecursiveMigration(grant, regexps, publicOnly);
     }
     }
 
 
   }
   }

+ 7 - 1
packages/app/src/server/service/search.ts

@@ -348,7 +348,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
   // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
   // TODO: optimize the way to check isFormattable e.g. check data schema of searchResult
   // So far, it determines by delegatorName passed by searchService.searchKeyword
   // So far, it determines by delegatorName passed by searchService.searchKeyword
-  checkIsFormattable(searchResult, delegatorName): boolean {
+  checkIsFormattable(searchResult, delegatorName: SearchDelegatorName): boolean {
     return delegatorName === SearchDelegatorName.DEFAULT;
     return delegatorName === SearchDelegatorName.DEFAULT;
   }
   }
 
 
@@ -401,16 +401,22 @@ class SearchService implements SearchQueryParser, SearchResolver {
         pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
         pageData.lastUpdateUser = serializeUserSecurely(pageData.lastUpdateUser);
       }
       }
 
 
+      // const data = searchResult.data.find((data) => {
+      //   return pageData.id === data._id;
+      // });
+
       // increment elasticSearchResult
       // increment elasticSearchResult
       let elasticSearchResult;
       let elasticSearchResult;
       const highlightData = data._highlight;
       const highlightData = data._highlight;
       if (highlightData != null) {
       if (highlightData != null) {
         const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
         const snippet = highlightData['body.en'] || highlightData['body.ja'] || '';
         const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
         const pathMatch = highlightData['path.en'] || highlightData['path.ja'] || '';
+        const isHtmlInPath = highlightData['path.en'] != null || highlightData['path.ja'] != null;
 
 
         elasticSearchResult = {
         elasticSearchResult = {
           snippet: filterXss.process(snippet),
           snippet: filterXss.process(snippet),
           highlightedPath: filterXss.process(pathMatch),
           highlightedPath: filterXss.process(pathMatch),
+          isHtmlInPath,
         };
         };
       }
       }
 
 

+ 1 - 1
packages/app/src/server/views/admin/users.html

@@ -7,5 +7,5 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block content_main %}
 {% block content_main %}
-<div id ="admin-user-page"></div>
+<div id ="admin-user-page" class="admin-user-page"></div>
 {% endblock content_main %}
 {% endblock content_main %}

+ 3 - 2
packages/app/src/server/views/login.html

@@ -110,17 +110,18 @@
       {% set registrationMode = getConfig('crowi', 'security:registrationMode') %}
       {% set registrationMode = getConfig('crowi', 'security:registrationMode') %}
       {% set isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
       {% set isRegistrationEnabled = passportService.isLocalStrategySetup && registrationMode != 'Closed' %}
       {% set isPasswordResetEnabled = getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') %}
       {% set isPasswordResetEnabled = getConfig('crowi', 'security:passport-local:isPasswordResetEnabled') %}
-
+      {% set isEmailAuthenticationEnabled = getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled') %}
       <div
       <div
         id="login-form"
         id="login-form"
         data-is-registering="{{ req.query.register or req.body.registerForm or isRegistering }}"
         data-is-registering="{{ req.query.register or req.body.registerForm or isRegistering }}"
         data-username ="{{ req.body.registerForm.username }}"
         data-username ="{{ req.body.registerForm.username }}"
         data-name ="{{ req.body.registerForm.name }}"
         data-name ="{{ req.body.registerForm.name }}"
-        data-email ="{{ req.body.registerForm.email }}"
+        data-email ="{{ req.body.registerForm.email || req.flash('email') }}"
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-is-registration-enabled="{{ isRegistrationEnabled }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-mode = "{{ registrationMode }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
         data-registration-white-list = "{{ getConfig('crowi', 'security:registrationWhiteList') }}"
         data-is-password-reset-enabled = "{{ isPasswordResetEnabled }}"
         data-is-password-reset-enabled = "{{ isPasswordResetEnabled }}"
+        data-is-email-authentication-enabled = "{{ isEmailAuthenticationEnabled }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-local-strategy-setup = "{{ passportService.isLocalStrategySetup }}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         data-is-ldap-strategy-setup = "{{ passportService.isLdapStrategySetup}}"
         data-is-google-auth-enabled = "{{ getConfig('crowi', 'security:passport-google:isEnabled') }}"
         data-is-google-auth-enabled = "{{ getConfig('crowi', 'security:passport-google:isEnabled') }}"

+ 52 - 0
packages/app/src/server/views/user-activation.html

@@ -0,0 +1,52 @@
+{% extends 'layout/layout.html' %}
+
+{% block html_base_css %}invited nologin{% endblock %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName('Registration') }}{% endblock %}
+
+
+
+{#
+# Remove default contents
+#}
+{% block html_head_loading_legacy %}
+{% endblock %}
+{% block html_head_loading_app %}
+{% endblock %}
+{% block layout_head_nav %}
+{% endblock %}
+{% block sidebar %}
+{% endblock %}
+{% block head_warn_alert_siteurl_undefined %}
+{% endblock %}
+{% block fixed-controls %}
+{% endblock %}
+
+{% block html_additional_headers %}
+  <script src="{{ webpack_asset('js/nologin.js') }}" defer></script>
+{% endblock %}
+
+{% block layout_main %}
+
+<div class="main container-fluid">
+
+  <div class="row">
+
+    <div class="login-header mx-auto col-sm-3">
+      <div class="logo">{% include 'widget/logo.html' %}</div>
+      <h1>GROWI</h1>
+
+      <div
+        id="user-activation-form"
+        data-message-errors="{{ req.flash('errors') }}"
+        data-inputs="{{ req.flash('inputs') }}"
+        data-email="{{ userRegistrationOrder.email }}"
+        data-token="{{ userRegistrationOrder.token }}"
+        class="col-sm-12"
+      ></div>
+
+  </div>{# /.row #}
+
+</div>{# /.main #}
+
+{% endblock %}

+ 3 - 0
packages/app/src/stores/context.tsx

@@ -120,6 +120,9 @@ export const useRevisionAuthor = (initialData?: Nullable<any>): SWRResponse<Null
   return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData ?? null);
   return useStaticSWR<Nullable<any>, Error>('revisionAuthor', initialData ?? null);
 };
 };
 
 
+export const useSlackChannels = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('slackChannels', initialData ?? null);
+};
 
 
 /** **********************************************************
 /** **********************************************************
  *                     Computed contexts
  *                     Computed contexts

+ 9 - 0
packages/app/src/stores/editor.tsx

@@ -0,0 +1,9 @@
+import { SWRResponse } from 'swr';
+import { useStaticSWR } from './use-static-swr';
+
+export const useIsSlackEnabled = (isEnabled?: boolean): SWRResponse<boolean, Error> => {
+  const initialData = false;
+  return (
+    useStaticSWR('isSlackEnabled', isEnabled || null, { fallbackData: initialData })
+  );
+};

+ 1 - 0
packages/app/src/styles/_layout.scss

@@ -1,5 +1,6 @@
 body {
 body {
   overflow-y: scroll !important;
   overflow-y: scroll !important;
+  overscroll-behavior: none;
 }
 }
 
 
 body:not(.growi-layout-fluid) .grw-container-convertible {
 body:not(.growi-layout-fluid) .grw-container-convertible {

+ 31 - 19
packages/app/src/styles/_override-bootstrap-variables.scss

@@ -23,9 +23,16 @@ $gray-600: lighten($dark, 10%) !default;
 $gray-700: lighten($dark, 5%) !default;
 $gray-700: lighten($dark, 5%) !default;
 $gray-800: $dark !default;
 $gray-800: $dark !default;
 $gray-900: darken($dark, 5%) !default;
 $gray-900: darken($dark, 5%) !default;
-$grays: ("50": $gray-50) !default;
+$grays: (
+  '50': $gray-50,
+) !default;
 $red: #ff0a54 !default;
 $red: #ff0a54 !default;
 
 
+// Options
+//
+// Quickly modify global styling by enabling or disabling optional features.
+
+$enable-shadows: true;
 
 
 // Grid breakpoints
 // Grid breakpoints
 //
 //
@@ -38,7 +45,7 @@ $grid-breakpoints: (
   md: 768px,
   md: 768px,
   lg: 992px,
   lg: 992px,
   xl: 1200px,
   xl: 1200px,
-  2xl: 1480px
+  2xl: 1480px,
 );
 );
 
 
 // Grid containers
 // Grid containers
@@ -50,44 +57,45 @@ $container-max-widths: (
   md: 720px,
   md: 720px,
   lg: 960px,
   lg: 960px,
   xl: 1140px,
   xl: 1140px,
-  2xl: 1320px
+  2xl: 1320px,
 );
 );
 
 
-
 //== Typography
 //== Typography
 //
 //
 //## Font, line-height, and color for body text, headings, and more.
 //## Font, line-height, and color for body text, headings, and more.
-$font-family-sans-serif:  Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif;
-$font-family-serif:       Georgia, "Times New Roman", Times, serif;
+$font-family-sans-serif: Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif;
+$font-family-serif: Georgia, 'Times New Roman', Times, serif;
 $font-family-monospace: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
 $font-family-monospace: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace;
-$font-family-base:        $font-family-sans-serif;
+$font-family-base: $font-family-sans-serif;
 
 
 $font-size-root: 14px;
 $font-size-root: 14px;
 $line-height-base: 1.42857;
 $line-height-base: 1.42857;
 
 
 $blockquote-small-color: $gray-500;
 $blockquote-small-color: $gray-500;
 
 
-
 //== Components
 //== Components
 //
 //
-$border-radius:               .15rem;
-$border-radius-sm:            .1rem;
-$border-radius-lg:            .25rem;
-$border-radius-xl:            .35rem;
+$border-radius: 4px;
+$border-radius-sm: 0;
+$border-radius-lg: 8px;
+
+// Buttons + Forms
+//
+// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.
+
+$input-btn-focus-box-shadow: none;
 
 
 // Buttons
 // Buttons
 //
 //
 // For each of Bootstrap's buttons, define text, background, and border color.
 // For each of Bootstrap's buttons, define text, background, and border color.
 $btn-link-disabled-color: $gray-500;
 $btn-link-disabled-color: $gray-500;
+$btn-focus-box-shadow: none;
+$btn-active-box-shadow: none;
 
 
 //== Forms
 //== Forms
 //
 //
 $input-border-color: $gray-300;
 $input-border-color: $gray-300;
 
 
-$input-border-radius: $border-radius-sm;
-$input-border-radius-sm: $border-radius-sm;
-$input-border-radius-lg: $border-radius;
-
 $input-placeholder-color: $gray-500;
 $input-placeholder-color: $gray-500;
 
 
 $custom-control-indicator-border-color: $gray-400;
 $custom-control-indicator-border-color: $gray-400;
@@ -106,9 +114,14 @@ $navbar-brand-padding-y: 0;
 $navbar-nav-link-padding-x: 1rem;
 $navbar-nav-link-padding-x: 1rem;
 
 
 //== Dropdowns
 //== Dropdowns
-$dropdown-border-radius: $border-radius-sm;
+$dropdown-border-radius: $border-radius-lg;
 $dropdown-link-disabled-color: $gray-500;
 $dropdown-link-disabled-color: $gray-500;
 $dropdown-header-color: $gray-500;
 $dropdown-header-color: $gray-500;
+$dropdown-box-shadow: 0 0.5rem 0.7rem rgba(black, 0.1);
+
+//== Popovers
+$popover-border-radius: $border-radius;
+$popover-box-shadow: 0 0.5rem 0.7rem rgba(black, 0.1);
 
 
 //== Pagination
 //== Pagination
 $pagination-disabled-color: $gray-500;
 $pagination-disabled-color: $gray-500;
@@ -122,6 +135,7 @@ $toast-header-color: $gray-500;
 
 
 //== Modals
 //== Modals
 $modal-content-border-width: 0;
 $modal-content-border-width: 0;
+$modal-content-border-radius: $border-radius-lg;
 $modal-header-padding-y: 0.75rem;
 $modal-header-padding-y: 0.75rem;
 $modal-header-padding-x: 1rem;
 $modal-header-padding-x: 1rem;
 
 
@@ -132,7 +146,6 @@ $alert-color-level: -10;
 
 
 //== Progress bar
 //== Progress bar
 $progress-height: 4px;
 $progress-height: 4px;
-$progress-border-radius: $border-radius-sm;
 $progress-bg: $gray-100;
 $progress-bg: $gray-100;
 $progress-box-shadow: none;
 $progress-box-shadow: none;
 
 
@@ -153,4 +166,3 @@ $pre-color: dummyinvalildcolor; // disable pre color specification with invalid
 $custom-checkbox-indicator-border-radius: 0px;
 $custom-checkbox-indicator-border-radius: 0px;
 $custom-control-indicator-focus-box-shadow: none;
 $custom-control-indicator-focus-box-shadow: none;
 $custom-control-indicator-size: 1.2rem;
 $custom-control-indicator-size: 1.2rem;
-

+ 11 - 6
packages/app/src/styles/_page-tree.scss

@@ -1,8 +1,8 @@
+$grw-sidebar-content-header-height: 58px;
+$grw-sidebar-content-footer-height: 50px;
+
 .grw-pagetree {
 .grw-pagetree {
-  .grw-pagetree-item-wrapper {
-    margin-top: 10px;
-    margin-left: 10px;
-  }
+  min-height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width + $grw-sidebar-content-header-height + $grw-sidebar-content-footer-height));
 
 
   .grw-pagetree-item {
   .grw-pagetree-item {
     &:hover {
     &:hover {
@@ -19,8 +19,14 @@
 
 
     .grw-pagetree-button {
     .grw-pagetree-button {
       background-color: transparent;
       background-color: transparent;
+      transition: all 0.2s ease-out;
+      transform: rotate(0deg);
+
+      &:focus {
+        box-shadow: none;
+      }
 
 
-      &.rotate {
+      &.grw-pagetree-open {
         transform: rotate(90deg);
         transform: rotate(90deg);
       }
       }
     }
     }
@@ -28,7 +34,6 @@
     .grw-pagetree-title-anchor {
     .grw-pagetree-title-anchor {
       width: 100%;
       width: 100%;
       overflow: hidden;
       overflow: hidden;
-      color: inherit;
       text-decoration: none;
       text-decoration: none;
 
 
       .grw-pagetree-title {
       .grw-pagetree-title {

+ 47 - 26
packages/app/src/styles/_search.scss

@@ -31,8 +31,8 @@
 
 
   .search-clear {
   .search-clear {
     position: absolute;
     position: absolute;
-    top: 5px;
-    right: 4px;
+    top: 4px;
+    right: 26px;
     z-index: 3;
     z-index: 3;
     width: 24px;
     width: 24px;
     height: 24px;
     height: 24px;
@@ -63,29 +63,32 @@
   }
   }
 }
 }
 
 
-// input styles
-.grw-global-search {
-  .search-clear {
-    top: 3px;
-    right: 26px;
+// styles for admin user search
+.admin-user-page {
+  .search-typeahead {
+    .search-clear {
+      top: 7px;
+      right: 4px;
+    }
   }
   }
+}
 
 
+// input styles
+.grw-global-search {
   .dropdown-toggle {
   .dropdown-toggle {
     min-width: 95px;
     min-width: 95px;
     padding-left: 1.5rem;
     padding-left: 1.5rem;
-    border-top-left-radius: 40px;
-    border-bottom-left-radius: 40px;
   }
   }
 
 
   .search-typeahead {
   .search-typeahead {
     // corner radius
     // corner radius
-    border-top-right-radius: 40px;
-    border-bottom-right-radius: 40px;
+    border-top-right-radius: $border-radius;
+    border-bottom-right-radius: $border-radius;
     .rbt-input-main {
     .rbt-input-main {
       padding-right: 58px;
       padding-right: 58px;
       // corner radius
       // corner radius
-      border-top-right-radius: 40px;
-      border-bottom-right-radius: 40px;
+      border-top-right-radius: $border-radius;
+      border-bottom-right-radius: $border-radius;
     }
     }
     .rbt-menu {
     .rbt-menu {
       @extend .dropdown-menu-right;
       @extend .dropdown-menu-right;
@@ -163,14 +166,19 @@
       }
       }
     }
     }
   }
   }
-  .search-typeahead {
-    border-radius: 0 25px 25px 0;
-  }
 }
 }
 
 
 // TODO : keep the selected list in the same positino as other lists
 // TODO : keep the selected list in the same positino as other lists
 // TASK : https://redmine.weseek.co.jp/issues/82470
 // TASK : https://redmine.weseek.co.jp/issues/82470
 .search-result {
 .search-result {
+  .search-control {
+    padding: 5px 0;
+  }
+  .search-control-include-options {
+    .card-body {
+      padding: 5px 10px;
+    }
+  }
   .search-result-list {
   .search-result-list {
     position: sticky;
     position: sticky;
     top: 0px;
     top: 0px;
@@ -183,10 +191,12 @@
     .nav.nav-pills {
     .nav.nav-pills {
       > .page-list-li {
       > .page-list-li {
         &.active {
         &.active {
-          // add this negative margin to avoid inner elements of .page-list-li.active
-          // moving to right side by border-left's px size.
-          margin-left: -3px;
           border-left: solid 3px transparent;
           border-left: solid 3px transparent;
+          .search-item-checkbox {
+            // subtract 3px from padding left applied by .search-item-checkbox
+            // as 3px of border-left is added above
+            padding-left: 4px !important;
+          }
         }
         }
         > a {
         > a {
           word-break: break-all;
           word-break: break-all;
@@ -200,8 +210,10 @@
           }
           }
         }
         }
         .page-list-meta {
         .page-list-meta {
-          > span {
-            margin-right: 12px;
+          .meta-icon {
+            width: 14px;
+            height: 14px;
+            margin-right: 14px;
           }
           }
           .footstamp-icon {
           .footstamp-icon {
             margin-right: 2px;
             margin-right: 2px;
@@ -209,6 +221,9 @@
         }
         }
       }
       }
     }
     }
+    .search-result-item {
+      min-height: 136px;
+    }
 
 
     .search-result-meta {
     .search-result-meta {
       font-weight: bold;
       font-weight: bold;
@@ -223,7 +238,17 @@
       vertical-align: middle;
       vertical-align: middle;
     }
     }
   }
   }
-
+  .search-item-text {
+    .picture-sm {
+      width: 20px;
+      height: 20px;
+    }
+    .item-meta {
+      .top-label {
+        display: none; // not show top label in search result list
+      }
+    }
+  }
   .search-result-content {
   .search-result-content {
     padding-bottom: 36px;
     padding-bottom: 36px;
 
 
@@ -287,10 +312,6 @@ body.on-search {
   }
   }
 }
 }
 
 
-.search-page-item {
-  height: 130px;
-}
-
 @include media-breakpoint-down(sm) {
 @include media-breakpoint-down(sm) {
   .grw-search-table {
   .grw-search-table {
     th {
     th {

+ 2 - 7
packages/app/src/styles/_sidebar.scss

@@ -22,6 +22,8 @@
   position: sticky;
   position: sticky;
   top: $grw-navbar-border-width;
   top: $grw-navbar-border-width;
 
 
+  height: 100vh;
+
   .grw-navigation-resize-button {
   .grw-navigation-resize-button {
     position: fixed;
     position: fixed;
 
 
@@ -233,13 +235,6 @@
       font-size: 18px;
       font-size: 18px;
     }
     }
   }
   }
-
-  .grw-sidebar-content-footer {
-    position: absolute;
-    bottom: 0;
-    width: 100%;
-    border-top: solid 1px $border-color;
-  }
 }
 }
 
 
 // Dock Mode
 // Dock Mode

+ 0 - 2
packages/app/src/styles/_subnav.scss

@@ -42,7 +42,6 @@
   .btn-bookmark {
   .btn-bookmark {
     height: 40px;
     height: 40px;
     font-size: 20px;
     font-size: 20px;
-    border-radius: $border-radius-xl;
   }
   }
 
 
   .btn-bookmark {
   .btn-bookmark {
@@ -94,7 +93,6 @@
 
 
       height: 30px;
       height: 30px;
       font-size: 15px !important;
       font-size: 15px !important;
-      border-radius: $border-radius-xl;
     }
     }
 
 
     .total-likes,
     .total-likes,

+ 1 - 1
packages/app/src/styles/_tag.scss

@@ -8,7 +8,7 @@
   .grw-tag-label {
   .grw-tag-label {
     font-size: 12px;
     font-size: 12px;
     font-weight: normal;
     font-weight: normal;
-    border-radius: $border-radius-sm;
+    border-radius: $border-radius;
   }
   }
 }
 }
 
 

+ 4 - 1
packages/app/src/styles/atoms/_buttons.scss

@@ -47,10 +47,13 @@
   overflow: hidden;
   overflow: hidden;
   color: white;
   color: white;
   text-align: center;
   text-align: center;
-  cursor: pointer;
   background-color: rgba(lighten(black, 15%), 0.5);
   background-color: rgba(lighten(black, 15%), 0.5);
   border: none;
   border: none;
 
 
+  &:not(:disabled) {
+    cursor: pointer;
+  }
+
   .btn-label {
   .btn-label {
     position: relative;
     position: relative;
     z-index: 1;
     z-index: 1;

+ 8 - 2
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -255,9 +255,15 @@ ul.pagination {
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     .grw-pagetree-item {
     .grw-pagetree-item {
+      &.grw-pagetree-is-target {
+        background: $bgcolor-list-hover;
+      }
+
       .grw-triangle-icon {
       .grw-triangle-icon {
-        svg {
-          fill: $gray-500;
+        &:not(:hover) {
+          svg {
+            fill: $gray-500;
+          }
         }
         }
       }
       }
       &:hover {
       &:hover {

+ 8 - 2
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -172,9 +172,15 @@ $border-color: $border-color-global;
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     .grw-pagetree-item {
     .grw-pagetree-item {
+      &.grw-pagetree-is-target {
+        background: $bgcolor-list-hover;
+      }
+
       .grw-triangle-icon {
       .grw-triangle-icon {
-        svg {
-          fill: $gray-400;
+        &:not(:hover) {
+          svg {
+            fill: $gray-400;
+          }
         }
         }
       }
       }
       &:hover {
       &:hover {

+ 13 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -310,6 +310,19 @@ ul.pagination {
     }
     }
   }
   }
 
 
+  .grw-pagetree {
+    .grw-pagetree-item {
+      .grw-pagetree-title-anchor {
+        color: inherit;
+      }
+    }
+  }
+  .grw-pagetree-footer {
+    .h5.grw-private-legacy-pages-anchor {
+      color: inherit;
+    }
+  }
+
   .grw-recent-changes {
   .grw-recent-changes {
     .list-group {
     .list-group {
       .list-group-item {
       .list-group-item {

+ 78 - 0
packages/app/src/test/integration/service/page.test.js

@@ -871,5 +871,83 @@ describe('PageService', () => {
 
 
   });
   });
 
 
+  describe('v5InitialMigration()', () => {
+    test('should migrate all public pages & replace private parents with empty pages', async() => {
+      jest.restoreAllMocks();
+
+      // initialize pages for test
+      const pages = await Page.insertMany([
+        {
+          path: '/publicA',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/publicA/privateB',
+          grant: Page.GRANT_OWNER,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/publicA/privateB/publicC',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/parenthesis/(a)[b]{c}d',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+        {
+          path: '/parenthesis/(a)[b]{c}d/public',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+        },
+      ]);
+
+      const parent = await Page.find({ path: '/' });
+      await Page.insertMany([
+        {
+          path: '/migratedD',
+          grant: Page.GRANT_PUBLIC,
+          creator: testUser1,
+          lastUpdateUser: testUser1,
+          parent: parent._id,
+        },
+      ]);
+
+      // migrate
+      await crowi.pageService.v5InitialMigration(Page.GRANT_PUBLIC);
+
+      const nMigratedPages = await Page.count({
+        path: {
+          $in: ['/publicA', '/publicA/privateB/publicC', '/parenthesis/(a)[b]{c}d', '/parenthesis/(a)[b]{c}d/public', '/migratedD'],
+        },
+        isEmpty: false,
+        parent: { $ne: null },
+      });
+      const nMigratedEmptyPages = await Page.count({
+        path: {
+          $in: ['/publicA/privateB', '/parenthesis'],
+        },
+        isEmpty: true,
+        parent: { $ne: null },
+      });
+      const nNonMigratedPages = await Page.count({
+        path: {
+          $in: ['/publicA/privateB'],
+        },
+        parent: null,
+      });
+
+      expect(nMigratedPages).toBe(5);
+      expect(nMigratedEmptyPages).toBe(2);
+      expect(nNonMigratedPages).toBe(1);
+    });
+  });
 
 
 });
 });

+ 7 - 7
packages/ui/src/components/PagePath/PageListMeta.jsx

@@ -14,34 +14,34 @@ export class PageListMeta extends React.Component {
     // top check
     // top check
     let topLabel;
     let topLabel;
     if (isTopPage(page.path)) {
     if (isTopPage(page.path)) {
-      topLabel = <span className="badge badge-info">TOP</span>;
+      topLabel = <span className="badge badge-info meta-icon top-label">TOP</span>;
     }
     }
 
 
     // template check
     // template check
     let templateLabel;
     let templateLabel;
     if (checkTemplatePath(page.path)) {
     if (checkTemplatePath(page.path)) {
-      templateLabel = <span className="badge badge-info">TMPL</span>;
+      templateLabel = <span className="badge badge-info meta-icon">TMPL</span>;
     }
     }
 
 
     let commentCount;
     let commentCount;
     if (page.commentCount != null && page.commentCount > 0) {
     if (page.commentCount != null && page.commentCount > 0) {
-      commentCount = <span><i className="icon-bubble" />{page.commentCount}</span>;
+      commentCount = <span className="meta-icon"><i className="icon-bubble" />{page.commentCount}</span>;
     }
     }
 
 
     let likerCount;
     let likerCount;
     if (page.liker != null && page.liker.length > 0) {
     if (page.liker != null && page.liker.length > 0) {
-      likerCount = <span><i className="icon-like" />{page.liker.length}</span>;
+      likerCount = <span className="meta-icon"><i className="icon-like" />{page.liker.length}</span>;
     }
     }
 
 
     let locked;
     let locked;
     if (page.grant !== 1) {
     if (page.grant !== 1) {
-      locked = <span><i className="icon-lock" /></span>;
+      locked = <span className="meta-icon"><i className="icon-lock" /></span>;
     }
     }
 
 
     let seenUserCount;
     let seenUserCount;
     if (page.seenUserCount > 0) {
     if (page.seenUserCount > 0) {
       seenUserCount = (
       seenUserCount = (
-        <span>
+        <span className="meta-icon">
           <i className="footstamp-icon"><FootstampIcon /></i>
           <i className="footstamp-icon"><FootstampIcon /></i>
           {page.seenUsers.length}
           {page.seenUsers.length}
         </span>
         </span>
@@ -50,7 +50,7 @@ export class PageListMeta extends React.Component {
 
 
     let bookmarkCount;
     let bookmarkCount;
     if (this.props.bookmarkCount > 0) {
     if (this.props.bookmarkCount > 0) {
-      bookmarkCount = <span><i className="icon-star" />{this.props.bookmarkCount}</span>;
+      bookmarkCount = <span className="meta-icon"><i className="icon-star" />{this.props.bookmarkCount}</span>;
     }
     }
 
 
     return (
     return (

+ 4 - 2
packages/ui/src/components/User/UserPicture.jsx

@@ -38,8 +38,10 @@ export class UserPicture extends React.Component {
   RootElmWithLink = (props) => {
   RootElmWithLink = (props) => {
     const { user } = this.props;
     const { user } = this.props;
     const href = userPageRoot(user);
     const href = userPageRoot(user);
-
-    return <a href={href} {...props}>{props.children}</a>;
+    // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
+    // Nested anchor tags causes a warning.
+    // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
+    return <span onClick={() => { window.location.href = href }} {...props}>{props.children}</span>;
   }
   }
 
 
   withTooltip = (RootElm) => {
   withTooltip = (RootElm) => {

Разница между файлами не показана из-за своего большого размера
+ 218 - 301
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов